回调出问题时最常见的对话是「你们没推过来」和「我们推了」,双方各拿一份日志,谁也证伪不了谁。原因不是谁在撒谎,而是没人能说清事件卡在哪一段。把链路切成五段、每段定一个可观测的判据,大部分回调延迟和丢失能在十分钟内定位到段,剩下的才是真正需要深挖的。下面这套分段按 wecomapi 的回调链路来排,自建接入也照样能套。
先分症状,再切链路
「没收到」「收到得晚」「收到了但没生效」是三种不同的故障,共用一套排查步骤只会浪费时间。没收到通常卡在网络层或验签,晚了通常卡在响应时延或队列,没生效基本都在业务层。开口第一句先问清是哪一种,能省掉一半无用功。
接下来是切链路。排查回调最贵的环节不是修复,是扯皮 —— 判断「平台到你」还是「你内部」需要一个双方都认的分界点。这个分界点就是接入层的一条日志:在应用最外层、验签之前,把请求到达时刻和一个自己生成的追踪标识记下来,不管这个请求后面是被拒还是被处理。有了它,「有没有推过来」就不再是口头争论。
光有分界点还不够,延迟需要的是时间轴。一条事件在链路里至少有五个时刻值得记:事件在平台侧产生、请求到达你的接入层、ACK 返回、进入消费者、业务动作完成。第一个时刻取 wecomapi 事件体里带的产生时间,后四个由你自己打点。把它们挂在同一个追踪标识下,任何一次延迟都能一眼看出是哪一段吃掉的。
// 示意:同一条事件在五个位置各记一次,全部挂在同一个 traceId 下
mark(traceId, "produced", eventProducedAt(evt)); // 平台侧产生时刻,字段以 wecomapi 文档为准
mark(traceId, "received", now()); // 最外层、验签之前
mark(traceId, "acked", now()); // 响应返回
mark(traceId, "consumed", now()); // 消费者取到
mark(traceId, "done", now()); // 业务动作完成
// received - produced → 网络与平台侧排队
// acked - received → 同步处理耗时,直接决定会不会被重投
// consumed - acked → 队列积压
// done - consumed → 业务处理耗时如果只能加一条日志,加请求到达那条 —— 它是整条链路上唯一能把责任切成两半的位置,而且必须在验签之前。放在验签之后,验签失败的请求会从日志里彻底消失,而那恰好是最需要证据的一批。
第一段:网络层,请求到没到你的机器
应用日志里什么都没有,不代表请求没来。先去看接入层的访问日志 —— 网关、负载均衡或 Nginx。那里有记录而应用没有,问题就在中间层;两边都没有,才轮到往外查。
- HTTP 到 HTTPS 的整站跳转:投递方通常不跟随重定向,回调地址填成 http 就等于全丢,而你的访问日志里只看到一串 301,看着完全正常
- 证书链不完整:浏览器会自动补中间证书所以你本地打开没问题,严格校验的服务端客户端直接握手失败
- WAF 或安全策略拦截:带 JSON 大 body 或特殊字符的请求被判成攻击,返回的是防护页面,应用侧毫无痕迹
- 白名单、安全组、端口只对内网开放:表现为完全静默,最容易在换机房或扩容后突然冒出来
用 curl 从外网按 wecomapi 的投递形态模拟一次请求,比读三遍配置快得多。重点不是看返回体,是看重定向次数、TLS 握手耗时和状态码这三个数。
# 从公网侧模拟一次投递,只看链路指标,不看返回体
curl -sS -o /dev/null \
-w 'redirects:%{num_redirects} tls:%{time_appconnect}s ttfb:%{time_starttransfer}s code:%{http_code}\n' \
-X POST https://callback.example.com/wecom/events \
-H 'Content-Type: application/json' -d '{"ping":1}'重定向次数不为 0 基本就是根因。回调地址要填最终地址,不要依赖跳转帮你兜底。
第二段:验签与解析,收到了但被自己拒了
访问日志里有 401 或 400,业务却报「没收到」—— 这一段最容易被跳过,因为它从外面看和「平台没推」长得一模一样。
- body 被框架先读走:签名要对原始字节计算,而多数框架的 JSON 中间件会把请求流消费掉。拿反序列化再序列化的结果去算签名,字段顺序或空白一变就对不上
- 服务器时钟漂移:带时间戳校验的签名会在偏差超阈值时全线失败,没配 NTP 的机器过几周就会踩到
- 多实例配置不同步:换密钥时只滚动了一部分实例,表现为间歇性丢失而不是全挂,这是最难查的一种
- 编码问题:中文内容在某一层被转码,字节变了签名就废了
间歇性丢失先算失败率。如果失败率稳定在 1/N 附近,而你恰好有 N 个实例,几乎可以直接断定是某个实例的配置或时钟不一致 —— 把实例标识打进日志按实例聚合一下就能锁定。失败均匀分布在所有实例上,才需要往签名算法和编码上查。
验签失败要拒绝,但拒绝之前必须留下一条带追踪标识、原始字节长度和来源地址的日志。很多实现直接返回 401 什么都不记,等于亲手销毁了唯一的线索。
第三段:响应时延,你的慢会变成平台的重试
回调是同步 HTTP,你的响应时间不只是你自己的事。投递方一般对单个接收端有并发上限,你慢一条后面就排一条;如果为了保序按会话串行投递,一条卡住整条会话都在等。业务侧看到的是「延迟」,根因在自己身上。
所以 ACK 之前的路径要短到没有外部依赖。最常见的反面案例是在 handler 里同步查数据库做去重:数据库抖一下,响应变慢,投递方判超时重发,重发的请求又去查同一个数据库 —— 正反馈一旦成立,几分钟就能把库压死。去重要做,但做在消费者里,不在 ACK 路径上。
排查这一段别看平均值,直接按追踪标识把 ACK 耗时最长的那几十条拉出来逐条看 —— 制造重投的永远是尾部那一批,它们在平均值里不留任何痕迹。看完再清点一遍 ACK 路径上还挂着哪些外部依赖:除了「把原始报文可靠落下来」这一件必须做的,其余都该搬到消费侧;确实去不掉的必须有硬超时和降级分支,超时就放弃这次依赖,而不是拖着响应一起等。
第四段:队列积压,延迟在你自己的管道里
ACK 很快、投递方没重试、事件一条不少,但业务侧就是晚十分钟 —— 这时候看队列。三个数就够:队列深度、消费速率、投递速率。深度持续上涨说明消费跟不上;深度稳定但一直很高,说明消费者在原地打转。
- 分片粒度太粗:为了保序把所有事件塞进一个分区,一条慢消息把整条链路堵住
- 消费者里同步调外部接口:尤其是发送类接口自带频控,一旦被限流,消费速率直接掉到限流值
- 重试没有退避和上限:失败消息被立刻塞回队头反复失败,占满消费能力,健康消息排在后面饿死
- 没有死信队列:一条永远处理不了的毒消息,足以让整条链路停摆
这四条的修法都落在队列设计上 —— 分片粒度怎么定、消费组怎么拆、重试与死信策略怎么配,站内另有一篇专门讲,这里不展开。
排查阶段要做的只有两件事:把「消费跟不上」和「消费者在原地打转」区分开,再指认出是上面四条里的哪一条 —— 责任段定完了,改设计是下一步的事。
第五段:业务异常,事件到了但效果没出来
链路全通、日志显示处理成功、业务就是没变化。这一段的问题几乎都出在「成功」的定义上。
- 处理器吞异常:catch 之后只打一行日志继续走,事件被算作已处理
- 只看 HTTP 状态:下游返回 200 但响应体里是业务失败,被当成成功
- 幂等键设计过宽:多个处理器共用一个键,或者键里少了区分维度,真事件被当成重复丢掉
- 订阅或过滤规则吃掉了事件:类型没订阅、路由规则不匹配、灰度开关没打开
「处理成功」必须定义成业务动作已完成,而不是没抛异常。日志里记的应该是处理结果和受影响的业务标识,不是一句「已处理」。这一条改完,这类问题的定位时间通常从半天降到几分钟。
- 1先问清是没收到、晚了还是没生效,三种故障的入口完全不同
- 2查接入层访问日志里有没有这条请求:没有就往网络层查,有就继续往下
- 3看状态码:4xx 查验签与解析,5xx 查处理逻辑,2xx 继续往下
- 4比对 ACK 时刻减到达时刻:超过几百毫秒,说明 ACK 路径上还挂着外部依赖
- 5看队列深度与消费速率:深度在涨就是积压,此时先别重启消费者,先确认是不是被下游限流
- 6以上都正常就去业务层,按追踪标识把这条事件的处理结果日志翻出来
本文讲的是排查方法与链路分段。具体的签名算法、事件字段与重试策略以 wecomapi 线上接口文档为准,示意代码只用于说明位置关系。
常见问题
- 怎么快速判断是平台没推,还是我们没收到?
- 在应用最外层、验签之前记一条只写「请求已到达」的日志,带上到达时刻和自己生成的追踪标识。这是链路上唯一能把责任切成两半的位置:它有记录就往内部查,它没有就去看接入层访问日志和网络层。注意这条日志必须在验签之前,否则验签失败的请求会从证据里消失。
- 回调偶尔丢、但不是全丢,一般是什么原因?
- 间歇性丢失先算失败率再看分布。失败率稳定在 1/N 附近而你恰好有 N 个实例,基本是某个实例的配置或时钟不一致;失败均匀分散在所有实例上,则更可能是签名计算、字符编码或某类特定报文的解析问题。在 wecomapi 的回调地址后面挂多实例时,把实例标识打进日志按实例聚合,是最快的锁定方式。
- 事件都收到了,业务就是晚十几分钟,问题在哪?
- 这类延迟基本不在平台侧,先看队列深度和消费速率。深度持续上涨是消费跟不上,常见于消费者同步调用带频控的外部接口,或者为了保序把所有事件塞进一个分区。按会话标识哈希分片、给重试加退避和死信,多数情况能解决。
准备好动手了?
精确字段、鉴权与端点以线上文档为准;可在控制台创建密钥后联调。
