PostgreSQL Serializable 实战(第 3 篇):两行都没写错,值班医生为什么一个也没剩
发布时间:2026/9/4 21:59:45 作者:尧图编辑部 阅读量:1,286
:两行都没写错,值班医生为什么一个也没剩)
排班规则要求至少一名医生值班。Alice 下班前确认 Bob 仍在Bob 也确认 Alice 仍在两人分别只修改自己的记录没有覆盖对方数据两个事务都成功提交最终却无人值班。行级写入没有冲突不代表跨行不变量安全。PostgreSQL Repeatable Read 提供稳定快照但仍允许 write skewSerializable 才保证成功提交的结果等价于某个串行顺序。先证明问题不是丢失更新以下实验只应在 PostgreSQL 18.6 测试库执行需要两个独立会话。初始化数据DROPTABLEIFEXISTSdoctor_on_call;CREATETABLEdoctor_on_call(doctortextPRIMARYKEY,on_callbooleanNOTNULL);INSERTINTOdoctor_on_callVALUES(alice,true),(bob,true);两个事务都使用 Repeatable Read。会话 ABEGINISOLATIONLEVELREPEATABLEREAD;SELECTcount(*)ASon_call_countFROMdoctor_on_callWHEREon_call;-- 预期2UPDATEdoctor_on_callSETon_callfalseWHEREdoctoralice;保持 A 未提交。会话 BBEGINISOLATIONLEVELREPEATABLEREAD;SELECTcount(*)ASon_call_countFROMdoctor_on_callWHEREon_call;-- 预期2UPDATEdoctor_on_callSETon_callfalseWHEREdoctorbob;COMMIT;回到会话 ACOMMIT;第三个会话验收业务结果SELECTdoctor,on_callFROMdoctor_on_callORDERBYdoctor;SELECTcount(*)ASon_call_countFROMdoctor_on_callWHEREon_call;两次 UPDATE 都正确命中一行也没有修改同一业务主键因此没有发生 lost update错误发生在两次正确写入的组合结果上。两个事务各自基于稳定但过时的快照判断“另一人仍值班”最终计数为 0。这就是 write skew事务读取一个集合或谓词却分别写入不同记录使跨行规则失效。为什么 Repeatable Read 没有违反承诺PostgreSQL 18 的 Repeatable Read 不允许脏读、不可重复读和幻读但官方隔离级别表仍明确标记它可能发生 serialization anomaly。对当前实验而言不存在任何串行顺序能得到相同结果若 Alice 先完整提交Bob 随后应看到只剩自己值班不能下班若 Bob 先完整提交Alice 同理不能下班并发执行却让两人都依据最初的“两人值班”完成更新。Repeatable Read 保证同一事务反复读取时快照不漂移并不保证多个成功事务的总体效果等价于串行执行。Serializable 如何改变结果恢复初始状态UPDATEdoctor_on_callSETon_calltrue;会话 ABEGINISOLATIONLEVELSERIALIZABLE;SELECTcount(*)FROMdoctor_on_callWHEREon_call;UPDATEdoctor_on_callSETon_callfalseWHEREdoctoralice;会话 BBEGINISOLATIONLEVELSERIALIZABLE;SELECTcount(*)FROMdoctor_on_callWHEREon_call;UPDATEdoctor_on_callSETon_callfalseWHEREdoctorbob;依次尝试提交两个事务COMMIT;至少一个事务应以 SQLSTATE40001即serialization_failure失败。失败可能出现在 UPDATE 或 COMMIT具体位置受交错顺序影响应用不能假定只有提交语句会报错。最后重新查询至少应有一名医生仍在值班。真正的验收是跨行不变量恢复而不是“日志里出现了 40001”。SIReadLock不是普通阻塞锁在两个 Serializable 事务保持打开时可从第三个会话只读观察SELECTpid,locktype,mode,relation::regclass,page,tuple,grantedFROMpg_locksWHEREmodeSIReadLockORDERBYpid,locktype,relation,page,tuple;这条查询读取锁状态不修改数据需要能够看到相关会话信息。SIReadLock用于记录 Serializable 事务读过的谓词范围或数据并不意味着普通写入一定被它阻塞。PostgreSQL 的 Serializable Snapshot Isolation 会追踪事务间的 rw-conflictT1 读取“当前值班集合” → T2 修改 Bob 行 T2 读取“当前值班集合” → T1 修改 Alice 行 两条反向读写依赖形成危险结构 → 为避免不可串行化结果中止一个事务pg_locks中出现SIReadLock只能证明系统在追踪 Serializable 读依赖不能单独证明某个事务一定会失败。完整证明仍需要并发结果和最终业务计数。为什么普通 CHECK 约束帮不了这个规则可以给每行加CHECK却不能用它可靠表达“整张表至少一行 on_calltrue”。PostgreSQL 官方文档明确说明CHECK 不支持以其他行数据作为持续一致性保证跨行规则应优先考虑UNIQUE、EXCLUDE、外键、显式锁、Serializable 或重新建模。如果把规则改成每个班次都有一条固定的shift_guard记录并要求下班事务先锁住它那么SELECT ... FOR UPDATE可以把同一班次的判断串行化。代价是所有相关写入都必须遵守同一协议热点班次会形成等待队列。三种方案面对同一不变量共同前提是两个应用实例并发修改不同医生记录提交后必须至少保留一名值班医生。方案正确性来源优势成立条件代价与失效边界应用查询后直接更新没有并发裁决只有单写者或业务天然无竞争多实例并发会 write skew锁定班次守卫行同一班次写入被显式串行化所有入口锁同一行且锁顺序一致热点等待、死锁治理、遗漏入口Serializable 重试SSI 检测不可串行化依赖所有相关读写进入 Serializable 事务发生 40001 时必须重试完整事务如果规则能重构为普通唯一或排他约束约束通常更直接若规则依赖动态集合、聚合判断和多条 SQLSerializable 能覆盖应用没有显式枚举出的读写关系。40001只是重试协议的开始PostgreSQL 不自动重试因为数据库不知道事务外的业务决策和副作用。正确处理必须满足捕获 SQLSTATE40001回滚失败事务从BEGIN前的业务决策开始重试不只重放最后一条 UPDATE每次重试重新读取当前数据不能复用旧快照计算出的值设置有限次数、指数退避与随机抖动避免冲突风暴让事务内写入幂等或能识别已经完成的业务请求把不可回滚的邮件、HTTP 调用和消息发送放到提交后或使用 Outbox 等可恢复设计。官方文档还指出40P01 deadlock_detected往往也适合重试而23505、23P01是否可重试要结合业务判断因为它们可能代表持续存在的约束冲突不能一律无限重放。生产验证不能只统计失败率至少同时观察业务不变量是否出现无人值班SQLSTATE40001的比例、租户或班次分布重试结果首次失败后最终成功、放弃和超时数量事务时长长事务会扩大依赖追踪范围外部副作用数据库回滚后是否留下重复消息或调用用户体验高竞争时的尾延迟和明确错误。低比例40001可能是 Serializable 正常保护业务持续高比例则说明热点建模或并发策略需要调整不能靠无限提高重试次数掩盖。实验边界与清理本实验能证明 PostgreSQL Repeatable Read 允许写偏差Serializable 能通过中止事务保护该不变量不能证明 Serializable 对所有负载都最优也不能代表跨逻辑副本读取获得相同完整性保护。官方文档明确提醒这种 Serializable 一致性保护不扩展到 hot standby 或逻辑副本读取。实验结束后DROPTABLEdoctor_on_call;面试表达Write skew 不是丢失更新两个事务读同一业务谓词却写不同记录Repeatable Read 下都可能提交。PostgreSQL Serializable 用SIReadLock追踪 rw-conflict发现危险结构后返回40001应用必须重试完整事务并把外部副作用放到可恢复的提交边界之外。官方资料Transaction IsolationSerializable Isolation LevelSerialization Failure HandlingData Consistency Checks at the Application LevelConstraintspg_locksPostgreSQL 18.6 源码标签 REL_18_6