接口 timeout 冒出来那晚,我怎么确认不是应用也不是网络,而是下游慢了
一次订单确认 timeout 事故里,真正有用的不是把 timeout 讲成三种概念,而是先把样本分堆,再按证据决定排除顺序。最后把故障收住的,是一条完整的收敛链。
20:13,先响起来的是订单确认页的投诉。
用户说“点提交后转两秒就失败”,几乎同一分钟里,网关 504 开始抬头,order-service 里也陆续出现 timeout。群里第一波判断很自然:是不是应用线程池卡住了,或者机房到下游那段网络抖了。
那晚真正把现场拉直的,不是先盯住一个最红的监控,而是先把 10 分钟里捞出来的 86 条 timeout 样本摊开。因为这些报错虽然都叫 timeout,长相并不是一种。
86 条 timeout 样本摊开以后,先分成了三堆
当时我们把异常类型、耗时、接口、节点分布放在一张表里,看完以后,样本自己就分了层:
| 样本堆 | 数量 | 典型异常 | 耗时 | 分布 | 第一眼更像什么 |
|---|---|---|---|---|---|
| A | 11 | java.util.concurrent.TimeoutException | 980ms - 1020ms | 只出现在 /order/preview,集中在 2 个 Pod | 应用内部某段异步等待超时 |
| B | 9 | ConnectTimeoutException | 260ms - 480ms | 只出现在一个节点 10.6.14.27,调用的是消息中心 | 节点局部网络或建连问题 |
| C | 66 | Read timed out | 1900ms - 2100ms | 14 个 Pod 都有,几乎都在 /order/confirm 调 coupon-service | 下游处理变慢 |
用户投诉、网关 504、业务损失,真正对应的是第三堆,不是第一堆,也不是第二堆。
这一步很关键。因为如果不先分堆,现场会特别像“应用也有 timeout,网络也有 timeout,下游也有 timeout,三边一起炸了”。可一旦把样本按接口和长相分开,排查顺序其实已经出来了:先盯 C,再解释 A 和 B 为什么不是这次事故的主线。
为什么那一刻我先没追应用
A 堆最像应用内部问题:异常很整齐,几乎都卡在 1 秒附近,看起来就是本地某个 timeout 阈值打出来的。
但它有两个地方对不上现场。
第一,它发生在 /order/preview,用户大量报错的是 /order/confirm。如果真是 order-service 自己先卡住,受影响的不会只是一条预览链路。
第二,order-service 当时的资源面板并不支持“应用已经被自己拖死了”这个判断:
| 指标 | 20:05 正常值 | 20:15 事故窗口 |
|---|---|---|
| CPU | 38% | 43% |
| Full GC | 0 | 0 |
| 业务线程池 active | 41 / 200 | 58 / 200 |
| 业务线程池 queue | 0 | 0 |
| Hikari pending | 0 | 0 |
| Tomcat busy | 72 / 400 | 95 / 400 |
如果是应用内部线程池、连接池、锁等待先把自己拖住,通常会看到队列、pending 或者统一阈值附近的大面积超时。可那时真正抬头的是 /order/confirm 调下游优惠服务的 read timeout,而不是本地线程排队。
A 堆不能算噪音,但它更像另一个次要问题:预览页里的异步营销标签查询刚好也在同一时段踩到了 1 秒阈值。它值得后面单独处理,却不是这次订单提交失败的主线。
也为什么我没先扑去抓网络
B 堆也很诱人。ConnectTimeoutException 一出来,值班群里最容易有人说:“先抓包吧,肯定是网络抖了。”
但那 9 条样本有三个明显的局部特征:
- 只出现在一个节点
10.6.14.27 - 调的是消息中心,不是
coupon-service - 耗时在 300ms 左右散开,没有跟用户侧 2 秒失败对齐
更关键的是,出问题的 /order/confirm 请求并不是“发不出去”,而是“发出去了、对方也收到了,但迟迟回不来”。
我们从 order-service 的 tracing 里随手抽了一条请求,时间线是这样的:
| 时间 | 事件 |
|---|---|
| 20:13:12.004 | 网关收到 /order/confirm |
| 20:13:12.021 | order-service access log 记到请求 |
| 20:13:12.067 | order-service 发起 coupon-service/queryAvailable |
| 20:13:12.081 | coupon-service access log 收到同一个 traceId |
| 20:13:14.019 | order-service 侧报 Read timed out |
| 20:13:14.036 | 网关返回 504 |
这条链基本把“先查网络”压下去了。
因为如果问题主要在 order-service 到 coupon-service 这段网络,最常见的现象应该是建连慢、握手慢、请求根本到不了对端,或者至少不会这么稳定地在对端 access log 里看到同一批 traceId 已经进来了。可这次不是。请求在 14ms 内就到下游了,真正消耗掉 2 秒预算的,是它到达下游之后那一段等待。
为了防止误判,我们还从两台问题 Pod 上直接探了一次 coupon-service:TCP 建连 8ms 到 15ms,TLS 握手 20ms 以内,DNS 没抖,跨可用区也正常。网络这条线不能说永远没事,但它已经不值得排在最前面。
证据真正闭环,是在把下游日志和资源池接上之后
排掉应用主因和网络主因之后,剩下的问题其实只有一个:coupon-service 为什么把订单确认链路拖住了。
把 traceId 对到下游日志以后,现象一下子清楚了。coupon-service 在 20:10 之后开始出现成片的长请求,绝大多数都卡在 1.8 秒到 2.3 秒之间;同时它自己的数据库连接池 pending 从 0 升到了 19,慢 SQL 里开始稳定出现一条查券规则绑定关系的语句。
那张当时最有说服力的对照表长这样:
| 指标 | 20:05 | 20:15 |
|---|---|---|
coupon-service p95 RT | 92ms | 1980ms |
coupon-service DB pool pending | 0 | 19 |
| 慢 SQL 数 | 0 | 47 / 分钟 |
order-service 调优惠接口 timeout | 0.2% | 18.7% |
| 网关 504 | 0.1% | 6.4% |
顺着这个时间点往前翻,刚好又对上了 20:09 上线的一项变更:优惠规则回填任务启动,把大量历史券规则重新刷进绑定表,查询命中了全表扫描,数据库连接被占住,coupon-service 自己先慢下来,order-service 的 2 秒 read timeout 只是外层最先被用户感知到的结果。
到这里,现场终于不是“应用 / 网络 / 下游三选一”的概念题了,而是一条能讲完整的收敛链:
- 症状最早出现在订单确认页,用户 2 秒左右失败。
- timeout 样本分成三堆,真正和用户损失重合的是 C 堆。
- A 堆和 B 堆都存在,但分别只打在别的接口和单一节点上,优先级自然往后放。
- traceId 证明请求很快进了下游,不是卡在网络入口。
- 下游 access log、DB pool pending、慢 SQL 再把证据补齐,说明慢发生在
coupon-service自己处理请求这段。
真正把事故收住,是两次动作后的回落顺序
证据补到这一步,还差最后一刀:动作和回落顺序。
20:21,我们先在 order-service 灰度开了一个降级开关:优惠查询超过 300ms 就不再阻塞订单确认,券面板改成“稍后到账后补展示”。这个动作只放了 10% 流量,结果 3 分钟内,灰度 Pod 上 /order/confirm p95 从 2.1 秒掉到 420ms,timeout 率直接腰斩。
这个验证很关键。它说明订单确认真正卡住的,就是那段对优惠服务的等待;如果主因在应用本地线程池,或者在网络建连,单独切掉这段等待不会这么快见效。
20:26,优惠服务同学暂停了 20:09 刚启动的规则回填任务,同时把那条全表扫描 SQL 对应的索引回滚到上线前版本。随后几分钟里的回落顺序非常干净:
| 时间 | 先回落的东西 | 后回落的东西 |
|---|---|---|
| 20:27 | coupon-service DB pool pending、慢 SQL 数 | - |
| 20:28 | coupon-service p95 RT | order-service 调优惠接口 timeout |
| 20:29 | order-service /order/confirm p95 | 网关 504 |
| 20:31 | 用户投诉量 | 基本恢复 |
这条回落顺序把最后一点疑问也打掉了。
如果是网络先恢复,往往会先看到建连错误消失;如果是 order-service 自己的线程池先松动,应用内部的队列和 busy 指标会先掉。可这次最先回来的,是 coupon-service 的数据库连接和慢 SQL,然后才是一层层向外回落的 timeout、504 和用户投诉。
这次事故最后收敛到哪一层
最后收敛到的不是“timeout 有三种分类”这种答案,而是一个很具体的结论:订单确认超时这次的主因在下游 coupon-service 的处理层,更具体一点,是 20:09 启动的规则回填任务把数据库连接占住了,导致优惠查询 RT 飙升;order-service 和网关只是顺着同一条等待链依次把 timeout 报了出来。
那晚最救命的也不是多懂几个 timeout 名词,而是把样本分堆以后,愿意先排掉看起来也“有点像”的方向。应用里那 11 条 1 秒 timeout 不是没有价值,单节点上的 9 条 connect timeout 也不是不存在,但它们都没和用户真正受损的那一堆样本重合。把这个顺序拉直,后面的证据、动作和验证才会越走越窄。
如果你现在碰到的是“谁先超时”的预算问题,可以接着看网关 504、应用超时、客户端超时,到底谁先超时?;如果 timeout 已经被一层层重试放大,再去看重试风暴把慢接口进一步放大时,怎么区分表象和根因?。
真正容易把人带偏的,从来不是 timeout 这个词本身,而是你看到三种报错以后,太快相信它们属于同一个现场。先把样本分开,再决定先排哪一层,事故才有可能真的被收住。