ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

AI服务响应异常根因排查:缓存击穿与故障转移风暴

AI服务响应异常根因排查:缓存击穿与故障转移风暴

那个周日晚上的告警,我现在还记得很清楚:群里一下子弹出十几条,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 开始排障之前,我们先做了一件“反直觉”的事:建故障数据库

正式动手查链路之前,我把大家拉进会议室,仅用了五六分钟就完成了一次“假设库”的建立。也就是说,把过去半年在线上遇到过的同类响应异常、延迟尖刺、缓存击穿的案例从记录里翻出来,列成一张故障假设清单。

这个习惯是我从一次运维事故里学到的。当时排查到半夜才发现,问题根源是半个月前一个同事在配置中心改了连接池参数,而我们所有人都没往“变更”方向想。从那时候起,凡是发生线上故障,第一件事永远是拉取最近一周的变更记录、发布记录、配置改动,再对照历史故障库做匹配。这次也一样,清单拉出来以后,我们的排查顺序立刻从“猜”变成了“排除”。

当时的清单大概是这样的:

  1. 最近有没有灰度发布过推理服务的路由策略?
  2. 连接池、线程池、队列参数有没有被动过?
  3. 上游模型服务有没有做过集群节点的扩缩容?
  4. 缓存服务的 TTL 策略有没有变化?
  5. 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 prio=5 os_prio=0 cpu=1200.0ms elapsed=45.32s tid=0x... 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。也就是说,晚上某个时间段热门的问题,在第二天同一时刻会像约好了一样一起过期。

如果只是时间重叠,其实问题也不大。但只要这期间有一两个热点问题被大量用户反复重试,缓存击穿就发生了:

  1. 一批热 key 在同一时刻 TTL 到期。
  2. 多个请求同时发现缓存 miss,一起穿透到后端模型。
  3. 模型实例本来就已经在排队,这些新增请求直接把队列打爆。
  4. 打爆之后请求超时,客户端开始重试,重试的每个请求又发现缓存还是 miss。
  5. 缓存 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, ttl=3600 + random.randint(-300, 300)) return result

第三道:缓存依赖降级开关。当时有人提议直接把缓存层摘掉,让流量全走模型。这是绝对禁止的,因为故障期间模型已经在排队,去掉缓存等于彻底引爆。正确做法是,如果检测到模型队列深度超过阈值,应该主动让缓存 miss 的请求排队等待并返回柔和提示,而不是把所有 miss 流量直接灌进模型。

三道保险一上,模型队列在几分钟内就恢复了,缓存命中率从 70% 慢慢回升到 88% 左右。这也从效果上验证了缓存击穿确实是根因之一。

5. 修复组合拳:从客户端重试到故障转移调优的落地配置

5.1 客户端重试:指数退避与幂等上限

排查时我就发现,客户端 SDK 的重试策略简直就是雪崩放大器:无限重试、无退避、无随机抖动。一个请求失败后,SDK 会立刻用同样的并发再打一次,打不成功再来一次,一台机器上的 N 个请求线程会同时触发,导致故障期间无效请求占比高达 60% 以上。

线上重试参数的原则,从我踩过的坑里总结成三条铁律:

  1. 必须限制最大重试次数。一般服务设 2-3 次足够了,超时重试一次,连接错误再重试一次,再多基本没有意义。
  2. 必须使用指数退避加随机抖动。避免所有客户端在同一时刻集中重试。比如第一次退避 100ms,第二次退避 400ms,第三次退避 900ms,再叠加加减 50ms 的随机值。
  3. 必须保证请求幂等。这个其实在我们场景里反而是天然的,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 排队、缓存击穿、故障转移策略等完全不同的东西。只盯着资源利用率或者单点监控,永远找不到真相;你不把整条链路拆开逐层证明“谁没问题”,就会一直在真正的问题外面打转。希望这篇记录能让你的线上排障少走几步弯路。

返回列表