Skip to content

ACID 事务机制

事务是数据库区别于文件系统的核心特性。ACID 四个特性中,原子性、隔离性、持久性由存储引擎(InnoDB)实现,一致性则贯穿所有层面。理解 InnoDB 如何用 undo log、redo log、锁机制和 MVCC 实现这些特性,是进行事务调优和故障排查的基础。

原子性(Atomicity)

原子性要求事务中的操作要么全部成功,要么全部回滚,不存在中间状态。InnoDB 通过 undo log 实现原子性。

undo log 的结构

undo log 记录的是数据的逆向操作。当执行 INSERT 时,undo log 记录 DELETE;执行 DELETE 时,undo log 记录 INSERT;执行 UPDATE 时,undo log 记录旧值。这种设计使得回滚只需要反向应用 undo log 中的记录即可恢复到事务开始前的状态。

undo log 存储在回滚段(rollback segment)中,每个回滚段包含多个 undo 槽位。MySQL 5.6 之前回滚段数量固定为 128 个,5.6 之后支持动态扩展,最多 128 个回滚段,每个回滚段支持 1024 个 undo 槽位,理论上支持 128 × 1024 = 131072 个并发事务。

undo log 的管理由 Purge Thread 负责。事务提交后,undo log 并不立即删除,而是等待可能需要它的其他事务(比如 RC 隔离级别下的长事务)不再需要时,才由 Purge Thread 回收。这就是为什么长事务会导致 undo log 膨胀——它阻塞了历史版本的清理。

undo log 与崩溃恢复

崩溃恢复时,如果发现事务只写入了部分操作就崩溃了(事务状态为 ACTIVE),恢复进程会利用 undo log 将这些操作全部撤销,保证原子性。如果事务已经提交(COMMIT 状态),则不需要回滚,只需要确保已提交的数据通过 redo log 持久化。

隔离性(Isolation)

隔离性解决的是并发事务之间的相互影响问题。SQL 标准定义了四个隔离级别:读未提交(READ UNCOMMITTED)、读已提交(READ COMMITTED)、可重复读(REPEATABLE READ)、串行化(SERIALIZABLE)。InnoDB 默认使用可重复读(RR),但实际实现的 RR 比标准定义的更强——它通过 MVCC 和间隙锁的组合,在 RR 级别下也解决了部分幻读问题。

MVCC 的实现

MVCC(Multi-Version Concurrency Control)允许读写操作并发执行而不互相阻塞。InnoDB 的 MVCC 依赖两个隐藏列和 undo log 版本链实现。

每行数据有两个隐藏列:DB_TRX_ID 记录最后一次修改该行的事务 ID,DB_ROLL_PTR 指向该行的上一个版本(存储在 undo log 中)。每次修改一行数据时,InnoDB 不会覆盖原数据,而是将旧版本写入 undo log,并通过 DB_ROLL_PTR 构成版本链。查询时,通过 Read View 判断版本链中哪个版本对当前事务可见。

Read View 包含四个关键字段:creator_trx_id(创建该 Read View 的事务 ID)、m_ids(创建时刻所有活跃事务的 ID 列表)、min_trx_id(m_ids 中的最小值)、max_trx_id(创建时刻系统应分配给下一个事务的 ID,即 m_ids 中最大值 + 1)。可见性判断规则:如果版本的事务 ID 小于 min_trx_id,说明该版本在 Read View 创建前已提交,可见;如果大于等于 max_trx_id,说明该版本在 Read View 创建后才开始,不可见;如果在 min_trx_id 和 max_trx_id 之间,则检查是否在 m_ids 中,不在则说明已提交,可见。

RC 和 RR 的区别仅在于 Read View 的生成时机。RC 在每次 SELECT 时都生成新的 Read View,因此每次都能读到最新已提交的数据。RR 在第一次 SELECT 时生成 Read View,后续复用,因此整个事务中看到的数据一致。

锁机制

MVCC 解决了读写冲突,但写写冲突仍需要锁来保证。InnoDB 实现了行级锁,具体包括:

记录锁(Record Lock)锁定索引记录本身,防止其他事务修改或删除该记录。间隙锁(Gap Lock)锁定索引记录之间的间隙(开区间),防止其他事务在间隙中插入新记录。临键锁(Next-Key Lock)是记录锁和间隙锁的组合,锁定记录及其前面的间隙,即左开右闭区间。这是 InnoDB 在 RR 级别下的默认加锁方式。

InnoDB 的加锁是针对索引的。如果查询没有命中索引,InnoDB 会扫描聚簇索引并对所有记录加锁,实际上退化为表锁。因此 SQL 中使用索引不仅是性能问题,也是并发问题。

意向锁(Intention Lock)是表级锁,用于快速判断表中是否有行锁存在。IS(意向共享锁)表示事务打算对某行加共享锁,IX(意向排他锁)表示事务打算对某行加排他锁。意向锁之间不冲突,但与表级共享锁(S)和排他锁(X)冲突。意向锁由 InnoDB 自动添加,无需用户干预。

不同隔离级别下的加锁行为

在 RC 级别下,普通 SELECT 不加锁(快照读),加排他锁的 SELECT(SELECT ... FOR UPDATE)只锁住满足条件的行(记录锁),不使用间隙锁,因此允许其他事务在间隙中插入。在 RR 级别下,加锁的 SELECT 使用临键锁,锁定记录和间隙,这解决了幻读问题——其他事务无法在查询范围内插入新记录。

死锁是并发事务的常见问题。InnoDB 通过 wait-for graph(等待图)检测死锁:事务 A 等待事务 B 持有的锁,事务 B 又等待事务 A 持有的锁,形成环路。检测到死锁后,InnoDB 选择回滚 undo log 量最小的事务。工程实践中减少死锁的常见做法是:保证多个事务以相同的顺序访问表和行;保持事务简短,减少锁持有时间;使用 RC 隔离级别减少间隙锁的使用范围。

持久性(Durability)

持久性要求事务提交后,数据不因系统故障而丢失。InnoDB 通过 redo log 和 doublewrite buffer 两层机制保证持久性。

WAL 机制

InnoDB 采用 WAL(Write-Ahead Logging)策略:先写日志,再写数据页。修改数据时,先写入 redo log buffer,再修改缓冲池中的数据页。数据页是随机写,而 redo log 是顺序追加写,性能差距显著(随机 I/O 延迟约 10ms,顺序 I/O 约 0.1ms)。WAL 机制将随机写转换为顺序写,大幅提升了事务吞吐量。

redo log 是固定大小的环形文件,默认两个文件,每个 1GB(可通过 innodb_log_file_size 配置)。写入位置由 write pos 标记,恢复时的检查位置由 checkpoint 标记。write pos 追赶 checkpoint,当两者重合时,redo log 空间耗尽,InnoDB 必须暂停新的事务写入,等待 checkpoint 推进(即等待脏页刷盘),这就是 redo log 满导致性能突然下降的原因。

redo log 的刷盘时机

redo log 从 buffer 刷写到磁盘有三种时机。第一种是事务提交时,innodb_flush_log_at_trx_commit = 1 表示每次提交都刷盘(最安全但最慢),= 2 表示提交时写入操作系统缓存(write)但不保证 fsync(折中方案),= 0 表示每秒刷盘一次(最快但可能丢失 1 秒数据)。MySQL 推荐生产环境设置为 1。

第二种是后台线程每秒刷盘一次。第三种是 redo log buffer 空间使用超过一半时触发刷盘。无论哪种配置,InnoDB 都保证 redo log 至少每秒刷盘一次。

doublewrite buffer

部分写(partial write)是磁盘 I/O 的一种故障模式:一个 16KB 的数据页写入时,操作系统只完成了部分扇区的写入(比如写了 4KB 后断电),导致页面内容不一致。B+ 树的一个页损坏可能导致整个索引不可用。

InnoDB 通过 doublewrite buffer 解决这个问题。doublewrite buffer 分为两部分:内存中的 doublewrite buffer(2MB)和磁盘上的 doublewrite 区域(系统表空间中连续的 128 个页,共 2MB)。刷脏页时,先将页写入内存的 doublewrite buffer,再分两次顺序写入磁盘的 doublewrite 区域(每次 1MB),最后才写入数据文件的目标位置。恢复时,如果数据文件中的页损坏,可以从 doublewrite 区域拷贝完整的页进行修复。

doublewrite 带来的额外开销是每次脏页刷盘都要多写一次,顺序写 2MB 的开销相对可控。如果底层存储支持原子写(如 ZFS、F2FS),可以关闭 doublewrite(innodb_doublewrite = 0)来提升性能。

一致性(Consistency)

一致性是事务的最终目标:事务将数据库从一个一致状态转换到另一个一致状态。与其他三个特性不同,一致性不是由单一机制保证的,而是原子性、隔离性、持久性以及应用层逻辑共同作用的结果。

InnoDB 在多个层面保障数据一致性。Schema 层面通过数据类型约束、NOT NULL、DEFAULT、UNIQUE、PRIMARY KEY、FOREIGN KEY 等约束防止非法数据写入。行级层面通过 MVCC 保证读一致性——事务只看到自己应该看到的数据版本。恢复层面通过 undo log 和 redo log 的配合保证崩溃后数据一致。应用层面,开发者需要在事务中编写正确的业务逻辑,比如转账场景下检查余额、扣款、入账,这三步要么全部成功,要么全部回滚。

工程实践中,一致性问题的排查往往是最困难的,因为它可能是原子性问题(事务没回滚)、隔离性问题(读到了不该读的数据)、持久性问题(数据丢失)或应用逻辑错误的综合表现。建议将一致性检查分散到各个层面:在应用层做业务规则校验,在数据库层用约束兜底,在运维层监控数据完整性。