InnoDB 工作原理
上一篇我们介绍了数据库的发展历史以及 NewSQL 的设计思想。为了在做数据库选型和表结构设计时有更清晰的判断,本篇先回到最常见的 MySQL InnoDB,看看它是如何通过索引、缓存、日志、锁和 MVCC 支撑事务语义的。
理解 InnoDB 并不是为了背实现细节,而是为了在建模时知道哪些设计会放大成本。比如主键是否有序,会影响聚簇索引的写入;索引是否覆盖查询,会影响回表次数;事务范围是否过大,会影响锁等待、undo 清理和主从延迟。
InnoDB 解决什么问题
MySQL 是 Server 层加存储引擎的架构。SQL 解析、优化器、权限、连接管理、binlog 等能力属于 MySQL Server 层;数据如何存储、如何加锁、如何恢复事务,主要由存储引擎负责。InnoDB 是 MySQL 最常用的事务型存储引擎,它主要提供以下能力:
- 事务支持:提供 ACID 语义,支持提交、回滚和崩溃恢复。
- 行级锁:相比表级锁,能在 OLTP 场景下提供更高的并发能力。
- MVCC:通过多版本并发控制,让读写冲突尽量减少。
- 聚簇索引:数据行按照主键组织,主键就是数据的物理访问入口。
- 崩溃恢复:通过 redo log、undo log、checkpoint 等机制保证数据可恢复。
这些能力合在一起,支撑了我们平时习以为常的能力:执行一条 UPDATE,数据库既要让并发事务看到正确的数据版本,又要保证机器宕机后数据不会变成半提交状态。
存储结构
InnoDB 的数据不是简单地一行一行平铺在文件里,而是按照页来组织。默认情况下,一个页大小为 16KB,页是磁盘和内存之间交换数据的基本单位。
从逻辑上看,InnoDB 的存储层级可以理解为:
- 表空间:存放表和索引数据的逻辑空间。
- 段:表、索引、回滚段等不同用途的数据区域。
- 区:由连续的数据页组成,默认一个区为 1MB。
- 页:InnoDB 读写磁盘的基本单位,默认 16KB。
- 行:真正的业务数据记录。
这套结构意味着数据库不是“只读一行”,而是把包含这一行的数据页加载到 Buffer Pool 中。一次随机查询如果无法命中缓存,就需要先读取整页;一次范围查询如果数据在 B+Tree 上相邻,就可以顺着页链表连续读取,成本会低很多。
聚簇索引与二级索引
InnoDB 使用 B+Tree 组织索引。B+Tree 的特点是所有数据都在叶子节点,非叶子节点只保存搜索路径,因此非常适合磁盘页结构:树高较低,单次查询通常只需要少量随机 IO。
InnoDB 的特殊之处在于它采用聚簇索引。表数据本身就是按照主键构建的一棵 B+Tree,叶子节点存放完整的数据行。所以:
- 通过主键查询,可以直接在聚簇索引中找到完整记录。
- 二级索引的叶子节点不存完整数据行,而是存二级索引键和主键值。
- 通过二级索引查询非索引列时,需要先找到主键,再回到聚簇索引查询完整数据,这个过程就是回表。
因此主键设计会直接影响 InnoDB 的数据组织方式。业务上常见的几个原则是:
- 主键尽量短,二级索引都会携带主键,主键过大会放大所有二级索引空间。
- 主键尽量稳定,主键变更会导致数据在聚簇索引中移动。
- 主键尽量有序,完全随机的主键会增加页分裂和写入放大。
- 不要把业务含义过强的字段作为物理主键,业务规则变化会牵连底层存储。
这也是很多系统会使用 BIGINT 类型的代理主键,再通过唯一索引约束业务唯一性的原因。
Buffer Pool
Buffer Pool 是 InnoDB 最重要的内存结构。数据页、索引页、undo 页等都会被缓存到 Buffer Pool 中,尽量把磁盘随机 IO 转换为内存访问。
当查询读取某个页时,InnoDB 会先查 Buffer Pool:
- 如果页已经在内存中,直接读取。
- 如果页不在内存中,从磁盘加载到 Buffer Pool。
- 如果 Buffer Pool 空间不足,根据淘汰策略选择部分页刷出或淘汰。
当事务修改数据时,InnoDB 通常不会立即把数据页写回磁盘,而是先修改内存中的页,把它标记为脏页,再由后台线程异步刷盘。这样可以把多次随机写合并成更可控的批量写,但也带来了一个问题:如果脏页还没刷盘机器就宕机了,如何恢复?
答案就是日志。
Redo Log 与 Undo Log
InnoDB 的事务恢复主要依赖两类日志:redo log 和 undo log。
redo log 记录的是“对数据页做了什么修改”,用于保证事务的持久性。事务提交时,只要 redo log 已经安全落盘,即使对应的数据页还没刷盘,数据库崩溃后也可以通过 redo log 重放修改。
undo log 记录的是“修改前的数据版本”,主要用于两个场景:
- 事务回滚时,根据 undo log 撤销已经执行的修改。
- MVCC 快照读时,根据 undo log 还原出旧版本数据。
可以把 redo log 理解为“向前恢复”,把 undo log 理解为“向后回滚”。它们共同支撑了事务的原子性、持久性和一致读。
Binlog 与两阶段提交
除了 InnoDB 自己的 redo log,MySQL Server 层还有 binlog。binlog 记录逻辑变更,主要用于主从复制、数据恢复和审计。
这就带来一个一致性问题:一次事务提交既要写 InnoDB redo log,又要写 MySQL binlog。如果其中一个成功另一个失败,主库和从库、存储引擎和 Server 层看到的状态就可能不一致。
MySQL 通过两阶段提交协调 redo log 和 binlog:
- InnoDB 写 redo log prepare。
- Server 层写 binlog。
- InnoDB 写 redo log commit。
崩溃恢复时,MySQL 可以根据 redo log 和 binlog 的状态判断事务应该提交还是回滚。对于业务开发来说,这解释了为什么事务提交不是一次简单写盘,而是跨存储引擎日志和复制日志的协同过程。
MVCC 与一致读
MVCC 是 InnoDB 支持高并发读写的关键。它的目标是让读操作尽量不阻塞写操作,写操作也尽量不阻塞普通快照读。
InnoDB 每行记录中会维护一些隐藏字段,比如创建或修改该行的事务 ID,以及指向 undo log 的回滚指针。当事务执行一致性读时,InnoDB 会生成一个 Read View,用来判断哪些事务版本对当前事务可见。
如果当前记录版本不可见,InnoDB 就沿着 undo log 构造历史版本,直到找到一个对当前事务可见的版本。这样,一个事务即使正在修改某行数据,其他事务也可以读取到它们应该看到的旧版本。
不同隔离级别下,Read View 的生成时机不同:
- 读已提交:每次一致性读都会生成新的 Read View,因此同一事务内两次查询可能看到不同结果。
- 可重复读:事务第一次一致性读时生成 Read View,后续复用,因此同一事务内普通查询结果相对稳定。
MySQL InnoDB 默认使用可重复读隔离级别,并通过 next-key lock 等机制缓解幻读问题。但这并不意味着所有查询都不会遇到并发异常,尤其是范围更新、唯一约束冲突、当前读等场景仍然需要谨慎设计。
锁模型
InnoDB 的锁不是只有“行锁”一种。常见锁包括:
- 记录锁:锁定某条索引记录。
- 间隙锁:锁定索引记录之间的间隙,防止其他事务插入新记录。
- next-key lock:记录锁加间隙锁,用于范围查询和范围更新。
- 插入意向锁:多个事务插入同一间隙内不同位置时,用来减少无谓冲突。
- 意向锁:表级锁,用来表示事务即将对表中某些行加共享锁或排他锁。
InnoDB 的行锁是加在索引上的。如果查询条件没有命中合适索引,数据库可能扫描大量记录并加锁,甚至表现得像锁住了整张表。因此很多线上“锁等待”问题,根源并不是事务逻辑复杂,而是缺少正确的索引。
写入路径
一次典型的更新大致可以简化为:
- 根据索引找到目标记录所在的数据页。
- 如果数据页不在 Buffer Pool 中,先从磁盘加载。
- 生成 undo log,用于回滚和 MVCC。
- 修改 Buffer Pool 中的数据页,并将其标记为脏页。
- 写入 redo log,保证崩溃后可以恢复。
- 提交事务时协调 redo log 和 binlog。
- 后台线程在合适时机将脏页刷回磁盘。
从这个路径可以看到,写入性能不只取决于“写一行数据”本身,还取决于索引数量、页分裂、日志刷盘、事务大小、主从复制和脏页刷新等因素。
对建模的启发
理解 InnoDB 后,很多建模规范就不再是教条:
- 主键短而稳定,是为了降低聚簇索引和二级索引的空间成本。
- 尽量让主键有序,是为了减少随机写和页分裂。
- 高频查询需要合适的联合索引,是为了减少扫描和锁范围。
- 避免大事务,是为了减少 undo log、锁等待和复制延迟。
- 避免过多索引,是因为每个索引都会增加写入和维护成本。
- 谨慎使用范围更新,是因为它可能触发 next-key lock,扩大锁冲突。
- 字段类型越精确越好,是因为类型大小会影响行大小、索引大小和缓存命中率。
数据库建模不是把对象属性机械地映射成表字段,而是要把业务访问模式翻译成存储引擎能够高效执行的结构。InnoDB 给了我们可靠的事务能力,但可靠性背后始终有成本,建模的职责就是让这份成本落在真正需要的地方。