订单场景如何同时保障幂等与数据一致性?
未标记
考查重点:重复执行与部分成功恢复。幂等键守重复动作,事务守本地状态,跨库存/支付用可靠事件与补偿对账,分别验重复和部分失败
本题目录
参考答案
核心回答
订单场景中,幂等解决“同一个业务动作被重复请求或重复投递时,只产生一次预期效果”;一致性解决“订单、库存、积分、支付等相关状态,按业务规则一起成立或最终收敛”。两者需要组合设计。
一个常用方案是:用稳定的请求号或业务流水识别重复,在数据库中用唯一约束和条件状态更新兜底;同库的去重记录与业务变更放在同一事务;跨服务通过可靠事件、消费幂等、重试和补偿推进。 只加一个 Redis 锁、只禁用前端按钮,或只让消息队列重试,都不能独立保证这两件事。
先解决“超时后又点了一次创建订单”
以下是一套参考设计。用户发起一次下单意图时生成请求号,超时重试仍使用原请求号;真正再次购买才换新的请求号。服务端按用户或租户、操作类型及请求号确定去重范围,并核对关键请求内容。同一个请求号却对应另一套商品或金额时,应明确拒绝冲突,不能返回与本次意图无关的订单。
订单表可保存唯一的创建请求标识,或设置专门的幂等记录,关联订单号和处理结果。首次请求在数据库事务内创建订单及相关记录;并发重复请求由唯一约束仲裁,不能只先查一次“有没有”,再无条件插入。
如果第一次已提交、只是响应丢失,重试应查询并返回已有订单。如果第一次事务回滚,后续应能够重新进入;如果异步任务仍在进行,返回可查询的处理状态,而不是虚报成功或再建订单。进程崩溃后,长期停在“处理中”的记录也需要恢复机制。
同一用户购买同一商品两次可能是合法业务,不能仅按“用户+商品”永久去重。反过来,每次重试都生成新订单号,也识别不了同一次下单意图。请求标识、身份与业务语义需要一致。
同一个数据库内,怎样同时保证幂等和一致性
假设订单与积分在同一数据库,业务要求订单创建与积分扣减同时成功:可在一个本地事务里完成请求去重、条件扣积分、创建订单、保存业务流水和结果。任何一步失败,都回滚本次事务,避免留下“去重标记成功,但订单没创建”或“订单创建了,积分没扣”的半成品。
库存或积分扣减还需检查余额、库存等条件,并判断条件更新是否成功;唯一约束防重复,不会自动防止不同合法订单把库存扣成负数。跨多个请求同时操作同一份数据时,还要采用合适的行锁、条件更新或版本控制,维护业务约束。
订单后续操作通过状态机限制合法迁移,例如只有符合条件的待支付订单才能首次确认支付。同一次确认中,状态推进、资金流水和后续事件一起提交;重复处理可以返回已确认结果,不能再次扣减或发货。退款、取消、发货是不同业务动作,应各有相应流水或操作标识,不能把订单号当作所有事件共用的“一次性开关”。
事务与唯一约束都有适用范围。去重表和业务表分布在不同数据库时,不能仅在代码上放进一个事务方法就认为已经原子提交;本地锁也不能覆盖其他服务实例。
跨服务时,把每个阶段做成可恢复的动作
例如订单服务与库存、积分服务分别持有数据库,可以先创建待确认订单,并把待发送事件写入本地事务发件箱。订单和事件同事务提交,再由后台投递,避免订单提交成功但消息永远没有发出的双写缺口。
消费者按“业务实体+动作+相应流水或事件版本”识别重复,在自己的本地事务中完成消费去重记录和实际业务更新,提交成功后才确认消息。若提交后确认丢失,消息会再来,但业务效果不重复;若业务更新失败,去重记录也回滚,下一次才有机会真正补做。
不同消息 ID 也可能代表同一个业务操作,仅按消息系统生成的 ID 去重未必够。消息 Key 只是携带业务标识,也不会自动替应用建唯一约束或事务。
跨服务会有中间状态。可以先预占库存或冻结积分,再确认订单;某一步最终失败时,执行相应的释放或返还,并让补偿本身也幂等。如果业务确实要求更强的跨服务原子约束,需要评估合适的分布式事务方案及参与者能力,不能把最终一致性包装成全链路瞬时一致。
失败、乱序和超时怎样收敛
消息乱序:依据订单当前状态和事件语义判断能否应用;迟到的创建、取消或支付结果不能无条件覆盖更晚的事实。可以结合实体版本、顺序处理或补查缺失步骤,不能简单认为时间戳较大就一定正确。
外部支付结果未知:本地超时不代表渠道没有扣款。保留原支付业务标识,按渠道契约查单或重试,再确认业务状态;本地回滚不能撤销已经发生的外部扣款。重复真实收款也需要记录与退款处理,不能靠忽略通知消除资金事实。
重试窗口和去重数据清理:保留期要覆盖实际可能发生的重试、延迟消息和补偿。删除请求结果缓存后,持久化的业务约束仍应避免重复资金与履约效果;清理去重记录不能悄悄改变原有业务保证。
长期未完成:任务重试要有边界,记录失败原因、当前阶段和下次动作。通过未完成任务扫描、业务流水对账和人工处置入口,使异常最终能推进或补偿,而不是只把失败写到日志里。
常见追问
分布式锁为什么不够?
锁负责让某一时刻的操作串行,不能记住历史请求是否已经成功。锁过期、进程退出或下一次重试时,仍要查询持久化结果并受业务约束限制。锁可以减少竞争,不能替代最终落库的幂等判断。
去重表先插成功,后面就能放心做业务吗?
如果两个动作不在同一事务,进程可能在插入去重标记后、真正执行业务前退出,导致重试永远被跳过。要么同事务完成,要么明确设计可恢复的处理中状态与重入协议,不能把“登记过”当作“完成了”。
如何验证方案?
围绕同请求并发、事务回滚、提交后响应丢失、消息重复与乱序、消费者提交后退出、补偿重放等场景,检查订单数量、库存和积分流水、资金与履约结果。应能看到一次预期业务效果,也应能看到未完成动作如何恢复;不能只检查接口返回成功。
参考资料
- 国内:JavaGuide:幂等性设计:借鉴业务唯一键、状态机与去重表同事务的说明,解释正文中重复识别和订单、库存等更新如何落在同一事务边界。
- 国内:Doocs:分布式服务接口的幂等性如何设计:采用多实例重复付款与超时重试的场景,解释正文中稳定业务标识、持久记录及其与业务效果原子衔接的必要性。
- 国内:阿里云:RocketMQ 消息幂等:参考生产重试与消费确认丢失的说明,区分正文中的消息 ID 和业务操作标识,并解释消费幂等的职责。
- 国外:Stripe:Idempotent requests:借鉴同键重试、参数校验与结果保留的 API 契约,说明正文中超时后复用结果和幂等记录清理窗口;具体响应缓存规则仍以实际产品为准。
- 国外:AWS:Transactional outbox pattern:参考业务更新与消息发布的双写缺口、outbox 和重复消息,解释正文中订单成功后如何可靠推进下游及为何仍需消费幂等、补偿。

