声明式事务之下:InnoDB 的 MVCC 与锁

📅
2 分钟阅读
·

系列目录

  1. MySQL 索引与慢查询:B+ 树如何减少扫描
  2. 声明式事务之下:InnoDB 的 MVCC 与锁(本篇)

回滚后读到中间值的原因

2016 年写一个涉及状态流转的接口时,一个事务把某条记录的状态从 A 改成 B,随后因异常回滚。紧接着我用数据库客户端查询,看到状态还是 B,怀疑回滚没有生效。重启服务后再查询几次,数据又变回 A。当时我无法解释这个现象,于是把查询移到事务提交后执行。

之后补充原理时我发现,看到的「中间值」不可能来自那条回滚的更新。回滚的更新从未提交,RC 和 RR 都读不到未提交的值。更可能是另一条已经提交的更新把状态改成了 B,之后又被一次修正改回 A;我混淆了事件的先后顺序。当时数据库客户端和业务事务使用两个连接,我把两个连接在两个时间点读到的状态当成同一个事务的行为。

问题的根源是把不同连接的隔离行为混为一谈。需要区分的是:同一个事务多次读取同一行时,结果是否变化,以及变化的原因。这取决于隔离级别,具体由 MVCC 和锁的行为实现。

数据库连接池与声明式事务)讲了 @Transactional 的代理机制与使用规范,关注的是「一条连接上多条 SQL 如何原子提交」。本篇讲存储引擎层:InnoDB 如何在不加读锁的情况下让读和写并发,又如何用锁保证写的正确性。

MVCC:用版本链让读不阻塞写

InnoDB 通过 MVCC(多版本并发控制)让读操作不必加锁等待写操作提交。每行除了用户列,还有两个隐藏列:DB_TRX_ID 记录最后修改该行的事务 ID,DB_ROLL_PTR 指向 undo log 中该行的上一个版本。每次更新一行时,旧值被写入 undo log,新行的 DB_ROLL_PTR 指向它;undo log 里各版本再通过各自的 DB_ROLL_PTR 串成一条版本链。

读一行时,InnoDB 不直接返回最新版本,而是生成一个 ReadView,沿版本链找一个对该 ReadView 可见的版本。ReadView 的核心是生成时刻的活跃事务 ID 列表 m_ids,以及由它推出的下限 min_trx_idm_ids 中最小值)和上限 max_trx_id(生成时系统将要分配的下一个事务 ID)。可见性判断沿版本链逐个版本进行:

  • 版本的 trx_id < min_trx_id:修改它的事务在 ReadView 生成前已提交,该版本可见。
  • 版本的 trx_id >= max_trx_id:修改它的事务在 ReadView 生成后才开始,不可见,沿 DB_ROLL_PTR 找上一个版本。
  • min_trx_id <= trx_id < max_trx_id:若 trx_idm_ids 中,说明该事务生成 ReadView 时还活跃,不可见;否则已提交,可见。

这三条针对其他事务的版本;生成 ReadView 的事务对自己未提交的修改始终可见,事务内先改后读同一行读到的就是自己的新值。

举一个具体的读取过程。假设当前行的最新版本 trx_id=200,沿 DB_ROLL_PTR 找到 v2(trx_id=150),再找到 v1(trx_id=100)。某事务生成 ReadView 时,事务 200 尚未提交,活跃事务列表 m_ids={120, 200}min_trx_id=120max_trx_id=220。从最新版本开始判断:trx_id=200 落在 [min, max) 区间内且在 m_ids 中,说明该事务仍活跃,不可见,沿链到 v2;trx_id=150 落在区间内但不在 m_ids,说明已提交,可见,返回 v2。若 ReadView 生成时 200 已提交(m_ids 不含 200),则最新版本直接可见,不必沿链查找。

MVCC 让读操作读取历史版本,写操作更新当前版本,两者可以并发进行。读不必等待写锁释放,写也不必等待读完成。undo log 中的旧版本不能立即清理:只要仍有活跃的 ReadView 可能依赖某个版本,该版本就要保留,直到没有 ReadView 再需要它时才由 purge 线程回收。

快照读与当前读。 普通 SELECT 是快照读,走 ReadView 选可见版本,不加锁。SELECT ... FOR UPDATESELECT ... LOCK IN SHARE MODEUPDATEDELETEINSERT 是当前读(INSERT 要读唯一索引最新版本做唯一性检查),读最新已提交版本并加锁。当前读必须读最新值:UPDATE ... WHERE id = ? 如果读不到别人刚提交的修改,就会基于过期数据判断 WHERE 是否命中,写之间无法正确冲突。这也是为什么 UPDATEDELETE 即使在 RR 下也不复用快照:它们必须看到最新已提交状态才能正确加锁和修改。

RR 与 RC 的 ReadView 生成时机。 两种隔离级别通过 ReadView 的生成时机区分。RC(读已提交)每条快照读都生成新的 ReadView,因此一个事务内两次读取之间,其他事务提交的修改可以被看到。RR(可重复读)在事务的第一条快照读时生成 ReadView,之后在整个事务内复用,多次读取同一行的结果一致。

版本链与 RR/RC 的 ReadView 生成时机

RC 中,事务内后一次快照读可以看到其他事务新提交的修改,因此可能发生不可重复读。RR 复用同一个 ReadView,使事务内的快照读保持一致,但可能读到较旧的版本。RR 的「可重复读」只对快照读成立,当前读仍读最新值,并配合间隙锁防止幻读。

锁的粒度:记录、间隙与意向

当前读和写操作靠锁保证正确性。InnoDB 的行锁作用于索引记录,数据行所在的物理页本身不被锁定。

记录锁(Record Lock)。 锁定单个索引记录。UPDATE ... WHERE id = 5 在 id=5 的索引记录上加记录锁,其他事务修改这条记录要等待。

间隙锁(Gap Lock)。 锁定两个索引记录之间的间隙,防止其他事务向间隙插入新记录。间隙锁只在 RR 下存在,用于防止幻读。范围条件 WHERE id BETWEEN 10 AND 20 的当前读,除了锁住命中的记录,还要锁住 10 到 20 之间可能被插入新记录的间隙;否则其他事务插入一条 id=15 后,同一事务内再次范围读会多出一行(幻读)。RC 下没有间隙锁,范围读只锁命中记录,因此允许并发插入,也可能出现幻读。

临键锁(Next-Key Lock)。 记录锁加上该记录前面的间隙锁,是 RR 下默认的行锁形式,区间前开后闭。假设索引上有 id=10、20、30 三条记录,对 WHERE id BETWEEN 10 AND 20 的当前读,除了锁住命中记录和 (10,20) 的间隙,范围扫描要读到第一条不满足条件的记录(id=30)才停止,id=30 的临键锁 (20,30] 也会被加上。效果是 id=15 甚至 id=25 这样的新插入都会被阻塞。锁的范围比命中行大,是 RR 防幻读的直接代价。

意向锁(IS/IX)。 表级锁。事务要给某行加行锁前,先给表加对应的意向锁。需要给整张表加表锁的事务(如 LOCK TABLES)可以据此快速判断表上是否有行锁,不必逐行扫描。意向锁之间不冲突,只和表级锁冲突。意向锁本身不阻塞行操作,只用于快速判断。

用最小可复现示例排查死锁

那次死锁的具体表结构和 SQL 我已经记不清了,下面用一个可复现的最小死锁示例说明排查过程。两个事务对同一张表的两行以相反顺序更新:

-- 事务 A
BEGIN;
UPDATE account SET balance = balance - 10 WHERE id = 1;  -- 持有 id=1 的记录锁
UPDATE account SET balance = balance + 10 WHERE id = 2;  -- 等待 id=2 的记录锁

-- 事务 B(几乎同时)
BEGIN;
UPDATE account SET balance = balance - 10 WHERE id = 2;  -- 持有 id=2 的记录锁
UPDATE account SET balance = balance + 10 WHERE id = 1;  -- 等待 id=1 的记录锁 → 死锁

A 持有 id=1 等 id=2,B 持有 id=2 等 id=1,形成等待循环。InnoDB 的死锁检测发现循环后,回滚代价较小的一方,另一个事务收到 ERROR 1213 (40001): Deadlock found

SHOW ENGINE INNODB STATUS 输出的 LATEST DETECTED DEADLOCK 段会打印两个事务各自持有和等待的锁,包括锁所在的表、索引和具体记录。事务 A 的记录会写明它正在等待 id=2 的锁,而事务 B 持有 id=2、等待 id=1,据此可以确定等待循环。根据加锁顺序,把所有事务的更新统一按 id 升序执行(先 id=1 后 id=2)后,两个事务不会反向等待,也不会发生这里的死锁。

死锁发生在两个事务以不同顺序竞争同一批资源时。排查时可从 SHOW ENGINE INNODB STATUS 还原两个事务的加锁顺序,找到顺序相反的操作。统一加锁顺序可以减少死锁;跨多表、多条件的更新很难完全统一顺序,应用层仍需要处理死锁回滚(重试或报错)。

使用限制

  1. 长事务会延迟旧版本清理。 ReadView 存在期间,它可能依赖的所有旧版本都不能被 purge(清理),undo log 版本链持续增长。其他事务读取旧行时要沿链查找,可能降低性能;undo 表空间也会增长。事务中应避免包含远程调用、长时间计算或等待外部结果,这些操作会拉长事务,让版本链保留更久。
  2. RR 下的间隙锁会扩大锁范围。 范围更新或当前读会加间隙锁,锁住比命中行更大的范围,影响并发插入。高并发写入的表上,间隙锁可能成为瓶颈,需要结合 EXPLAIN 和锁等待监控判断。
  3. RC 不使用间隙锁。 这有利于并发写入,但事务内不保证可重复读,也不防止幻读。读多写少、对一致性要求高的场景通常选择 RR;写多读少、能接受不可重复读的场景通常选择 RC。隔离级别应按具体表和查询选择;同一库内不同会话可以通过会话级设置使用不同隔离级别。
  4. 并发更新仍可能发生死锁。 InnoDB 自动检测并回滚一方,应用层要处理回滚(重试或报错)。统一加锁顺序能减少死锁,但无法完全消除,仍需要重试机制。
  5. 快照读不会读取未提交的修改。 RC 和 RR 的快照读都不能读取其他事务未提交的修改。如果读到「中间值」,先确认它是否来自一条已提交的其他更新,而不是怀疑某次回滚失效。

认知边界

我当时只理解 redo log 和 undo log 的崩溃恢复概念:redo log 用于崩溃后重做已提交事务,undo log 用于回滚未提交事务,写入顺序遵循 WAL(write-ahead logging)。我没有深入阅读 checkpoint 的触发机制、崩溃后 redo 与 undo 的协作流程,以及 purge 线程清理版本链的具体时机。上面的 MVCC 与锁的描述基于《MySQL 技术内幕:InnoDB 存储引擎》和 MySQL 5.6/5.7 官方文档的理解,没有逐行验证实现细节。

参考资料

  • MySQL 5.6/5.7 官方文档(InnoDB 一致性非锁读、锁机制、事务隔离级别章节)
  • 《MySQL 技术内幕:InnoDB 存储引擎》(姜承尧,2016 年前已出版)
  • 数据库连接池与声明式事务(2016 系列第 3 篇,事务使用层,与本篇分工)
  • 《MySQL 索引与慢查询:B+ 树如何减少扫描》

527 字 · 45 段落
ximing

Follow onGitHub

相关文章