支付系统对接第三方支付平台时,几乎都会遇到一个绕不开的问题:支付结果的异步通知(回调)可能被重复发送。支付宝、微信支付等平台为了保证商户能感知到支付结果,通常会设置重试机制,如果商户接口没有及时返回成功应答,或者网络出现抖动,同一笔支付结果的通知会被发送多次,少则两三次,多则十几次。如果回调处理接口没有做去重,同一笔订单就可能被重复加分、重复发货、重复写入流水,后果相当严重。本文围绕如何利用Redis实现支付结果通知的去重展开,给出一套完整的幂等性设计方案。

一、为什么支付通知会被重复发送
首先要理解重复通知的来源。第三方支付平台发送异步通知后,会等待商户接口返回一个表示“接收成功”的应答。以支付宝为例,商户处理完成后返回success字符串即视为通知成功;如果返回的是fail或者其他内容,或者接口超时未响应,支付平台就会按衰减间隔重试,比如1分钟、5分钟、10分钟、1小时……直到达到最大重试次数。
这就带来一个客观事实:即使你的接口逻辑完全正确,重复通知也可能因为以下几种原因出现。第一,商户处理成功但响应报文在网络传输中丢失,支付平台判定失败而重试。第二,商户处理耗时过长,超过了支付平台设置的应答超时时间。第三,网络抖动导致支付平台本身触发了重发。第四,用户在支付平台侧刷新或查询操作触发了补偿通知。这些情况在高峰期并不罕见。
还有一个容易被忽视的场景是通知乱序。支付平台的重试通知和商户主动查询补偿的结果可能交错到达,比如“支付成功”的通知还没处理完,商户定时任务查询到已支付又触发了一次处理。因此去重方案不能只考虑“拦截重复请求”,还要考虑并发同时到达的情况。
二、常见去重方案对比:为什么选择Redis
最朴素的方案是利用数据库的唯一索引。给支付流水表加一个以支付平台交易号(如支付宝的trade_no、微信的transaction_id为唯一键的约束,重复插入时捕获DuplicateKeyException直接返回成功。这个方案实现简单、强一致,但缺点是每次通知都要落库一次,即使是被拦截的重复通知也会打到数据库,高峰期数据库压力较大。而且如果业务处理涉及多张表,唯一索引只能保护其中一张。
另一个方案是基于状态机判断。处理前先查询订单状态,如果已经是“已支付”就直接返回。这种方案在单线程下没问题,但并发场景下两个请求同时读到“待支付”状态,都会继续执行后续逻辑,存在明显的竞态条件,必须配合数据库乐观锁(版本号更新)才能保证安全。
Redis方案的优点在于拦截发生在请求入口处,重复通知根本不会触达业务逻辑和数据库,性能开销极低,单次判断耗时在毫秒级以下。配合SET NX原子命令和合理的过期时间,既能挡住绝大多数重复请求,又能与数据库唯一索引形成双保险。下面详细展开。
三、基于SET NX命令实现去重的核心代码
Redis去重的核心是SET key value NX EX seconds命令。NX表示只在key不存在时才设置,EX设置过期时间,整个操作是原子的。key的设计一般是pay:notify:{支付平台}:{平台交易号}或者pay:notify:{商户订单号}。推荐优先使用平台交易号,因为它是支付平台侧的唯一标识,同一笔交易即使重试多次交易号也不会变。
处理逻辑的伪代码如下:
public NotifyResult handlePayNotify(NotifyMessage msg) {
String key = "pay:notify:alipay:" + msg.getTradeNo();
// NX:key不存在才设置成功,EX:25小时后自动过期
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(key, "PROCESSING", 25, TimeUnit.HOURS);
if (Boolean.FALSE.equals(first)) {
// 说明已有请求在处理或处理完成,直接视为重复通知
log.info("重复通知,已拦截: {}", msg.getTradeNo());
return NotifyResult.SUCCESS; // 返回成功,阻止支付平台继续重试
}
try {
// 1. 验签
if (!verifySign(msg)) {
redisTemplate.delete(key); // 验签失败要释放key,避免污染
return NotifyResult.FAIL;
}
// 2. 核对金额
if (!checkAmount(msg)) {
return NotifyResult.FAIL;
}
// 3. 执行业务:更新订单状态、记录流水、扣库存等(建议事务)
orderService.markPaid(msg.getOutTradeNo(), msg.getTradeNo(), msg.getTotalAmount());
// 4. 标记为处理完成,供后续重复通知快速判断
redisTemplate.opsForValue().set(key, "DONE", 25, TimeUnit.HOURS);
return NotifyResult.SUCCESS;
} catch (Exception e) {
// 处理失败时删除key,允许支付平台重试通知再次进入
redisTemplate.delete(key);
return NotifyResult.FAIL;
}
}这里有几个关键细节需要注意。第一,过期时间设置为25小时,是因为支付宝和微信的通知重试周期最长约24小时,过期时间要覆盖重试窗口。太短会导致key提前失效,重试通知再次穿透到业务层;太长则会占用Redis内存。第二,处理失败时必须删除key,否则后续重试通知会被误拦截,导致这笔支付永远无法完成回调处理。第三,重复通知返回时要返回“成功”,告诉支付平台不用再重试了,这是很多人踩过的坑——返回失败会导致平台无休止地重发。
第四个细节是验签失败的处理。验签失败说明报文非法(可能是伪造请求),此时应该删除key并返回失败。但如果是并发场景下第一个请求验签失败、第二个合法请求被拦截,删除key的操作可以保证第二个请求在平台重试时能正常进入,逻辑是自洽的。
四、并发通知与边界情况的处理
上面的方案能覆盖绝大多数场景,但严格的幂等设计还要考虑边界。第一个边界是业务执行时间较长的情况。假设业务处理需要5秒,期间支付平台又发来一次通知,虽然会被Redis拦截,但如果第一个请求最终处理失败,key已被删除,而重试通知早已被“吃掉”,这笔订单可能要等很久才会被下一次重试或对账任务修复。为此可以引入“处理中”状态加短暂等待,或者干脆依赖支付平台的后续重试,一般24小时窗口内有多次机会,风险可控。
第二个边界是Redis不可用。如果Redis宕机,setIfAbsent抛异常,接口不能直接返回成功(可能漏处理),建议的做法是降级走数据库唯一索引兜底,或者捕获异常后仍然执行业务逻辑,由数据库层的唯一约束保证最终幂等。这也印证了前面的观点:Redis拦截是性能优化层,数据库约束才是正确性兜底层,两者缺一不可。
第三个边界是业务不具备事务性。如果订单更新和积分发放分属两个系统,中间失败会出现半完成状态。这种情况下应该引入本地消息表或者基于RocketMQ事务消息来保证最终一致性,去重key的生命周期也要与整个流程绑定,而不是只覆盖第一步。此外,强烈建议搭配一个定时对账任务,主动查询支付平台订单状态,修复因各种异常遗漏的订单,形成完整闭环。
五、方案总结与最佳实践
一套可靠的支付通知去重体系可以概括为三层防线:入口层用Redis的SET NX EX命令拦截高频重复通知,把99%以上的重复流量挡在业务逻辑之外;持久层用数据库唯一索引做兜底,保证即使Redis失效也不会产生脏数据;补偿层用定时对账任务修复遗漏,覆盖所有极端情况。三层各司其职,既保证了正确性又兼顾了性能。
实践中有几条经验值得遵守。去重key优先使用支付平台交易号而非商户订单号,避免订单号复用带来的干扰;重复通知务必返回成功应答,避免平台无限重试;处理失败的key要及时释放,处理成功的key要覆盖整个重试窗口;所有金额校验、订单状态校验不能因为有了去重就省略,防重与防篡改是两回事。把这些细节做扎实,支付回调接口的幂等性就有了坚实的保障。