凌晨两点被电话叫起来,说小程序里一堆用户付了钱但订单还是"待支付",客服后台全是投诉。打开日志一看,我们自己的订单表里根本没有那批支付记录,说明支付平台的异步回调通知压根没到。这就是典型的微信支付回调通知收不到问题,做过支付的同学大概都经历过这种心脏骤停的时刻。这类问题的麻烦在于它不在你的主流程里,平时跑得好好的,一旦出问题就是"用户已经扣款但业务没反应"的资金类事故,排查窗口还特别短——通知重试次数用完之后,支付平台就不会再主动发了。这篇我把自己和身边同行踩过的坑整理一遍,从链路原理、配置检查、代码陷阱到日志定位方法,按"能不能自己复现"的思路串起来,适合已经接过支付、正在被回调折磨的开发者,也适合刚接手支付模块、想提前避坑的同学。
1. 先把这条链路摸清楚:回调通知不是"支付成功"的附属品
很多人对回调通知的理解停留在一句话:"用户付完钱,平台会通知我的服务器。"这句话本身没错,但它省略了太多前提条件,导致排查的时候完全不知道从哪儿下手。我见过不少同学遇到收不到通知,第一反应是"平台是不是挂了",然后去翻社区帖子,折腾半天,最后发现是自己配置里多写了一个斜杠。想有效排查,得先把这条链路拆成几个可以独立验证的环节。
1.1 用户付完钱之后,两件事其实是并行发生的
用户在收银台输入密码点确认,支付平台内部完成扣款,然后同时触发两个动作:一是给你前端返回支付结果,二是向你的服务器发起一次异步通知。这两个动作是互相独立的,走的是完全不同的网络路径。
前端拿到结果靠的是页面跳转或轮询查单,它只对当前这个用户的浏览器有效,你服务端的订单表并不会因为前端拿到成功结果就自动变。真正让服务端"知道"这笔钱到账的,是那条异步通知。所以当通知丢了,现象就是:用户手机上显示支付成功,你的系统里订单还挂着待支付。
理解了这一点,你就明白为什么不能用前端回传的结果去改订单状态——那是可以被伪造的,而且用户关掉页面就没了。回调通知是唯一可信的资金到账信号,正因为它这么关键,丢了才会这么疼。
还有一个常见的认知偏差:有人以为通知是"实时"的,用户付完钱下一秒就该到。实际上平台是先扣款、再发通知,中间有个很短的间隔,正常情况几秒内到,但如果你的服务响应慢或者网络抖动,第一次通知可能就失败了,进入重试队列。所以看到"付完钱十分钟了还没收到",先别慌,先去看重试记录。
1.2 通知的重试节奏决定了你的排查窗口有多短
支付平台对通知失败是有重试机制的,但次数有限、时间跨度有限。以常见的 APIv3 通知为例,失败后的重试间隔大致是这样一组:15秒、15秒、30秒、3分钟、10分钟、20分钟、30分钟、30分钟、30分钟、60分钟、3小时、3小时、3小时、6小时、6小时,加起来大约覆盖 24 小时,总计十几次。
这组数字要刻在脑子里,因为它直接决定了你的应急响应策略:
| 阶段 | 时间范围 | 你在做什么 | 应该做什么 |
|---|---|---|---|
| 黄金期 | 0-5分钟 | 用户投诉刚起来 | 立刻查自己的访问日志,判断请求有没有进来 |
| 抢修期 | 5分钟-1小时 | 还在改代码 | 优先用主动查单接口把已支付订单补上,别让用户等 |
| 补救期 | 1-24小时 | 修复已上线 | 观察重试是否成功,必要时手动触发补发 |
| 失效期 | 24小时后 | 通知彻底停了 | 只能靠主动查单或对账把漏单捞回来 |
我在实际项目里见过最惨的一种情况:通知从下午开始就全挂了,但那天订单量不大,一直到晚上才被发现,等修复上线已经是第二天,重试早就耗尽了,最后靠离线对账脚本把两百多笔订单手工补回来,写了一晚上的道歉邮件。
所以应急的第一原则不是"定位根因",而是"止损"——先用主动查询接口把这段时间的订单状态同步一遍,再慢慢查为什么通知没来。这两件事不冲突,可以并行。
1.3 什么算"通知成功":应答规范比你想的严格
很多人以为只要接口返回了 HTTP 200 就算成功,其实细节上有讲究。APIv3 的通知,平台判断成功的标准是你的 HTTP 状态码是 200 或 204,且响应体为空或者内容不被解析。你返回 200 但带了一段 JSON 错误信息,某些情况下仍会被视为成功——但如果你返回 200 之后业务逻辑抛异常了,那就是另一回事了。
更常见的错误是返回了非 2xx 状态码。比如验签失败时,很多代码习惯性地throw new RuntimeException(),框架默认返回 500,平台一看 500,判定通知失败,进入重试。这时候你的日志里明明有请求记录,接口也确实被调用了,但通知就是"收不到"——因为你把每一次通知都拒了。
还有一种隐蔽的:接口返回 200,但响应体里写了个{"code":0}或者{"errcode":0}之类自定义结构。APIv3 不要求返回体,多数情况下不影响判定,但 APIv2 时代要求返回success字符串的 XML,返回别的内容会被当成失败。如果你维护的是老项目,这一点要特别确认。
提示:不要靠记忆,去官方文档确认你所用接口版本的成功应答标准。版本之间的差异是踩坑高发区,尤其是从 v2 迁移到 v3 的项目。
2. 配置层:八成的"收不到"死在这一步
讲完链路,来说最朴素的排查顺序。我的习惯是从外往里查:地址通不通、网络到不到、证书过不过、中间件拦不拦,最后才看代码。这个顺序的好处是,前几层任何一层挂了,后面的代码写得再对也没用。而现实中,配置层的问题占了相当大的比例。
2.1 回调地址的十种错法
在商户后台填的那个回调地址,是整个链路的入口。这个字段看起来简单,实际上是错误高发地。我总结了一份自查清单,每次上线前对着过一遍:
| 错误类型 | 具体表现 | 怎么发现 |
|---|---|---|
| 协议写错 | 填了http://但服务器只开 443 | 访问日志里完全没有记录 |
| 域名写错 | 测试域名带到了生产环境 | 同上,或打到另一台机器 |
| 端口错误 | 用了 8080 但只对外开了 443 | 同上 |
| 路径笔误 | /notify写成/notfiy | 日志有记录但 404 |
| 大小写问题 | 路径大小写不一致(部分服务器区分) | 日志 404 |
| 多余斜杠 | 结尾多了/,路由匹配不上 | 日志 404 或 301 |
| 被重定向 | 网关把 http 301 到 https,POST 变 GET | 日志显示 301,body 丢失 |
| 测试/生产混填 | 生产环境填了测试回调地址 | 测试服务器收到一堆生产通知 |
| 未备案/未校验 | 域名未完成必要校验,通知不下发 | 后台配置处有提示 |
| 带参数 | 地址后跟了 query 参数,被网关截断 | 实际路径和预期不符 |
这里我要特别强调"重定向"这一条,因为它极其隐蔽。有些团队为了统一管理,在网关层配置了 HTTP 到 HTTPS 的强制跳转,结果回调地址填的是 http 开头。平台发起 POST 请求,网关返回 301,很多 HTTP 客户端在收到 301 后会把 POST 改成 GET 再请求一次,而且不带请求体。你的接口收到了一个 GET 请求,验签必然失败,日志里看着"有请求进来",但实际上是一个空壳。
排查方法很简单:用curl -i -X POST手动发一次,看返回的状态码。如果是 301 或 302,基本就锁定了。
2.2 域名从公网能不能打进来
配置正确不代表网络可达。开发环境往往在内网,能访问外网不代表外网能访问你,这个方向性经常被忽略。典型的翻车场景是:开发在本地用内网穿透工具调试通了,一切正常,上线之后发现服务器在私有网络里,没有公网入口,也没做任何映射。
验证方法很直接,找一台完全独立的公网机器(比如随便一个外部的命令行环境),执行:
# 先看 DNS 解析到哪 dig +short your-domain.com # 再看端口通不通 nc -zv your-domain.com 443 # 最后看 HTTP 层 curl -i -X POST https://your-domain.com/pay/notify \ -H "Content-Type: application/json" \ -d '{"test":1}' \ --max-time 10这三条命令分别验证了 DNS 解析、TCP 连通性、应用层响应。如果第一条就解析错了,说明 DNS 记录有问题;如果解析对但端口不通,那是安全组或防火墙;如果端口通但 curl 超时,可能是应用没起来或者中间设备吞了请求。
还有一种情况是 DNS 解析到了多个 IP,其中一部分是坏的(比如扩容时新机器没配好)。这时候表现为"时灵时不灵",通知一会儿到一会儿不到,非常迷惑。遇到这种间歇性故障,一定要多查几次解析结果,确认所有 A 记录对应的机器都健康。
2.3 证书与TLS:握手就挂了的排查方法
现在基本都是 HTTPS 回调,证书问题就成了另一个高频雷区。支付平台侧的 HTTP 客户端在 TLS 校验上是比较严格的,以下情况会直接导致握手失败,请求根本到不了你的应用:
- 证书链不完整,只配了站点证书没配中间证书(这是最常见的一种,浏览器能容忍,但严格的客户端不行)
- 使用了自签名证书
- 证书已过期或者域名不匹配
- 服务器只支持过低的 TLS 版本,或者不支持的加密套件
- 多域名共用服务器时没有正确配置 SNI
验证证书链完整性的命令:
openssl s_client -connect your-domain.com:443 -servername your-domain.com -showcerts输出里如果能看到完整的证书链(站点证书 + 一个或多个中间证书),并且最后有Verify return code: 0 (ok),说明证书配置没问题。如果只看到一张证书,或者返回非 0 的验证码,那就是有问题。
我在一个项目里遇到过这样的情况:证书是从某处导出的,运维只把站点证书贴进了配置,中间证书落下了。用浏览器访问一切正常(浏览器会自己补全中间证书),用 curl 也没问题(某些版本有缓存),但支付平台发过来的请求全部握手失败。这种问题用浏览器测是测不出来的,必须用openssl s_client或者curl --cacert这种严格模式验证。
提示:TLS 握手失败时,你的应用日志里一个字都不会有。所以"日志里没有请求"这个现象,既可能是网络不通,也可能是 TLS 没协商成功,两者要分开验证。
2.4 中间层设备的静默拦截
现代架构里,请求到你应用之前通常要过好几道关:负载均衡、WAF、API 网关、Nginx。这些设备每一层都可能把请求拦下来,而且拦得很安静。
常见的拦截原因有这几种。一是 WAF 规则把回调请求当成攻击了,比如请求体里包含某些特殊字符、JSON 结构复杂、或者 body 比较大,触发了规则。二是网关对 POST 请求做了频率限制,短时间内大量通知被限流。三是 Nginx 对请求体大小有限制,默认client_max_body_size是 1M,虽然支付通知体一般不大,但如果你的网关还代理了别的接口,有人调小过这个值,就可能踩到。四是超时设置,如果网关的proxy_read_timeout设得很短(比如 5 秒),而你的业务处理要 8 秒,网关先把连接断了,平台侧看到的是一次失败。
排查这类问题,最有效的方式是看每一层的日志。负载均衡有没有连接记录?WAF 有没有拦截日志?Nginx 的 access log 里有没有这条请求?access log 里有但 upstream 没有,说明是 Nginx 到应用这一段的问题;access log 里压根没有,说明请求在更外层就被拦了。
# 把这两个值调大,避免大 body 和慢处理被误杀 client_max_body_size 2m; proxy_read_timeout 30s; proxy_send_timeout 30s; # 回调路径单独放开,别和其他接口共用限流 location /pay/notify { access_log /var/log/nginx/pay_notify.log; proxy_pass http://backend; }给回调路径单独配一个 access_log,是我强烈推荐的做法。支付通知的流量很小,独立日志不会撑爆磁盘,但排查的时候一眼就能看到全貌,不用在几万行日志里 grep。
3. 代码层:请求进来了但你没认出来
如果访问日志里明确有这条 POST 请求,状态码也不是 4xx/5xx,那问题就转移到应用内部了。这一层的问题特点是:日志看着"一切正常",接口被调用了,但业务没生效,或者平台侧认为通知失败。
3.1 验签失败为什么会导致"收不到"
这是最经典的误解。很多人把"收不到通知"和"通知没到达"画等号,实际上验签失败导致的后果和没到达是一样的——业务没处理,重试耗尽,最终表现为没收到。
验签失败的原因五花八门,我列几个高频的:
- 用的是旧版平台证书,平台轮换后没更新
- 请求头里的证书序列号和你本地缓存的证书对不上
- 验签时用的字符串拼接顺序错了(时间戳、随机串、报文主体、换行符的顺序是有严格要求的)
- 报文主体在验签前被框架改写过(比如被反序列化再序列化)
- 时间戳超出容差范围(一般是 5 分钟),服务器时间不同步
最后一条经常被忽略。如果你的服务器时间比标准时间慢了几分钟,平台发来的通知时间戳在你的系统看来是"未来"或者"过期",验签直接失败。排查的时候顺手执行一下date和ntpq -p,确认时间同步服务是正常工作的。
验签失败的日志一定要打全,至少包含:请求头里的时间戳、随机串、证书序列号、报文体的长度和哈希值、失败的具体原因。没有这些信息,你只能知道"失败了",不知道"为什么失败"。
// 伪代码:验签前先把原始报文留一份 byte[] rawBody = request.getInputStream().readAllBytes(); log.info("notify raw len={}, sha256={}, serial={}, ts={}, nonce={}", rawBody.length, DigestUtils.sha256Hex(rawBody), request.getHeader("Wechatpay-Serial"), request.getHeader("Wechatpay-Timestamp"), request.getHeader("Wechatpay-Nonce")); // 再用 rawBody 去做验签和解密,不要用反序列化后的对象3.2 请求体被提前消费的坑
这个问题在 Spring 系项目里极其常见。你写了一个拦截器或者过滤器,为了打日志把request.getInputStream()读了一遍,读完之后流就空了。后面控制器里再读,拿到的是空 body,验签自然失败,解密也解不出来。
还有一种变体是全局的请求日志组件,它会把 body 读出来记录,然后包装成一个新的请求往下传。如果包装实现有 bug,body 就丢了。表现是:日志里明明打印了完整的通知内容,但业务代码里收到的 body 是空的。
解决办法是在最外层做一次读取并缓存,用ContentCachingRequestWrapper这类包装器,让后续所有环节都从缓存里读。或者干脆在验签的入口处只读一次,把原始字节数组往下传,不在中间环节反复读流。
// Spring 里比较稳的做法 @Component public class CachedBodyFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws IOException, ServletException { if (req.getRequestURI().startsWith("/pay/notify")) { byte[] body = StreamUtils.copyToByteArray(req.getInputStream()); CachedBodyHttpServletRequest cached = new CachedBodyHttpServletRequest(req, body); chain.doFilter(cached, resp); return; } chain.doFilter(req, resp); } }关键点是:验签和解密必须基于收到的原始字节。任何经过 JSON 反序列化再序列化的操作,都可能改变字段顺序、空格、转义方式,导致哈希值对不上。这个问题在我印象里至少坑过三次,每次都是排查到半夜才发现是"报文被重新序列化了"。
3.3 应答写错导致白重试
前面提过,成功应答的标准是 HTTP 200/204 且响应体为空。实际代码里常见的错误有这么几种:
一种是把业务结果当成 HTTP 状态码返回。业务处理成功返回 200,业务处理失败返回 500。但注意,如果通知对应的订单在你系统里不存在(可能因为主从延迟或者并发问题),你返回 500,平台就会认为通知失败,一直重试。正确的做法是:只要验签通过、报文格式正确,就应该返回成功应答,业务上的异常记录下来走补偿,不要通过状态码告诉平台"我失败了"。
另一种是框架的全局异常处理器把异常统一转成了 JSON 响应,状态码 200 但响应体是一坨错误信息。这种情况多数时候不影响平台判定,但如果异常发生在写响应之前,可能状态码已经变成 500 了。
还有一种是返回了重定向。有些框架在路径不匹配时会返回 302 到默认页,平台收到 302 会认为失败。
我的建议是,把回调接口写得"笨"一点:验签 → 落库(或投递到消息队列)→ 立刻返回成功。真正的业务处理放到异步去,接口本身不做任何复杂逻辑。这样接口的响应时间可控,出错概率也低。
3.4 耗时与超时
平台对通知的响应是有超时要求的,通常几秒内要给出应答。如果你的接口里同步做了很多事——查数据库、调用其他服务、发消息、写多个表——很容易超时。
超时的表现和失败一样:平台没收到应答,判定失败,重试。而你的日志里可能显示"处理成功",因为业务逻辑最终跑完了,只是应答发出去的时候连接已经被对端断开了。
这里有个隐蔽的坑:业务逻辑跑完了,数据库也更新了,但因为应答超时,平台会重发。如果重发时你的业务逻辑没有幂等控制,同一笔订单会被处理两次。这就从"收不到"变成了"收到太多次"。
所以正确的架构是:回调接口只做落库和投递,业务异步处理,接口快速返回。
# 极简示意:耗时操作不要放在回调同步路径里 def handle_notify(request): raw = request.body if not verify_signature(request.headers, raw): return Response(status=401) data = decrypt(raw) # 只做一件事:可靠地记录下来 save_to_queue(data) # 立刻应答 return Response(status=204)把save_to_queue做成幂等的(用通知的唯一标识做去重键),即使重复通知进来也不会重复处理。这比在业务逻辑里到处加判断要干净得多。
4. 分界线判定:用日志和三行命令锁定问题在哪一层
前面讲的是"可能的原因",这一节讲"怎么确定是哪一个"。排查的核心思想是找到一条分界线:请求到底有没有到达你的应用。这条线一划,问题范围立刻缩小一半。
4.1 先看网关访问日志有没有这条请求
这是整个排查流程里最有价值的一步。打开 Nginx 或者负载均衡的访问日志,按时间范围和路径过滤,看有没有对应的 POST 记录。
# 假设日志格式里包含了时间、方法、路径、状态码 grep "POST /pay/notify" /var/log/nginx/access.log | tail -50三种结果对应三条完全不同的排查路径:
结果一:日志里完全没有这条请求。说明请求在到达 Nginx 之前就被挡了。往前的环节有:DNS 解析、网络连通性、防火墙/安全组、TLS 握手、更外层的负载均衡或 WAF。这时候去看负载均衡的日志(如果有),或者直接在服务器上抓包看有没有 TCP 连接进来。
# 抓包确认有没有来自外部的连接尝试 tcpdump -i any -n 'tcp port 443' -c 100如果连 SYN 包都看不到,那就是网络层根本没通,回去检查安全组、白名单、路由。
结果二:有请求但状态码是 4xx。401/403 一般是认证或 WAF 拦截,404 是路径不对,413 是 body 太大,429 是限流。这几个都指向前面配置层的问题。
结果三:有请求且状态码是 2xx。说明你的应用收到了并且应答了。这时候问题在应用内部:可能验签失败了但返回了 200(这种情况最坑,因为平台认为成功,不会重试,但你的业务没处理),可能业务抛异常了但被吞掉,可能落库失败了。
第三种情况要特别注意"假成功"。有些代码的实现是:验签失败就 log 一下然后 return 200,理由是"不想让平台一直重试"。这做法看起来省事,实际上是灾难——通知只发一次就"成功"了,业务没处理,你也不会收到重试,最后只能靠对账发现。
4.2 用 curl 自己打自己一次
确认了网络和配置没问题,接下来要验证应用本身能不能正确处理通知。最直接的办法是构造一个真实的报文,用 curl 发一次。
但这里有个难点:真实的回调报文是加密的,你自己造不出来合法的签名。有两个变通方案。一是从日志里捞一条历史通知的原始报文(前提是你之前存过),原样重放。二是直接调用支付平台的"查询订单"接口拿到订单信息,然后手工构造一个测试报文——不过这种方式验签一定通不过,只能用来验证"请求能不能进来、路由对不对、应答格式对不对"。
curl -i -X POST 'https://your-domain.com/pay/notify' \ -H 'Content-Type: application/json' \ -H 'Wechatpay-Timestamp: 1700000000' \ -H 'Wechatpay-Nonce: testnonce' \ -H 'Wechatpay-Serial: TEST' \ -H 'Wechatpay-Signature: test' \ -d '{"id":"test","event_type":"TRANSACTION.SUCCESS","resource":{}}' \ --max-time 10看返回的状态码和响应体:状态码是 200 或 204,说明接口通;返回 401 说明验签逻辑正常工作(因为签名是假的);返回 500 说明代码里有异常,去看应用日志的堆栈;返回 404 说明路由没配对。
这个自测很有价值,因为它把"网络是否可达"和"应用是否正常"这两件事分开验证了。
4.3 把验签失败的现场信息打全
如果你的应用日志里能看到请求进来了,但业务没处理,八成是验签或解密环节出了问题。这时候日志的质量直接决定了排查效率。
我要求团队在验签失败时至少打印这几项:请求头里的时间戳和随机串、平台证书序列号、本地使用的证书序列号、原始报文的 SHA256、验签使用的拼接字符串的十六进制表示、具体的异常类型。
特别要打出来的是"本地使用的证书序列号",因为平台证书是会轮换的。轮换之后,旧证书在一段时间内还能用,之后就不行了。如果你本地缓存的还是旧证书,验签就会失败。APIv3 里请求头带了一个序列号字段,你要用它去匹配本地的证书,匹配不到就说明证书该更新了。
String serial = request.getHeader("Wechatpay-Serial"); X509Certificate cert = certCache.get(serial); if (cert == null) { log.error("unknown cert serial={}, cached serials={}", serial, certCache.keySet()); // 触发一次证书刷新 refreshCertificates(); cert = certCache.get(serial); }这个"按序列号查证书 + 查不到就刷新"的逻辑,是保证证书轮换平滑过渡的关键。很多团队只在启动时加载一次证书,跑几个月之后平台轮换,通知就全挂了,而且挂得很突然。
提示:证书轮换是有预告的,但预告往往发在后台公告里,容易被忽略。更稳妥的做法是代码里做自动刷新,别指望人工记得。
5. 兜底架构:别把订单状态押在一条通知上
聊完排查,说点更根本的。回调通知本质上是"对方主动推给你"的机制,而任何推送机制都有丢失的可能:网络抖动、对方系统故障、你的服务重启、重试耗尽。如果你的订单状态完全依赖这一条推送,那这个设计本身就是脆弱的。
成熟的支付系统都有一个共同的认知:通知是加速器,不是唯一真相来源。真相在支付平台的订单系统里,你要做的是保证自己能主动去查。
5.1 主动查单 + 延迟队列
最简单的兜底方案是延迟查单。用户下单时,创建一个延迟任务,比如 30 秒后执行,去支付平台查询这笔订单的真实状态。如果已支付,就走和回调一样的处理逻辑;如果未支付,再延迟一段时间重查,重试几次后判定为超时关闭。
这个方案的好处是实现简单、覆盖全面,坏处是有延迟,用户付完钱可能等几十秒才看到订单状态变化。所以通常和回调配合使用:回调先到就用回调,回调没到就用查单兜底。
// 下单时投递一个延迟消息 DelayMessage msg = new DelayMessage(orderId, 30, TimeUnit.SECONDS); delayQueue.send(msg); // 消费者收到后查单 public void onDelayMessage(String orderId) { Order order = orderRepo.find(orderId); if (order.getStatus() == PAID) { return; // 已处理,跳过 } PayQueryResult result = payClient.query(orderId); if (result.isPaid()) { orderService.markPaid(orderId, result.getTransactionId()); } }更讲究一点的做法是用阶梯式延迟:30秒、2分钟、10分钟、1小时、6小时、24小时各查一次。间隔逐渐拉长,既保证及时性又不至于给平台接口太大压力。这个阶梯的设计依据就是前面提到的通知重试节奏——在重试还在进行的时间段内密集查,重试停了之后靠长间隔的查单和最终对账兜底。
5.2 幂等与状态机
引入查单之后,同一笔订单可能被处理两次:回调处理了一次,查单又处理一次。所以幂等是必须的。
幂等的实现方式有很多,我推荐的是"带状态校验的更新":
UPDATE orders SET status = 'PAID', transaction_id = ?, paid_at = ? WHERE order_no = ? AND status = 'UNPAID';这条 SQL 的返回值(影响行数)就是判断依据:返回 1 说明这次更新成功了,继续做后续的发货、通知等动作;返回 0 说明订单已经是 PAID 状态,这次是重复请求,直接跳过。
这个模式的好处是原子性由数据库保证,不需要额外的分布式锁。缺点是要求状态流转是单向的,不能有回退。所以设计订单状态机时要提前想清楚:UNPAID → PAID → SHIPPED → COMPLETED,以及 UNPAID → CLOSED 的超时关闭路径。关闭之后如果又收到支付成功通知(用户在下单和关闭之间的窗口期内付了款),需要有异常处理流程,通常是自动退款或者人工介入。
状态机的另一个价值是防止乱序。通知和查单可能并发,如果两个线程同时处理,没有状态校验就可能出问题。带上AND status = 'UNPAID'这个条件,能保证只有一个线程成功,另一个自然退出。
5.3 对账与告警
兜底的最后一道防线是对账。按天(或者更频繁)拉取支付平台的交易流水,和你自己的订单表做比对,找出"平台已支付但我方未处理"的记录,自动补单或者告警。
对账脚本的核心逻辑很简单:拉取时间范围内的所有成功交易,逐条查你本地订单,状态不对就修正并记录。难点在于数据量大时的分页、限流和幂等,以及差异记录的处理策略。
def reconcile(date): platform_orders = fetch_all_paid_orders(date) # 分页拉取 for po in platform_orders: local = db.query_order(po.out_trade_no) if local is None: alert("订单不存在", po) continue if local.status != "PAID": log.warning("发现漏单 %s, 平台状态=%s, 本地=%s", po.out_trade_no, po.status, local.status) fix_order(local, po) # 走幂等补单逻辑告警这块,我建议至少监控三个指标:一是回调接口的错误率(5xx 和验签失败的比例),二是"支付成功但订单未更新"的数量(可以通过定时对账的差异数体现),三是通知到达量的突降(正常情况下每天的通知量是稳定的,突然掉到零肯定有问题)。
第二个指标最重要,因为它直接反映业务损失。有条件的团队可以做一个实时比对:每分钟统计一次支付平台的成功交易数和本地的已支付订单数,差值超过阈值就告警。这个监控做好之后,通知再出问题,你也能在几分钟内发现,而不是等用户投诉。
6. 一次完整的排查复盘:从"客服爆炸"到定位到Nginx
把上面的方法论串成一个真实的排查过程。这个案例是虚构的,但每一步都是我在实际项目里见过或者亲手做过的。
6.1 时间线还原
下午三点十分,客服群开始有人反馈,说用户付了钱订单还是待支付。运营在群里 @ 技术,说已经积压了二十多单。
三点十二分,我打开监控看回调接口的 QPS,发现从两点五十开始就掉到零了。这说明不是"处理失败",是"压根没请求进来"。这个判断很关键,直接把范围从"业务代码"缩小到"网络和配置"。
三点十五分,登上服务器看 Nginx 的访问日志,grep "POST /pay/notify",最后一条记录停在了两点四十九分。之后一条都没有。确认了请求确实没到 Nginx。
三点十八分,用 curl 从外部打自己的回调地址,返回 502。502 说明 Nginx 收到了请求但后端连不上。这时候意识到问题可能比想象的严重——不只是支付通知,整个服务是不是挂了?赶紧看其他接口,发现其他接口正常。说明只有这条路径有问题。
三点二十二分,看 Nginx 的 error.log,发现大量connect() failed (111: Connection refused) while connecting to upstream,upstream 指向的是一个内网地址和端口。查了一下,这个端口上的服务在两点五十左右被重启过,重启后监听的地址变了(配置里绑定的是具体 IP 而不是 0.0.0.0),导致 Nginx 连不上。
三点二十五分,修改服务配置改为监听所有地址,重启,curl 自测返回 401(验签失败,符合预期,说明路通了)。
三点三十分,联系平台侧在后台手动触发补发通知,同时启动主动查单脚本,把两点五十之后的所有订单扫了一遍,补了三十多笔。四点钟,积压订单全部处理完毕。
6.2 根因与修复
根因很朴素:一次配置变更导致服务监听的地址不对,而这条路径的 upstream 配置是写死 IP 的,没有健康检查,Nginx 也不知道后端挂了,一直往一个不通的地址转发。
修复做了三件事:一是把服务监听地址改成0.0.0.0;二是给 upstream 加上健康检查和备用节点;三是把回调路径的 upstream 和其他接口分开配置,避免一个服务的变更影响所有路径。
这里我要多说一句关于"为什么两小时才发现"。因为回调通知本身就是低频接口,平时一天可能就几十次调用,掉到零并不会触发任何基于"错误率"的告警——没有请求就没有错误。所以监控不能只看错误率,还要看"请求量是否异常归零"。这个教训我们后来补上了,加了一条"支付成功订单数与回调请求数的比值"告警。
6.3 事后补的几道保险
这次事故之后,我们加了几个东西,后来确实救过命。
第一是回调路径独立部署。把回调接口拆成一个独立的小服务,只做验签、落库、投队列三件事,不依赖任何业务服务。这样即使主业务服务挂了,通知仍然能收进来,落到队列里等业务恢复后处理。这个改动成本很低,收益极大。
第二是入口连通性拨测。每五分钟从外部机器发一个带假签名的请求到回调地址,期望返回 401。如果返回超时、502、连接拒绝,立刻告警。这个探针能发现绝大多数网络和配置问题,而且因为是主动发起的,不受通知量影响。
第三是证书和密钥的自动刷新。启动时加载,之后定时检查,收到未知序列号时立刻刷新。这一条挡住了后来的一次证书轮换,否则又是一次半夜救火。
第四是双通道兜底。除了延迟查单,还加了一个"用户主动刷新订单状态"的入口。用户在小程序里下拉刷新时,如果订单是待支付状态,后端会实时去平台查一次。这样即使所有异步机制都失效了,用户自己刷新一下也能看到正确状态,投诉量会大幅下降。
最后再分享一个细节上的体会。这类问题的排查,最耗时间的往往不是定位,而是"确认边界"——你要花很多精力去证明"不是这一层的问题"。所以平时就要把日志和监控做扎实,让每一层都有明确的证据。等出问题的时候,你只需要看几个地方就能判断出问题在哪一层,而不是靠猜。我在实际项目里最大的感受是,支付这块的稳定性,八成靠的是兜底设计和监控,而不是把主流程写得多完美。主流程总有出问题的时候,关键是出问题之后能不能快速发现、快速补上,别让用户等太久。