MVCC 快照读为什么读不到刚提交的数据:ReadView 的 4 条可见性规则

个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <
其他栏目: > redis <
其他栏目: > mysql <




文章目录

    • 一、每一行都藏着一个版本链
    • 二、ReadView:一次快照的"名册"
    • [三、4 条可见性规则](#三、4 条可见性规则)
    • [四、RC 和 RR 的真正差别只有一句话](#四、RC 和 RR 的真正差别只有一句话)
    • [五、6 个能跑出来的小实验](#五、6 个能跑出来的小实验)
    • [六、RR 不等于没有幻读](#六、RR 不等于没有幻读)
    • [七、长事务为什么危险:undo 不能清理](#七、长事务为什么危险:undo 不能清理)
    • 小结

先复现一个让人怀疑自己学错了的现象。在 MySQL 的 InnoDB 存储引擎里,account 表中有这样一行 balance = 100,两个会话按顺序操作:

sql 复制代码
-- 会话 B(先跑)
BEGIN;
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;                       -- B 已经提交了

-- 会话 A(在 B 提交前就 BEGIN 过)
BEGIN;
SELECT balance FROM account WHERE id = 1;   -- 100
-- ... 这里 B 提交了 ...
SELECT balance FROM account WHERE id = 1;   -- 还是 100 ?!

B 明明提交了,A 却像没看见一样。这不是缓存、不是连接池、也不是主从延迟------这是 MVCC(多版本并发控制)在按规则办事 :A 的第二次查询用的是事务开始时生成的快照,而不是"当前最新的数据"。

要真正理解它,关键不是背"RR 可重复读、RC 读已提交"这两句话,而是搞清三件事:版本链怎么形成、ReadView 里存了什么、可见性怎么判断。

一、每一行都藏着一个版本链

InnoDB 的每行记录除了你的字段,还有两个隐藏列:

隐藏列 含义
DB_TRX_ID 最后一次修改这行的事务 id(6 字节)
DB_ROLL_PTR 回滚指针,指向 undo log 里这行的上一个版本
DB_ROW_ID 没有主键时自动生成的隐藏主键(本篇不涉及)

每次 UPDATE 并不是"原地改掉",而是:写入一条 undo 记录保存旧值 → 修改当前行 → 把这一行的 DB_TRX_ID 改成自己的事务 id → DB_ROLL_PTR 指向那条 undo。这些旧版本会一直躺在数据库的 undo 表空间里,谁需要谁去顺着链取。

我们假设 id=1 这行的初始版本由事务 30 写入,balance = 100。事务 40 把它改成 300,事务 50 又改成 500,版本链长这样:

text 复制代码
当前行:  balance=500  DB_TRX_ID=50 ──┐
                                     ↓ roll_ptr
undo:    balance=300  DB_TRX_ID=40 ──┐
                                     ↓
undo:    balance=100  DB_TRX_ID=30   ──→ NULL

所以"能不能读到某个值"这个问题,被转换成了:从当前行出发,顺着这条链往回找,哪一版对我可见?

二、ReadView:一次快照的"名册"

快照读(普通 SELECT)在需要时会生成一个 ReadView,它其实就是当时活跃事务的一张快照名册,四个字段:

字段 含义
m_ids 生成 ReadView 时,**还活着(未提交)**的事务 id 列表
min_trx_id m_ids 里的最小值
max_trx_id 下一个将要分配的事务 id(注意:不是 m_ids 的最大值)
creator_trx_id 创建这个 ReadView 的事务自己的 id

以开头那个场景为例:A 先 BEGIN(拿到事务 id 40),B 拿到 50 并改了数据、提交了。如果 A 在整个事务一开始就生成了 ReadView,那么 m_ids = [40, 50]、min_trx_id = 40、max_trx_id = 51、creator_trx_id = 40。

三、4 条可见性规则

拿到一行数据后,按下面的顺序判断(伪代码就是 InnoDB 的真实逻辑):

text 复制代码
对版本链上的某一版,记它的 DB_TRX_ID 为 trx:

1) trx == creator_trx_id        → 可见(这是我自己改的)
2) trx <  min_trx_id            → 可见(改它的那个事务早就提交了)
3) trx >= max_trx_id            → 不可见(它在我生成快照之后才开始)
4) min_trx_id <= trx < max_trx_id:
      trx 在 m_ids 里            → 不可见(我生成快照时它还没提交)
      trx 不在 m_ids 里          → 可见(我生成快照时它已提交)

不可见时:顺着 DB_ROLL_PTR 找到上一版,重新从第 1 条开始判断;
一直找到可见版本,或者链走完(说明这行对我"不存在")。

回到开头的例子:A 看到的当前行 DB_TRX_ID = 50。规则 4 命中------50 在 m_ids 里(生成快照时 B 还没提交),不可见 ;顺着指针回到上一版 DB_TRX_ID = 30,30 < min_trx_id(40),规则 2 命中,可见,读到 100。B 提交与否,对 A 的这个 ReadView 毫无影响。

四、RC 和 RR 的真正差别只有一句话

这两级事务隔离级别的实现差别,不在于判断规则,而在于生成 ReadView 的时机:

隔离级别 ReadView 生成时机 效果
READ COMMITTED 每次 SELECT 都重新生成 别人一提交就能看见
REPEATABLE READ 事务里第一次 SELECT 时生成,之后一直复用 整个事务看到同一个快照

这也解释了另一个常见疑问:为什么在 RR 下,事务里第一条 SELECT 之前别人提交的数据是能看见的------因为ReadView 那时候还没生成,等你第一次查询时,名册是按当时状态拍的。

sql 复制代码
-- 想自己验证"RR 只在第一次 SELECT 时拍快照",按这个顺序敲
SET SESSION transaction_isolation = 'REPEATABLE-READ';
BEGIN;
SELECT balance FROM account WHERE id = 1;   -- 这里才生成 ReadView
-- 另一个会话 UPDATE + COMMIT
SELECT balance FROM account WHERE id = 1;   -- 仍是旧值
COMMIT;

五、6 个能跑出来的小实验

# 操作 结果 说明
1 RR 下 A 读、B 更新并提交、A 再读 两次相同 快照复用,符合直觉预期
2 同上但隔离级别是 RC 第二次看到新值 每次 SELECT 重新拍快照
3 A 自己 UPDATE 后再 SELECT 能看到自己的修改 规则 1:trx == creator_trx_id
4 A 读、B 更新(不提交)、A 再读 仍是旧值 B 在 m_ids 里,且脏读被阻断
5 A SELECT ... FOR UPDATE 看到 B 已提交的新值 当前读,不走 ReadView
6 A 快照读期间大量写入 A 看不到任何新行 幻读在这条路径上被抑制

第 5 条是最容易踩的坑:SELECT ... FOR UPDATE、UPDATE、DELETE 都是当前读,它们读的是最新已提交版本,还会加锁。所以你会看到同一个事务里:

sql 复制代码
BEGIN;
SELECT balance FROM account WHERE id = 1;              -- 100(快照读)
SELECT balance FROM account WHERE id = 1 FOR UPDATE;   -- 500(当前读,同一事务里数值跳了)

顺便说一下 UPDATE 的"半一致性读":更新时如果发现最新版本被别的事务锁着,会等锁;等到了就读取最新版本再做判断,所以 UPDATE ... WHERE balance = 100 在 RR 下也可能改到"你以为不存在"的行。

六、RR 不等于没有幻读

很多人把"RR 解决了幻读"当成定理。准确说法是:RR 下快照读不会幻读,当前读仍可能幻读。

sql 复制代码
-- 会话 A
BEGIN;
SELECT COUNT(*) FROM t_order WHERE user_id = 10;    -- 0,快照里确实没有
-- 会话 B 插入 (10, ...) 并提交
SELECT COUNT(*) FROM t_order WHERE user_id = 10;    -- 仍然是 0(快照读)
UPDATE t_order SET status = 1 WHERE user_id = 10;   -- 影响 1 行!当前读看到了新行
SELECT COUNT(*) FROM t_order WHERE user_id = 10;    -- 现在是 1(自己的修改对自己可见)

同一个事务里 COUNT 从 0 变成 1,这就是幻读。真正挡住插入的是间隙锁(见另一篇),不是 MVCC。

七、长事务为什么危险:undo 不能清理

版本链是给"还活着的 ReadView"服务的。只要还有一个事务没提交,InnoDB 就必须保留它可能需要的旧版本,于是 purge 线程清不掉 undo。

判断标准是 history list length:

sql 复制代码
SHOW ENGINE INNODB STATUS\G      -- 搜 History list length
SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS secs,
       trx_rows_locked, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;

当 History list length 持续上涨、undo 表空间不断变大、查询越来越慢时,十有八九是某处挂着长事务(常见来源:事务里发了 MQ、调了 RPC、@Transactional 包住了整个批量任务、或者连接池里一个 BEGIN 后忘了提交)。注意:只读的长事务同样会拖住 purge,因为它持有的 ReadView 决定了旧版本的下限。

小结

  • 每行数据通过 DB_TRX_ID + DB_ROLL_PTR 串成版本链,UPDATE 不改历史,只追加新版本。
  • ReadView 是"活跃事务名册",4 条规则决定某一版能不能被我看见;看不见就顺着链往回退。
  • RC 与 RR 的差别只在生成 ReadView 的时机:每条 SELECT vs 事务首次 SELECT。
  • 快照读走 MVCC,当前读(FOR UPDATE / UPDATE / DELETE)走最新版本 + 加锁,两者在同一事务里数值可以不一致。
  • 长事务会把 undo 按住不放,监控 History list length 比监控慢查询更早发现问题。

理解到这一层,"为什么读不到刚提交的数据"就不再是玄学,而是一条能画出来的判断链。

相关推荐
2501_933670791 小时前
2027届数据类专业投用户增长岗:A/B实验、指标和SQL准备思路
数据库·sql
用户337922545681 小时前
Sirchmunk 深度解析(一):一个无需向量数据库的自进化搜索引擎架构设计
数据库
染指11101 小时前
139.Agent-多Agent框架-Skills渐进式加载文档
数据库·人工智能·langchain·agents
骑着蜗牛撵大象3271 小时前
SpringBoot+Vue3 企业智能体侧挂架构:独立服务、独立数据库与主线零侵入落地
数据库·spring boot·架构·vue·springboot·事件驱动·服务拆分
螺蛳粉 螺蛳粉1 小时前
第四篇:Keepalived + MySQL 主从高可用实战
数据库·mysql·adb·keepalived·高可用
陈年老古董2 小时前
Hive DML 语言学习笔记:数据加载、插入、导出与导入
hive·笔记·学习·mysql
麦壳饼2 小时前
INSERT INTO:向时序表写入数据
数据库·sonnetdb
七夜zippoe2 小时前
多 Agent 协作架构:Supervisor 模式——主管 Agent 调度实战
数据库·ai·架构·agent
我不会起名字3223 小时前
Redis 缓存与数据库一致性:先删缓存还是先更新库的 4 种方案
数据库·redis·缓存·一致性·延迟双删