订单去重与幂等设计的落地核心是“唯一请求标识+状态机”的组合:同一业务请求只能生效一次,重复到达直接返回已有结果,不重复扣款、不重复发货、不覆盖数据。
交易系统看似简单的“下个单”,背后藏着用户双击、网关重试、支付回调重推、消息队列重复消费等一系列重复请求可能,任何一个环节漏处理,轻则多一笔订单,重则公司账面亏一大笔,业内专家指出,订单类事故中相当一部分源自幂等实现不彻底。
订单去重与幂等设计怎么落地才算真正达标
先分清两个概念
去重针对“同一请求来了多次”,只保留一次处理机会,幂等要求更高,它指同一个操作执行一遍和执行一百遍,系统最终状态完全相同,所以下单接口要做幂等,不只是挡住重复点击,更要保证在延迟、超时、回滚等异常路径下依然不产生副作用。
哪些场景最容易触发重复请求
- 用户连续点击提交按钮,前端做了防抖,但网络慢时按钮状态来不及变化,双击依然能发出两个请求。
- 网关或RPC框架超时重试,客户端迟迟等不到响应,自动重发,但服务端可能已经处理完了,重发的那条就成了多余请求。
- 支付平台回调重复推送,微信、支付宝、银联都会按一定策略重推回调,最多可达数十次。
- 消息队列消费者手动确认失败,消费方处理成功后还没来得及Ack就宕机,MQ重新投递,业务被重复消费。
多数情况下判断达标的标准
在核心交易链路中下单、支付、退款、发货任何接口被重复调用时,响应结果一致、系统状态不变、返回数据正确,这三点同时满足,幂等才算闭环。
幂等方案对比:唯一约束、Redis锁与状态机
数据库唯一约束方案
方案核心是按业务唯一键建一张防重表,每次请求先尝试插入一条记录,插入成功才继续执行后续逻辑,插入冲突说明请求重复,直接返回已存在的结果。
CREATE TABLE dup_check ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, biz_type VARCHAR(32) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_req_biz (request_id, biz_type) );
事务内先插防重记录,再改订单状态,所有操作同一事务提交,并发插入同一request_id时,数据库唯一索引保证只有一个请求能插入成功,另一个收到Duplicate entry异常,转为查询返回。
这个方案在中小体量交易系统里最实用,优点是强一致、不需要额外中间件,缺点是数据库压力随请求量增加,不适合极高并发场景。
Redis分布式锁方案
利用Redis的SETNX命令获取请求级锁,拿到锁才允许处理,拿不到锁说明同一个请求正在处理中或已经处理完毕,直接返回对应结果。
SET order_lock:{request_id} 1 NX EX 10
- 返回OK:请求首次到达,执行业务。
- 返回nil:请求重复,等待或查询最终结果。
实现的注意点在锁的过期时间要和业务处理时间匹配,太短导致并发穿透,太长拖慢后续重试请求的响应。
行业共识认为,Redis方案适合高并发短事务场景,比如秒杀下单、抢券,但要接受极端情况下锁提前失效带来的微小重复概率。
状态机方案
订单从创建到完成按状态严格流转,每个状态只允许特定动作触发,支付回调只接受“待支付”状态的订单,如果订单已经“已支付”,回调直接按成功处理,不重复改状态。
待支付 → 已支付 → 已发货 → 已签收 → 已完成
↘ 已关闭
状态机方案靠业务状态本身做天然过滤,只要状态推进是单向的,重复请求到了已支付状态就会被拦住,缺点是业务复杂时状态分支很多,需要维护一张清晰的流转表。
三种方案适用场景对比
| 方案 | 适用规模 | 一致性 | 复杂度 | 典型场景 |
|---|---|---|---|---|
| 数据库唯一约束 | 中小规模 | 强一致 | 低 | 常规下单、支付回调 |
| Redis锁 | 高并发短事务 | 最终一致 | 中 | 秒杀、抢单、风控校验 |
| 状态机 | 长流程复杂业务 | 强一致 | 高 | 订单全生命周期、退款 |
实际生产中,多数团队选择状态机+防重表组合,状态机管主流程,防重表管入口重复,两者职责清晰,不冲突。
支付回调幂等设计场景的实操细节
支付回调是所有交易系统里重复频率最高的场景,微信支付官方文档明确说回调可能多次推送,商户必须自行处理幂等,设计时建议分三步。
第一步,确定幂等键。支付平台返回的transaction_id(支付平台流水号)加商户自己的订单号,两者组合作为唯一标识,只用商户订单号不够稳,因为同一订单可能存在多笔支付尝试,比如用户先扫了A渠道的码又扫了B渠道的码。
第二步,先查后改。收到回调后先按transaction_id查支付结果表,查不到记录才执行写入,查到了直接返回成功。
SELECT id FROM pay_result WHERE transaction_id = ? AND order_no = ? -- 存在 -> 返回成功,不重复处理 -- 不存在 -> 开启本地事务写入支付结果并更新订单状态
第三步,事务边界要收窄。本地事务里只做支付结果记账和订单状态流转,不要把发MQ、调外部系统、同步库存放在同一事务里,事务外部的动作在失败时要能够反查补偿,否则回调重试时反而因为事务回滚导致数据不一致。
实际操作中可以在支付结果表里加一个“状态”字段:0=处理中、1=成功、2=失败,回调先插入状态0的记录,等所有流程走完再更新为1,如果后来重复回调查到状态是0,说明前一个请求还在处理中,直接返回“处理中”让对方稍后重推。
落地的边界坑位和应对方法
唯一键必须由服务端生成
用客户端传的时间戳或者随机数当幂等键是常见事故源,客户端时钟回拨、时区差异、用户同时提交两个不同随机数,都能让同一动作产生两套唯一键,服务端统一使用Redis自增ID或雪花算法生成请求号,从源头保证同一语义动作绑定同一个键。
防重记录和业务操作必须同事务
防重表插入成功、业务更新失败,事务整体回滚,防重记录也跟着回滚,这是可接受的,可怕的是防重表单独提交,业务更新失败,防重记录留下“已处理”标记,后续重试直接被挡在门外,数据缺失无法自动恢复,解法是把两者放在同一个Spring事务里,或者用本地消息表配合异步补偿。
分布式环境下锁粒度要一致
同一个订单的多个不同操作要共用一把锁时,锁key的粒度必须收敛到“订单级+操作类型”,比如支付和退款是两个操作,但它们操作同一笔订单的钱款,如果各自用不同的锁key,并发时可能两边都拿到锁,同时修改订单金额,最终结果取决于后提交者,容易覆盖前一个操作的结果,统一锁为order:{orderNo}:pay_or_refund能避免这个坑。
重试不能无脑放行
幂等返回成功不代表接口真的成功,很多团队在防重表命中后直接响应一个固定值,但外部系统可能期望返回原请求的真实业务结果,建议防重表中冗余一份最终结果字段,重复请求命中时读取并返回,让调用方获得正确反馈。
交易系统幂等设计常见问题解答
高并发下幂等方案选Redis还是数据库
Redis方案吞吐量明显优于数据库唯一约束,但需要关注锁过期带来的小概率并发穿透,数据库方案强一致,性能上限有限,折中做法是Redis做第一层拦截,数据库唯一约束做兜底,既能扛住突发流量,又能保证极端情况下数据不出错。
订单号和幂等键有什么区别
订单号是业务单据的标识,幂等键是请求层面的唯一标识,同一个订单号可能对应多次重复请求,比如用户反复点击提交,订单号都是同一个,但请求号可能相同也可能不同,只有把请求号固定下来,才能可靠地实现重复请求拦截。
支付回调已经支付成功又收到状态为已关闭的回调怎么处理
以第一笔回调为准,后续无论回调携带什么状态,只要本地记录已经是成功,全部按成功处理,支付结果以平台数据结合本地落库记录对账为准,不要被后续异常状态的回调影响已确认的支付事实。
首发原创文章,作者:王坚,如若转载,请注明出处:https://idctop.com/article/760041.html





