正在刊行长文 · Essay
2026-08-30所有内容
随机比特 · Random Bits

数据库没有脏读,两个值班人为什么还能同时下线

2026-08-30AI Engineering / Systemsrbits.uk
数据库没有脏读,两个值班人为什么还能同时下线

报警响起时,值班表里已经没有人。甲下线前看见乙仍在岗,乙也看见甲仍在岗;两人各改自己的记录,两笔事务都提交成功。它们没有读到对方尚未提交的内容,也没有更新同一行。这就像两位守门人各自确认“对方还在”,然后同时离开。每次判断都有依据,合在一起却让“至少一人在岗”失守。这种读同一条件、写不同记录的异常,称为写偏斜(write skew)。

快照判断合成错误终态的机制

我们用真实的 PostgreSQL 15.19 做了双会话实验。实例运行在临时用户目录,只通过私有 Unix 套接字连接,未连接任何用户数据库。初态只有 Alice 和 Bob 两行,两人均在岗,业务不变量是 COUNT(on_call = true) >= 1。在 PostgreSQL REPEATABLE READ 下,两个连接先各自读到 other_on_call=true,再在读后写前的屏障处会合,随后分别将自己的行改为下线。

两次提交的 SQLSTATE 为 00000/00000,终态是 Alice=false、Bob=false,on_call=0。这里的原始读取观测是“对方在岗”这个布尔值,不是双方都查得 COUNT=2;计数只用于终态断言。如果两笔事务串行执行,后来者会重新读到 other_on_call=false,此时总数仍为 1,因而不能再让自己下线。

最小复验只需两个 psql 会话。两边都完成 SELECT 后再越过屏障执行 UPDATE

S>  CREATE TABLE doctors(doctor text PRIMARY KEY, on_call boolean NOT NULL);
S>  INSERT INTO doctors VALUES ('Alice', true), ('Bob', true);
T1> BEGIN ISOLATION LEVEL REPEATABLE READ;
T2> BEGIN ISOLATION LEVEL REPEATABLE READ;
T1> SELECT on_call FROM doctors WHERE doctor='Bob';   -- true
T2> SELECT on_call FROM doctors WHERE doctor='Alice'; -- true
    [barrier]
T1> UPDATE doctors SET on_call=false WHERE doctor='Alice'; COMMIT; -- 00000
T2> UPDATE doctors SET on_call=false WHERE doctor='Bob';   COMMIT; -- 00000

PostgreSQL REPEATABLE READ 双泳道显示 Alice 与 Bob 分别在一致快照中读到对方在岗,各自更新自己的记录,两次提交 SQLSTATE 均为 00000,最终在岗人数为 0

分离写入下的共享谓词风险

本次调度中,一笔事务写 Alice 行,另一笔写 Bob 行,没有形成 PostgreSQL REPEATABLE READ 可据以拒绝其中一笔的同一目标行写冲突。这不能简化为“行锁只看写集”:该级别遇到同一目标行的并发更新时,本来也可能返回 40001。本例失效的是跨行条件“至少一人在岗”,它没有在这个级别下获得可串行化保证。

数据库看到的是两组分离的写入,业务看到的是两笔决策共同依赖同一条规则。

“没有脏读”只排除读到其他事务未提交的数据,不代表并发结果能对应某个串行顺序。PostgreSQL 用快照隔离实现自己的 REPEATABLE READ,但其他数据库使用同名隔离级别时,必须重查官方语义并重跑实验。

对照图左侧两个事务的物理写集分别为 Alice 行与 Bob 行且不相交,右侧都依赖至少一人在岗的逻辑谓词,底部显示 Serializable 返回 40001 后整事务重试并保留一人在岗

把业务不变量写进并发验收

同一固定调度改用 SERIALIZABLE 后,本次 PostgreSQL 15.19 实验得到 00000/40001,被中止的更新没有落库,因此 on_call=1。PostgreSQL 保留快照并发,同时监测危险的读写依赖,并不是把事务物理排队。相关可观测模式名为 SIReadLock,它不是普通的阻塞行锁。本次固定运行是第二笔事务失败,产品不承诺固定牺牲哪一方,依赖监测也可能保守中止。

应用应按 SQLSTATE 40001 识别序列化失败,不匹配可能变化的英文报错。重试单位必须是整笔事务。应用要新建事务、获取新快照、重读条件,然后重新决定是否写入。实验中完整重试读到 other_on_call=false,因此更新 0 行,以 00000 提交,终态仍为 on_call=1。只重放旧 UPDATE 会沿用已失效的决策;高争用下还要限制次数并允许多次重试。

并发验收可以用三句话完成。先写出跨行不变量与读取谓词;再在读后写前设屏障,同时断言提交 SQLSTATE 和全局终态;最后让数据库特定的保护方案与整事务重试接受同一复验。显式锁并非无效,但必须让双方在同一个真实共同对象上相遇;只锁各自要修改的不同行,不会自动保护跨行计数规则。

同样的检查也适用于多条审批的累计额度和并发预约容量。先问一笔写入的决定是否依赖一组记录的合计、存在性或容量,再看并发事务是否会写向不同记录。对代码评审而言,关键问题不是单条 UPDATE 是否原子,而是它前面的决策究竟读了什么范围。

整套实验 28/28 项断言通过,连续两次完整复跑的规范化结果一致。原始日志 SHA-256 为 6342f9f4c69d01f6f79d7080e651c4488834a0a34250a013b27ca0bfde558860,结果文件 SHA-256 为 5d55c4e593b2cd82ce2443b27424a17cd204390a9a7278d557c84002fb909f2b。它只证明固定两行、固定交错下 PostgreSQL 15.19 的正确性结果,不证明其他数据库、其他版本或生产性能。官方语义参考 PostgreSQL 18.6 文档。

资料依据包括 PostgreSQL 18.6 《Transaction Isolation》《Serialization Failure Handling》

随机比特公众号二维码
公众号 · 随机比特
从 AI 工具热闹里拆工程真相

写边界、控制面、上下文、成本与安全。