退款是微信支付里最容易出问题的环节之一。商户发起退款后,微信会把退款结果以异步通知的方式推送到商户配置的回调地址,商户系统需要解密报文、验签、更新退款单状态。问题在于,微信的通知并不是发一次就结束,只要商户没有在规定时间内返回成功的应答,微信就会按一定频率重复推送,最多可达十几次。如果回调处理逻辑没有幂等保护,同一个退款单可能被重复处理,比如重复修改订单状态、重复给用户发退款到账消息、重复写入流水记录,严重时直接造成资金账目错乱。这篇文章就来系统讲讲退款通知处理中的幂等性该怎么设计。

一、退款通知为什么会重复到达
很多人以为重复通知是微信的锅,其实重复推送恰恰是协议设计上的兜底机制。微信在文档里明确说明:当商户应答失败或超时(比如返回非200状态码、应答报文格式错误、处理耗时超过5秒),通知会按照15s、15s、30s、3m、10m、20m、30m、30m、30m、60m、3h、3h、3h、6h、6h这样的频率重新发送。也就是说,只要你的接口有一点点不稳定,重复通知就不可避免。
除了微信主动重推,还有几种常见情况也会导致重复处理。第一是网络层面的重放,商户侧的网关或者负载均衡发生超时重试,同一条请求体被转发两次。第二是业务系统自身的重试,比如消费MQ消息时处理成功但ACK失败,消息被重新投递。第三是运维补偿任务和实时通知打架,定时任务扫描退款单去微信查询结果,恰好和异步通知在同一时刻处理同一笔单子。这几种场景叠加起来,幂等性就不是可选项,而是必须项。
二、先理清退款通知的处理流程
在谈幂等方案之前,先把退款通知的标准处理流程过一遍。退款通知和支付通知有个很大的区别:退款通知的报文内容是加密的,字段req_info是使用商户证书公钥加密后的密文,需要用商户API证书的私钥先做Base64解码,再用RSA解密得到AES-256-ECB的密钥,再解密出真正的退款结果明文。整个解密链路如下:
// refundInfo 为通知中的 req_info 字段
byte[] Base64decoded = Base64.getDecoder().decode(refundInfo);
// 使用商户私钥进行RSA解密,得到MD5(mchKey)的小写结果作为AES密钥
String md5Key = DigestUtils.md5Hex(mchKey).toLowerCase();
Cipher aesCipher = Cipher.getInstance("AES/ECB/PKCS5Padding");
aesCipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(md5Key.getBytes(), "AES"));
String plainText = new String(aesCipher.doFinal(Base64decoded), "UTF-8");
// plainText 是XML格式的退款结果,包含 out_refund_no、refund_status、refund_fee 等字段解密拿到明文后,处理流程一般是:解析退款单号、校验退款状态、校验金额是否与本地退款单一致、更新退款单状态、触发后续业务(如恢复库存、发送消息)。幂等保护要覆盖的正是从「更新退款单状态」开始往后的所有步骤,尤其是那些会产生副作用的动作。
三、方案一:数据库唯一索引兜底
最朴素也最可靠的方案,是利用数据库的唯一索引做天然幂等。做法是为退款通知处理设计一张专门的记录表,以微信退款单号out_refund_no(或微信通知的唯一标识)作为唯一键,收到通知时先尝试插入这条记录,插入成功说明是第一次处理,继续执行业务;插入失败抛出唯一键冲突异常,说明已经处理过,直接返回成功应答。
CREATE TABLE refund_notify_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, out_refund_no VARCHAR(32) NOT NULL COMMENT '商户退款单号', refund_status VARCHAR(16) NOT NULL, notify_content TEXT, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_out_refund_no (out_refund_no) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
try {
refundNotifyRecordMapper.insert(record); // 唯一索引冲突会抛 DuplicateKeyException
// 首次处理,执行退款单状态更新和后续业务
refundOrderService.handleRefundSuccess(record.getOutRefundNo());
} catch (DuplicateKeyException e) {
// 已处理过,直接幂等返回
log.info("退款通知重复到达,out_refund_no={}", record.getOutRefundNo());
}这个方案的最大优点是强可靠,不依赖任何外部组件,数据库事务能保证插入和业务更新要么都成功要么都回滚。缺点是它只能挡住「完全相同的单号重复处理」,如果业务处理到一半失败回滚,唯一索引记录也一起回滚了,下次通知还能进来,这其实是正确的行为。但对于更新订单状态这类操作,还需要配合状态条件更新才能形成完整闭环。
四、方案二:状态机条件更新
第二种方案是把退款单本身建模成状态机,更新状态时附带前置条件。退款单的生命周期通常是:申请中、退款中、退款成功、退款失败。处理通知时,Update语句带上原状态作为Where条件,利用数据库行锁保证并发安全。
UPDATE refund_order
SET refund_status = 'SUCCESS',
success_time = #{notifyTime},
update_time = NOW()
WHERE out_refund_no = #{outRefundNo}
AND refund_status = 'PROCESSING';影响行数为1,说明这次更新真正生效,才继续执行后续业务;影响行数为0,说明单子已经是成功或失败状态,本次通知属于重复或乱序通知,直接忽略并返回成功。这种写法把幂等判断和状态流转合并成一条原子SQL,既防止了重复处理,也天然处理了乱序问题,比如「退款成功」和「退款异常」两种通知乱序到达时,后到的通知不会覆盖已经终态的结果。
状态机方案适合所有有明确生命周期流转的业务,它的短板在于只能保护单条记录的状态变更,如果后续还有发消息、调库存等分布式副作用,这些动作本身还得有自己的幂等手段,或者通过本地消息表把副作用和状态更新放进同一个事务。
五、方案三:Redis分布式锁加缓存判重
高并发场景下,很多团队会在数据库前面加一层Redis判重。收到通知后先SETNX一个以退款单号为key的标记,设置成功才继续处理,设置失败说明正在处理或已处理过。为了防止处理过程中服务宕机导致死锁,需要给key设置过期时间;但过期时间又带来了新的问题:key过期后重复通知再来,判重就失效了。所以Redis判重一般作为「并发拦截层」,配合数据库的唯一索引或状态机做最终兜底。
String lockKey = "refund_notify:" + outRefundNo;
Boolean first = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofMinutes(10));
if (Boolean.FALSE.equals(first)) {
// 并发重复通知,直接应答成功,让微信停止重推
return successResponse();
}
try {
// 数据库层再做一次状态机条件更新,双保险
int rows = refundOrderMapper.markSuccess(outRefundNo, notifyTime);
if (rows > 0) {
// 仅首次生效时执行副作用
messagePublisher.publishRefundSuccessEvent(outRefundNo);
}
return successResponse();
} catch (Exception e) {
redisTemplate.delete(lockKey); // 处理失败释放标记,允许下次通知重试
throw e;
}这种分层设计的思路是:Redis挡住99%的并发重复,减少数据库压力;数据库条件更新保证最终正确性,即使Redis宕机、key丢失、缓存不一致,最坏情况也只是多执行一次无效的Update,不会产生脏数据。需要注意的是,删除标记的操作要考虑业务执行失败的场景,上面代码在异常时主动删除key,让下一次重推可以重新触发处理,这是和纯锁语义不同的地方。
六、方案组合与几个容易踩的坑
实际生产中,推荐把上面的方案组合起来:Redis判重做第一层快速拦截,状态机条件更新做第二层数据库保障,唯一索引表做审计记录和第三层兜底。同时要保证所有副作用动作(发MQ消息、调营销系统、发站内信)都携带退款单号,消费端自己做幂等,这样即使消息被重复投递,下游也不会重复执行。
还有几个坑值得提醒。第一,处理完成后必须给微信返回符合规范的应答,返回码是SUCCESS且HTTP状态为200,否则微信会一直重推;但注意应答成功代表的是「我收到了」,不要把业务处理失败伪装成成功应答,否则这笔退款单会永远卡在中间状态,只能靠对账任务补偿。第二,一定要校验退款金额和解密出来的退款单号是否与本地记录一致,防止报文被篡改。第三,验签和解密失败要记录原始报文并告警,不要吞异常,这类问题往往是证书配置错误导致的,静默失败会让你错过处理时机。第四,幂等key的选择要谨慎,用退款单号而非微信通知id更稳妥,因为同一笔退款在不同状态下可能有多条通知,直接用通知id判重会把不同状态的通知误判为重复。
总结一下,退款通知的幂等不是单点技术,而是一套分层防御体系:入口判重、状态机更新、唯一约束兜底、副作用幂等,四层缺一不可。把这套结构搭好,再配合定时对账做最终校验,退款链路的资金安全就有了扎实的保障。