
那个周日晚上的告警我现在还记得很清楚群里一下子弹出十几条AI 服务 P95 延迟从 280ms 直接飙到 3.8 秒错误率破 2%已经持续了快十分钟才被值班同事捞起来。这是这个月第二次因为“线上响应异常”被拉进复盘会议室。上一次好解决一键重启完事但这次我从本地压测完全复现不出来——模型单次推理只要 40ms怎么压都正常线上却大面积超时用户那边要么一直转圈要么拿到的是上一次的旧结果。更要命的是所有节点的 CPU、GPU 指标看着都挺健康平均利用率连 40% 都不到。这次我拦住了想直接重启的同事花了大几个小时把链路从入口到推理实例彻查了一遍。最后定位到的根因并不是单个服务挂掉而是两个因素叠加在一起推理集群的故障转移策略在制造“慢实例风暴”再加上线上语义缓存同时过期两边互相放大硬生生把一个小抖动拖成了大故障。这篇就把整个排查过程、每个环节的证据链、以及最后落地的修复组合拳完整写出来给同样在跑大模型线上服务的同学做个参考。1. 故障快照从“局部变慢”到“持续雪崩”的恶化链条1.1 告警指标速览P95、SLO 与“沉默”的 GPU先把这一次故障的关键时间线列出来后面讲的每一段排查都会回扣到这张表。时间P95 延迟错误率活跃连接数现场情况20:10280ms0.2%1200基线水平一切正常21:051.3s0.8%1450延迟开始抬升错误率同步爬升21:262.5s1.6%1800持续恶化出现零星超时21:473.8s2.3%2300触发 P0 告警全面超时这张表里最值得注意的不是最高的延迟而是“活跃连接数”一直在涨。P95 变高说明不是在个别用户那边慢而是大量请求都在排队错误率跟着涨说明已经有一部分请求达到超时阈值并被前端放弃连接数翻倍则是一个非常典型的信号——客户端在疯狂重试而且重试的力度大于正常流量的增长速度。GPU 指标在 Grafana 上看了一圈显存占用率不高不低计算利用率只有 30% 多。从资源利用率的角度没有人会认为 GPU 是短板。这也是这类故障最容易骗过人的地方AI 服务不是 CPU 或者 GPU 转不动了而是请求根本排不到计算单元上去。1.2 用户侧收到的信号转圈、旧结果与重试风暴故障真正的引爆点其实从用户反馈就看得出来。一开始只是个别用户说“网页转圈 5 秒才出结果”后来开始有人投诉“返回的是上一次的问题答案”再往后连重试都救不回来了直接报错。这里有两个关键词值得单独拿出来说“转圈”说明请求已经进入到服务端但服务端迟迟没有给响应HTTP 连接一直挂着。这不是入口层的连接拒绝而是后端的处理耗时超过了人的忍受阈值。“返回旧结果”这个现象很危险因为它说明有缓存层在兜底。一部分请求走了缓存拿到旧内容另一部分请求没有命中缓存继续打后端于是用户在不同请求之间体验到完全不同的结果误以为服务在“发疯”。我们当时最担心的就是第二种情况。如果服务是纯没结果反而好处理一旦出现“新旧结果交替”排障时就得同时考虑缓存和服务端两条链路。1.3 开始排障之前我们先做了一件“反直觉”的事建故障数据库正式动手查链路之前我把大家拉进会议室仅用了五六分钟就完成了一次“假设库”的建立。也就是说把过去半年在线上遇到过的同类响应异常、延迟尖刺、缓存击穿的案例从记录里翻出来列成一张故障假设清单。这个习惯是我从一次运维事故里学到的。当时排查到半夜才发现问题根源是半个月前一个同事在配置中心改了连接池参数而我们所有人都没往“变更”方向想。从那时候起凡是发生线上故障第一件事永远是拉取最近一周的变更记录、发布记录、配置改动再对照历史故障库做匹配。这次也一样清单拉出来以后我们的排查顺序立刻从“猜”变成了“排除”。当时的清单大概是这样的最近有没有灰度发布过推理服务的路由策略连接池、线程池、队列参数有没有被动过上游模型服务有没有做过集群节点的扩缩容缓存服务的 TTL 策略有没有变化RPC 调用有没有出现新的超时重试配置这份清单成了后面几个小时的排障主线。可以说没有这个“先建库再动手”的过程我们大概率会像上次一样绕远路。2. 第一轮排查入口、网关与网络层的“清白证明”2.1 不要把“ping 内网显示一般故障”当成主要线索排障一开始有个同事跑了批量的内网 ping 探测反馈“ping 内网显示一般故障”大家的心一下提了起来以为网络层出大事了。但这条线索在我们花了十分钟验证之后基本可以认定是误导。先说原理ping 走的是 ICMP 协议它检测的是网络层的连通性检测对象是主机和网卡。而 AI 服务走的是 HTTP/HTTPS经过的路径里还有负载均衡七层、网关、容器网络、Service Mesh 代理。ICMP 通了只能说明这台机器是活的代表不了 HTTP 服务是好的ICMP 不通也有可能是防火墙策略丢弃了 ICMP 报文但 HTTP 流量照样正常转发。我们这次的实际做法是一边在网关容器里对上游 Pod 发起 HTTP 探活一边看负载均衡器的 upstream 健康状态同时跑了一段curl -w看详细耗时拆解curl -s -o /dev/null -w http_code%{http_code} time_total%{time_total}s time_connect%{time_connect}s time_starttransfer%{time_starttransfer}s\n \ https://api.example.com/v1/chat/completions -H Content-Type: application/json -d {query:ping}输出显示time_connect很小只有几毫秒但time_starttransfer高达 2.3 秒。这说明 TCP 连接根本没有瓶颈问题在服务端把 HTTP 响应体迟迟吐不出来——典型的“后端处理慢”跟网络层关系不大。所以我想说一句直接的经验线上排查时看到“ping 一般故障”这类告警先把网络层记录在这里但千万不要让它带着你跑偏。真正的黄金指标是 HTTP 的状态码和耗时拆解以及负载均衡层每个 upstream 节点的响应时间分布。2.2 网关层超时配置与“假 200”问题网络层排除之后我把目光放到了网关。网关通常承担了超时控制、重试、限流等功能很多线上故障其实就是网关配置在放大后端的问题。查配置的时候发现两个隐患第一网关到后端的proxy_read_timeout被设置成了 10 秒而业务侧设置的预期响应时间是 3 秒。也就是说后端哪怕已经明显失速网关也会忍耐很久才返回超时客户端在这个窗口内大量挂着等连接数自然狂涨。第二后端的某些健康检查接口在业务异常时仍然返回 HTTP 200。当时 upsream 节点的健康检查是这样的只要进程活着就返回 200 OK。这种“假 200”导致负载均衡一直往已经排队的实例上送流量进一步加剧了某个实例的拥挤。这里还出现了一个有意思的假象从网关的整体指标看所有实例的平均响应时间并不高但打开每个 upstream 实例维度的指标发现延迟差异巨大——有三个实例的响应时间在 200ms 左右另外两个却已经到 3-5 秒。平均值被正常实例稀释掉了。看全局不如看分位看分位不如看分实例这个教训后面还会再出现。2.3 负载均衡观察连接数翻倍但 RT 正常意味着重试在放大负载均衡器后面简称 LB有个指标特别好用活跃连接数。故障期间LB 的连接数从 1200 涨到 2300但比较诡异的是LB 看到的上游请求量其实只有预期值的一半。缺口去哪里了两条路一部分请求被网关拦截后超时返回这部分不计入上游另一部分请求是客户端自己重试产生的新连接它们像潮水一样不断涌进来但每个连接都在排队等旧请求释放资源。也就是说不是流量真的翻倍了而是无效流量在反复折叠把有限的连接资源吃掉了。当时我做了一个小验证在 LB 上按 header 里的 trace-id 统计同一请求出现次数。结果发现同一个业务请求在 30 秒内出现了 4-6 次明显是客户端 SDK 在做无限制重试。这个验证基本锁定了下一步的排查方向必须同时看服务端排队情况和客户端重试策略。3. 服务层剖开线程栈、排队与“集群故障转移”的帮倒忙3.1 线程栈 dumped 之后等待都指向同一个入口网络层和网关层基本排除掉之后我把目光放到了应用服务进程里。AI 服务大多用的是 Python/Java 的混合架构前端网关是 Java推理执行器是 Python。先说 Java 这边。利用jstack抓了几次线程栈发现大量业务线程停在同一个位置上模型推理队列的take()或者poll(timeout)方法上。线程栈的关键内容大概是这样的grpc-server-12 #42 prio5 os_prio0 cpu1200.0ms elapsed45.32s tid0x... java.lang.Thread.State: WAITING (parking) at jdk.internal.misc.Unsafe.park(Native Method) at java.util.concurrent.locks.LockSupport.park(LockSupport.java:351) at java.util.concurrent.CompletableFuture.get(...) at com.example.inference.BatchExecutor.submitAndWait(...)这是什么意思简单说线程池没有被打满、网络也没有异常但所有线程都阻塞在“等待推理批处理槽位”的位置。也就是请求已经进入队列但推理执行单元迟迟不来接活。这个发现指向一个很多人容易忽略的事实——AI 服务的瓶颈经常不是算力跑满了而是队列和批处理机制卡住了。3.2 AI 推理服务特有的“计算充足、队列紧缺”为什么 GPU 利用率才 30%请求却排不上队这里得讲清楚大模型推理服务的一个特点动态批处理。正常的推理服务会把同一时间到达的若干请求拼成一个 batch 一起算这样吞吐高、算力利用好。但这个设计有个前提batch 的拼装逻辑和排队时机必须合适。我们线上的推理执行器用的是类似“最大 batch 数8等待批次窗口5ms”的配置。理论上每次凑齐 8 个请求或者等到 5ms 就会送进去算但实际上当某个请求特别长比如长文本生成执行器会长时间占着一个 batch后面排队的请求只能等着。当等待窗口结束但 batch 没凑满时执行器会带着半空 batch 去算算力被浪费掉排队请求却没有及时清空。于是就会出现我们看到的场景GPU 利用率和显存指标都不高但请求侧已经堵成堰塞湖。只看资源利用率来判断 AI 服务健康度基本等于刻舟求剑。真正要看的指标是“当前排队深度”和“平均排队等待时长”。我后来把线上推理实例的“排队深度”和“响应时间”画在同一个坐标系里看了一下两条曲线几乎是完全同步的延迟的尖峰完全是排队的尖峰。这一下从现象层面把“算力不足”的嫌疑彻底排除了。3.3 集群故障转移策略为何制造了第二轮放大如果故障到这里只是个排队问题其实还好收场。但糟糕的是我们聊到的第二个热词“集群故障转移”在这个节骨眼上出来帮了一把倒忙。K8s 集群里编排系统会通过 readiness 探针决定是否把流量调度到某个 Pod。当时我们给推理实例做的 readiness 探测是“TCP 端口存活检查”也就是说只要进程在、端口能连上就认为它健康。故障发生时那几个已经排队堵死的 Pod 明明已经处理不动任何请求了但仍然被判定为健康持续接收新流量。另一个更隐蔽的问题是当某个实例因为某些指标异常被判定为不健康并摘除时它的存量请求并不会被丢弃而是继续挂在实例内部排队。本来流量分摊到 5 个实例现在 2 个被摘掉剩下的 3 个瞬间收到更多请求瞬间也进入超时状态。紧接着负载均衡又把它们标记为不健康继续摘……这个循环在几分钟内把整个推理集群都洗了一遍最后所有实例都在“刚摘除-切流量-被冲垮-再摘除”的节奏里空转。这个现象有一个著名说法叫“故障转移风暴”。它和真正节点宕机不一样宕机是直接没有服务但这里每个实例都活着只是都忙到没法干活。活着的忙和死掉的静在运维系统眼中是完全不同的信号处理方式也完全不同。我们的故障转移策略把“忙”当成了“坏”然后自己制造了更大的故障。我一再提醒团队集群故障转移的配置不是越灵敏越好判断一个 AI 推理实例是否健康不能只看进程存活还得看它的最近一分钟成功率、平均延迟和队列深度并且要有一定的容忍阈值。4. 线上语义缓存本应兜底却成了雪崩助燃剂4.1 为什么给 AI 响应做缓存成本与延迟的双重考虑先交代一下背景。我们的业务场景是文档助手类 AI 服务用户经常会问高度相似的问题。之前为了省成本和降延迟在模型前面加了一层语义缓存会把用户问句做向量化然后在向量库里找相似度超过阈值的历史问题如果命中就直接返回历史答案不再调用模型推理。这个设计平时效果很好缓存命中率大概在 90% 左右模型实例的压力小了很多响应也快。这次故障的另一个核心原因恰恰就出在这个“平时很完美”的缓存层上。4.2 固定 TTL 热点 KEY同时过期与惊群效应查缓存轨迹的时候发现故障发生前的几秒钟有一大批教育相关的问答突然“集体过期”。原因是当初上线缓存时为了省事所有 KEY 都设置成了固定 3600 秒的 TTL。也就是说晚上某个时间段热门的问题在第二天同一时刻会像约好了一样一起过期。如果只是时间重叠其实问题也不大。但只要这期间有一两个热点问题被大量用户反复重试缓存击穿就发生了一批热 key 在同一时刻 TTL 到期。多个请求同时发现缓存 miss一起穿透到后端模型。模型实例本来就已经在排队这些新增请求直接把队列打爆。打爆之后请求超时客户端开始重试重试的每个请求又发现缓存还是 miss。缓存 miss 又让更多请求穿透到模型模型更堵。这场“缓存击穿”和“模型排队”就直接形成了一个正反馈循环。缓存原本是拿来吸收冲击的结果它自己的冲击反而成了雪崩的引信。这里还要区分两个概念缓存穿透是请求到了缓存层但 key 不存在可能是查询的数据本身不存在也可能是缓存误删缓存击穿是某个热点 key 突然失效大量请求同时去打后端。我们这次属于典型的后者。如果有分布式缓存集群还要考虑更糟的情况部分缓存节点因为故障下线客户端从这些节点读取所有 key 都会失败。哪怕我们当时缓存集群没挂但只要 key 的分布不均匀热点集中在某个分片这个分片一旦变慢缓存 miss 率就会瞬间飙升。故障期间的 Redis 慢日志我们也看了确实有不少DEL/EXPIRE命令在批量清理过期 key 时触发了阻塞进一步拖慢了缓存响应。4.3 单飞、TTL 抖动和降级开关把缓存路重新堵上针对上面的问题我们当晚临时加了三道保险第一道TTL 加抖动。把所有缓存 key 的过期时间从固定 3600 秒改成 3600 加上正负 10% 的随机偏移。这是最简单、也最有效的防止集体过期的方法。后面很多同学在写缓存代码时也建议默认都这么做不要图省事写死 TTL。第二道单飞singleflight。也就是在缓存 miss 时对同一个 key 只允许一个请求真正去后端查询其他请求等待这个请求的结果。这个机制可以非常有效地砍掉“热点 key 失效瞬间的惊群流量”。一个简化的伪代码是# 单飞逻辑的核心同一时刻同一 key 只放一个请求穿到后端 async def get_or_compute(key, query): val await cache.get(key) if val is not None: return val async with inflight.lock_for(key): # 只允许一个请求进入 val await cache.get(key) # 二次检查防止等待期间已被填充 if val is not None: return val result await model.infer(query) # 真正打模型 await cache.set(key, result, ttl3600 random.randint(-300, 300)) return result第三道缓存依赖降级开关。当时有人提议直接把缓存层摘掉让流量全走模型。这是绝对禁止的因为故障期间模型已经在排队去掉缓存等于彻底引爆。正确做法是如果检测到模型队列深度超过阈值应该主动让缓存 miss 的请求排队等待并返回柔和提示而不是把所有 miss 流量直接灌进模型。三道保险一上模型队列在几分钟内就恢复了缓存命中率从 70% 慢慢回升到 88% 左右。这也从效果上验证了缓存击穿确实是根因之一。5. 修复组合拳从客户端重试到故障转移调优的落地配置5.1 客户端重试指数退避与幂等上限排查时我就发现客户端 SDK 的重试策略简直就是雪崩放大器无限重试、无退避、无随机抖动。一个请求失败后SDK 会立刻用同样的并发再打一次打不成功再来一次一台机器上的 N 个请求线程会同时触发导致故障期间无效请求占比高达 60% 以上。线上重试参数的原则从我踩过的坑里总结成三条铁律必须限制最大重试次数。一般服务设 2-3 次足够了超时重试一次连接错误再重试一次再多基本没有意义。必须使用指数退避加随机抖动。避免所有客户端在同一时刻集中重试。比如第一次退避 100ms第二次退避 400ms第三次退避 900ms再叠加加减 50ms 的随机值。必须保证请求幂等。这个其实在我们场景里反而是天然的AI 问答请求本身没有副作用重复提交顶多多花算力。但如果是支付、写操作类的服务幂等设计就是重试的底线。这里我给团队算了一笔账假设正常流量是 100 QPS每个请求失败后重试 3 次无退避的情况下峰值流量会变成 400 QPS。如果后端本来就在变慢这 4 倍流量分配到已经拥堵的实例上SLA 瞬间崩溃是必然的。重试不是免费的它本质上是用“更多的请求”去换“更高成功率”前提是后端还有余量。5.2 基于队列深度的扩缩容与流控让背压显性化当晚修复模型的排队问题后我们在推理实例上增加了队列深度指标并把它接到了 K8s 的自定义指标扩缩容HPA上。具体做法是从推理框架的监控接口里把pending_request_count暴露成一个 Prometheus 指标然后配置 HPA 让它根据这个指标伸缩而不是根据 CPU/GPU 利用率。这样在流量尖峰抵达时队列深度先涨Pod 会在大约 1-2 分钟内扩容。同时在服务入口做了“主动限流”策略如果队列深度超过实例最大处理能力的 80%直接返回 HTTP 503 Retry-After头让客户端过了 1 秒再试。这个“直接拒绝而不是排队”的思路特别适合 AI 服务。因为模型推理是典型的“排队长了会击穿”的场景与其让 1000 个请求堵在内存里不如直接告诉客户端“现在忙稍后再来”。从概率上讲主动拒绝一部分流量可以让整体成功率更高而不是所有请求一起超时。5.3 故障转移与健康检查调优用 L7 的成功率代替 L4 的端口探测这是本次故障里最关键、也最容易被忽视的一块。原先的 readiness 探针是 TCP 探测只验证端口存活。我们改成了 HTTP 探测并且探测的路径不是随便一个/healthz而是真正会走一遍业务逻辑简化链路的/ready接口。这个接口会返回最近一分钟的成功率、平均延迟、队列深度三个值只有在三项都正常时才会返回 200。同时给故障转移加了“慢启动保护”。具体配置是软摘除当实例的最近 1 分钟平均延迟超过基线 5 倍时负载均衡先把它从活跃列表摘除但保留少量流量继续探测给它恢复机会。硬摘除连续 3 次健康检查失败或者 1 分钟错误率超过 50%才彻底摘除并触发重新调度。最小存活数限制无论摘除多少至少保留 2 个实例在集群里避免故障转移风暴把全部实例洗掉。为什么要这么谨慎因为 AI 服务故障转移和普通无状态服务不一样。推理实例在摘除时往往还有大量在途请求粗暴摘除会导致这些请求直接失败而且大模型的冷启动时间非常长GPU 初始化和模型加载可能要几十秒如果频繁摘除重建整体的可用性反而更差。5.4 监控补全AI 服务真正要盯的黄金信号这次故障之后我把监控面板全部重做了一遍从以前“看 CPU/GPU/内存”的传统思维切换成“看服务健康信号”的思路。现在线上最依赖的五个监控指标是指标为什么重要故障时的表现队列深度直接反映请求在等什么从 3 冲到 300端到端首字延迟TTFT真实用户体验从 200ms 涨到 3s缓存命中率反映模型压力来源从 90% 掉到 65%实例维度错误率识别慢实例和坏实例个别实例超过 20%客户端重试率识别重试风暴从 1% 升到 25%这套组合拳打完之后再没有出现过同类问题。后面有一次缓存集群单节点故障依赖新的健康检查和缓存击穿保护服务端也没有被拖进雪崩这就是验证。还有一个小细节我觉得值得分享故障复盘时我们顺手把整个事件做成了“结构化的故障案例”存进了内部故障数据库里面记录了现象、指标、根因、修复步骤、验证结果。后续再出现类似“响应异常”的告警值班同学可以直接在库里搜索“排队”“缓存”“故障转移”这几个关键词先看案例再上手排查。这个“故障数据库 AI 构建”的思路现在已经在团队里正式落地了不是用 AI 来自动定位而是用 AI 把大量历史故障记录整理成了可检索的决策树让每一次排障都不再从零开始。回头复盘这次故障我最大的体会是AI 服务线上“响应异常”这个名字起的太粗了它背后的实质可能是网络、网关、线程池、GPU 排队、缓存击穿、故障转移策略等完全不同的东西。只盯着资源利用率或者单点监控永远找不到真相你不把整条链路拆开逐层证明“谁没问题”就会一直在真正的问题外面打转。希望这篇记录能让你的线上排障少走几步弯路。