两个人同时预订最后一间房,为什么“库存没有负数”仍然可能超卖?支付成功晚于预留期限时,为什么不能直接把订单改成成功?下面用两个可重复操作的时序案例,连接库存、订单与支付的边界。

这是浏览器内的确定性教学模型:按钮控制事件顺序,没有连接数据库或支付服务。它展示需要守住的不变量,生产实现仍需要数据库事务、持久化幂等记录和故障恢复。

本文目录
  1. 最后一间房的竞争
  2. 把不变量放进事务
  3. 迟到支付与重复回调
  4. 崩溃与补偿清单
  5. 面试追问

案例一:最后一间房,两个请求

设同一晚容量为 1,可售库存为 1。请求 A 和 B 都先查询可售库存,然后各自创建预订并写回“原值减一”。两个请求可能都写入 0,数据库的非负约束没有报错,但已经接受了两份预订。

真正的不变量:可售库存 + 有效预留 + 已售数量 = 总容量。只检查 available ≥ 0 不足以避免旧值覆盖。

步骤可售库存有效预留
A 读取库存 110
B 读取库存 110
A 写入 0,接受预订01
B 写入 0,也接受预订02

默认展示错误顺序:容量只有 1,却产生了 2 份有效预留。选择原子扣减查看区别。

将库存与预订放在同一个事务里

对于预先存在的一晚库存行,条件更新只允许仍有库存的请求成功;应用必须检查更新行数。预订记录也要在同一个事务内写入,不能先扣库存再在另一个事务创建订单。

UPDATE room_inventory
SET available = available - 1
WHERE room_night_id = $1 AND available > 0
RETURNING available;

在 PostgreSQL Read Committed 中,竞争更新会等待相关行的事务结束,并在已更新的行上重新检查条件。因此第二个请求看到剩余为 0 时,不应再拿到一个成功更新。这个特性适用于这里的单行条件,不能推广成“任意跨行检查都安全”。PostgreSQL 事务隔离说明

  • 多晚预订:确认每晚的库存行都存在,按固定日期顺序修改;任何一晚缺失或更新失败,都回滚整笔事务。多晚可用性不能只靠最后一次 UPDATE 成功判断。
  • 重复创建请求:在持久存储上约束业务请求键唯一,重复请求返回同一笔预订结果,不再扣库存。
  • 统一锁顺序:取消、确认和过期释放也要采用约定的锁顺序;死锁或序列化失败时,有限次数重试完整事务。
  • 外部支付:先提交短事务,再调用支付服务。不要在网络调用期间占住库存行锁。

案例二:预留到期后,支付才成功

预留期限是第 10 秒,初始状态为 RESERVED,可售库存为 0。本例采用明确策略:数据库时间达到期限后不再确认原预留;如果已经扣款,就排队退款。确认必须在 now < deadline 时完成。

演示假设回调签名、支付对象、金额、币种和预订身份已核验。选择不同顺序,观察状态、库存与退款任务数。

事件预订状态可售退款任务处理结果
10s:过期任务HOLD_EXPIRED10释放一次
11s:支付成功HOLD_EXPIRED11排队退款
12s:重复过期任务HOLD_EXPIRED11不再释放
13s:重复支付回调HOLD_EXPIRED11不再创建退款

默认展示过期先发生:释放一次库存,排队一次退款;退款排队不代表退款完成。

把演示变成可恢复的服务

状态判断、库存变更、业务幂等记录和 outbox 事件应在同一个数据库事务提交。退款 worker 再消费持久化任务,调用支付方,并记录待处理、成功或失败结果。下表是恢复设计检查项,不是本演示已经实现的后端能力。

故障窗口恢复决策
过期 worker 没运行,支付已迟到回调处理也检查数据库期限。若仍是 RESERVED,原子地过期并释放一次库存,再排队退款。
扣款请求超时,结果未知保留原支付对象与操作键,查询结果或按同一幂等操作重试。不能用新的请求键创建第二次扣款。
状态已提交,但服务发送消息前崩溃同事务写 outbox,worker 恢复后重试发送;消费者按业务效果去重。
退款已成功,worker 在记录成功前崩溃复用退款操作键并核对支付方结果。幂等记录保留多久、何时需要对账必须明确。
回调重复或乱序事件 ID 去重之外,还要用状态与业务操作键保证同一效果只执行一次。不要让较旧事件覆盖终态。

用这五个追问检验方案

  1. 如果已经有库存非负 CHECK 约束,为什么第一个演示仍会超卖?
  2. 一共预订 3 晚,第三晚扣减失败时,前两晚如何回滚?
  3. 支付在第 9 秒完成,但回调第 11 秒才到,是否退款?请先说明产品规则。本例按确认时的数据库期限判断。
  4. 退款队列积压时,用户页面应该显示什么?哪些告警区分“退款待处理”和“退款失败”?
  5. 去重记录的生命周期比支付方幂等键更长或更短,会出现什么问题?

Stripe 的文档说明了回调签名、重试与事件顺序和幂等请求语义。本文的时序、策略和交互演示是教学示例,不代表某个平台的真实架构。

完整设计:酒店预订 · 电商结账 · DDIA 事务 · 30 题路线

常见问题

库存不为负数就能保证不超卖吗?

不能。两个请求可能都读到 1、都写入 0,并各自创建一份预订。需要将条件扣减和预订记录绑定在同一个原子事务中,并验证容量不变量。

支付成功晚于预留期限怎么办?

先定义业务策略。本例不恢复已失效预留,而是在核验支付后排队幂等退款。另一种方案可以重新申请库存,但必须作为新的、可失败的预留流程。

重复回调只按事件 ID 去重够吗?

不一定。不同事件可能描述同一个业务效果。还需要基于预订状态、支付身份和业务操作键限制重复扣减、重复确认及重复退款。