如果你打算研究研究 MySQL 的 InnoDB 存储引擎架构实现,在啃书之前,让我先来帮你捋捋思路。
先了解一下 InnoDB 在 MySQL 架构中的位置:
MySQL 上层是每个 RMDB 都有的功能如 SQL 分析器和优化器等,下层的存储引擎主要负责底层物理结构的实现,上层为下层预定义了一套 API 接口,可根据不同需求为每个表指定不同的存储引擎。
InnoDB 作为目前 MySQL 的默认存储引擎支持事务,主要设计面向在线事务处理引用。本文主要聊聊它在索引,锁,事务和内存缓冲池架构的一些重要概念。
我们查字典的时候不会一页一页翻,而是从目录找第一个字母所在的页,然后在页里找单词。数据库也是一样,在 InnoDB 里面,对于每张表,以每行记录的主键(若没定义, MySQL 会帮你创建一个)排序做一个 B+Tree 。
为什么用 B+Tree ?我们知道,磁盘 IO 相对于内存 IO 是非常耗时的,而 B+Tree 可以很好控制树的高度进而控制磁盘 IO 的次数,以保证读取性能。
B+Tree 的叶子节点不仅存储了行的主键,还存储了这行记录的所有列数据,这种方式称之为聚簇索引。
辅助索引和主键索引一样,也是以 B+Tree 存储的,例如:key (city, age)
先按 city 排序,再按 age 排序。叶子节点只存储了 city , age 和该行的主键值。这种结构导致了:
select * from my_table where age = 17
是不能利用 key(city, age)
的。如果直接利用辅助索引能得出结果,这种索引情况叫作覆盖索引。比如:select id from my_table where city = "北京" order by age
,因为不需要再查聚簇索引了,所以减少了磁盘 IO 次数,提高了效率。
辅助索引虽能提高查询效率,但同时也会增加修改数据的负担,试想一个一百万行数据的表,每次数据都得更新一个百万节点索引树。为了提高辅助索引的更新效率, InnoDB 内部采用 Insert/Change Buffer 机制,可理解为先将对辅助的修改缓存起来,通过 merge 操作把单个随机修改转换成多个顺序修改提升性能。这种技术有个限制:修改的索引不能有唯一约束,想想也是,如果有唯一性限制,每个修改操作必须依赖整棵 B+Tree 的状态。
除此之外, InnoDB 内部还有自适应哈希索引(AHI),如字面意思,如果我们老是通过某个索引查询数据, InnoDB 就会把这个索引加入一个 hash 表中,这样相同的查询就是 O(1) 的复杂度了。
事务代表了一次数据库操作执行单元,可以是一条 SQL 语句,也可以是多条 SQL 语句组合。所谓执行单元是指要么每条 SQL 全执行,要么都不执行。事务最为严格的是要满足 Atomicity Consistency Isolation Durability 特性。
InnoDB 默认隔离级别是 REPEATABLE READ ,使用 Next-key Lock 算法,它是一种锁算法,结合了 Record Lock 和 Gap Lock 。
Record Lock 顾名思义,锁一行,通常是锁主键记录。 Gap Lock 锁一个范围但不包括本身,通常锁非唯一索引,主要目的是防止多个事务将记录插入到统一范围,而这会导致幻读。所以 InnoDB 的 REPEATABLE READ 已达到 SERIALIZABLE 的隔离级别。
需要注意的是,执行 select * from my_table where city = "北京" for update
, 如果 key (city, age)
没有命中,此时会锁住整个 my_table 表。
另一方面,锁是性能第一大杀手。为了提升事务性能, InnoDB 实现了一致性非锁定读,它是指当一个事务读取的行在另一个事务中正被 DELETE 或者 UPDATE 时,读取操作不需要等待锁释放,而是读取该行的一个历史快照数据。这种行数据带有多个历史版本的技术叫做多版本并发控制(MVCC)。典型的空间换时间策略。
InnoDB 在每次事务 COMMIT 时,会将在本次事务所有对底层文件的物理操作以日志文件的形式存储到文件系统,只有当这个操作成功后,才能认为本次事务 COMMIT 成功。这个日志叫做重做日志 (redo log),如果数据库在事务修改数据时发生故障,我们可以用它把事务的物理操作再执行一遍,这样就保证了事务的持久性。
原子性是指:在一个事务执行过程中,出现错误或执行 CALLBACK ,需要将数据库恢复到事务开始的状态。 InnoDB 中采用撤销日志(undo log)实现原子性,它保存了事务修改操作的逻辑反操作,例如在执行 update my_table set age = 23 where id = 10
的同时会生成相应的 undo log update my_table set age = 17 where id = 10
。 undo log 存在了 MySQL 的一张表里,而不是文件中。
InnoDB 作为存储引擎接受来自 MySQL 上层的调用,最终修改底层文件数据。如果每次操作都立刻同步到文件系统,频繁的磁盘 IO 会严重数据库性能,所以 InnoDB 维护了一个内存缓冲池。执行读操作时,首先把磁盘读到的页放在缓存池中,下次相同页的读操作会直接从缓存中拿数据。同样的,执行修改操作会先对缓冲池的数据页修改,等待时机刷新回磁盘。我们之前提到的 Insert Buffer 和 AHI 也会在缓冲池做缓存。
缓冲池可以很好的提高性能,也带了新的问题。我们把数据页的修改放在了内存,如果机器宕机,有些数据不就丢失了?别怕,我们不是已经存了重做日志,它可以帮我们把数据库同步到宕机时的状态。
既然数据是安全,那试想一下如果我们的内存足够大,为重做日志提供足够的磁盘空间,是不是就意味着我们的大量磁盘 IO 都可以放在内存执行?那运行性能绝对很高。但,这并不现实,没有谁总能有足够的内存和精确地评估重做日志的增长速度。而且即使满足了这两个条件,如果数据库在长时间运行后宕机,重新利用重做日志恢复数据的过程也会非常耗时。
InnoDB 针对缓冲池的数据页刷新回磁盘的时机控制采用 CheckPoint 机制。就是在一些条件下触发回写磁盘操作,同时记录当前数据库的数据版本。缓冲池不够用,重写日志磁盘空间紧张都会强制执行同步磁盘。
刚刚提到数据恢复的问题,我们可以通过重写日志同步数据库状态。但有个极端情况:某一页在进行磁盘同步的时候机器宕机,物理数据页损坏,内存数据页数据丢失,无法恢复。为了解决这问题, InnoDB 采用了 doublewrite 技术,其实还是 Write Ahead Log 策略,在数据页写入磁盘前,先将内存中准备操作的数据页(128 个共 2M) memcpy 到内存中的 doublewirte buffer 中,接着立刻将这 2M fsync 到磁盘(顺序写,速度快),然后再把 doublewirter buffer 中的各个页写入到磁盘中(离散写,速度慢)。有了这个机制,我们就可以从磁盘找到损坏页的副本恢复数据页,保障了数据的安全性。
以上针对 InnoDB 的几个主要技术特性做了简要的描述,同样重要但没有涉及到的还有:后台线程,表的组织和存放,数据的备份和复制,不同应用场景下的硬件需求等。
深入学习需要好书和实践,我推荐《高性能 MySQL 》《 MySQL 技术内幕 InnoDB 存储引擎》《索引的设计和优化原理》。
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.