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 被绕过出过一次事故。正确做法是验签放在统一拦截器或网关层,所有回调路径无差别经过,想跳过都做不到。安全机制要靠架构保证,不能靠人记得。
第三个陷阱是「只信渠道回调」。渠道的回调不是合同,是通知。真正可靠的是「回调 + 主动查单 + 对账」三件套。我们对掉单的敏感度要高于渠道的可靠性承诺,系统层面的信任应当建立在验证之上。这套思路与分布式系统的「校验而非信任」原则一致。
团队协作:渠道对接的标准化
接多个支付渠道时,重复设计最容易出错。我们把渠道对接抽象成「验签、查询、关单、退款」四个标准动作,每接入一个新渠道就按标准动作写适配器。新渠道接入从两周缩短到三天,且各渠道的行为一致。标准化让「对接渠道」从玄学变成了流水线作业。
| 标准动作 | 接口约定 | 差异点 |
|---|---|---|
| 验签 | 回调全字段签名 | 算法不同 |
| 查单 | 按订单号查状态 | 返回结构 |
| 关单 | 主动关闭未支付单 | 部分渠道不开放 |
| 退款 | 按渠道流水号退 | 手续费规则 |
每个渠道的适配器有「渠道特质表」,记录这个渠道独有的行为:超时时间、重试次数、回调字段差异。特质表是踩坑记录的副产品,也是新人接手的第一份读物。这套标准化让团队的渠道知识可积累,而不是「只有老王会调这个渠道」。
