整整两天,我盯着日志里的API超时和连接重置,试遍了所有能想到的代码层方案——调大超时、重建连接池、加重试退避、换HTTP版本,问题依旧。到第三天上午,我用一台完全独立的服务器打了个测试请求,才发现这次故障真不在我这边:上游服务的API网关在特定区域、特定时间段内持续丢包,跨地域请求成功率差了近20个百分点。这篇就把完整的排查链路记录下来,从第一天的盲目改代码,到最终用探针数据锁定外部故障,中间的经验希望对做接口联调、处理线上偶发故障的后端开发、运维同学有直接参考价值。这套"定位问题归属"的流程不依赖特定技术栈,任何语言、任何框架都能拿去用。
1. 故障现场:"断连"到底长什么样
1.1 第一波告警:超时率从0.2%飙到12%
先从事故现场说起。周一晚上七点半,监控群突然刷屏:核心服务模块调用第三方内容审核API的超时率,从平时的0.2%一路涨到12%以上,紧接着告警升级,大批HTTP 502和connection reset by peer开始往外冒。
我们的业务背景是:一个用户上传内容的实时审核场景,服务端同步调用上游开放平台的内容审核API,正常P99耗时稳定在180ms左右。所谓P99,就是1000个请求里最慢的10个也不超过180毫秒。这个基线平时没人注意,但故障排查时它就是"冲锋号"——一旦超过,说明系统一定发生了变化。
刚开始故障的表现不是全挂,而是间歇性抽风:这一分钟成功率正常,下一分钟超时率飙到15%,再过两分钟又恢复了。"一会好一会坏"的故障最折磨人,你在抓问题的时候它偏偏正常,等刚放松警惕它又来一下。
当时我翻了日志,看到几类报错混在一起:
[WARN] read timeout after 10000ms, url=https://api.example.com/v1/check [ERROR] connection reset by peer, host=x.x.x.x:443 [ERROR] connect timeout to x.x.x.x:443正是这种混杂的报错模式,让我一开始误判成是代码层的超时与连接管理问题。毕竟前两天正好改过HTTP客户端配置,按"谁最近改动谁背锅"的团队潜规则,嫌疑首先落在我自己头上。
1.2 三类报错混在一起:慢是慢,断是断
排障之前有必要把现象分清楚:"慢"和"断"完全不同。我看到的报错可以归成三类:
第一类是读超时。TCP连接建好了,请求发出去了,对方一直没有返回,直到本地设置的readTimeout到期。第二类是连接被重置。对方在数据传输中途直接断开连接,抓包能看到RST包而不是正常的FIN挥手。第三类是建连失败。请求连TCP握手都没完成,socket层就报connection timeout。
这三类混在一起,表象上都是"调用失败",但背后原因不一样。如果只是"慢",多数是资源不足、带宽拥塞;如果出现大量RST,多半是中间链路或对端网关主动做了某种重置动作——比如限流触发、防火墙拦截、或者节点异常重启。这也是我事后复盘时的第一个关键线索:现象里RST占比太高,从一开始就不太像传统的"服务响应慢"。但当时我满脑子都是"自己代码有问题",完全没往外部想。
2. 第一轮排查:把所有代码层嫌疑都试了一遍
2.1 调大超时反而帮了倒忙
我做的第一件事是检查超时参数。我们用Java的OkHttp,原来的配置是连接超时5秒、读超时10秒、写超时10秒。第一直觉就是:会不会是超时设太短,上游在高峰期响应稍微慢一点,就被本地掐断,然后断掉的连接堆着没人管,最后雪崩?
我把读超时从10秒改成30秒,上线观察半小时。结果意外的是,超时率没怎么降,反而有一小部分请求真的吊死在了30秒才回来。这说明对面并不是"响应很慢但最终会返回",而是"压根没打算返回"。超时调大只会让所有挂住的请求占满线程池,把一个小故障放大成服务整体不可用。
这个教训值得单独说:改超时之前,先想清楚你在治什么病。超时是兜底策略,不是治疗手段。一个合理的超时配置,应该配合你的重试预算、线程池大小、业务容忍度去算,而不是凭感觉往上加。比如我们后来定的方案是:连接超时3秒、读超时8秒、总调用时间上限10秒,重试预算另算,这样单个请求最坏情况下也不会占住资源超过10秒。
2.2 连接池与HTTP版本:改了但毫无波澜
第二个重点怀疑对象是连接池。排查时发现OkHttp默认对单个路由最多5个并发连接,闲置连接5分钟回收。我想当然觉得,会不会是连接不够用,请求排队等着占连接,导致大量超时?于是把最大连接数调到50,并顺手改了keep-alive行为。
结果配置上线后,超时率没有任何改善,倒是监控里看到服务器上TIME_WAIT状态的连接暴涨了几倍。这说明问题根本不是连接不足。之后我又做了关闭HTTP/2改用HTTP/1.1的对比实验,结果同样没有变化。
虽然这些实验都白做了,但说句公道话,排查的"排除法"就是这个过程——你只有把一个一个变量都验证过,后面给上游发工单时,才敢理直气壮地写"我们已排除客户端配置因素"。在没做这些实验之前,你说"问题在你那边"是没有说服力的。所以这一段我不想简单用"白干"概括,它是整个证据链里最耗时间但也最必要的一环。
2.3 重试策略成了故障期的帮凶
第三个坑是重试。原来的重试策略很粗暴:失败后最多重试2次,无退避,立即重发。平时接口偶发失败,这种策略确实能提高成功率;但等到上游半死不活的时候,等于故障期间我们自己的调用量瞬间翻三倍。日志里能看到同一个请求ID在几秒内出现三次调用,最后一次是在对端已恢复后才成功的。
这里顺便放一下我们之前和现在的重试逻辑对比:
// 之前的粗暴重试:失败立刻重发,没有等待 for (int i = 0; i < 3; i++) { try { return client.newCall(request).execute(); } catch (IOException e) { // 立即重试 } } // 现在的指数退避 + 抖动 long backoff = Math.min(1000L * (1 << attempt), 10000L); backoff += ThreadLocalRandom.current().nextLong(0, 500); Thread.sleep(backoff);把重试改成指数退避加抖动之后,我们这边发出去的流量降下来了,但故障期间的成功率依然是波浪形。也就是从这一刻开始,我隐约意识到代码层能做的都做了,问题不在这里。这里也强调一个重试的正确设计思路:重试次数上限2到3次,退避基数1到2秒,指数递增,最好再加随机抖动,避免多个客户端在同一时刻涌上来;同时要确保业务接口是幂等的,否则重试会制造脏数据。
2.4 DNS和TLS排雷:又是两个无效动作
代码层排查得差不多后,我把目光放到DNS和TLS上。生产环境用的是云厂商的DNS服务器,我发现目标API域名的TTL很短,怀疑频繁解析导致异常。为了验证,我把域名直接用hosts固定指向一个解析结果,绕过DNS,问题依旧。
再查TLS:抓包看到一部分连接卡在ClientHello之后没有下文,像是TLS握手被中断。我尝试调整TLS版本和加密套件组合,依然无改善。到这里可以确定:我们应用的出站配置、DNS解析、TLS协商都没有问题。排查方向只剩两条路:中间网络链路,或上游服务端。这正是从应用层钻进网络层的信号。
3. 网络层真相:从应用层钻进抓包与MTR
3.1 tcpdump里的两类异常信号
从应用层钻进网络层后,我先在一台出问题的服务器上跑抓包:
tcpdump -i eth0 host x.x.x.x and port 443 -s 0 -w /tmp/capture.pcap大概抓了90秒,故障复现期间的数据非常有代表性。正常连接应该是三次握手、TLS握手、HTTP数据交换、四次挥手;故障期间则主要出现两种异常。
第一种是大量重复ACK(Dup ACK)和TCP重传。同一个序列号的包在短时间内重传三到五次,最后对端以RST收场。这说明链路中间存在明显丢包,可能是一个物理链路或路由节点在做"黑洞"。
第二种是TCP握手阶段的RST。连握手都完不成,而且这样的RST成片出现,不太像业务超载(超载通常是排队而不是直接重置),更像是有网关安全策略在主动掐断新建连接——比如并发数限制、限流规则、或者边缘节点的健康检查误杀。
怎么区分呢?我的经验是看RST的位置:数据收发中途的RST,优先怀疑链路丢包;刚建连就RST,优先怀疑对端入口限流;特定时间点同时大量RST,基本可判定是网关或节点在"抖"。
3.2 MTR把丢包定位到特定网络区域
tcpdump只能看到我们到目的地之间的表现,要看具体哪一跳出问题,我用MTR跑了一段持续监测:
mtr -t -r -c 300 x.x.x.xMTR会持续发送探测包,并统计每一跳的丢包率和RTT,比traceroute更接近真实业务路径。跑的结果是:全程约十几个节点,大部分丢包率为0,只有两个节点随着故障时间窗口出现了20%到40%的丢包,而且这两个节点正好处在网络区域边界。
这里有个容易误判的坑:中间路由节点丢包不全等于它的故障,有些核心设备本身就对ICMP探测包的优先级较低,业务流量可能照样畅通。所以要盯的是最后一跳(目标服务器所在网络)的丢包率。我们这次最后一跳的丢包率和中间异常节点几乎同步,这就坐实了整条链路在这个时间段是真实丢包。至于丢包是上游机房网络限流还是中间链路节点的问题,仅凭MTR还不能最后定性,于是我再做了对照实验。
3.3 对照实验矩阵:换机器、换地域、换探针
排障的核心思维不是"猜",而是"不断改变一个变量、观察结果是否跟着变"。我设计了这样一组对照实验:
| 实验变量 | 目的 | 结果 |
|---|---|---|
| 同机房换一台新机器 | 排除本机资源与本地配置因素 | 现象不变,排除本机问题 |
| 换华东、华南轻量服务器 | 改变出口地域 | 华东、华南正常,仅华北受影响 |
| 用独立监控平台的探针发起请求 | 完全隔离我方环境 | 特定地域节点偶发失败,时间窗与业务告警重合 |
这张表是整个排查过程最重要的产出。有了它,结论不只是"我觉得是上游的问题",而是"同一份代码、同一个请求,换一个地域出口成功率从82%变回98%,我们没有改任何东西,唯一变化的是网络路径"。这个说服力比一百句抱怨都强。
有一点要特别说明:做对照实验时,每次只改一个变量。如果同时改两个变量,结果变了你都说不清楚是哪个造成的。我们第一轮排查就是犯了"同时调超时又调连接池"的错误,后面才学乖拆开做。
4. 确凿证据:上游API网关在特定区域丢包
4.1 独立探针报告:让数据自己说话
到这一步,我写了一个独立的探测脚本:每5秒发起一次真实业务请求,分别从华北、华东、华南三个区域各跑1000次,记录成功率、P95耗时和错误分布。30分钟后的数据是这样的:
- 华北出口:成功率82.6%,P95耗时1460ms,错误以RST和读超时为主
- 华东出口:成功率98.9%,P95耗时205ms
- 华南出口:成功率98.5%,P95耗时224ms
同一种API,同一套请求内容,唯一区别就是地域出口,结果却差了16个百分点。再结合之前工单里的反馈,基本可以确认:上游API整体服务是正常的,但他们某个区域的接入网关或边缘节点确实存在连续、间歇性的故障。
同时,我也拉了一个外部状态监控服务(业界主流的拨测平台都行)的数据做佐证。这里有个技巧:如果服务商提供了面向客户的状态页或服务健康JSON接口,定期抓下来存库;没有的话,就在自建拨测工具里配置一个"关键API健康检查",每30秒探一次。多个独立数据源的时间窗口重合之后,给上游提工单就是一套铁证。
4.2 服务状态页的教训:绿着不代表没问题
在等待上游确认的时间里,我盯着他们的status page刷新了无数遍,自始至终都显示绿色All Systems Operational。这件事给了我一个重要教训:状态页只能作事后参考,不能作为实时的故障依据。
原因有三。一是检测阈值不同:大平台通常看"全球平均成功率",某一两个区域的异常波动很容易被平均值抹平。二是边缘节点与中心控制面的信息差:API网关全球多节点部署,状态页展示的是整体健康度,单个节点抽风对全局数字来说只是小数点后第三位的扰动。三是有不少平台的SRE流程是"先确认再发布",在故障没被完全定位之前,不会主动标记incident,避免引起客户恐慌。
所以排障时一定记住:状态页绿着,不等于对方的服务真的没出问题;你的用户都失败成一片了,你更需要相信自己的数据和工单渠道,而不是那个页面。
4.3 一份让上游无法推诿的工单
工单来回折腾了快一天。第一轮回复永远是模版:"请联系我们检查网络配置""请确认API Key是否正确""您的请求量可能触发限流"。这时候别生气,继续把证据栈叠上去。我整理了一份比较管用的工单内容模板,现在一直保留着:
第一段:故障时间窗口、受影响区域、失败特征一句话概括。第二段:我们做过的客户端排障清单(超时、连接池、重试、DNS、TLS都列出来),证明锅不在我们。第三段:独立探针报告,直接附上三地的成功率对比表。第四段:请求URL、请求头、关键报错和pcap抓包文件的下载链接(提前传到对象存储)。第五段:明确请求,请对方重点排查"xx区域接入网关在指定时间段的健康状态"。
这样一份工单,对方基本不用二次返工,内部可以直接转给自己的网络团队。我们这边提完以后,他们当晚就确认了特定区域的一个边缘接入点存在不稳,并告知可以短期内切换接入地址绕开故障。
5. 复盘与防御:"问题不在我"不等于"我可以躺平"
5.1 48小时都耗在哪:先入为主是最大成本
故障从发现到最终确认,前后约48小时。真正有价值的排查操作,压缩一下其实大约半天。剩下的时间主要耗在三个地方。
第一是我先入为主的"变更有罪"假设。前24个小时全在改超时、连接池、重试这些代码参数,没有停下来先分类"慢"与"断"。第二是一直在刷新状态页和等工单,把判断依据寄托在别人身上。第三是没有第一时间拉起跨地域探针——如果第二天上午就做,至少能提前6到8个小时锁到区域性问题。
复盘时我把这点写成了一条团队规范:遇到第三方依赖故障,第一优先是采集证据,第二才是猜测原因。采集证据最快的方式永远是"多个源头同时打点",而不是盯着一块日志硬看。"数据是最后的法官"这句话,在这场排查里被验证得淋漓尽致。
5.2 快速三分法:客户端、链路、服务端
经过这次的折腾,我沉淀了一套自己的快速定性流程,无论是自研服务问题还是第三方API问题都能用:
第一步,抓短时包先分类,目的是解决"失败到底是建连失败、读超时还是RST"。第二步,看失败的时间分布,是持续性的还是集中在特定时间段,如果是特定时间段,把本地监控的时间戳对齐。第三步,做跨地域对照,起一台其他区域的机器,或者用一个拨测平台的探针,同一时间段各打一批请求。如果其他区域正常,基本锁定为特定区域的网络路径问题;如果所有区域都挂,优先怀疑API服务端全局故障。第四步,查自己的依赖配置,确认API Key、网关地址、代理设置和DNS没有问题,把"我们侧正常"单列成证据。第五步,提交证据工单,把上述内容一次性甩给上游,请对方检查对应区域的接入节点。
这套方法我建议每个团队都写成SOP文档,尤其是对接了一堆外部API的业务,真到故障发生时才去回忆流程,是会手忙脚乱的。
5.3 四层防御:外部依赖生病时业务不瘫痪
"问题不在我"是归因的结论,但产品对外部API的依赖是不可消除的现实。我现在的看法是:第三方API对业务来说,永远是"需要防的雷",而不是"协议里承诺的可靠性"。基于这次经历,我们做了这样几层防御。
第一层是超时与重试预算。给所有第三方调用设置独立的超时矩阵,核心是"总调用时间上限",不能把所有请求都吊死在同一根超时绳上。重试统一使用指数退避加抖动,并且限定总重试预算,防止雪崩时自我放大。
第二层是熔断器。使用Resilience4j时的配置是:10秒窗口内失败率超过50%,熔断器打开,后续请求快速失败并落本地兜底;每5秒释放一个探测请求,对端恢复后自动半开、关闭。如果这次故障前我们就配好熔断,那波性能雪崩是可以直接拦住的。
第三层是降级预案。内容审核场景里,临时方案是先把待审核内容送进本地缓冲队列,等上游恢复后异步补偿;实在承担不了风险,就切换成功能降级模式,人工抽检并记录。重要的不是降级方案多完美,而是故障的时候你有一条路可以走。
第四层是跨区域热备接入。不少平台都会提供多区域的接入地址,我们在配置中心里维护了主备两组地址,按权重分配流量。监控到主区域成功率低于阈值时,自动切走部分流量。这次如果提前有这个配置,用户侧几乎不会有感觉,顶多监控里看到一条"已自动切换接入区域"的日志。
5.4 可观测性改造:一次调用拆成五个时间切片
最后聊一聊事后做的可观测性改造。以前对接第三方API时,监控只有"成功率"和"平均耗时"两个指标,连失败发生在哪个阶段都不知道。现在我们把一次调用拆成了可观测的多个时间切片:
- DNS解析耗时、TCP建连耗时、TLS握手耗时、首字节时间(TTFB)、总耗时
- 连接复用情况:是否使用了连接池中的老连接
- 目标地址:命中的域名、IP、区域接入点
- 错误分类:超时、RST、TLS失败、HTTP状态码
这些指标拿来干什么?举个例子,如果某个时段TTFB异常高,问题往往在服务端处理链路;如果TCP建连耗时和TLS握手耗时一起飙升,问题大概率在网络路径;如果普通耗时平稳但成功率低,可能要看重试和限流。加了这些维度之后,任何人打开Dashboard都能一眼判断"自己代码、网络链路、外部服务"三者的嫌疑顺序,不用再翻着日志猜。
告警规则也做了调整。之前只对成功率低于99%告警,故障时被刷屏到麻木;现在改成多级、持续性告警,比如"成功率低于99.9%持续5分钟"是warning,"低于99%持续5分钟"才是critical,并且把外部依赖告警和业务告警分开,值班人看到告警就能区分"该切流量了"还是"该看代码了"。
这次的经历,等于用两天两夜的不眠,换了一套可以反复使用的"外部依赖故障排查手册"。以后再碰到API调用老是断,我反而不会先慌了:先把现象分清楚,再拉跨地域数据,最后用证据说话。最反直觉的一点是——越是急着改代码,越容易错过真正的病灶;越是先把现象剖清楚,越能在最短时间内把问题归属定下来。这里的"甩锅"不是推卸,而是技术判断到位:该认的自己认,不该认的用铁证说话。这套流程现在是我们团队的默认SOP,希望你用的时候,不用再熬那两个晚上。