适用读者:能背出「RC 读最新、RR 读快照」但说不清快照存在哪里的人;被长事务拖慢 purge、导致磁盘暴涨的人;想知道 SELECT ... FOR UPDATE 为什么看到的值和普通 SELECT 不一样的人
结论先行:InnoDB 的 MVCC 不是「给每行存多个副本」,而是一行只有一份最新记录,旧值挂在 undo log 里,用 DB_ROLL_PTR 串成一条版本链。读的时候拿一个 Read View,沿着链往回走,找到第一个对当前事务可见的版本。RC 和 RR 的唯一差别,是 Read View 的创建频率:每条语句一个,还是每个事务一个。
阅读时间:约 18 分钟 | 难度:进阶 | 收获:隐藏列与 undo log 的物理结构、Read View 的四个字段与完整可见性规则、RC/RR 的真实差异、快照读与当前读的边界、以及长事务为什么危险
目录
- 前言:同一行,两个事务看到不同的值
- 隐藏列:版本链的骨架
- undo log:旧版本到底存在哪里
- 版本链怎么串起来
- Read View 的四个字段
- 可见性判断的完整规则
- RC 与 RR 的唯一差别
- 快照读与当前读
- 一条 UPDATE 之后发生了什么
- DELETE 的标记与 purge
- 长事务为什么危险
- 怎么观测版本链与历史链表
- 五个常见误区
- 小结
前言:同一行,两个事务看到不同的值
先摆一张只有两列的表:
CREATE TABLE account (
id BIGINT PRIMARY KEY,
balance INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO account VALUES (1, 100);
会话 A 和会话 B 同时开事务,隔离级别是 RR:
-- 会话 A
BEGIN;
SELECT balance FROM account WHERE id = 1; -- 100
-- 会话 B
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;
-- 会话 A 再读一次
SELECT balance FROM account WHERE id = 1; -- 还是 100
A 明明在 B 提交之后才读,看到的却还是 100。很多人把这件事解释成「A 读的是快照」,但快照到底是什么、存在哪里、什么时候生成,往往说不清楚。
答案由三部分组成:隐藏列、undo log、Read View。
隐藏列:版本链的骨架
InnoDB 的聚簇索引叶子记录里,除了业务列,还有几个隐藏字段:
DB_TRX_ID 是「谁改的」,
DB_ROLL_PTR 是「上一个版本在哪」。这两个字段合起来,就是版本链的全部元数据。
注意
DB_TRX_ID 只记录
最后一次修改的事务,不记录历史。历史版本不在聚簇索引里,而在 undo log 里。
undo log:旧版本到底存在哪里
每次
UPDATE 或
DELETE,InnoDB 都会写一条 undo 记录。undo 记录里保存的是「被修改前的旧值」,以及指向上一个 undo 记录的指针。
undo 记录分两类:
- insert undo:
INSERT 产生,只用于回滚,提交后可以很快丢弃;
- update undo:
UPDATE / DELETE 产生,既用于回滚,也用于 MVCC 读旧版本,必须保留到没有事务再需要它为止。
一条 update undo 记录大致包含:
- 旧记录的主键;
- 被更新列的旧值;
- 指向上一条 undo 记录的
DB_ROLL_PTR;
- 事务 ID、undo 类型等头部信息。
于是同一个主键的所有历史版本,通过
DB_ROLL_PTR 串成一条单向链表,最新的在聚簇索引里,最老的在 undo log 链尾。
版本链怎么串起来
假设
id = 1 这行被三个事务依次修改:
trx 10: balance = 100 (初始插入)
trx 20: balance = 150
trx 30: balance = 200
聚簇索引里的当前记录是
(id=1, balance=200, DB_TRX_ID=30, DB_ROLL_PTR=ptr30)。沿着
ptr30 找到 undo 记录:
undo 30: 旧值 balance=150, 上一个版本 ptr20
undo 20: 旧值 balance=100, 上一个版本 ptr10
undo 10: 插入 undo,没有更早版本
读的时候,如果当前事务的 Read View 认为
trx 30 不可见,就沿链走到
trx 20 的版本;如果还不可见,再走到
trx 10。直到找到一个可见版本,或者链走到头(说明这行在视图创建时还不存在)。
这条链就是「版本链」。它不在数据页上,而是在数据页 + undo 页之间来回跳。
Read View 的四个字段
Read View 是事务在某个时刻拍下的一张「活跃事务名单」。InnoDB 的
ReadView 类(
read0types.h)核心字段有四个:
m_ids 是判断「这行是不是被一个还没提交的事务改过」的关键。
m_up_limit_id 和
m_low_limit_id 则给出两个边界:小于
m_up_limit_id 的事务一定已经结束,大于等于
m_low_limit_id 的事务一定是在视图之后才开始的。
可见性判断的完整规则
对一条记录的
DB_TRX_ID,判断顺序如下:
- 如果
DB_TRX_ID == m_creator_trx_id,可见:自己改的行,自己当然看得到;
- 如果
DB_TRX_ID < m_up_limit_id,可见:这个事务比所有活跃事务都早,说明它已经提交;
- 如果
DB_TRX_ID >= m_low_limit_id,不可见:这个事务在视图创建之后才开始;
- 如果
DB_TRX_ID 在 m_ids 里,不可见:它创建视图时还活着;
- 其余情况可见。
这套规则的直觉版本是:
视图创建时已经提交的,看得见;视图创建时还没提交的,看不见;视图创建之后才开始的,也看不见。
如果当前版本不可见,就沿
DB_ROLL_PTR 找上一个版本,重复判断,直到找到可见版本或链结束。
RC 与 RR 的唯一差别
很多人以为 RC 和 RR 是两套不同的 MVCC 实现,其实它们共用同一套版本链和可见性规则,差别只在 Read View 什么时候创建:
这里有一个高频误区:RR 的视图不是
BEGIN 时创建的,而是
第一次真正读数据时创建的。如果
BEGIN 之后先做了一堆写操作,再读,视图是在读的那一刻才拍下的。
如果想让视图在事务开始时就创建,用:
START TRANSACTION WITH CONSISTENT SNAPSHOT;
这个语法对 RR 有意义;在 RC 下,视图仍然是每条语句一个。
查看当前隔离级别:
SELECT @@transaction_isolation;
MySQL 8.0 用
transaction_isolation,5.7 用
tx_isolation。
快照读与当前读
MVCC 只作用于「快照读」。下面这些是快照读:
SELECT ...; -- 普通查询
SELECT ... FROM t WHERE ...; -- 普通查询
下面这些是「当前读」,会读最新版本,并且加锁:
SELECT ... FOR UPDATE;
SELECT ... FOR SHARE; -- 8.0;5.7 写作 LOCK IN SHARE MODE
UPDATE ...;
DELETE ...;
INSERT ...;
当前读不走 Read View,它直接读聚簇索引里的最新记录。如果最新记录被别的事务锁住,就等待;等待超时由
innodb_lock_wait_timeout 控制。
这解释了一个常见现象:同一个事务里,普通
SELECT 看到的是旧值,
SELECT ... FOR UPDATE 看到的是新值。不是数据不一致,是两种读的语义不同。
一条 UPDATE 之后发生了什么
以
UPDATE account SET balance = 200 WHERE id = 1 为例:
- 当前读定位到
id = 1 的最新记录,加排他锁;
- 把旧值写进 update undo,形成一条新的 undo 记录;
- 修改聚簇索引里的记录:
balance = 200,DB_TRX_ID 改成当前事务 ID,DB_ROLL_PTR 指向新 undo;
- 如果
balance 上有二级索引,还要同步维护二级索引;
- 事务提交后,undo 记录不会立刻删除,要等 purge 判断「没有更早的视图还需要它」。
第 5 步是长事务问题的根源。一个跑了十分钟的事务,会让它开始之后产生的所有 undo 都不能被清理。
DELETE 的标记与 purge
DELETE 在 InnoDB 里通常不是立刻把记录从页里抹掉,而是给记录打一个「删除标记」(delete mark)。记录还在,版本链还在,只是对新的视图不可见。
真正的物理删除由 purge 线程完成:
- purge 扫描 undo log,找到已经没用的 update undo;
- 如果对应记录已被删除标记,且没有更早的视图需要它,就把记录从页里移除;
- 页里的记录被移除后,可能触发页合并;
- 页变空后,可能被回收进 freelist。
所以「删了数据表却没变小」是正常的:
DELETE 只是标记,空间要等 purge 真正清完才可能回收;而页内碎片、未合并的页,还需要
OPTIMIZE TABLE 这类重建操作才能整理。
长事务为什么危险
长事务的危害不止「锁住行」,还有三笔更隐蔽的账:
- undo 无法清理:历史链表(history list)持续增长,undo 表空间膨胀;
- purge 跟不上:
innodb_max_purge_lag 触发后,新事务可能被延迟,写入吞吐下降;
- 版本链变长:每次读都要沿链走更多节点,CPU 成本上升;
- 复制延迟:大事务在主库产生大量 binlog,从库回放压力集中。
观测长事务:
SELECT trx_id, trx_state, trx_started, trx_isolation_level
FROM information_schema.INNODB_TRX
ORDER BY trx_started;
trx_started 距离现在越久,越可疑。再看历史链表长度:
SHOW ENGINE INNODB STATUS\G
在
TRANSACTIONS 段里找
History list length。这个值持续上涨,基本就是有长事务或 purge 跟不上。
怎么观测版本链与历史链表
除了
INNODB_TRX 和
SHOW ENGINE INNODB STATUS,还有几个入口:
-- 当前事务与锁
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
-- 事务事件
SELECT * FROM performance_schema.events_transactions_current;
performance_schema.data_locks 能看出当前读加了哪些锁;
events_transactions_current 能看出事务状态与隔离级别。
想看页级细节,可以用
innodb_ruby 这类工具导出页结构,观察
DB_TRX_ID、
DB_ROLL_PTR 和
PAGE_GARBAGE。生产环境不要随便跑,测试库或副本上做更安全。
五个常见误区
误区一:「MVCC 会给每行存多个副本」。不存。只有一份最新记录,旧版本在 undo log 里。
误区二:「RR 的视图在
BEGIN 时创建」。不是,在第一次快照读时创建;要提前创建得用
WITH CONSISTENT SNAPSHOT。
误区三:「RR 完全不会幻读」。快照读确实看不到新插入的行,但当前读(
FOR UPDATE、
UPDATE)会看到,并且依靠间隙锁阻止插入。快照读和当前读混用时,仍然可能出现意料之外的结果。
误区四:「
SELECT ... FOR UPDATE 也是快照读」。不是,它是当前读,读最新版本并加锁。
误区五:「事务提交后 undo 立刻释放」。不会,要等 purge 确认没有更早的视图还需要它。长事务会把这段时间拉得很长。
小结
相关阅读