InnoDB 的默认隔离级别是 REPEATABLE READ。一个事务开始之后,不管期间有多少别的事务提交了多少次修改,它反复读同一行,看到的值始终不变——而且全程不需要给这一行加读锁,不会挡住别人写。这篇讲清楚这是怎么做到的:undo log 版本链 + 一致性视图(ReadView)。
同样先用真实实验验证结论,再看源码里具体怎么实现。
UPDATE 一行的时候,InnoDB 不会就地覆盖丢掉旧值——旧版本被写进 undo log,新版本留在原地,两者用指针串成一条链。
每个事务开始时分配一个递增的 id。版本链上的每个版本都记着"是哪个 trx_id 创建的我"——这是判断"这个版本对你可不可见"的关键。
UPDATE 时旧值被搬进 undo log,和新值用指针连起来,形成一条从新到旧的版本链。这条链既用来回滚,也用来给老的读操作提供"过去的值"。
事务第一次做一致性读时,给自己拍一张"此刻谁还没提交"的快照。之后沿着版本链找,第一个"对这张快照可见"的版本,就是这次事务该看到的值。
开两个真实连接:A 开事务读一次;B 改完提交;A 在同一个事务里再读一次;最后 A 提交,开一个新事务再读。
整个过程 A 从来没有对这一行加过任何锁,B 的 UPDATE 也完全没有被 A 挡住——这正是 MVCC 的意义:读不阻塞写,写也不阻塞读,靠的是"读到旧版本"而不是"等别人让路"。
"这个版本对我可见吗"这句话,在源码里是一个只有几行的函数。
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 开一张新视图读。
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 能不能往这个区间插入新数据。