4.1 支付接入:回调验签与幂等

支付接入上线第一天就出了事故。渠道的回调接口接收了 600 次回调,其中 200 次是重复回调,因为我们的处理逻辑在更新成功后又抛了个异常给上游,渠道以为没收到便疯狂重试,每重试一次我们就更新一次状态,重复的退款记了 8 笔。这一节讲支付回调的三条红线:验签、幂等、返回约定。

场景:回调链路的四个危险点

支付回调的坑集中在四个地方。伪造请求:没验签前,任何人都能 POST 一个假回调改订单状态。重复回调:渠道侧的重试机制(通常各自设计为 8 次退避重试)让同一回调被发送多次。乱序回调:支付成功的回调可能比失败回调晚到。返回值含糊:处理失败时返回 500 让渠道重试,但有些失败重试也没用(比如订单已关闭)。

方案:验签与幂等的分工

验签解决真伪,幂等解决重复,返回约定解决渠道重试。处理流程与对应设计:

环节 设计要点 处理失败的方式
验签 公钥验签 + 时间戳防重放 直接返回成功,内部告警
参数校验 订单号、金额、渠道流水 返回成功,告警人工处理
幂等检查 渠道流水号唯一键 已处理直接返回成功
业务更新 状态机条件更新 状态冲突返回成功
未知异常 兜底 catch 返回 500,渠道重试

一个反直觉的设计:大多数处理失败都要返回成功给渠道,因为重试解决不了(数据不对),徒增噪音。真正让渠道重试的只有「未知异常」这一类可重试故障。这个设计把渠道的重试机制用在了正确的地方。

代码:回调处理骨架

@PostMapping("/pay/callback")
public String handle(@RequestBody PayCallback cb) {
    if (!signVerifier.verify(cb)) {        // 验签失败:假回调或重放
        log.warn("验签失败 {}", cb.getOrderNo());
        return SUCCESS;                    // 不暴露内部错误
    }
    if (payLogDao.exists(cb.getChannelSeq())) {
        return SUCCESS;                    // 幂等返回
    }
    try {
        orderService.handlePayCallback(cb);
    } catch (Exception e) {
        log.error("回调处理异常 {}", cb.getOrderNo(), e);
        return RETRYABLE_ERROR;            // 仅这类才让渠道重试
    }
    return SUCCESS;
}

三处设计值得注意。验签失败的返回值是 SUCCESS:这是安全策略,不告诉攻击者验签逻辑是否存在,同时不触发渠道重试。幂等检查依赖渠道流水号的唯一索引,这个索引比订单状态检查更可靠,因为流水号是渠道保证唯一的。catch 块只兜真正未知的异常,业务异常已经被业务层按上面的表处理了。

踩坑:三个支付接入的坑

第一个是验签用的证书过期。渠道的公钥证书有有效期,某天凌晨证书轮换,验签全部失败。我们的处理方式是验签失败先告警不阻断返回,同时接入证书更新订阅。实际上这次故障暴露的是「配置变更无监控」的系统性问题,与 服务治理 强调的配置变更可观测是一个道理。

第二个是金额单位不一致。我们订单系统以元为单位,渠道以分为为单位,回调金额忘了乘 100,校验一直在做错误金额的比对,直到一笔订单金额差了 100 倍才发现。从此规约:金额统一在接入层做单位换算,内部系统永远用同一单位。同样的问题在 对账设计 里也是常见差异来源。

第三个是幂等粒度不对。早期用订单号做幂等键,一个订单在渠道的两笔退款流水被幂等挡掉了。幂等键应当用「渠道流水号」而不是「订单号」,因为一个订单可能有多次资金动作。这个错误过了三周才被发现,因为正常情况一个订单只有一笔支付,异常路径才暴露。

第四个细节是回调延迟统计。回调到达时间和支付成功时间的差值是监控的核心指标,超过 30 秒说明渠道在重试链上挣扎,通常预示掉单。延迟趋势图与 监控告警 一节的大盘直接联动。

进阶实战:回调重试的数学

理解渠道的重试机制实际上是道数学题。渠道通常按指数退避重试 8 次,间隔 15 秒、30 秒、1 分、2 分、5 分、10 分、20 分、40 分。也就是说一次回调丢失,渠道会持续尝试约一个半小时。这决定了两件事:第一,幂等必须覆盖一个半小时以上,我们的幂等键存 24 小时足够;第二,重试放过来的时候必须仍然正确处理,而不是报错拒绝。

重试次数 间隔 累计时间
1 15 秒 15 秒
2 30 秒 45 秒
3 1 分钟 1 分 45 秒
4 2 分钟 3 分 45 秒
5 5 分钟 8 分 45 秒
8 40 分钟 约 1.5 小时

重试机制的另一个含义:我们的回调处理高峰是「支付高峰 + 网络抖动」的叠加,高峰时回调流量可达平时的 5 倍。回调接口的线程池必须按这个峰值评估,我们给回调接口配了独立的线程池(下单接口的池子串了,回调就稳定了),容量是大促峰值的 3 倍。

验签性能也值得留意。非对称验签比较吃 CPU,大促时验签占用约 15% 的 CPU,我们后来把验签做成了异步流水写入加实时同步验签的双轨:同步验签保证安全,异步记录验签耗时用于监控。

速查清单

支付回调的落地清单:

项 配置/做法
验签 公钥验签,时间戳防重放
幂等键 渠道流水号唯一索引
幂等时长 大于渠道最大重试时长
线程池 独立于下单接口
失败返回 大多返回成功,仅未知异常可重试

小结

支付回调的三条红线:验签防伪造,幂等防重复,返回约定让渠道重试只发生在对的地方。幂等键用渠道流水号,金额单位在接入层统一,验签失败返回成功但告警。下一篇讲回调不来怎么办,主动查单机制。

常见误解:回调设计的三个陷阱

第一个陷阱是「回调失败就返回失败」。这会触发渠道疯狂重试,如果是数据问题(订单不存在),重试一万次也不会成功,反而把接口打挂。判断标准要分清「可重试故障」与「永久故障」:网络抖动、数据库锁竞争是前者;订单已关闭、金额不符是后者。代码层把这两类异常分开,返回码不同,路线也不同。

陷阱 后果 对策
失败一律返回 500 重试洪水 区分可重试异常
验签放业务代码里 容易漏验 网关层统一验签
靠渠道回调驱动一切 掉单无感知 主动查单并行

第二个陷阱是「验签逻辑散落在业务代码」。某个早期接口验签写在 controller 里,后来那个 controller 被绕过出过一次事故。正确做法是验签放在统一拦截器或网关层,所有回调路径无差别经过,想跳过都做不到。安全机制要靠架构保证,不能靠人记得。

第三个陷阱是「只信渠道回调」。渠道的回调不是合同,是通知。真正可靠的是「回调 + 主动查单 + 对账」三件套。我们对掉单的敏感度要高于渠道的可靠性承诺,系统层面的信任应当建立在验证之上。这套思路与分布式系统的「校验而非信任」原则一致。

团队协作:渠道对接的标准化

接多个支付渠道时,重复设计最容易出错。我们把渠道对接抽象成「验签、查询、关单、退款」四个标准动作,每接入一个新渠道就按标准动作写适配器。新渠道接入从两周缩短到三天,且各渠道的行为一致。标准化让「对接渠道」从玄学变成了流水线作业。

标准动作 接口约定 差异点
验签 回调全字段签名 算法不同
查单 按订单号查状态 返回结构
关单 主动关闭未支付单 部分渠道不开放
退款 按渠道流水号退 手续费规则

每个渠道的适配器有「渠道特质表」,记录这个渠道独有的行为:超时时间、重试次数、回调字段差异。特质表是踩坑记录的副产品,也是新人接手的第一份读物。这套标准化让团队的渠道知识可积累,而不是「只有老王会调这个渠道」。

延伸阅读