MySQL 单点详解(06):一条记录为什么有多个版本,ReadView 到底在比什么
面试官:MVCC 是怎么实现的?
面试官:RR 和 RC 差在哪里,底层只差一行代码吗?
MVCC 解决的不是并发,是"读的时候不用等写"。
一条记录能同时是老样子和新样子,靠的是版本链加一个快照。
一、版本链是怎么长出来的
InnoDB 的每一行,除了建表时写的那几列,还藏着两个看不见的列。
表① 藏起来的两个列
| 列 | 作用 |
|---|---|
| 事务号 | 最后改这一行的事务是哪个 |
| 回滚指针 | 指向 undo 里这个值的上一个版本 |
改一次,就多一个版本。旧值不进数据页,进 undo。
举个具体的:余额原本 100,被事务 8 改成 200。数据页里是 200,undo 里留着 100。
回滚指针从 200 那一版,指向 100 那一版。
回滚指针一根一根往回串,串成一条链。链头是现在,链尾是最早。
第五期讲过 undo 记"改之前",这里就是它的用处:不只是回滚,还给读提供历史版本。
所以一条记录能同时是"现在的样子"和"三分钟前的样子",就靠这条链。
二、ReadView 是个什么快照
有了链,还得知道该停在哪一节。
ReadView 就是那张车票:生成它的那一刻,谁已经提交、谁还在跑,全记下来。
表② 快照里记了四个值
| 值 | 含义 |
|---|---|
| 活跃事务列表 | 生成快照时还没提交的事务号 |
| 最小值 | 上面那堆里最小的那个 |
| 上限 | 下一个要发出去的事务号 |
| 自己 | 当前这个事务的号 |
四个值一摆,"该看哪一版"就变成一道比较题,不需要再猜。
三、可见性就三步判断
拿链上某个版本的事务号,跟快照比。
表③ 三个判断,五种结果
| 事务号的情况 | 结论 |
|---|---|
| 小于最小值 | 早就提交了,可见 |
| 大于等于上限 | 快照之后才开的,不可见 |
| 夹在中间,且在活跃列表里 | 当时还没提交,不可见 |
| 夹在中间,但不在列表里 | 已经提交,可见 |
| 等于自己 | 自己改的,可见 |
不可见就顺着指针往回走一格,再比一次。一直走到可见为止。
比如快照里活跃列表是 8 和 9,最小值 8,上限 12。
链上某一版的事务号是 7,比 8 小,直接可见。是 10,落在中间又不在列表里,说明它已经提交,同样可见。
只有 8 和 9 这两个号,以及大于等于 12 的,才要往回翻。
这就是"读不阻塞写"的全部秘密:读的是一份历史,写的是最新一版,两边不抢同一把锁。
四、RC 和 RR 只差一个时机
表④ 两个隔离级别,同一套机制
| 项 | RC | RR |
|---|---|---|
| 快照什么时候建 | 每条语句建一次 | 事务里第一次读建一次 |
| 同一事务读两次 | 可能不一样 | 一定一样 |
机制完全一样,只是快照的寿命不同。
RC 每条语句都重新拍一张,所以能看到别人刚提交的数据,这就是不可重复读。
RR 一张票用到事务结束,所以两次读到的永远一致。
顺带分清一对容易混的概念:快照读看见的是历史,当前读看见的是现实,两者在同一个事务里可以并存。
面试里问"RR 怎么实现的",答"事务开始时建一个 ReadView,全程复用",一句就够。
五、面试官会往下问的三层
第一层:MVCC 能解决幻读吗?
只能解决一半。普通 select 走快照,看不见新插入的行,这半是解决了。
但加锁读、update、delete 读的是最新版本,照样能看见别人新插的行。那半靠间隙锁,下一期讲。
所以说 RR 不是"完全没有幻读",准确说法是"快照读没有幻读"。
第二层:既然有版本链,为什么还要加锁?
因为写和写之间没法用版本解决。两个事务改同一行,谁赢?
版本链只服务读,写要靠锁排队。
第三层:旧版本什么时候能删?
等没有快照还需要它的时候。系统会记住最老的活跃快照,比它还老的版本才能清掉。
所以一个长事务会拖住整个库的清理,undo 越堆越大,磁盘也跟着涨。
收尾
这一期可以压成一句话:改的时候往 undo 里留一份旧的,读的时候拿快照比一比,比到可见的那一版为止。