去重负责拦截重复请求,幂等负责确保处理结果唯一,两者必须协同落地,而非二选一,最终目标是“同样的请求重复发来,效果与一次相同”。
支付回调、订单提交、库存扣减,这三条链路是重复请求的高发地带,今天不谈理论,直接聊我在真实交易系统里踩过的坑,以及验证过的落地要点。
被动去重还是主动幂等?先分清场景再动手
很多团队把去重和幂等混为一谈,导致方案设计时互相干扰。去重是入口处的过滤器,幂等是处理器的自保能力。
- 去重适合实时性要求高、请求特征明确的场景,比如用户双击提交订单,同一毫秒内发来两次相同请求。
- 幂等适合链路长、涉及异步回调的场景,比如支付宝或微信的支付通知,可能重试十几次,间隔从几秒到几小时。
行业共识认为,真正的解法是前置去重拦截重复请求,后置幂等逻辑保证极端情况下的结果一致性,两件事不在一个层面对比,也不能互相替代。
从实际验收角度来看,判断一个系统是否达标有两条硬指标:重复请求到达时,不产生副作用,以及并发相同的写请求时,数据最终状态是确定的,能达到这两条,就说明你的防线是稳的,否则大概率还有漏洞。
透传业务流水号,让每一次请求都有“身份证”
去重和幂等的前提是能找到“这是同一次业务”的标识,很多团队直接用用户ID或订单号做去重键,这是典型的自欺欺人,同一个用户可能在两秒内下了两笔完全不同的订单,不能用用户ID去重。
我的建议是强制要求上游请求必须携带全局唯一的业务流水号,比如支付平台的交易流水号或自定义的requestId,这个环节要通过网关层统一校验,缺失直接拒绝,网关侧通常配合token令牌机制,请求到达时先获取一次性token,执行业务时携带该token,处理完毕后token作废,重复使用即被拦截。
有了这个“身份证”字段,后续所有去重和幂等逻辑才有讨论基础。我们还会把它注入日志链路,方便排查时精确筛选同一次请求的全链路轨迹,没有这个前置设计,后面全都是补丁。
实操时注意业务流水号不等于订单号,如果同一笔订单被用户取消后重新发起支付,生成新的流水号,这就是一次新请求,如果同一次支付回调因网络抖动被重发,流水号不变,这就是一次重复请求,需要拦截。
对于幂等键的选择,行业中更大的共识是优先使用天然具备业务语义的键,比如用户ID、支付单号或商户订单号,而UUID这类随机字符串虽然性能好,但会丢失业务可读性,排查问题时定位成本高,笔者的经验是内部系统重业务语义,外部对接场景再用随机字符串兜底。
分布式锁与唯一索引,双保险拦截重复订单
拿到流水号之后,就要用强力手段拦住重复请求,我见过太多团队只在代码里判断if (order != null) return,这在单机模式勉强能用,一旦服务多实例部署,多个实例同时读到“订单不存在”的铁定状态,然后各自插入一条订单,直接造成重复订单事故。
正确落地方式是两个层面嵌套:
-
数据库唯一索引是第一道防线,也是最终防线,在订单表里给业务流水号字段建唯一约束,插入时靠数据库本身拒绝重复记录,这是最可靠的兜底方案,无论代码有多少个实例并发请求,数据库层面都会拦住。
-
分布式锁是业务层面的提前拦截,避免无谓的数据库压力,比如用Redis的
SETNX命令实现锁,拿到锁的实例才继续执行业务逻辑,但请注意,锁的粒度不能太粗,否则会阻塞对同一用户的其他正常下单请求。
举个例子,我的一个老朋友做电商系统时只加了Redis分布式锁,结果锁过期时间设置了30秒,下游接口响应慢,锁提前失效,两个实例同时进入下单逻辑,产生了两条相同的订单,后来加上数据库唯一索引,这类问题才根治了。
你该怎么选?我给个实际建议:
- 请求量>5000QPS的场景,优先用Redis分布式锁前置拦截
- 请求量<1000QPS的场景,直接建数据库唯一索引,简单可靠
- 涉及跨机房部署的场景,必须选用支持全局一致的分布式锁方案,否则本地锁天然失效
这里不禁让我想问,你是否真正思考过高并发下订单幂等怎么实现? 核心并不是锁本身,而是锁和数据库约束的配合方式,先尝试获取锁,成功后再执行查询和插入,插入冲突时捕获唯一索引异常,做降级处理。
状态机驱动幂等,让订单流转的每一步都有据可查
去重解决了“重复请求不处理”,幂等还要解决“重复请求不改变结果”,这需要引入状态机的概念,让订单的每一步流转都有明确的前置状态约束。
订单状态一般包括:待支付、已支付、已发货、已完成、已关闭。每次状态流转都必须在数据库中校验当前状态是否允许目标状态,比如待支付订单不允许直接跳到已完成,这在代码层面靠一条带状态的更新语句落地,比如UPDATE order SET status = 'PAID' WHERE order_id = ? AND status = 'PENDING_PAY',受影响行数为0则说明状态不匹配,重复请求直接返回成功但不重复执行。
对高频的交易系统来说,状态字段本身就是一个天然乐观锁,每次更新操作前带上期望的旧状态,数据库就会自动拒绝并发条件下的状态冲突,这也是高并发接口幂等性的最低成本解法。
更完整的方案是为订单增加幂等结果表,每次处理请求时先插入一条幂等记录(业务流水号为主键),记录处理状态和处理结果,如果重复请求到达,直接取出上一次的处理结果返回,不再重复执行,这套方案在支付回调场景极其常见。
消息队列重复消费,一把辛酸泪换来的教训
支付回调场景往往还需要把“支付成功”的消息发给下游进行积分发放或库存扣减,这里有个隐性坑:消息队列的at-least-once语义会导致消息被重复消费,就算你保证了订单接口的幂等性,下游消费逻辑没有幂等设计,积分照样发放两遍。
消息消费者层面的幂等同样是整个订单一致性闭环的关键一环,处理策略是:
- 消费者在本地消息表记录已处理的消息ID
- 每次收到消息先查本地表,如果已存在则跳过处理
- 如果本地表不存在则插入处理记录,再执行业务逻辑
这个方案看起来简单,但落地时要注意处理逻辑和本地表记录的写入必须在同一个本地事务内,否则就会存在中间状态,消息消费失败重试时又会产生重复,另外不少团队在消费者的入口层面对消息内容做哈希,以哈希值为唯一键建表,也能达到同样效果,还能缩短索引长度。
最终一致性兜底,你需要的另一个视角
无论去重和幂等做得多么完善,网络抖动、上下游超时、人工重试等场景仍会产生漏网之鱼,这时候需要对账任务兜底。
- 每日凌晨运行对账Job,对比订单系统和支付系统的流水,自动补齐差异
- 对账单里对不上的记录,自动触发补偿流程或人工介入
- 补偿流程要保留完整的操作审计日志,方便后续追溯复盘
对账任务本质上是最后一道安全网,在核心业务链路上无关紧要,但在安全治理层面又不可或缺。 它不会直接帮你拦截重复请求,却能在你防线漏洞暴露后快速止血,控制影响面。
实践中我们的对账任务被设计为可重入的,对账任务本身也可能被重复拉起执行,因此任务的执行批次号也要具备幂等性,避免同一天的执行批次重复处理数据,这算是对称设计,你上层的幂等机制,自己的底线系统同样要具备。
设计取舍:幂等粒度、存储选型和成本
没有银弹,所有方案都在权衡,常见的三个取舍点:
请求级别的幂等粒度能精确拦截重复请求,但存储开销大,每笔请求都要写幂等记录。业务级别的幂等粒度只关心最终结果,比如只要订单是已支付状态,就认为幂等成功,存储开销小,但无法区分同一订单的多次支付回调之间的微小差异。
存储选型上,Redis适合高吞吐场景,但数据可能丢失;数据库持久化可靠,但性能上限受限,多数情况下,我们采用双写策略:热数据用Redis拦截,冷数据落数据库审计。
幂等设计是用空间换时间,不可能完全零成本,从业务价值角度,优先覆盖资金操作类接口(支付、退款),再覆盖资产类接口(优惠券、积分),最后才是纯查询类接口。
从性能和安全的平衡考虑,高频交易系统的每个写接口都应该过一遍幂等设计评审,至少覆盖去哪几个核心路径。
交易系统订单去重方案对比:这三种业务场景优选策略不同
- 即时下单场景:数据库唯一索引 + 前置业务流水号校验,够用且性能有保障
- 支付回调场景:分布式锁 + 状态机更新 + 幂等结果表,三层嵌套确保万无一失
- 异步消息场景:消息ID本地去重表,成本最低效果最直接
选择哪种方案组合取决于你的订单量、技术栈和团队运维能力,小团队建议先用最笨但最稳的数据库唯一索引,稳定运行后再优化性能瓶颈。
交易系统幂等设计的常见误区
实操中容易犯的错我列出来,请对照检查自己的系统:
- 只在代码层面判重,没有数据库约束兜底
- 锁粒度过大,锁住用户维度导致大量请求排队
- 幂等结果表和业务操作不在同一事务内,导致结果表记录存在但业务失败
- 消息消费端各自为政,每套逻辑自己实现幂等,缺少统一工具类
- 忽略了状态机的约束,同一步操作反复更新
这些错误微信支付和支付宝官方文档里都有提及,属于典型的行业通病,大家踩坑的姿势都一样,只是时间早晚问题。
如何验证幂等性达标?上线前的三道必考题
团队内部验证起来,可以按这套思路做压力测试,测试的是系统的安全性边界,责任人应是核心开发成员自己:
- 并发场景:100个线程同时下单,请求同一笔业务流水号,检查订单表数量是否是1
- 重复消费场景:手动把同一支付回调消息投递5次,检查积分发放记录是否是1条
- 状态异常场景:已完成订单再次发起取消操作,检查订单状态是否仍然正常流转
验证通过后把测试结论留档,作为系统安全性的证据留存,今后每次改动订单链路,这三道题必须重新跑一遍,防止历史方案被无意破坏。
杭州某电商团队就是因为改了下单逻辑,把唯一索引所在字段从业务流水号改成了自增ID,导致原有防重机制失去约束,结果大促当天出现大量重复订单,回过头看,责任不在改成自增ID这个动作,而在于没有回跑这三道必考题,这也侧面说明了持续回归对幂等性保证的重要性。
Q&A:幂等与去重常见困惑解析
问:高并发下订单幂等怎么做才能性能最优?
答:先Redis拦截绝大多数重复请求,再用数据库唯一索引兜底极端并发场景,Redis层的key设置业务流水号,value存放处理结果,TTL建议比业务超时时间稍长,比如订单提交场景设2分钟,超过TTL的重复请求落入数据库层由唯一索引拦截。
问:接口幂等性和去重这两个词的含义区别在哪?
答:去重发生在请求入口,目标是减少无效流量对业务逻辑的冲击,是性能层面的优化,幂等发生在业务处理中,目标是保证重复执行的业务结果与执行一次相同,是数据一致性层面的保障,一个典型的组合是,网关层做去重,应用层做幂等,两者各司其职,核心诉求其实是一致的:保证最终数据一致性。
问:如何处理第三方支付回调的重复通知?
答:以支付宝为例,支付成功异步通知会以其官方指定的回调参数为幂等键,商户系统收到通知后,先用该键查询本地支付记录,已存在则直接返回成功应答,不存在则校验金额后更新订单状态,同时在本地事务中记录该回调标识,后续重复通知因记录已存在,会被安全拦截。
去重与幂等设计不是一次性工作,而是交易系统上线前必须严肃对待的全链路工程,后端服务每新增一个接口都要重新审视这套机制是否仍然有效。从入口拦截到数据库约束再到状态机校验,层层设防,才能真正杜绝重复订单、重复扣款这类重大事故。 先把唯一索引建上,再逐步完善上层策略,这是最可靠的演进路径。
首发原创文章,作者:王坚,如若转载,请注明出处:https://idctop.com/article/630566.html





