
个人主页 我不会起名字322 欢迎各位大佬莅临其他栏目 技术栈学习笔记 其他栏目 力扣Hot100题目解析 其他栏目 Go项目学习笔记 其他栏目 redis 其他栏目 mysql 文章目录一、每一行都藏着一个版本链二、ReadView一次快照的名册三、4 条可见性规则四、RC 和 RR 的真正差别只有一句话五、6 个能跑出来的小实验六、RR 不等于没有幻读七、长事务为什么危险undo 不能清理小结先复现一个让人怀疑自己学错了的现象。在 MySQL 的 InnoDB 存储引擎里account表中有这样一行balance 100两个会话按顺序操作-- 会话 B先跑BEGIN;UPDATEaccountSETbalance200WHEREid1;COMMIT;-- B 已经提交了-- 会话 A在 B 提交前就 BEGIN 过BEGIN;SELECTbalanceFROMaccountWHEREid1;-- 100-- ... 这里 B 提交了 ...SELECTbalanceFROMaccountWHEREid1;-- 还是 100 ?!B 明明提交了A 却像没看见一样。这不是缓存、不是连接池、也不是主从延迟——这是MVCC多版本并发控制在按规则办事A 的第二次查询用的是事务开始时生成的快照而不是当前最新的数据。要真正理解它关键不是背RR 可重复读、RC 读已提交这两句话而是搞清三件事版本链怎么形成、ReadView 里存了什么、可见性怎么判断。一、每一行都藏着一个版本链InnoDB 的每行记录除了你的字段还有两个隐藏列隐藏列含义DB_TRX_ID最后一次修改这行的事务 id6 字节DB_ROLL_PTR回滚指针指向 undo log 里这行的上一个版本DB_ROW_ID没有主键时自动生成的隐藏主键本篇不涉及每次UPDATE并不是原地改掉而是写入一条 undo 记录保存旧值 → 修改当前行 → 把这一行的DB_TRX_ID改成自己的事务 id →DB_ROLL_PTR指向那条 undo。这些旧版本会一直躺在数据库的 undo 表空间里谁需要谁去顺着链取。我们假设 id1 这行的初始版本由事务 30 写入balance 100。事务 40 把它改成 300事务 50 又改成 500版本链长这样当前行: balance500 DB_TRX_ID50 ──┐ ↓ roll_ptr undo: balance300 DB_TRX_ID40 ──┐ ↓ undo: balance100 DB_TRX_ID30 ──→ NULL所以能不能读到某个值这个问题被转换成了从当前行出发顺着这条链往回找哪一版对我可见二、ReadView一次快照的名册快照读普通SELECT在需要时会生成一个 ReadView它其实就是当时活跃事务的一张快照名册四个字段字段含义m_ids生成 ReadView 时**还活着未提交**的事务 id 列表min_trx_idm_ids里的最小值max_trx_id下一个将要分配的事务 id注意不是m_ids的最大值creator_trx_id创建这个 ReadView 的事务自己的 id以开头那个场景为例A 先BEGIN拿到事务 id 40B 拿到 50 并改了数据、提交了。如果 A 在整个事务一开始就生成了 ReadView那么m_ids [40, 50]、min_trx_id 40、max_trx_id 51、creator_trx_id 40。三、4 条可见性规则拿到一行数据后按下面的顺序判断伪代码就是 InnoDB 的真实逻辑对版本链上的某一版记它的 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 3030 min_trx_id40规则 2 命中可见读到 100。B 提交与否对 A 的这个 ReadView 毫无影响。四、RC 和 RR 的真正差别只有一句话这两级事务隔离级别的实现差别不在于判断规则而在于生成 ReadView 的时机隔离级别ReadView 生成时机效果READ COMMITTED每次SELECT都重新生成别人一提交就能看见REPEATABLE READ事务里第一次SELECT时生成之后一直复用整个事务看到同一个快照这也解释了另一个常见疑问为什么在 RR 下事务里第一条SELECT之前别人提交的数据是能看见的——因为ReadView 那时候还没生成等你第一次查询时名册是按当时状态拍的。-- 想自己验证RR 只在第一次 SELECT 时拍快照按这个顺序敲SETSESSIONtransaction_isolationREPEATABLE-READ;BEGIN;SELECTbalanceFROMaccountWHEREid1;-- 这里才生成 ReadView-- 另一个会话 UPDATE COMMITSELECTbalanceFROMaccountWHEREid1;-- 仍是旧值COMMIT;五、6 个能跑出来的小实验#操作结果说明1RR 下 A 读、B 更新并提交、A 再读两次相同快照复用符合直觉预期2同上但隔离级别是 RC第二次看到新值每次 SELECT 重新拍快照3A 自己UPDATE后再SELECT能看到自己的修改规则 1trx creator_trx_id4A 读、B 更新不提交、A 再读仍是旧值B 在m_ids里且脏读被阻断5ASELECT ... FOR UPDATE看到 B 已提交的新值当前读不走 ReadView6A 快照读期间大量写入A 看不到任何新行幻读在这条路径上被抑制第 5 条是最容易踩的坑SELECT ... FOR UPDATE、UPDATE、DELETE都是当前读它们读的是最新已提交版本还会加锁。所以你会看到同一个事务里BEGIN;SELECTbalanceFROMaccountWHEREid1;-- 100快照读SELECTbalanceFROMaccountWHEREid1FORUPDATE;-- 500当前读同一事务里数值跳了顺便说一下UPDATE的半一致性读更新时如果发现最新版本被别的事务锁着会等锁等到了就读取最新版本再做判断所以UPDATE ... WHERE balance 100在 RR 下也可能改到你以为不存在的行。六、RR 不等于没有幻读很多人把RR 解决了幻读当成定理。准确说法是RR 下快照读不会幻读当前读仍可能幻读。-- 会话 ABEGIN;SELECTCOUNT(*)FROMt_orderWHEREuser_id10;-- 0快照里确实没有-- 会话 B 插入 (10, ...) 并提交SELECTCOUNT(*)FROMt_orderWHEREuser_id10;-- 仍然是 0快照读UPDATEt_orderSETstatus1WHEREuser_id10;-- 影响 1 行当前读看到了新行SELECTCOUNT(*)FROMt_orderWHEREuser_id10;-- 现在是 1自己的修改对自己可见同一个事务里COUNT从 0 变成 1这就是幻读。真正挡住插入的是间隙锁见另一篇不是 MVCC。七、长事务为什么危险undo 不能清理版本链是给还活着的 ReadView服务的。只要还有一个事务没提交InnoDB 就必须保留它可能需要的旧版本于是purge线程清不掉 undo。判断标准是history list lengthSHOWENGINEINNODBSTATUS\G-- 搜 History list lengthSELECTtrx_id,trx_started,TIMESTAMPDIFF(SECOND,trx_started,NOW())ASsecs,trx_rows_locked,trx_queryFROMinformation_schema.innodb_trxORDERBYtrx_started;当History list length持续上涨、undo 表空间不断变大、查询越来越慢时十有八九是某处挂着长事务常见来源事务里发了 MQ、调了 RPC、Transactional包住了整个批量任务、或者连接池里一个BEGIN后忘了提交。注意只读的长事务同样会拖住 purge因为它持有的 ReadView 决定了旧版本的下限。小结每行数据通过DB_TRX_IDDB_ROLL_PTR串成版本链UPDATE不改历史只追加新版本。ReadView 是活跃事务名册4 条规则决定某一版能不能被我看见看不见就顺着链往回退。RC 与 RR 的差别只在生成 ReadView 的时机每条 SELECT vs 事务首次 SELECT。快照读走 MVCC当前读FOR UPDATE/UPDATE/DELETE走最新版本 加锁两者在同一事务里数值可以不一致。长事务会把 undo 按住不放监控History list length比监控慢查询更早发现问题。理解到这一层为什么读不到刚提交的数据就不再是玄学而是一条能画出来的判断链。