ENGINEERING NOTES · 工程笔记

支付链路的幂等设计:同一条回调来三次,只能入一次账

做资金系统的第一条军规:重复是常态,不是异常。我们的多租户小程序 SaaS(Java 21 / Spring Boot / PostgreSQL,27 个业务域)刚接通微信支付那会儿,压测脚本把同一条成功回调原样发了三次,商户余额加了三倍。从那天起,"幂等"升格为支付链路的一等公民:碰钱入口先过幂等评审,再写业务代码。

一、资金系统里的"重复"从哪来

四个来源,个个躲不掉:

  1. 回调重试:第三方支付渠道的回调是"至少一次"投递。你没应答成功、应答慢了、网络抖了,它就按退避策略再发——1 分钟、5 分钟、10 分钟地追你几十次;
  2. 用户连点:支付按钮双击、页面卡顿后的报复性连点,两个创建请求几乎同时到达;
  3. 超时重发:调用方超时重试,第一次其实已成功、只是响应没回来——重试的那次是纯重复;
  4. 对账补单:定时任务发现掉单,主动补录。这本质是"合法的重放",和渠道重试没有区别。

来源不同,到达的却是同一个处理入口。所以幂等不能只在某一处打补丁,要分层设防。

二、幂等的三个层次

第一层:入口幂等。收到回调,先用幂等键查"处理记录表"——键 = 第三方单号 + 事件类型(如 txn_8827...:PAY_SUCCESS)。查到记录,说明处理过,直接应答成功。先查后写挡掉绝大多数重复。

第二层:状态幂等。状态机守卫:一笔已"支付成功"的单子,再来一次成功回调,不需要任何入账动作,校验通过后直接应答成功。应答成功是关键——你应答失败,渠道会按退避策略无限重试,重复没有减少,只是换了个姿势涌入。

第三层:数据幂等。处理记录表上建唯一索引,数据库层面拒绝重复行。哪怕前两层在并发下被击穿,INSERT 撞上唯一约束,事务回滚,账还是只入一次。这是最后防线,也是最可靠的一层——它不依赖任何代码的执行时序。

这里必须补一段辨析:应答成功 ≠ 处理完成。应答是协议层的语言,只对渠道说"别再发了";这笔钱入没入账,应答体一个字没提——可能"刚处理完",可能"三分钟前就处理过了",也可能"已收下、异步处理中"。渠道问"要不要重试",系统问"入没入账",两条轴不能互相冒充。想用应答失败去拒金额对不上的回调?渠道会重试一整天。正确姿势:验签失败应答失败;业务校验不过则应答成功、留痕告警,别让渠道空转。

三、"先查后写"防不住并发

先查后写有个经典漏洞:两条相同回调并发到达,都执行"查"——都没查到记录——然后各自执行"写"。两次入账,查后写形同虚设。这在单机测试里复现不了,上量后必现。

复盘一次真实事故(已脱敏)。主角是分账结果通知——没有状态机可倚仗,"先查后写"是当时唯一的防线。渠道的一次重试和补单任务几乎同时开跑:

14:32:05.118 [exec-41] 查幂等记录 ps_7301…:PROFIT_SHARE_RESULT → 不存在
14:32:05.121 [exec-57] 查幂等记录 ps_7301…:PROFIT_SHARE_RESULT → 不存在   ← 3ms 并发窗口
14:32:05.124 [exec-41] 分账入账 320.00 元
14:32:05.125 [exec-57] 分账入账 320.00 元                    ← 重复在此发生
14:32:05.131 [exec-41] INSERT 处理记录 → 成功,事务提交
14:32:05.132 [exec-57] INSERT 处理记录 → 唯一索引冲突,整个事务回滚
14:32:05.133 [exec-57] 捕获冲突转幂等应答,第二笔 320.00 元随回滚消失

两个线程在 3 毫秒窗口里都查到"不存在",都放行、都入账——应用层至此全线失守。救场的是唯一索引:第二条 INSERT 等第一个事务落定后判定冲突;入账与插记录同在一个事务,回滚把第二次入账一并带走。约束不看线程先后,只认"这个键最多一行"——代码可能被并发绕过,约束不会。

有状态机可倚仗的支付回调,还有第二个解法——条件更新看 affected rows

// 伪代码(Java 风格,已脱敏):affected rows 的三分支判断
int rows = orderDao.update(
    "UPDATE pay_order SET status='PAID' " +
    "WHERE out_trade_no=? AND status='PAYING'",   // 守卫条件写进 WHERE,不是写进 if
    e.outTradeNo());
if (rows == 1) {    // 抢到了:唯一把订单从 PAYING 推到 PAID 的人,继续入账
} else {            // rows == 0:订单已不在"支付中"
    Order o = orderDao.find(e.outTradeNo());
    if (o.isPaidOrAfter()) return Ack.SUCCESS;   // 已被并发赢家处理 → 幂等应答
    throw new StateConflict(o.status());         // 非法迁移 → 报错留痕
}

赢家提交前,输家的 UPDATE 阻塞在行锁上;赢家一提交,输家拿到 0 行。affected rows 是最诚实的裁判:它不说"你失败了",只说"这单不归你处理了"。

先查后写仍要保留,但定位变了:它是性能优化(省一次写),不是正确性保障。正确性由唯一索引和条件更新负责。

四、各场景的幂等键选择

场景幂等键理由(各配一例)
支付成功回调渠道交易号 + PAY_SUCCESS交易号由渠道全局唯一。例:余额付失败换银行卡重付,同一订单对应两次支付——用自家单号当键,第二笔合法支付会被吞掉
退款成功回调渠道退款单号 + REFUND_SUCCESS退款单号独立于交易号。例:先退 50 再退 80,两个退款单号不同、都该处理;键里不含退款单号,第二笔必被吞
分账回调分账单号 + PROFIT_SHARE_RESULT分账与分账回退共用分账单号,却是两个事件。例:键漏了事件类型,回退通知会命中分账结果的旧记录,被当"已处理"放走
前端重复提交服务端预生成的草稿单号提交前先建单拿号,提交只认这个号。例:800 毫秒内双击支付按钮,两次请求带同一个草稿单号,第二笔在入口就被挡下
定时补单/对账补录与回调共用同一个键补单就是重放。例:上文事故正是补单与回调并发——补单自成一键、自成入口,等于绕过全部防线

前端的按钮置灰、防抖只是体验优化,服务端幂等才是底线。

五、脱敏伪代码

// 伪代码(Java 风格,已脱敏),支付成功回调处理
@Transactional
public Ack handlePayNotify(NotifyEvent e) {
    verifySignature(e);                        // 验签失败:应答失败,不进幂等
    checkAmount(e);                            // 回调金额必须与订单一致
    String key = e.txnId() + ":PAY_SUCCESS";   // 幂等键 = 第三方单号 + 事件类型

    if (notifyLog.exists(key)) {               // 第一层:入口幂等
        return Ack.SUCCESS;                    // 处理过:幂等应答,让渠道停止重试
    }
    int rows = orderDao.update(
        "SET status='PAID' WHERE out_trade_no=? AND status='PAYING'",
        e.outTradeNo());                       // 第二层:条件更新守卫
    if (rows == 0) {
        Order o = orderDao.find(e.outTradeNo());
        if (o.isPaidOrAfter()) return Ack.SUCCESS;  // 状态幂等:已成功,直接应答
        throw new StateConflict(o.status());        // 非法迁移:报错留痕
    }
    ledger.credit(e);                          // 入账、发核销码……
    try {
        notifyLog.insert(key);                 // 第三层:唯一索引兜底
    } catch (DuplicateKeyException ex) {
        return Ack.SUCCESS;                    // 并发赢家已处理,本事务回滚
    }
    return Ack.SUCCESS;
}

处理记录、订单状态、入账在同一事务内——记录落了业务没落(或反过来),幂等就会误伤下一次重试。这套路子在全库 1241 个测试方法里有一组并发用例专盯:两个线程、同一个键、断言只入一次账。

六、踩坑清单(现象 → 定位 → 修法)

  • 回调追个不停。现象:同一单回调追了四十多次;定位:应答体显示已处理的回调仍应答失败;修法:已处理一律应答成功,仅验签失败应答失败。
  • 有记录没订单。现象:处理记录存在,订单却停在"支付中";定位:两个写入不在同一事务;修法:记录与业务变更绑进同一事务。
  • 重试风暴。现象:生产偶发唯一约束异常告警,随后回调量不降反涨;定位:冲突被当普通异常上抛,应答失败引来下轮重试;修法:捕获冲突,转幂等应答。
  • 伪造回调入账。现象:对账时某笔入账找不到对应渠道流水;定位:只做幂等没做金额校验;修法:验签与金额校验放在幂等判断之前。
  • 补单绕防线。现象:入账流水重复,处理记录只有一条;定位:补单自己写插入逻辑,没走回调入口;修法:补单复用回调入口,键也共用。
  • 键每次都变。现象:同一次支付的重试被当新事件处理了多次;定位:日志里幂等键混入了时间戳、随机数;修法:键只取业务确定性字段——确定性是幂等键的命。

七、三句话总结

幂等不是一处代码,是三层防线:入口先查后写挡掉大多数重复,状态机守住业务语义,唯一索引在数据库层面兜底。先查后写防不住并发,防得住的是唯一索引与条件更新,affected rows 是最诚实的裁判。对渠道的正确姿态是"已处理就应答成功"——同一条回调来三次,只能入一次账。

运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。

本文为位元跃迁原创内容,仅代表编辑观点,不构成经营或投资建议;文中涉及的品牌与案例仅作公开信息分析示例。

LET'S TALK

把方法用进你的生意

如果你正在为门店做数字化规划,欢迎和我们聊聊。位元跃迁是微信支付合作伙伴、微信开放平台第三方平台,提供小程序定制、开发咨询与企业数字化服务。

18601279913

业务咨询 · 项目合作 · 代理商合作

了解位元跃迁的服务 →
位元跃迁业务联系微信二维码
微信咨询