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