跳到主内容

支付回调重试风暴:SetNX 幂等防重入账避坑实录 ​

问题背景:支付回调为什么会重复打进来 ​

支付系统里最危险的代码,往往不是主动支付接口,而是第三方支付平台的回调接口。业务服务处理成功后如果没有及时返回成功响应,支付平台通常会按照固定间隔继续重试;当服务发布、数据库抖动、网络超时同时出现时,同一个支付单号可能在几秒内被并发投递多次。若接口只判断“订单是否已支付”,而没有把“检查—入账—更新状态”做成原子过程,就可能出现两个请求同时读到未支付状态,然后各自执行余额增加、优惠券发放或积分结算。

支付回调幂等的目标不是让回调只来一次,而是承认它可能来很多次,并保证同一笔支付通知无论执行多少次,最终业务结果只生效一次。Redis SetNX 的价值在于提供一个轻量、低延迟的并发拦截层:第一个到达的请求获得处理权,后续重复请求快速返回;数据库唯一约束则作为最后防线,避免 Redis 故障或键过期后重复落库。

方案对比:只加锁不够,状态机才是核心 ​

常见做法有三种。第一,查询订单状态,未支付就入账,实现最简单,但并发下存在典型检查与执行之间的时间窗口。第二,使用 Redis SetNX 抢占支付单号,适合拦截短期重试风暴,但如果只依赖缓存且没有过期、释放和落库约束,遇到 Redis 丢键或主从切换仍有风险。第三,以数据库订单状态和流水唯一索引为最终依据,Redis 只做前置削峰与并发互斥,这是支付场景更稳妥的方案。

支付平台回调可能多次重试SETNX 抢占支付单号处理权状态机校验仅待支付可入账事务入账写流水与订单重复回调直接返回成功唯一索引兜底拒绝重复流水

推荐原则是:Redis 负责快速挡住并发重复请求,数据库状态条件更新负责业务正确性,支付流水唯一索引负责最终兜底。三者职责不同,不能互相替代。

Redis SetNX 幂等键与关键数据结构 ​

幂等键必须来自业务全局唯一标识,优先使用第三方支付平台的支付单号,组合本地商户订单号也可以,但不要使用回调请求 ID,因为有些平台重试时会生成新的请求 ID。建议键名包含固定前缀、支付渠道和支付单号,便于排查与批量治理。

下面的命令可以直接在 redis-cli 中运行。SET 命令必须带上 NX 和 EX 参数:NX 表示键不存在才设置成功,EX 保证异常宕机后锁可以自动释放,避免死锁。

bash
# 第一个回调请求抢占处理权,成功返回 OK
SET pay:idem:wx:420000213920240601 NX EX 300

# 查看剩余过期时间,单位为秒
TTL pay:idem:wx:420000213920240601

# 并发重试再次抢占,返回 nil,说明当前已有请求在处理
SET pay:idem:wx:420000213920240601 NX EX 300

# 处理成功后写入短时间成功标记,也可以直接复用原键并刷新过期时间
SET pay:idem:wx:420000213920240601 SUCCESS XX EX 86400

# 读取幂等标记
GET pay:idem:wx:420000213920240601

如果需要记录处理上下文,可以使用 Hash,但不要把大对象塞进 Redis。Hash 中只保留排查所需的最小字段。

bash
# 支付回调处理上下文,字段简短,设置 10 分钟过期
HSET pay:handle:420000213920240601 status PROCESSING channel wx amount 9900
EXPIRE pay:handle:420000213920240601 600

# 查看处理状态与金额
HGETALL pay:handle:420000213920240601

实操步骤:从回调验签到事务入账 ​

第一步,完成签名校验、金额校验和商户单号解析,非法请求直接失败,不要写幂等成功标记。第二步,对支付单号执行 SetNX。抢占失败时不能简单返回处理中让平台继续重试,而应查询订单状态:订单已支付则返回成功,订单仍在处理则返回处理中,订单异常则告警。第三步,抢占成功后在数据库事务中执行条件更新,只有订单处于待支付状态才允许入账。

伪代码如下,重点展示顺序与异常分支:

java
// 仅为关键流程伪代码,生产中应补充日志、告警与监控
String idemKey = "pay:idem:" + channel + ":" + transactionId;

// 抢占 5 分钟处理权,防止回调并发进入临界区
Boolean locked = redis.setIfAbsent(idemKey, "PROCESSING", Duration.ofMinutes(5));
if (!Boolean.TRUE.equals(locked)) {
    String mark = redis.get(idemKey);
    if ("SUCCESS".equals(mark) || orderRepository.isPaid(orderId)) {
        return "SUCCESS"; // 重复回调必须返回成功,阻止平台继续重试
    }
    return "PROCESSING";
}

try {
    boolean paid = orderService.confirmPayment(orderId, transactionId, payAmount);
    if (!paid) {
        // 条件更新失败通常意味着订单已处理或状态异常,查询后决定返回内容
        return orderRepository.isPaid(orderId) ? "SUCCESS" : "FAIL";
    }
    redis.set(idemKey, "SUCCESS", Duration.ofDays(1));
    return "SUCCESS";
} catch (Exception e) {
    // 不设置成功标记;删除处理锁,让稍后的重试重新进入数据库校验
    redis.delete(idemKey);
    throw e;
}

数据库层应使用条件更新和唯一索引兜底:

sql
-- 支付流水唯一索引,保证同一支付单号最多插入一条
CREATE UNIQUE INDEX uk_pay_transaction_id
ON pay_transaction(transaction_id);

-- 只有待支付订单才能更新为已支付,避免状态回退和重复入账
UPDATE payment_order
SET status = 'PAID', paid_at = NOW(), transaction_id = ?
WHERE order_no = ?
  AND status = 'WAIT_PAY';

应用事务中先插入支付流水,再更新订单状态,再执行余额、积分等内部入账。若内部账户也可能重复写入,同样需要业务流水号和唯一约束,不能只靠外层支付单号。

过期时间、主从切换与返回语义 ​

幂等键过期时间要覆盖正常业务处理耗时,并预留数据库抖动和发布时间,通常设为 3 到 10 分钟;成功标记建议保留 1 到 7 天,用于拦截平台延迟较久的重复通知。Redis 若采用主从复制,SETNX 在主节点成功后尚未同步到从节点就发生故障切换,理论上可能出现两个客户端都抢到锁。支付资金类场景不能把 Redis 当作唯一裁判,因此数据库唯一索引和条件更新必不可少。

对回调返回值也要谨慎:已经确认成功的重复通知必须返回成功,否则平台会持续重试;验签失败、金额不一致不能返回成功,否则会中断平台补偿;正在处理中可以返回平台约定的处理中状态,同时保证下一次重试仍具备幂等能力。

避坑总结:至少记住这五条 ​

  1. 先查订单再入账,仍然会重复。 常见错误是两个线程同时查询到 WAIT_PAY,随后都执行入账。修正方法是把条件放进更新语句,使用 WHERE status='WAIT_PAY' 判断影响行数,并给支付流水加唯一索引。
  2. 使用 SETNX 不加过期时间,异常后永久占锁。 现象是某笔支付一直提示处理中,即使服务重启也无法恢复。应使用 SET key value NX EX seconds,不要拆成 SETNX 和 EXPIRE 两条命令。
  3. 处理失败却返回成功,平台不再重试。 数据库短暂超时时如果回调直接返回成功,会造成支付平台显示成功而业务订单未入账。失败分支应释放处理锁或保留短 TTL,并返回失败或处理中,让平台按策略重试。
  4. 幂等键选错字段,重试全部穿透。 使用请求 ID、日志 traceId 作为键,平台每次重试都可能变化。应使用支付单号这种业务全局唯一且稳定的字段,并在验签通过后再生成键。
  5. 只在 Redis 防重,不做数据库唯一约束。 Redis 键被淘汰、过期、主从切换或人工误删后,重复请求仍可能落库。资金流水必须以数据库唯一索引作为最终防线,并对唯一键冲突按“已成功处理”返回。

支付回调幂等的本质,是用 SetNX 降低并发冲突概率,用状态机保证状态只能向前推进,用唯一约束守住数据正确性。这样即使面对重试风暴,系统也能做到快速响应、可重试、不重复入账。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。