一句话:三方支付回调经过自研网关做签名校验,灰度后验签失败率飙升;查了三天,根因不是密钥、不是拼接规则,而是网关的时间戳窗口默认按「秒」解释,回调方给的却是毫秒时间戳。
背景
- 架构:网关(Spring Cloud Gateway)+ 支付服务 + 回调方
- 回调链路:三方 → 网关(签名校验、时间戳窗口、nonce 防重放)→ 支付服务
- 改造内容:把验签从支付服务前移到网关,做统一入口
现象
- 灰度 5% 后,回调 401 从 0 飙升到 30% 以上,高峰期接近全失败
- 错误统一是「签名无效」,日志只有一行 sign invalid
- 本地用同一套密钥和参数构造请求,能验过
第一轮排查:逐条排除
- 密钥:三方后台、网关配置、支付服务三处比对,一致,排除
- 拼接规则:按文档排序、空值处理、URL 编码,和抓包原文逐字节比对,一致
- 服务器时钟:NTP 同步正常,偏差 200ms 内,排除
- nonce:Redis 写入读回正常,排除
- 签名算法:SHA256WithRSA,换成已知正确样本交叉验证,能过
每条都对,但生产就是失败。
卡点:本地复现不出来
本地构造的请求能过;把生产抓包的原文复制到本地脚本里跑,也能过。这说明问题不在「签名内容」,而在「校验发生的环境」。
于是做了两件事:
- 在验签失败分支里把失败步骤拆开打点:
timestamp/sign/nonce三段分别判定 - 灰度 1% 并保存失败样本(脱敏后只留字段结构)
根因
失败样本里 100% 卡在时间戳窗口:
- 网关的时间戳单位配置默认
seconds - 回调方 SDK 发的是 13 位毫秒时间戳
- 窗口校验把毫秒当秒,差值瞬间变成 45000+,全部判定「过期」
三个「为什么」也就通了:
- 为什么本地能过:本地脚本用的是 10 位秒级时间戳,和生产不一致
- 为什么偶发:高峰期走的是另一条回调通道,时间戳单位不同
- 为什么日志没用:拆点之前只打「验签失败」,看不出卡在哪一段
修复
- 时间戳单位改成显式配置,并对 10 位 / 13 位做自动归一化
- 单位不匹配时打 WARN,不再静默失败
- 验签失败日志分步骤输出,带脱敏样本
- nonce 写入失败改为可观测降级,不再无声放行或拒绝
- 灰度开关 + 验签成功率指标,低于 99% 自动回滚
复盘:三条能迁移到别处
- 协议里的每个数字都要显式声明单位。时间戳、超时、金额精度、距离,文档没写,代码就有两种解释,两种解释就有一个是错的。
- 「签名无效」是垃圾日志。验签是复合流程,失败必须分步骤,否则排查成本是十倍。
- 本地能过不代表对。本地构造请求用的是「你以为的格式」,不是真实生产格式。灰度前先抓真实样本。
最后
这类问题有个共同特征:不是某个点写错了,而是两个模块的「默认值」不一致。
所以现在我做支付、网关、回调这类改造,第一步不是写代码,是先拉一张「字段与单位约定表」,各方确认后再动手。
遇到类似的问题可以找我聊,我的 GitHub 在 github.com/luobobo896。