题干与适用场景
表 doctorshifts(teamid, shiftdate, doctorid, on_call) 记录值班状态,业务规则是同一团队、同一班次至少有 1 名医生在岗。Alice 和 Bob 都在岗,两个请求同时尝试让不同医生离岗。每个事务先统计在岗人数;只要人数大于 1,就更新自己的医生行。
本题限定 PostgreSQL 18。初始在岗人数是 2,两个事务都在任何一方提交前完成读取,之后分别更新不同的行。数字和表结构是面试假设,不是医疗系统的通用数据模型。重点是跨多行谓词 count(on_call) >= 1,不讨论排班审批、时区或权限。
这类问题常见于库存不能为负、账户必须保留审批人、一个集群至少保留一个主节点等后端规则。只说“放进事务”不够:原子性保证单个事务全部成功或全部失败,隔离级别才决定并发事务能看到什么,以及数据库是否拒绝无法串行解释的结果。
面试官考察点
第一项信号是能否识别写偏斜。两个事务读取相同谓词,却写入不同的行,因此不会形成普通的同一行写写冲突。每个事务单独看都满足前置检查,两个结果合并后却让在岗人数变成 0。把它叫成脏读或简单的丢失更新,会导向错误修复。
第二项信号是能否把标准名称与数据库实现分开。PostgreSQL 的 Read Committed 每条语句取得新快照;Repeatable Read 使用事务级稳定快照,实际属于快照隔离,并仍允许序列化异常;Serializable 在类似快照的读取方式上追踪读写依赖,发现无法对应任何串行顺序时中止事务。其他数据库同名级别的锁与快照语义可能不同,不能照搬结论。
第三项信号是从不变量选择并发控制边界。若不变量跨多行,锁住“自己要更新的医生行”没有让两个事务相遇。安全方案必须让它们竞争同一个可锁对象、把规则收敛到同一行的原子条件,或交给 Serializable 检测冲突。
最后看失败处理。Serializable 和显式锁都可能让事务中止;前者常见 40001,锁顺序不一致还可能产生 40P01。强回答会重试完整事务、限制尝试次数,并把邮件等外部副作用放到成功提交之后的幂等链路中。
回答前需要澄清的问题
- 使用哪一种数据库和版本? 本题按 PostgreSQL 18 回答。若换成 MySQL、SQL Server 或分布式数据库,要重新查同名隔离级别的实现与重试错误。
- 规则只涉及一个班次吗? 若不变量是
(teamid, shiftdate)级别,就能为每个班次建立一个稳定竞争点;若规则跨地区或跨数据库,单库行锁无法覆盖完整边界。 - 在岗状态有哪些写入路径? 请假、调班、批量导入和后台修复都必须遵守同一协议。只修一个 API,旁路写入仍会破坏计数或绕开锁。
- 冲突是偶发还是持续高频? 低冲突时 Serializable 加重试通常最清晰;高冲突热点可用单行条件更新减少无效工作,但会把该班次变成串行热点。
- 允许请求等待多久? 悲观锁会等待持锁事务。若接口延迟预算严格,应设置短事务、锁等待上限和明确的“状态已变化,请重试”结果。
- 事务内是否有外部副作用? 重试会重新执行事务逻辑。发邮件、调用排班服务或写非事务消息必须延后到提交后,或使用事务性 outbox 与幂等消费。
30 秒回答框架
“这是写偏斜:两个事务都从同一在岗集合做判断,却更新不同医生行,所以 Repeatable Read 的稳定快照仍可能让两者提交。PostgreSQL Serializable 会追踪这种读写依赖,至少中止一个事务;应用必须按 40001 重跑完整决策。若不用 Serializable,我会把每个班次映射到同一个 roster 行,在 Read Committed 下原子执行 active_count > 1 的条件递减,并在同一事务更新医生状态;或者先锁 roster 行,再重新统计。最后用两个连接在读取后加屏障,证明弱隔离会得到 0,而安全方案最终恰好保留 1 人。”
分步骤深入解答
先写出并发历史。初始 Alice=true, Bob=true:
T1: read count(on_call) = 2
T2: read count(on_call) = 2
T1: update Alice to false
T2: update Bob to false
T1: commit
T2: commit若先串行执行 T1,T2 应读到 1 并拒绝;若先执行 T2,T1 也应拒绝。实际结果为 0,无法等价于任一串行顺序,因此属于序列化异常。它与丢失更新的关键差别是:T1 与 T2 没有覆盖同一行,普通行级写锁不会自然冲突。
在 Read Committed 下,两个统计语句各自看到语句开始时已经提交的数据。给定题目的屏障,两者都先读到 2,随后对不同目标行的 UPDATE 都能提交。后续语句会取得新快照,并不追溯推翻已经做出的业务判断。
PostgreSQL 的 Repeatable Read 为整个事务保留同一快照,避免同一事务内的不可重复读和幻读,却允许序列化异常。两边更新不同版本链,没有同一行并发更新触发的回滚条件,所以两者可能都提交。MVCC 解释了读取怎样避免阻塞写入;它本身不等于 Serializable,也不会自动理解“至少一人在岗”这个业务规则。
第一种安全方案是 Serializable:
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT count(*) AS active_count
FROM doctor_shifts
WHERE team_id = 42
AND shift_date = DATE '2026-07-20'
AND on_call;
-- Reject when active_count <= 1.
UPDATE doctor_shifts
SET on_call = false
WHERE team_id = 42
AND shift_date = DATE '2026-07-20'
AND doctor_id = 101
AND on_call;
COMMIT;两个请求同时执行时,PostgreSQL 检测到读谓词与并发写入组成了无法串行化的依赖,不能让两者都成功提交。失败事务返回 SQLSTATE 40001。重试必须从 BEGIN 前重新运行,包括重新统计和决定是否更新;只重放最后一个 UPDATE 会沿用过期判断。重试应加入随机退避、最大次数和总截止时间,因为高竞争下第二次仍可能失败。
Serializable 的优点是业务代码直接围绕真实谓词表达规则,不必为每项不变量手工设计锁。代价是依赖追踪开销和冲突重试;事务读集越大、持续越久、竞争越集中,失败概率通常越高。索引 (teamid, shiftdate)、短事务和提交前不等待网络调用,都能缩小重叠窗口。
第二种方案把跨行不变量投影到一行 oncallrosters(teamid, shiftdate, active_count),并在 Read Committed 下原子更新:
BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED;
UPDATE on_call_rosters
SET active_count = active_count - 1
WHERE team_id = 42
AND shift_date = DATE '2026-07-20'
AND active_count > 1
RETURNING active_count;
-- Continue only when exactly one roster row was returned.
UPDATE doctor_shifts
SET on_call = false
WHERE team_id = 42
AND shift_date = DATE '2026-07-20'
AND doctor_id = 101
AND on_call;
-- Commit only when both updates changed exactly one row; otherwise roll back.
COMMIT;两个请求会竞争同一 roster 行。PostgreSQL 的 Read Committed 在等待并发更新完成后,会对新版本重新检查 activecount > 1;第一个从 2 改为 1,第二个条件失败并返回 0 行。可以再为 activecount 加行级 CHECK (active_count >= 1) 作为最后防线。代价是所有在岗、离岗与数据修复路径都必须在同一事务维护计数;还要定期比对计数与明细,发现漂移就报警,而不是静默覆盖。
不想维护计数时,可以为每个班次保存稳定 roster 行,在 Read Committed 事务的第一步对它执行 SELECT ... FOR UPDATE,获得锁后再统计明细并更新。第二个请求等待后,其下一条 SELECT 会取得包含首次提交的新快照。这个方案的关键是所有写路径先锁同一 roster,且事务保持短小。若在 Repeatable Read 中先建立旧快照,再只锁一个未被修改的 guard 行,后续统计仍可能看到旧数据;“有 FOR UPDATE”不是脱离快照语义的万能证明。
直接锁所有当前在岗医生也需要谨慎。锁的集合来自一个谓词,遗漏的新行、不同查询顺序或旁路写入都会改变安全性;多行锁顺序不一致还会死锁。若确实采用多行锁,应按稳定主键顺序获取,并对 40P01 重跑完整事务。单独锁“我要请假的医生”肯定不足,因为两个请求仍锁不同的行。
验证时不要串行调用两个 API。用两个独立数据库连接和一个测试屏障:两边都开始 Repeatable Read,执行统计并确认都得到 2,屏障同时放行更新与提交。基线应稳定复现“两次提交、最终为 0”。换成 Serializable 后,断言不能出现两次成功;一个事务收到 40001,完整重试后看到 1 并拒绝离岗。换成 roster 条件更新后,断言恰好一个 RETURNING 有结果,最终明细与 active_count 都为 1。
把该并发用例重复运行并打乱提交顺序,同时覆盖同一医生的重复请求、第三名医生加入、事务在两次更新之间回滚、锁等待超时与死锁。线上监控 40001、40P01、重试次数、锁等待、事务时长、最终失败率,以及 roster 计数与明细的差异。冲突率突然升高通常说明热点、长事务或新写入路径进入了同一边界,不能只靠无限重试掩盖。
高质量示范回答
“我会先把它判定为写偏斜。两个事务检查的是同一条跨行规则,但 Alice 和 Bob 分别更新自己的行,因此行级写锁不会让它们冲突。给定两边都先读到 2 的执行顺序,Read Committed 可以让两者提交;PostgreSQL Repeatable Read 虽然让每个事务一直看到稳定快照,也仍可能得到无法用任何串行顺序解释的最终结果 0。
我优先用 Serializable 表达这个规则。事务内统计当前班次的在岗人数,大于 1 才更新。PostgreSQL 会跟踪谓词读取与并发写入的依赖;此例不能让两个事务都提交,其中一个会收到 40001。应用捕获它后,从头重新运行统计和更新,并限制重试次数。通知等外部副作用不放在可能重试的事务中,成功提交后再从幂等 outbox 处理。
如果这个班次是高竞争热点,我会考虑 oncallrosters 行。离岗事务在 Read Committed 下用 UPDATE ... SET activecount = activecount - 1 WHERE active_count > 1 RETURNING ... 抢同一行,成功后再更新医生明细;任一步没有更新一行就整体回滚。这样第二个请求会在最新行版本上重新检查条件并失败,代价是所有写路径必须同步维护计数。
验收时我会用两个连接和屏障固定交错顺序。先证明 Repeatable Read 下两边能读到 2 并把结果写成 0;再证明 Serializable 恰好有一个事务中止,重试后拒绝;计数方案则只有一个条件更新成功。最后监控序列化失败、死锁、锁等待和计数漂移,确认安全性没有靠测试时序碰巧成立。”
常见错误
- 只说“用了事务就安全” → 原子性没有规定并发事务的可见性与提交顺序 → 指出具体隔离级别,并写出能破坏不变量的交错历史。
- 把写偏斜当丢失更新 → 两个事务写不同的行,针对单行版本号的检查不会覆盖共享谓词 → 找出跨行不变量,并建立共同竞争点或使用 Serializable。
- 宣称 Repeatable Read 等于 Serializable → 稳定快照仍可能产生无法串行解释的合并结果 → 按 PostgreSQL 实现说明它允许序列化异常。
- 只锁各自医生行 → Alice 与 Bob 的锁互不冲突 → 锁同一个 roster 行、统一计数行,或让 SSI 检测谓词依赖。
- 在 Repeatable Read 旧快照后随手加
FOR UPDATE→ 锁住一个未变化的 guard 行不会刷新事务快照 → 在 Read Committed 下锁后重读,或改用 Serializable/可变计数行。 - 收到
40001只重试COMMIT或最后一条 SQL → 业务判断仍来自旧快照 → 重跑整个事务及决定 SQL 的应用逻辑。 - 无限立即重试 → 热点下请求会同步碰撞并放大数据库负载 → 使用带抖动退避、最大次数和总截止时间的重试策略。
- 事务里先发通知再提交 → 序列化失败重试可能发送重复通知 → 把外部副作用延后,使用事务性 outbox 与幂等消费。
- 维护计数却允许旁路更新明细 →
active_count与真实在岗人数漂移,条件更新失去意义 → 让所有写路径共享事务协议并持续对账。
追问及应对
追问一:为什么一个原子 UPDATE 能解决库存不能为负,却不一定解决本题?
单行库存可以写成 UPDATE inventory SET stock = stock - 1 WHERE stock > 0,条件与写入落在同一行;并发更新会在最新版本上重新检查条件。本题的条件是多行计数,而写入只触碰一名医生。要获得同样性质,需要把在岗人数物化到同一 roster 行,或使用能检测谓词依赖的 Serializable。
追问二:Serializable 失败率很高怎么办?
先缩短事务、补齐谓词索引、移除事务内网络调用,并按班次观察冲突分布。若少数热门班次占据大部分 40001,可仅把这些写入改成 roster 条件更新,让竞争在一行上排队。若所有班次都高冲突,应重新检查批量操作和事务边界,不能用更高重试上限替代容量分析。
追问三:能否只用 CHECK 约束保证至少一人在岗?
直接放在单个 doctorshifts 行上的检查只能验证该行字段,无法让该行独立证明同班次还有其他在岗医生。把不变量投影为 roster 的 activecount 后,CHECK (active_count >= 1) 才能作为数据库级防线;明细与计数仍必须在同一事务更新并做对账。
追问四:服务进程在 roster 计数递减后崩溃怎么办?
只要计数与医生状态处于同一个数据库事务,连接断开会让未提交事务回滚,两者不会只完成一半。若已经提交,客户端却没收到响应,请求结果属于未知;重试应带业务操作 ID,并先读取医生当前状态,避免把同一次离岗再次计数。
追问五:规则扩展为两个数据库中的医生总数怎么办?
单库 Serializable 和行锁只能保护本库可见的数据。需要先选择一个权威写入边界,例如把班次容量集中到一个强一致 roster 服务,再由其他库异步投影;或者引入分布式事务并接受协调可用性与延迟成本。若两个库各自判断后异步合并,就必须明确允许短暂违规以及补偿策略,不能沿用单库安全结论。