2601 字
约 8 分钟
14
S11-X11 · 隔离级别与 MVCC:可见性到底怎么算
2024-12-31
2026-11-08

同一个事务里同一条 SELECT 跑两次,结果不一样;或者在一个"读已提交"的库里,别人提交的数据忽然出现在你事务的中间。这类问题的答案通常不在锁上,而在可见性判断:InnoDB 到底把哪个版本的行交给你。MVCC 不是"缓存了旧数据",而是一套按事务号逐版本判断的规则,规则读懂了,隔离级别的所有怪现象都能推导出来。

按"隔离级别 → MVCC 组成 → ReadView 规则 → 快照读与当前读 → RC/RR 差异 → 长事务"拆开。


1. 四种隔离级别要解决的三件事

隔离级别 脏读 不可重复读 幻读 说明
READ UNCOMMITTED 可能 可能 可能 读到未提交版本,基本不用
READ COMMITTED 不会 可能 可能 每次读都看最新已提交快照
REPEATABLE READ 不会 不会 标准允许 InnoDB 默认,且额外避免了大部分幻读
SERIALIZABLE 不会 不会 不会 普通 SELECT 隐式加共享锁,并发骤降
异常 定义 典型表现
脏读 读到别人未提交的修改 对方回滚了,你读到的是从未存在的数据
不可重复读 同一事务内两次读同一行结果不同 第二次看到别人的 UPDATE 已提交
幻读 同一条件下两次读行数不同 两次 COUNT(*) 不一致
SELECT @@transaction_isolation;
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

2. InnoDB 的 RR 与标准定义不一样

标准里的 REPEATABLE READ 允许幻读,MySQL 的 RR 在多数场景下不发生,原因是两条路各堵一次:

读法 幻读的堵法
快照读(普通 SELECT) 新插入的行事务号大于 ReadView 的 max_trx_id,直接判不可见
当前读(FOR UPDATE、UPDATE…) 临键锁锁住区间,其他事务插不进去

所以"MySQL RR 没有幻读"这句话要加两个限定:只对走索引的当前读成立;唯一索引等值命中的当前读甚至只加记录锁,不加间隙锁——这时区间并没有被锁住,只是你查的那一行不存在,插入的位置被"不存在的行"保护不到。边界感就来自这里。


3. MVCC 的三个组成

组成 是什么 关键点
隐藏字段 每行都有 DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID 前者记录最后修改它的事务号,后者指向 undo 中的旧版本
undo log 版本链 一条行的所有历史版本串成链 快照读沿链往回找,直到找到可见版本
ReadView 事务对"此刻哪些事务还活跃"的快照 决定某个版本的 DB_TRX_ID 该不该被看见
SHOW ENGINE INNODB STATUS\G   -- TRANSACTIONS 段可见 History list length
SELECT trx_id, trx_state, trx_started, trx_rows_locked, trx_rows_modified
FROM information_schema.INNODB_TRX\G

DB_TRX_ID 与 DB_ROLL_PTR 的字节数(按 MySQL 8.0.x 文档核对:6 字节与 7 字节)决定了版本链指针的寻址方式,不必记死,但要记住一件事:版本链不是免费的,链越长,快照读回退的次数越多。


4. ReadView 的四个字段与完整判断规则

字段 含义
creator_trx_id 创建这个 ReadView 的事务号
m_ids 创建瞬间仍活跃(未提交)的事务号列表
min_trx_id m_ids 中的最小值
max_trx_id 下一个待分配的事务号(注意不是 m_ids 的最大值)

拿到一行的 DB_TRX_ID,按顺序判断:

顺序 条件 结论
1 trx_id == creator_trx_id 自己改的,可见
2 trx_id < min_trx_id 创建 ReadView 前已提交,可见
3 trx_id >= max_trx_id 创建之后才开启的事务,不可见
4 落在 [min_trx_id, max_trx_id) 且存在于 m_ids 当时还没提交,不可见
5 落在区间内但不在 m_ids 当时已提交,可见

不可见时沿 DB_ROLL_PTR 取上一版本,重新走这五步,直到找到一个可见版本或链到头(链头通常是"不存在",即这一行对本事务还不存在)。

max_trx_id 是最容易记错的一个:它是"下一个要发的号",所以判断用的是 >=;m_ids 的最大值只用于参考。写错了这条,幻读的解释就全拧了。


5. 快照读与当前读的分界

语句 读法 加的锁
SELECT 快照读 无
SELECT ... LOCK IN SHARE MODE 当前读 共享临键锁
SELECT ... FOR UPDATE 当前读 排他临键锁
UPDATE / DELETE 当前读(先读后改) 排他记录锁 + 间隙锁
INSERT 当前读 插入意向锁
START TRANSACTION;
SELECT * FROM accounts WHERE id = 1;               -- 快照读
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;    -- 当前读
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

分界很干净:只要语句会改数据,或者要求"读到最新的并锁住它",就是当前读。当前读一律绕过 ReadView,直接读最新已提交版本,然后按需加锁。所以"事务里先 SELECT 再 FOR UPDATE 两次结果不同"是正常现象,不是 BUG。


6. RR 与 RC 的根本差异:ReadView 什么时候建

隔离级别 ReadView 建立时机 直接后果
REPEATABLE READ 事务内第一次快照读时建立,之后复用 整个事务看到同一个时间点,不可重复读消失
READ COMMITTED 每次快照读都重建 每次都能看到最新已提交数据,不可重复读出现
-- 会话 A
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;   -- 100
-- 会话 B 此时把余额改成 200 并提交
SELECT balance FROM accounts WHERE id = 1;   -- RR: 100;RC: 200
COMMIT;

同样的脚本把隔离级别换掉,第二次读的结果就变了:

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
SELECT balance FROM accounts WHERE id = 1;   -- 100
-- 会话 B 提交 200
SELECT balance FROM accounts WHERE id = 1;   -- 200
COMMIT;

在 RC 下同一条语句两次结果不同不是异常,正是它的语义。要注意 RC 下"每条语句一个新快照"会让 UPDATE 的 WHERE 条件和随后的赋值可能落在不同快照上,这也是 RC 被广泛用于减少间隙锁的同时,要求 SQL 更克制的原因。

还有一种常被忽略的代价:一致性读并不便宜。RR 下事务越久,读到老版本的概率越高,而每个老版本都要沿 DB_ROLL_PTR 回退一次。回退发生在 server 层与引擎层之间,靠 row_search_mvcc 完成,单次开销不大,但一旦链长到几十上百个版本,同一条 SELECT 的成本会明显偏离 EXPLAIN 的预估。


7. 长事务为什么危险

现象 机制 后果
undo 不能清理 purge 线程只回收"没有 ReadView 还需要"的版本 回滚段与 ibdata/undo 表空间持续膨胀
版本链变长 每个新版本都挂在链上 快照读回退次数增加,读变慢
一致性读成本上升 读到的是很老的版本,要沿链回溯 单个查询的 CPU 与 IO 上升
主从延迟 大事务在从库串行回放 从库 Seconds_Behind_Master 上涨
SELECT trx_id, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS age_sec,
       trx_mysql_thread_id, trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started;

INNODB_TRX 里那些 age_sec 上千秒、trx_query 为 NULL 的事务最值得警惕——通常是应用开了事务却没提交,连接还挂在池里。这类事务本身什么都没做,但足以让 purge 停在那里,把磁盘慢慢吃掉。


8. 排错:五种"读到的东西不对"

现象 原因 处理
同一事务两次读结果不同 实际跑在 RC 下 确认 @@transaction_isolation 与会话级设置
事务里读不到别人刚提交的数据 RR 快照固定,这是预期行为 需要最新值就用当前读
FOR UPDATE 报锁等待 当前读绕过快照,真去抢锁 缩短事务;确认加锁顺序一致
undo 表空间只涨不缩 有长事务/未提交事务挡住 purge 查 INNODB_TRX,杀掉并修应用
明明没改数据却拿到旧值 版本链回退到了更早的可读版本 用当前读验证;检查是否有超长事务

9. 边界:MVCC 管不到的地方

场景 MVCC 是否解决 说明
读到最新值并锁定 不解决 必须用当前读
防止两事务同时改同一行 不解决 靠行锁
防止范围内插入新行 部分解决 靠临键锁,且要走到索引
跨库/跨实例一致性快照 不解决 需要全局事务或业务补偿
缓存与数据库一致 不解决 MVCC 只作用于 InnoDB 内部读取

一句话收束:快照读看不见"我之外"的写入,当前读看得见所有已提交写入并顺手加锁;RC 与 RR 的差别只有一条——ReadView 什么时候建。


10. 自测

  1. ReadView 里 max_trx_id 指的是什么?为什么判断用它而不是 m_ids 的最大值?
  2. 一行数据的 trx_id 落在 m_ids 区间外但在 [min_trx_id, max_trx_id) 内,可见吗?
  3. RR 下"快照读不幻读"和"当前读不幻读",机制分别是什么?
  4. 为什么同一个事务里先 SELECT 再 SELECT ... FOR UPDATE 会看到不同结果?
  5. 哪些语句属于当前读?
  6. 长事务是怎么把磁盘吃满的?
S11-X11 · 隔离级别与 MVCC:可见性到底怎么算
https://www.clxhxhhr.top/posts/5882/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。