← 完整项目实验室 · 第 6 篇

项目:库存预留与履约状态服务

用独立库存项目连接预留、确认、过期和补偿,设计事务、条件迁移与 outbox,防止重复消息改变库存。

学完这一篇,你能做到

定义库存不变量和预留状态,并检验并发、重复事件、超时和过期竞争。

把库存操作拆成有条件的状态迁移

库存服务最先需要保证的行为是:一份可用库存不能被两个有效预留同时占用。这个项目用合成商品和数量练习预留、确认、过期与补偿,不接真实支付。先在一个模块化应用和一个数据库内实现,再依据团队边界、负载和独立发布需求判断是否拆成微服务。

定义 available 为可继续预留的数量,预留处于 pending 时占用库存,确认后消耗它,过期或取消后释放它。若还要管理实物入库、退货与损耗,应另建相应账目,不能把 available 当成所有库存事实。对于固定初始数量的练习,可用量加待确认量加已消耗量应保持一致,且可用量始终非负。

每个操作都写清前提。确认只接受未过期的 pending,过期只释放 pending,取消不能再次释放已过期记录。状态名称本身不会阻止并发;数据库必须把“检查旧状态”和“迁移到新状态”作为一个受约束的操作。

请求身份与数据库事务一起设计

stock 保存商品和可用量,reservation 保存请求键、商品、数量、状态、到期时间与版本。请求键的唯一约束需要有明确范围,例如租户与请求键的组合。重复键必须检查请求内容是否一致;同一个键换了商品或数量不能复用原来的成功结果。

预留时在事务中检查或锁定库存行,满足可用量条件才扣减,再插入预留记录。也可以使用带数量条件的更新,并检查受影响行数。检查与扣减分开的两个请求会产生竞争;事务存在也不代表随意选择的语句和隔离级别自动消除超卖。行锁顺序、死锁重试和事务回滚都要按实际数据库验证。

响应超时只说明客户端没拿到结果,预留可能已经提交。客户端应先按逻辑请求键查询,重试仍使用同一个键。前端禁止重复点击有助于体验,但唯一约束与服务端幂等处理才决定重复请求是否改变库存。

用离线模型检查重复释放

下面使用两个合成库存单位。字典保存预留状态,只有 pending 可以被过期逻辑释放。重复提交和重复过期都返回失败且不再次修改数量。

stock = 2
reservations = {}
def reserve(key):
    global stock
    if key in reservations or stock == 0:
        return False
    stock -= 1
    reservations[key] = "pending"
    return True
def expire(key):
    global stock
    if reservations.get(key) != "pending":
        return False
    reservations[key] = "expired"
    stock += 1
    return True
assert reserve("R-1") and not reserve("R-1")
assert expire("R-1") and not expire("R-1")
assert reservations["R-1"] == "expired" and stock == 2
print(stock, reservations)

输出为 2 {'R-1': 'expired'}。它检查了一条顺序执行路径:一次扣减、一次释放、重复消息无额外效果。代码没有数据库锁、并发事务、持久化或真正的到期时钟,不能用它证明并发场景安全。

示例也没有确认函数和多单位数量校验。扩展时增加确认分支,并验证它不能接受已过期记录;对数量要求正整数,在接口边界拒绝负数、零和不合法类型。生产实现还需要请求内容比对,不能简单把所有重复键都当作同一种失败。

让确认和过期争用同一条记录

到期扫描器与确认请求可能同时看到 pending。两者都必须在数据库内进行条件迁移,并把库存效果放进同一事务。扫描器看到旧状态后不能直接加库存;确认成功后的过期更新应影响零行,然后根据当前状态结束。

到期时间由服务端确定,并明确边界比较。这个练习可以规定达到到期时间就不能确认,确认事务重新检查时间和状态;但支付已完成后的迟到消息需要业务补偿或人工处理,不能悄悄把过期预留变回成功。时间检查通过并不代表外部支付和库存处于同一个原子事务。

保留状态变化原因、请求身份和版本,方便排查“超时却占库存”“取消后仍扣量”等问题。记录不应包含真实付款凭据。定期检查不变量,发现差异时定位账目和事件来源,不应直接把负值修成零掩盖错误。

Outbox 记录提交后的消息意图

如果确认需要通知履约模块,把库存或订单变化与 outbox 记录写入同一数据库事务。提交成功后发布器读取 outbox 并投递消息。这样可以避免“数据库成功但应用在发消息前崩溃,通知意图也丢失”的缺口。

发布器仍可能在发送成功后、标记已发送前崩溃,下一次就会重复投递。RabbitMQ 的 publisher confirm 说明发布端与 broker 的相应交互结果,不能证明消费者已完成库存业务;consumer acknowledgement 是另一条确认路径。消费端用事件 ID 的唯一记录与业务更新在同一事务中去重,业务提交后再确认消息。

跨数据库或外部履约服务的补偿是新的业务动作,不是把所有历史操作整体回滚。设计补偿键、可重试状态和人工核实入口,不假设网络消息具有 exactly-once 的最终业务效果。永久失败消息应进入可观察的失败处理流程,避免无限重新入队。

查询已提交结果时也要检查订单或预留归属,不能因为请求键难以猜测就省略授权。对缺货请求是否保存幂等结果需明确:若失败也被冻结,同一个键稍后库存补足仍返回原结果;用户发起新意图应使用新键。该政策决定重试语义,应在接口文档和测试中保持一致。

从一条闭环扩展到故障矩阵

先实现查询库存、预留和按请求键查询,再实现确认、过期与审计。把 outbox 发布器作为后续步骤,这样能先证明数据库内的不变量。接口响应区分缺货、输入错误、请求键冲突与结果暂未知;不要把这些情况全部折叠成“重试”。

验证至少两名客户端争抢最后一个单位,确认与过期竞争,事务中途失败,提交后响应丢失,发布后标记失败,以及消费完成后确认丢失。对同一事件反复投递,检查最终数量与状态仍一致。真正的并发保证应由数据库集成测试和故障注入验证,而不只靠离线断言。

若以后拆服务,重新标出每个事务覆盖哪些记录、哪些消息可能重复、何时需要查询或补偿。保留同一套状态与不变量,服务数量增加不应改变库存含义。

资料来源与延伸阅读

资料核验:2026-11-10。本文为原创工程讲解,教学示例不代表线上实测。