MySQL 单点详解(08):RR 到底解决幻读没有,快照读和当前读各算一半
面试官:RR 隔离级别下,幻读解决了吗?
面试官:为什么加了 for update,就能挡住别的事务插进来?
幻读不是一个问题,是两个问题。
快照读看到的是历史,当前读看到的是现实,两套解法,混着答就会自相矛盾。
一、幻读到底长什么样
同一个事务里,同样的范围查询执行两次,第二次多出来几行,这就是幻读。
多出来的行,是别的事务插进去、并且已经提交的。
比如第一次查 id 大于 3 得到 3 行,别的事务插入一行 id 是 9 并提交,同样的语句再查一次,成了 4 行。
多出来的不是被改出来的,是被插出来的。这就是它和不可重复读的区别。
难点在于:想拦住插入,得先知道"哪里还没有行"。区间里的空隙,就是那个"哪里"。
二、先把读分成两种
表① 同样是 select,两种命
| 读法 | 长什么样 | 读到的版本 |
|---|---|---|
| 快照读 | 普通 select | 事务开始那一刻的历史 |
| 当前读 | for update、update、delete | 最新的、已提交的那一版 |
这个划分比什么都重要。幻读的题答歪,八成是把这两种读混成了一种。
快照读走的是第六期那条版本链,读的是一份固定的历史。
别人后来插的行,事务号比快照上限还大,一看就不可见,压根不会被返回。
所以快照读根本不需要间隙锁,它天生就看不见新行。
三、当前读才需要间隙锁
当前读要拿最新数据,就不能靠历史挡着。它得让"插入"这件事压根发生不了。
表② 三种读,会不会看到别人刚插的行
| 读法 | 看得到吗 | 靠什么挡 |
|---|---|---|
| 普通 select | 看不到 | 不需要挡 |
| for update | 看得到,所以要先挡 | 间隙锁挡住插入 |
| update | 看得到,所以要先挡 | 间隙锁挡住插入 |
于是就有了那个经典场景:
T1 执行 select * from t where id = 5 for update,查不到,但这个区间被锁上了。
id 等于 5 这一行并不存在,锁的不是行,是位置。空隙被锁住,插入必须落在这段空隙里,所以只能等。
T2 想插 id = 5,撞在间隙锁上,只能等。
T1 再查一次,还是空。没有幻读。
如果 T1 那句不带 for update,它是快照读,本来也看不到,但 T2 的插入它拦不住。
所以稳妥的写法是在事务开头就用加锁读把范围占住,而不是先查一遍、再决定要不要锁。后者中间那段空隙,别人随时能插。
四、RR 也不是完全免疫
这是最容易被追问的一层。看一个真实会发生的顺序。
表③ 四步,最后多出一行
| 步骤 | 结果 |
|---|---|
| 事务先做一次范围快照读 | 3 行 |
| 别的事务插入一行并提交 | 仍是 3 行 |
| 本事务 update 整个范围 | 影响 4 行 |
| 再快照读一次 | 4 行 |
第三步是关键。update 是当前读,它看见了别人刚插的那一行,并且把这行的事务号改成了自己。
第四步再走版本链时,这行的事务号等于自己,判定为可见,于是冒出来了。
所以严格说:RR 解决的是"快照读的幻读",不是"所有幻读"。
网上说"RR 完全解决幻读",答得太满,遇到这种追问就圆不回来。
五、面试官会往下问的三层
第一层:RC 为什么不行?
RC 每条语句都重建快照,同一事务两次读之间就可能变。
它也没有间隙锁,插入拦不住。两条路都断,所以 RC 一定有幻读。
第二层:那 RC 有什么好处?
间隙锁只在 RR 下用,锁的范围大、并发低。RC 不用,冲突少、死锁也少。
很多互联网业务主动选 RC,用应用层去处理那点不一致的公差。
第三层:既然快照读没问题,为什么还要间隙锁?
因为不是所有业务都能忍受"读到的是历史"。
扣库存、转账这类操作必须拿最新值,一拿最新值就回到当前读,就得靠锁。
很多系统干脆在应用层再加一个版本号,冲突了就让重试,用乐观锁兜住这一层。
收尾
这期能压成一句:快照读靠版本链躲开新行,当前读靠间隙锁堵住新行,两条路合起来才叫"RR 基本解决幻读"。