MySQL / InnoDB 存储引擎系列 · 第五篇

两个连接同时读写同一行,读的那个为什么不用等?

InnoDB 的默认隔离级别是 REPEATABLE READ。一个事务开始之后,不管期间有多少别的事务提交了多少次修改,它反复读同一行,看到的值始终不变——而且全程不需要给这一行加读锁,不会挡住别人写。这篇讲清楚这是怎么做到的:undo log 版本链 + 一致性视图(ReadView)

同样先用真实实验验证结论,再看源码里具体怎么实现。

100 → 100 → 200
同一事务两次读 vs 提交后新事务读,真实测出的三个值
3.2s 超时
向一个"空"区间插入数据,真实被 gap lock 挡住的时长

一行数据,其实是一条版本链

UPDATE 一行的时候,InnoDB 不会就地覆盖丢掉旧值——旧版本被写进 undo log,新版本留在原地,两者用指针串成一条链。

trx_id

单调递增的事务编号

每个事务开始时分配一个递增的 id。版本链上的每个版本都记着"是哪个 trx_id 创建的我"——这是判断"这个版本对你可不可见"的关键。

undo log

旧版本去哪了

UPDATE 时旧值被搬进 undo log,和新值用指针连起来,形成一条从新到旧的版本链。这条链既用来回滚,也用来给老的读操作提供"过去的值"。

ReadView

一致性视图

事务第一次做一致性读时,给自己拍一张"此刻谁还没提交"的快照。之后沿着版本链找,第一个"对这张快照可见"的版本,就是这次事务该看到的值。

真实实验:同一事务里,两次读到不同"世界"的结果一样

开两个真实连接:A 开事务读一次;B 改完提交;A 在同一个事务里再读一次;最后 A 提交,开一个新事务再读。

真实实测 -- 会话 A(通过命名管道保持连接不断开) START TRANSACTION; SELECT balance FROM accounts WHERE id=1; → 100 -- 会话 B(独立连接,自动提交) UPDATE accounts SET balance=200 WHERE id=1; → 提交成功,表里现在是 200 -- 回到会话 A,还是同一个事务,再读一次 SELECT balance FROM accounts WHERE id=1; → 100 ← 没变!尽管 B 已经提交了 200 -- 会话 A 提交,开一个全新事务再读 COMMIT; START TRANSACTION; SELECT balance FROM accounts WHERE id=1; → 200 ← 现在才看到 B 的修改

整个过程 A 从来没有对这一行加过任何锁,B 的 UPDATE 也完全没有被 A 挡住——这正是 MVCC 的意义:读不阻塞写,写也不阻塞读,靠的是"读到旧版本"而不是"等别人让路"。

ReadView 的真实判定算法

"这个版本对我可见吗"这句话,在源码里是一个只有几行的函数。

storage/innobase/include/read0types.h · mysql-server @ mysql-9.7.1, L163 bool changes_visible(trx_id_t id) const { if (id < m_up_limit_id || id == m_creator_trx_id) { return true; // 在开视图之前就已经存在,或者就是我自己改的 } if (id >= m_low_limit_id) { return false; // 在开视图之后才出现的事务,不可见 } else if (m_ids.empty()) { return true; // 开视图那一刻没有别的事务在跑 } // 夹在中间:开视图那一刻这个事务是不是"活跃但还没提交"? return !binary_search(m_ids, id); }

三个边界量,含义都很直白:m_up_limit_id 是开视图那一刻"最老还在跑的事务 id"(比它还小的一定早就提交了);m_low_limit_id 是"下一个要分配的事务 id"(大于等于它的,一定是视图之后才诞生的);m_ids 是开视图那一刻正在跑、还没提交的事务 id 集合——落在中间地带的版本,只有当它的创建者不在这个集合里(意味着早就提交了)才可见。

演示:沿着版本链,找第一个看得见的版本

用上面真实实验的结构复现:trx 100 创建行(值 100)并提交;trx 200 开视图读一次;trx 300 改成 200 并提交;trx 200 用同一张视图再读一次;最后 trx 400 开一张新视图读。

版本链 + ReadView 可见性判定

另一半故事:范围查询怎么防"幻读"

MVCC 解决的是"读到旧快照",但如果是 SELECT ... FOR UPDATE 这种要加锁的读,或者 UPDATE/DELETE 命中一个范围,还需要防止别人在这个范围里"插队"插入新行——这靠的是另一套机制:next-key lock,记录锁 + 间隙锁(gap lock)的组合。

storage/innobase/include/lock0lock.h · mysql-server @ mysql-9.7.1, L964-971 /* this flag denotes an ordinary next-key lock in contrast to LOCK_GAP or LOCK_REC_NOT_GAP */ constexpr uint32_t LOCK_ORDINARY = 0; /* when this bit is set, it means that the lock holds only on the gap before the record; ... locks of this type are created when records are removed from the index chain of records */ constexpr uint32_t LOCK_GAP = 512;

真实验证:让会话 A 对一个空区间(表里根本没有这个范围的行)加锁,看会话 B 能不能往这个区间插入新数据。

真实实测 -- 会话 A:锁一个空区间(id 在 10~20 之间,表里压根没有这些 id) START TRANSACTION; SELECT * FROM accounts WHERE id BETWEEN 10 AND 20 FOR UPDATE; → 0 rows,但间隙锁已经加上了 -- 确认 A 的事务真的在跑 SELECT trx_id, trx_state FROM information_schema.INNODB_TRX; → trx_id=121910 trx_state=RUNNING -- 会话 B:往这个"空"区间插入一行(id=15),自己设 3 秒超时 SET innodb_lock_wait_timeout=3; INSERT INTO accounts VALUES (15, 999); → ERROR 1205: Lock wait timeout exceeded(等了 3.24 秒) -- 会话 A 提交,释放间隙锁,B 立刻可以插入了 COMMIT; -- (会话 A) INSERT INTO accounts VALUES (15, 999); → 插入成功
表里根本没有 id=15 这一行,B 却依然被挡了 3 秒多——这就是"间隙锁"名字的由来:锁的不是某一条已存在的记录,而是两条记录之间的空隙本身。如果没有这道机制,A 的事务里如果两次执行同一个范围查询,第二次可能会因为 B 在中间插入了新行而多出几条"凭空出现"的记录——这就是幻读,gap lock 专门堵这个口子。

参考与说明

  • 本文两组真实实验(MVCC 快照读、间隙锁阻塞插入)均在本机真实运行的 MySQL 9.7.1 上完成:会话 A 通过命名管道(FIFO)保持一个长连接不断开,期间穿插会话 B 的独立一次性连接,所有输出为真实终端记录。
  • 源码引用(read0types.hReadView::changes_visiblelock0lock.hLOCK_GAP/LOCK_ORDINARY)取自 mysql/mysql-server 仓库 mysql-9.7.1 标签(commit a26ea1a2),与本机安装的 MySQL 9.7.1 完全一致;引用中的 C++ 语法做了轻微简化(去掉了命名空间前缀等无关细节),逻辑与原文一致。
  • 演示动画里的 changes_visible 实现是源码逻辑的直接 Python 移植,额外用 200 组随机构造的场景(每组 12 个事务 id、随机的提交状态)与一个独立的暴力对照算法逐条核对过,而不只是跑通了这一个精心挑选的例子。
  • 没有涉及:purge 线程如何回收不再被任何 ReadView 引用的旧版本、long-running 事务导致 undo log 膨胀的实际影响、READ COMMITTED 下每条语句都重新开视图与 REPEATABLE READ 整个事务共用一张视图的具体差异——这些是更深的话题。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电