VOL.001 · 创刊号Token 天团 API

Token 天团

—— 一份关于代码与生活的个人刊物 ——

技术Java教程

事务隔离与 MVCC:版本链上读历史——Java 工兵连第 11 篇(第二季开篇)

「Java 工兵连」第二季开篇,主题转向存储与中间件深水区。第一季讲并发时反复在跟「锁」打交道,这一季从 MySQL 讲起——先回答最经典的一题:「可重复读」到底是怎么做到的?

网上答案十有八九止步于「MVCC 多版本并发控制」六个字。这篇把它拆开:MVCC = 每行数据自带历史存档(版本链)+ 每个事务自带认可截止时刻(ReadView)——读操作靠翻历史档案完成,不加锁,这就是「读不阻塞写、写不阻塞读」的全部秘密。

一、先建直觉:修订中的章程

把一行数据想成一份不断修订的章程:

  • 每次修改,旧版本不销毁,存档(undo log),新版开头指一条「上一版在哪个档案盒」(DB_ROLL_PTR),存档盒一路串下去——这就是版本链;
  • 每个事务开始读之前,领一张认可截止时刻(ReadView):「我只认可此刻之前已经生效(提交)的修订,之后的当没看见」;
  • 于是两人同时读同一行,各自翻各自的档案,互不打扰——读者读历史,写者写现在。

上:一行数据的 undo 版本链与可见性判定;下:RC 与 RR 的时机差异

二、三件套:隐藏列、版本链、ReadView

1. 隐藏列。 InnoDB 每行都有两个隐形字段:DB_TRX_ID(最后一次修改它的事务 id)和 DB_ROLL_PTR(指向 undo log 里的上一版本)。

2. 版本链。 事务改一行时:旧值写入 undo log,新行的 ROLL_PTR 指过去。改三次就有三个版本,最新版在表上,旧版都在链上——就像章程的 v3 → v2 → v1(见 hero 图左上)。

3. ReadView(可见性快照)。 生成时刻拍下的活跃事务名单,四个要素:自己的 trx_id、当时活跃(未提交)事务集合、其中最小 id、系统下一个将分配的 id。拿到一个版本,判可见就看四条(hero 图右上):

情形 结论
trx_id == 我自己 ✓ 可见(自己改的)
trx_id < 活跃最小 id ✓ 可见(我来之前已提交)
trx_id ≥ 下一个将分配 id ✗ 不见(我之后才开的事务)
在活跃名单里 ✗ 不见(还没提交)

不可见就顺着 ROLL_PTR 翻上一版,直到翻到可见的为止。四句口诀:自己的可见,门前的可见;门后的不见,名单里的不见。

三、RC 与 RR 的全部差别:一句话

机制完全相同,只有一处不同——ReadView 什么时候生成:

  • RC(读已提交):每次查询都拍新快照。别人提交了,你下一次读就看见了 → 所以叫「读已提交」,也因此同一事务里两次读可能不一样(不可重复读);
  • RR(可重复读,MySQL 默认):只在第一次查询时拍一次,全程定格。别人后来改的、插的,你都按入场那一刻的档案读 → 重复读结果一致。

一句话:RC 次次拍,RR 只拍一次。 隔离级别听起来玄,落到实现上就这么朴素——记住这句,四个级别里最难啃的两个就通了。

四、幻读到底防没防住?——精确答案

面试标准答案「RR 解决了幻读」其实半对。精确版:

快照读(普通 SELECT):走 MVCC,读的是入场定格的档案。别的事务插入的行,trx_id 在你之后 → 不可见 → 幻读被 MVCC 天然挡住。

当前读(SELECT ... FOR UPDATE、UPDATE、DELETE):直接读最新版(要改它当然得拿最新数据)。这时别的事务插进来的行看得见——幻读回来了。InnoDB 的补位方案是 Next-Key Lock(记录锁 + 间隙锁):当前读时把命中记录和它们之间的间隙一起锁住,别的事务想在这个范围插入就得等——把「防插队」变成锁的问题(No.13 展开讲)。

还有一个经典的「露馅现场」:RR 下事务 A 普通 SELECT 没查到 id=5 的行(快照里没有),接着 UPDATE t SET ... WHERE id=5——UPDATE 是当前读,改成功了,而且改完这行的 DB_TRX_ID 变成了 A 自己 → 再 SELECT 突然看到这行了,像幽灵。教训:快照读与当前读混用,MVCC 的一致性保证就到边界了——先当前读确认再操作(SELECT ... FOR UPDATE 查一遍),不要赌快照。

五、长事务:数据库界的「无界资源」

第一季的暗线在这里再次报到。ReadView 只要还活着,比它老的事务就可能有版本要被看见,undo log 就不能清理——于是:

  • 版本链越拖越长,别人读这行要顺着链翻很多版 → 读越来越慢;
  • undo 表空间持续膨胀 → 磁盘告警。

而什么让 ReadView 长生不死?长事务:一个开着不提交的事务(含「事务里睡大觉」的代码、连接池借走连接忘了提交、autocommit 关了没人管)。这不就是 OOM 篇的「无界队列」换了舞台——不限时的快照,迟早拖垮版本链。

排查三板斧(背下来):

SELECT trx_id, trx_started, trx_mysql_thread_id
FROM information_schema.innodb_trx
WHERE trx_started < NOW() - INTERVAL 60 SECOND;   -- 跑超过 60 秒的事务

SHOW ENGINE INNODB STATUS;                        -- HISTORY LIST LENGTH:待清理 undo 版本数

监控盯两条曲线:长事务持续时长、History List Length——后者陡增 = 版本链在堆积。

老兵点评

  1. 「隔离级别」背表格是下策,落到 ReadView 时机是上策。RU 是不拍快照(读最新未提交)、RC 次次拍、RR 拍一次、串行化直接加锁——四个级别一句话讲完,面试官会记住你。
  2. 长事务是生产 MySQL 的慢性病之首。它的伤害不止锁:undo 拖累读性能、占磁盘、 replication 延迟跟着涨。框架层面(Spring @Transactional 别包 HTTP 调用、别包大数据导出——第 5 篇的边界思想)比 DBA 层面救火更治本。
  3. MVCC 的设计哲学值得所有中间件抄:读写分离到「时间维度」——读者活在自己的时间点,写者永远写现在。Git 的分支、Kafka 的 offset、Raft 的 log,全是同一个思想:不覆盖历史,只追加新版本。看懂这一点,你看很多系统都通透了。

带走三句话

  1. MVCC 三件套:隐藏列(trx_id + roll_ptr)、undo 版本链、ReadView;可见性四句口诀:自己的可见,门前的可见;门后的不见,名单里的不见。
  2. RC 与 RR 的全部差别:ReadView 生成时机——RC 次次拍,RR 只拍一次。
  3. 幻读精确答案:快照读被 MVCC 挡住,当前读靠 Next-Key Lock 补位;长事务拖着版本链不放——又是「无界」惹的祸。

本篇是「Java 工兵连」第 11 篇 · 第二季开篇。→ 上一篇:第 10 篇 · 排查 OOM 的固定套路(第一季收官) | 继续阅读:第 12 篇 · redo / undo / binlog 三本账 | 浏览整个专栏 →。

❦ 终 ❦

← 回到首页全部归档 →