第10章 一次性消费、幂等与并发事务
一次批准为什么会执行两次
网络超时后,客户端不知道退款是否发生,于是重试。与此同时,Agent 可能因没有收到工具结果再次调用。两个请求几乎同时读取 used=false,都完成退款,然后各自写 used=true。检查逻辑看似完整,但检查与修改不在同一个原子边界中,仍然产生双重执行。
本书用一把业务互斥锁包住审批状态读取、业务状态检查、审批标记、对象修改和审计追加。它只是单进程内存事务示意,不适合横向扩展。生产系统应把这些操作放进数据库事务,用行锁、条件更新或唯一约束保证一个审批只有一次成功的状态迁移。服务实例之间的本地锁无法互相协调。
常见实现是条件更新审批 WHERE id=? AND used_at IS NULL AND expires_at>now(),检查影响行数为一,再在同一事务内修改业务对象并写入审计或 outbox。事务隔离级别、锁顺序和异常回滚都需要验证。不能先提交退款,再异步标记审批;进程在两步之间崩溃会让同一批准再次可用。
幂等不是简单拒绝重试
一次性审批防止重复副作用,但产品仍需要让客户端在不确定状态下恢复。合理的幂等设计会为同一个业务请求保存稳定幂等键、规范化参数摘要与最终结果。相同键和相同参数的重试可返回原结果;相同键与不同参数应拒绝。幂等记录与业务修改要在同一事务中建立关系。
本书实验选择最清晰的观察方式:第一次执行成功,重放旧审批返回拒绝。它没有实现结果重放的幂等 API,也没有跨服务支付系统的对账。真实退款接第三方支付时,即使本地事务正确,外部系统也需要幂等键、状态查询和补偿流程。不要把“本地只执行一次”误认为分布式世界的 exactly-once 保证。
失败案例是系统只按任务 ID 缓存成功结果,不绑定参数。攻击者沿用原任务 ID 换金额,服务返回旧的成功,客户端却以为新金额已执行;后续流程因此做出错误账务决策。参数摘要与幂等键必须一起验证,响应也应清楚说明对应的规范化请求。
状态冲突与审批重放应分别可见
如果订单已经退款,第二次请求可能因状态不符失败;这不能单独证明审批已经一次性消费。测试特意在首次成功后把对象状态恢复为 paid,再提交旧审批,仍应拒绝。这样排除了“只是业务状态碰巧挡住”的解释。
并发测试发出十个真实 HTTP 请求,要求恰好一个返回成功,其余因审批失效拒绝。开启 Go race detector 可以发现数据竞争,但它不是业务一致性证明。十个请求全部成功也可能没有数据竞争,因为代码用了锁但锁住了错误区域。因此测试同时检查结果数量与状态语义。
可运行实验与迁移
运行 go test -race -run TestApprovalExpiryRevocationAndConcurrency -v ./lab。阅读 one_atomic_consumption 子测试和业务 handler 中锁的范围。再构思迁移到 PostgreSQL:审批表需要哪些唯一键,订单行什么时候锁定,审计是否进入同一事务,失败后审批保持什么状态。
练习:设计“执行成功但响应丢失”的恢复流程。客户端应如何查询结果,Agent 是否重新申请审批,人工如何判断是否发生副作用。一个成熟答案通常包含稳定请求引用、幂等查询和业务对账,而不是单纯重试三次。实验没有实现这一套,所以这部分应作为生产化新增验收项。
外部副作用需要可恢复的状态机
当退款通过远程支付渠道执行,本地事务不能同时锁住对方系统。可以先在本地创建待执行记录和 outbox,再由执行器使用稳定渠道幂等键提交,随后查询并记录结果。若超时,应先查询渠道状态,再决定重试;不能因未收到响应就假设没有扣款或退款。
状态机需要区分待发送、发送中、结果未知、成功和失败。结果未知不是可以随意重试的失败,也不是可以向用户承诺成功。Agent 应收到足够明确的状态和查询引用,避免继续执行依赖“退款已完成”的后续动作。人工处理界面也应能看见同一业务引用,而不是重新创建另一笔退款。
审批可在创建不可逆执行意图时消费,也可在实际提交时消费,选择取决于业务流程。但无论如何,都要防止一个审批创建多个可独立执行的意图。通过唯一约束把审批与执行记录关联,往往比在每个执行器中手动检查更可靠。这个关系必须在故障恢复测试中验证。