凌晨两点被电话叫起来说小程序里一堆用户付了钱但订单还是待支付客服后台全是投诉。打开日志一看我们自己的订单表里根本没有那批支付记录说明支付平台的异步回调通知压根没到。这就是典型的微信支付回调通知收不到问题做过支付的同学大概都经历过这种心脏骤停的时刻。这类问题的麻烦在于它不在你的主流程里平时跑得好好的一旦出问题就是用户已经扣款但业务没反应的资金类事故排查窗口还特别短——通知重试次数用完之后支付平台就不会再主动发了。这篇我把自己和身边同行踩过的坑整理一遍从链路原理、配置检查、代码陷阱到日志定位方法按能不能自己复现的思路串起来适合已经接过支付、正在被回调折磨的开发者也适合刚接手支付模块、想提前避坑的同学。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 到 httpsPOST 变 GET日志显示 301body 丢失测试/生产混填生产环境填了测试回调地址测试服务器收到一堆生产通知未备案/未校验域名未完成必要校验通知不下发后台配置处有提示带参数地址后跟了 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 读出来记录然后包装成一个新的请求往下传。如果包装实现有 bugbody 就丢了。表现是日志里明明打印了完整的通知内容但业务代码里收到的 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(status401) data decrypt(raw) # 只做一件事可靠地记录下来 save_to_queue(data) # 立刻应答 return Response(status204)把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 upstreamupstream 指向的是一个内网地址和端口。查了一下这个端口上的服务在两点五十左右被重启过重启后监听的地址变了配置里绑定的是具体 IP 而不是 0.0.0.0导致 Nginx 连不上。三点二十五分修改服务配置改为监听所有地址重启curl 自测返回 401验签失败符合预期说明路通了。三点三十分联系平台侧在后台手动触发补发通知同时启动主动查单脚本把两点五十之后的所有订单扫了一遍补了三十多笔。四点钟积压订单全部处理完毕。6.2 根因与修复根因很朴素一次配置变更导致服务监听的地址不对而这条路径的 upstream 配置是写死 IP 的没有健康检查Nginx 也不知道后端挂了一直往一个不通的地址转发。修复做了三件事一是把服务监听地址改成0.0.0.0二是给 upstream 加上健康检查和备用节点三是把回调路径的 upstream 和其他接口分开配置避免一个服务的变更影响所有路径。这里我要多说一句关于为什么两小时才发现。因为回调通知本身就是低频接口平时一天可能就几十次调用掉到零并不会触发任何基于错误率的告警——没有请求就没有错误。所以监控不能只看错误率还要看请求量是否异常归零。这个教训我们后来补上了加了一条支付成功订单数与回调请求数的比值告警。6.3 事后补的几道保险这次事故之后我们加了几个东西后来确实救过命。第一是回调路径独立部署。把回调接口拆成一个独立的小服务只做验签、落库、投队列三件事不依赖任何业务服务。这样即使主业务服务挂了通知仍然能收进来落到队列里等业务恢复后处理。这个改动成本很低收益极大。第二是入口连通性拨测。每五分钟从外部机器发一个带假签名的请求到回调地址期望返回 401。如果返回超时、502、连接拒绝立刻告警。这个探针能发现绝大多数网络和配置问题而且因为是主动发起的不受通知量影响。第三是证书和密钥的自动刷新。启动时加载之后定时检查收到未知序列号时立刻刷新。这一条挡住了后来的一次证书轮换否则又是一次半夜救火。第四是双通道兜底。除了延迟查单还加了一个用户主动刷新订单状态的入口。用户在小程序里下拉刷新时如果订单是待支付状态后端会实时去平台查一次。这样即使所有异步机制都失效了用户自己刷新一下也能看到正确状态投诉量会大幅下降。最后再分享一个细节上的体会。这类问题的排查最耗时间的往往不是定位而是确认边界——你要花很多精力去证明不是这一层的问题。所以平时就要把日志和监控做扎实让每一层都有明确的证据。等出问题的时候你只需要看几个地方就能判断出问题在哪一层而不是靠猜。我在实际项目里最大的感受是支付这块的稳定性八成靠的是兜底设计和监控而不是把主流程写得多完美。主流程总有出问题的时候关键是出问题之后能不能快速发现、快速补上别让用户等太久。
企业数字化 ERP 产品动态
相关推荐
ATPG本质:可控性与可观测性的电路测试编排 简介:本资源是Mentor Graphics官方发布的Tessent Scan and ATPG用户手册(2017.3版),面向IC设计工程师、可测性设计(DFT)工程师及高校VLSI测试方向研究者,聚焦自动测试向量生成(ATPG&… · 2026/11/3 16:32:54
3ds Max Biped骨骼搭建全流程:模型体检到蒙皮权重验收 做角色绑定的都懂一个道理:模型做得再漂亮,骨架立歪了,后面全是窟窿。我见过太多项目在动画阶段返工,追根溯源不是动画师手重,而是当初那套 BIP 骨骼搭建的时候就埋了雷——盆骨高度按感觉给、膝盖方向反了、蒙皮权重靠… · 2026/11/3 16:32:54
编程中的类型转换:原理、实践与优化 1. 类型转换的本质与分类在编程语言中,类型转换是指将数据从一种类型转换为另一种类型的过程。这种转换可能发生在编译时,也可能发生在运行时。根据转换方式的不同,我们可以将其分为两大类:隐式类型转换(Implicit Type… · 2026/11/3 16:31:52
网络安全-Javascript基础 网络安全-Javascript基础一般简称JS,然后用于嵌套在网页中,给网页添加动态效果用的node凭条,开发服务器也是用js
后期会用到recat、vue框架。常见的前端
HTML(结构层) 利用语言去搭建网页
CSS(样式层)利用样式美化网页… · 2026/11/3 19:27:23
别再复制粘贴代码了!Python 函数从封装到递归、lambda,老哥一次讲透 写程序最忌讳的就是复制粘贴—— 同一段代码贴三遍,改起来要改三处,累不说还容易漏。Python 给出的答案是函数:把一段会重复用的代码 "封装" 起来,起个名字,想用就调用。一、为什么用函数print() 其实就是个… · 2026/11/3 19:27:23
AdMergeX 获信通院多项认可,市场部负责人受聘铸基计划技术专家 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 19:27:23
客户复购预测真的值得做吗?从业务目标、方案取舍到上线验收 上一篇《从一家小店看懂数仓与机器学习:先理业务,再串技术》,沿着小林的店,把交易、客户状态、数仓与预测连接了起来。
接下来还有一组更难的问题:这件事值得投入吗?普通规则是否已经够用?模型… · 2026/11/3 19:27:23
什么是Unlimited-OCR-2?OCR-3.0时代百度无限文档解析模型完整指南 人工智能计算机视觉OCR多模态大模型 【免费下载链接】Unlimited-OCR-2 项目地址: https://ai.gitcode.com/hf_mirrors/baidu/Unlimited-OCR-2 点击查看 免费下载 Unlimited-OCR-2 是百度开源的超长文档解析模型(OCR 模型),主打&q… · 2026/11/3 19:26:23
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46
STM32F411RE与MAX14431实现SPI隔离:工业现场抗共模干扰实战 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
ESP32+大模型AI硬件开发:从Demo到产品的8个工程问题 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
基于MAX14431BAEE+与PIC18F85J10的SPI隔离方案设计与实践 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 1:01:59
双极步进电机控制实战:DRV8818PWPR与PIC18F86K22驱动调试 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/2 13:10:28
STM32F207ZG + DRV8818PWPR 步进电机驱动实战解析 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 6:04:24
DRV8818PWPR+dsPIC30F4013工业级双极步进电机控制方案 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/11/3 5:52:46