前言
最近遇到一个死锁问题。因为之前用 MySQL 比较多,现在切到了 PG,所以重新仔细研究了一下,重新复习了之前认为的这些八股文。随着工程经验的深入,我发现对之前的一些理解有了更深层次的认识。整理一下,记录下这次重新认识的过程。
MySQL 和 PostgreSQL 都遵循 SQL 标准定义的四种隔离级别:读未提交、读已提交(RC)、可重复读(RR)、串行化。但两家在 RR 级别下的实现有点区别,这也是这篇笔记的重点。
一、先分清两个概念:快照读与当前读
- 快照读(Snapshot Read):基于 MVCC,读某个时间点的数据快照,不加锁。普通
SELECT就是快照读。 - 当前读(Current Read):读最新已提交的数据,并加锁。
SELECT ... FOR UPDATE、UPDATE、DELETE都是当前读——写操作必须基于最新数据,不能改一个旧版本。
MVCC 快照的本质,就是某一瞬间”哪些事务已提交、哪些还在进行中”的一份清单。PostgreSQL 里每行数据都带着 xmin(创建它的事务 ID)和 xmax(删除它的事务 ID),查询时根据快照判断哪些行版本对当前事务可见。
两个隔离级别的核心区别,就在于这份快照什么时候拍:
| 隔离级别 | 快照拍摄时机 |
|---|---|
| 读已提交(RC) | 每条语句开始执行时都拍一张新快照 |
| 可重复读(RR) | 整个事务只在第一条查询时拍一次,之后全程复用 |
所以 RC 能看到别的事务的最新提交(不可重复读现象由此而来),RR 看到的世界则冻结在事务开始那一刻。
要注意一个容易搞混的点:读已提交不等于当前读。它依然是快照读,只是快照”每条语句都刷新”。如果一条查询跑了 10 秒,第 5 秒别人提交的数据它照样看不到(快照在第 0 秒就拍好了),真正的当前读才能看到。
二、MySQL 和 PostgreSQL 在 RR 下走的是两条完全不同的路
这是我这次理解最深的一块,之前一直以为两家的 RR 是同一套东西,其实差得很远。
PostgreSQL 的 RR 是纯快照到底:不光普通 SELECT,连 UPDATE、DELETE、FOR UPDATE 找目标行都基于事务开始时的那张快照。新插入的行对本事务完全隐形,所以天然没有幻读。但代价是,如果要改的行已经被别的事务改过并提交,直接抛错,不会帮你悄悄接上最新数据:
ERROR: could not serialize access due to concurrent update
MySQL 的 RR 更像是个”混血儿”:普通 SELECT 活在事务级快照里,但写操作(UPDATE/DELETE/FOR UPDATE)永远读的是最新版本,即当前读。读和写看到的可能是两个世界——这也是为什么快照里明明看到 name='a',一条 UPDATE 却基于别人刚提交的 'b' 往下改。正因为当前读能看到快照之后新插入的行,MySQL 才需要间隙锁锁住范围、禁止插入,堵上快照读和当前读之间的幻读缺口。
脑内速查一下:
- MySQL RR = 读用旧快照、写用最新版本 + 间隙锁兜底
- PG RC = 读用新鲜快照(每语句刷新一次)、写等锁后在最新版本上重新检查条件
- PG RR = 读写全用旧快照,一旦冲突就报错
顺带一提,PG 的 RR 在 FOR UPDATE 上也有个反直觉的漏洞:它只能锁住快照里已经可见的行,锁不住(甚至看不见)别人新插入的行。所以”防止某个范围内插入新行”(比如防重复下单)在 PG RR 下防不住,要么升级到串行化,要么老老实实上唯一约束。
三、老八股讲的三个’异常’,到底有多大用
大部分资料讲隔离级别都会甩出这张表:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交 | 会 | 会 | 会 |
| 读已提交(RC) | 挡住 | 会 | 会 |
| 可重复读(RR) | 挡住 | 挡住 | 标准上会,MySQL / PG 各自实现更强 |
| 串行化 | 挡住 | 挡住 | 挡住 |
三个异常分别是什么,其实名字已经说明白了:
- 脏读:读到了别人未提交的数据。危害很直白——对方事务一旦回滚,你读到的就是彻底不存在的数据,读未提交级别基本等于没做隔离。
- 不可重复读:同一事务内,同一行的值变了(第一次读余额是 50,别人提交后再读变成 100)。
- 幻读:同一事务内,查询的行集变了(第一次 COUNT 是 10,别人插入并提交后再查变成 11)。一个是”行变了”,一个是”行多了/少了”。
值得说的是,RR 这一格标准上只要求挡住不可重复读,幻读理论上还会发生,但 MySQL 和 PG 实际上都把幻读也挡住了,只是姿势不同:PG 靠纯快照顺手挡掉,MySQL 靠快照读挡普通查询、间隙锁补当前读的漏洞。
所以总的来看,这三个异常里除了脏读是硬伤,剩下两个对大多数业务的实际影响很有限——同一个事务里 SELECT 两次,第二次多了几行,谁的业务会因为这个坏掉?这几个异常之所以是”标准答案”,是因为它们是 1992 年 ANSI SQL 标准按”锁加多严”划出来的一把度量尺,而不是真实业务的痛点清单。真正咬人的异常另有其人,标准的表里反而找不到它们。
四、真正对业务有影响的两个’异常’
这两个异常其实挺常见的。但是之前我在写业务的过程中,从来没有从事务隔离级别去思考过这写问题,只是在业务层就把它处理了,认为这可能是业务逻辑处理的并发安全问题,其实和 DB 事务隔离级别也是息息相关的,个别的事务隔离级别其实不会出现这样的问题。
写丢失(Lost Update)
模式很常见:两个事务读同一行 → 在内存里算 → 写回同一行,后写的把先写的盖掉。
举个库存的例子:库存 = 10。事务 A 读到 10,算出 9;事务 B 也读到 10,算出 9;两个都 UPDATE stock = 9 提交。结果卖出去两件,库存却只减了一件,A 的那次更新凭空”丢了”。注意,这个过程里没有任何一步读到脏数据——问题出在”读”和”写”之间隔了一段应用层的计算,锁只保护写的那一瞬间,保护不了”读到、算完、再写回”这条因果链。
标准的防法有三种:
-- 1. 原子更新:把读和写压进一条带锁的语句
UPDATE inventory SET stock = stock - 1 WHERE id = 123 AND stock > 0;
-- 2. 悲观锁:先锁再读
SELECT * FROM inventory WHERE id = 123 FOR UPDATE;
-- 3. 乐观锁:靠版本号,影响行数为 0 就重试
UPDATE inventory SET stock = stock - 1, version = version + 1
WHERE id = 123 AND version = 5;
隔离级别在这里的表现也分出了高下:MySQL RR 防不住——UPDATE 是当前读,照样基于最新值往下改;PG RR 天然能防住——B 提交时发现这行在自己拍快照之后被改过且已提交,直接序列化错误回滚,逼你重试。
写偏斜(Write Skew)
模式:两个事务读同一个范围 → 基于结果做决定 → 各自写不同的行。
经典例子是值班表:规则是”至少要有 1 人在值班”,此刻 Alice 和 Bob 都在值班。事务 A 查出当前有 2 人值班,判断”我可以下班”,于是把 Alice 那一行改成不值班;事务 B 同时也查出 2 人值班,把 Bob 那一行改成不值班。两边都没读错数据,各自的判断在自己看到的世界里都成立,但双双提交后,值班人数变成了 0。
这里最要命的地方是:两个事务的写集根本没有交集,没有任何一行被同时碰过。行锁不会冲突,PG RR 的快照检查也发现不了任何一行”被改过”,乐观锁的版本号更是无从下手——因为坏掉的不是某一行的值,而是一条横跨多行的业务不变量,它不属于任何一行,行级别的机制天生管不到它。
防法就要往上一个维度走:
- MySQL 下可以用
SELECT ... FOR UPDATE把读过的范围先锁住,逼另一个事务排队; - PG 可以直接上串行化。它靠谓词锁记录”谁读过什么条件”(比如
on_call = true),一旦两个事务的读写依赖形成环,提交时就会有一个被判定为不可串行化,直接报错回滚; - 更取巧的办法是DDL收敛——把”值班人数”单独存成一个计数器字段,写偏斜就退化成了普通的写丢失,行级锁立刻就能生效。
两个异常放一起对比会更清楚:
| 丢失更新 | 写偏斜 | |
|---|---|---|
| 读 | 同一行 | 同一范围 |
| 写 | 同一行(互相覆盖) | 不同行(互不接触) |
| 坏掉的东西 | 某一行的值 | 跨行的业务不变量 |
| 行锁 / 乐观锁 | 有效 | 无效 |
| PG RR 能否防住 | 能 | 防不住 |
| MySQL RR 能否防住 | 防不住 | 防不住 |
一句话总结:写丢失是”两事务改单元格,后提交的事务会覆写前面事务内容”;写偏斜是”各改各的格子,但每人的决定都建立在对方还没动手的假设上”。
五、为什么不能都甩给隔离级别
看到这里会有个自然的想法:既然串行化能挡住上面所有问题,为什么不干脆全局开串行化?
因为代价太贵。串行化下谓词锁本身有开销,冲突之后是整个事务回滚重来,不是重跑某一条语句——事务越长,回滚的损失越大,热点行上甚至可能出现”回滚风暴”。绝大多数业务场景改的都是不同的行,根本用不上这种强保证,却要为它付出”必须写重试逻辑”的应用层成本。
所以更现实的做法是读已提交打底,具体问题用具体武器补枪:唯一约束防重复插入、原子更新或乐观锁防丢失更新、FOR UPDATE 或串行化或收敛建模防写偏斜。把隔离级别之外的活儿交给应用层,看起来是”没有让数据库把事情做绝”,但换来的是更好的并发和更低的侵入性。
这也解释了两家数据库默认值的分歧:
- PostgreSQL 选 RC:并发好、没有间隙锁、写冲突时等锁后重新检查一遍条件就能继续跑,不轻易报错。对短事务、高并发的 Web 场景来说吞吐量更友好,而不可重复读和幻读本身又没那么伤业务,RC 是照顾大多数场景的默认值。
-
MySQL 选 RR:更多是历史包袱。早期 MySQL 主从复制靠的是
statement格式的 binlog,原样重放 SQL 语句——如果用 RC,并发事务的提交顺序和执行顺序会不一致,重放结果可能和主库对不上。所以早期必须用 RR + 间隙锁来保证复制一致性。后来
row格式在 5.1 引入、5.7 之后才变成默认,基于行的实际变更而不是语句本身,这个限制其实已经不存在了,但默认隔离级别没有跟着改回来,一直保留至今。也正因为这样,现在不少互联网公司(包括阿里规范里面)拿到一个新库,第一件事就是手动把它调成 RC。
六、这几个异常最早是怎么定的
回过头看,为什么标准的表里找不到丢失更新和写偏斜这种真正有害的异常?1992 年的 ANSI SQL 标准用脏读、不可重复读、幻读三个异常来定义隔离级别,这套定义是当年适配纯锁实现的产物。1995 年 Berenson 等人写了一篇很有名的论文《A Critique of ANSI SQL Isolation Levels》,专门批判这个标准定得太粗糙,指出它漏掉了丢失更新和写偏斜这些真实存在的异常,还顺手定义了”快照隔离”(Snapshot Isolation)——也就是 PostgreSQL 的 RR 的理论原型,它比 ANSI 的 RR 更强(能防丢失更新),却又比串行化弱(防不住写偏斜),正好卡在标准的缝隙里。这之后 2008 年才有人提出 SSI(可串行化快照隔离)算法,在快照隔离的基础上加了依赖环检测,PostgreSQL 9.1 把它落地成了自己的串行化级别。
也就是说,教材教的还是 90 年代那张地图,工业界的真实演进一直在快照隔离这条线上往前走。真正有害的异常不在旧地图上,但一直都在领土里。
后言
这次折腾下来,感觉隔离级别这东西还是有用的,不是纯八股文。大部分数据安全确实还是靠业务层的约束和幂等设计来兜底,但对隔离级别理解得越深,遇到极端并发场景时越知道该往哪个方向想——是该加个唯一约束,还是该上乐观锁,还是干脆认命去写重试逻辑。选隔离级别这件事,本质上不如先认清自己面对的是哪种威胁模型。
本博客所有文章除特别声明外,均采用 @Oreoft 许可协议。转载请注明出处!
┌┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┬┐
├ 记得关注公众号:没有气的汽水 ┤
└┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┴┘