模型推理P99飙至3秒,根因是Redis缓存集群故障排查与优化

发布时间:2026/9/29 16:53:45
模型推理P99飙至3秒,根因是Redis缓存集群故障排查与优化
模型推理服务平时响应都在 80ms 上下突然有一天 P99 直接飙到 3 秒多用户反馈“转圈”的时间明显变长。第一反应是模型服务出了问题结果 GPU 利用率才 40%batch 大小没变服务线程池也没满。再从调用链往下追发现耗时大头全压在 Redis 缓存读取上——这个案例就是个典型的“模型推理响应慢根因却出在缓存集群”的故障。这篇文章把这次排查的完整思路、踩过的坑和最终落地优化都整理出来给遇到同类问题的朋友一个可参考的排查路径。这次故障涉及模型推理、Redis 缓存集群和分布式系统的连锁反应适合正在做 AI 服务落地、或者自己维护缓存集群的工程师参考。我尽量把排查过程中的判断依据和操作命令都列清楚而不是只给结论。1. 故障现象与初步定位思路1.1 现象描述从 P99 飙升到业务告警当时监控面板上的数据相当直观模型推理服务的平均响应时间从平时的 80-120ms 涨到了 800ms 以上P99 直接突破 3 秒P999 更是到了 6 秒开外。同时在告警群里接到了大量“推理超时”的报警上游业务方反馈 App 内的体验类功能明显卡顿部分请求直接超时失败。整个时段 QPS 并没有明显的突刺请求总量和平时差不多说明不是流量洪峰导致的服务过载。这时有一个细节值得注意服务 CPU 和内存都还在正常范围没有 OOM 或者 CPU 满载的迹象。这让我们一开始并没有往缓存集群的方向想而是先怀疑模型服务自身出了问题。比如是不是新上线的模型版本有 bug或者某个 batch 配置被意外改动。但检查完模型服务的部署版本、推理参数和 GPU 利用率之后发现都不是——GPU 利用率稳定在 40% 左右说明计算资源没有瓶颈模型推理本身并没有变慢。接着我们拉取了调用链追踪数据才发现问题出在缓存这一环。1.2 排查方向先应用层还是先缓存层遇到推理变慢的情况我一般的排查顺序是“服务自身 - 下游依赖 - 基础设施”。服务自身排除了模型版本、batch 和线程池的问题之后下一个怀疑对象就是下游依赖也就是 Redis 缓存集群。这里有一个经验不要一上来就扎进 Redis 里面看先看调用链确认耗时到底是在哪个环节增长。当时的调用链数据展示得很清晰推理服务内部的处理时间基本没变但 Redis 读取的耗时从平均 0.5ms 涨到了 30ms 以上个别请求甚至达到 200ms。而且出现了一个明显的规律——凡是走了缓存的请求耗时就高得离谱凡是直接放行的请求反而正常。这基本可以确定问题出在 Redis 侧而不是模型推理本身。确定方向之后我们开始针对缓存集群做逐项排查。这次用的 Redis 是一个三主三从的集群模式部署在 Kubernetes 环境里客户端是 Lettuce连接池用的 commons-pool2。接下来我会按排查的顺序把每一层的检查方法和判断依据写清楚。2. 从 Redis 集群侧逐项排雷2.1 慢查询日志第一手证据排查 Redis 问题我习惯先看 slowlog这是成本最低、信息量最大的入口。在集群的任意一个主节点上执行redis-cli -h 节点IP -p 6379 -a 密码 slowlog get 30正常情况下列表里应该是空的最多有一些偶尔超过 100ms 的 KEYS 操作这种操作本来就不该出现在生产环境。但这次看到的结果让我心头一紧大量的 SET 和 GET 操作耗时在 1-3 秒慢日志里排在最前面的几十条清一色是这两个命令。这说明不是某一个异常命令拖慢了服务而是 Redis 节点本身在处理基础命令时就已经“卡住”了。当时第一反应是节点负载过高但 CPU 和内存看着都没满。接着用info clients查看连接数发现单节点连接数已经从平时的 800 左右涨到了接近 4000客户端连接池基本被占满新的请求在客户端侧就开始排队等待连接了。这里有两个容易被忽略的点第一Redis 的慢日志只记录执行时间超过阈值的命令但客户端观察到的延迟还包括排队时间和网络时间所以慢日志里的数字和调用链里的耗时对不上是正常的第二连接数飙升本身可能只是结果不是原因——真正的病因是某个环节导致请求无法快速返回客户端只能不断创建新连接去“抢救”反而把连接数推高了。2.2 内存、持久化与节点健康状态连接数和慢日志异常之后下一步就是看内存和持久化的状态。在 Redis 节点上执行redis-cli info memory redis-cli info persistenceused_memory显示的数据并不算高三主三从里最大的主节点也只用了 4GB 左右相对分配给他的 6GB maxmemory 还有空间。但mem_fragmentation_ratio内存碎片率已经达到了 1.8这通常意味着 Redis 内部做了大量 key 的删除或过期清理内存页碎片化严重。持久化方面AOF 功能是开启的并且配置了 auto-aof-rewrite-percentage 100 和 auto-aof-rewrite-min-size 64mb。问题就出在 AOF 重写这里——当 AOF 文件体积增长到触发重写条件时Redis 会 fork 出一个子进程去做重写。如果这时候磁盘 IO 本身就有瓶颈fork 产生的阻塞时间会非常明显。我们查看节点所在宿主机的 IO 监控发现磁盘 util 已经持续跑在 90% 以上。这里解释一下为什么 AOF 重写会影响线上请求Redis 在执行 fork 时会调用fork()系统调用生成子进程。如果操作系统内存页表很大fork 的耗时可能达到秒级。更关键的是在fork()期间 Redis 无法处理任何新的请求所有命令都会排队等待。这台节点跑的本身就是模型推理服务的缓存吞吐量不小一笔几百毫秒的停顿就足以让下游请求超时。再检查集群层面的状态执行redis-cli cluster info结果里cluster_state:fail直接映入眼帘——这说明集群里已经有节点被认为处于不可服务状态整个集群的健康状态已经被拉低。进一步查看cluster nodes发现其中一个主节点处于disconnected状态对应的从节点完成了 failover接管了主节点角色。但问题在于这个从节点在接管前自己也在同一台物理机上IO 负载同样很高所以接管之后并没有让缓存服务恢复。3. 根因定位缓存穿透引发的连锁故障3.1 隐藏的瞬断与主从切换到这里整个故障链条逐渐清晰了。最初的起因是某台节点所在的宿主机磁盘 IO 被打满——因为同宿主机的其他容器在做大量的日志写入加上 Redis 自身的 AOF 重写也在跑两者叠加导致磁盘响应时间急剧上升。Redis 主线程在 AOF 写入时被阻塞了将近 2 秒超过集群心跳超时时间其他节点判定这个主节点失联触发了主从切换。这个过程本身是合理的自动故障转移但有一个隐藏的坑我们当时没有配置合理的cluster-node-timeout使用的是默认的 15 秒。这个值偏大导致故障检测本身有延迟而另一方面AOF 阻塞的 2 秒已经足够把客户端请求全部打超时了。主从切换之后更严重的问题来了——原本在主节点上的部分 key 因为数据同步延迟在从节点上并不存在。当客户端请求这些 key 时Redis 返回空结果。模型推理服务拿不到缓存只能回源到数据库和特征服务去查数据库瞬间被打爆。3.2 雪崩效应的传导路径我把这个故障的传导路径画成一条链的话大概是这样的宿主机磁盘 IO 瓶颈 - Redis AOF 重写阻塞 - 主节点瞬断触发主从切换 - 部分 key 未同步导致缓存穿透 - 请求回源数据库 - 数据库连接池被打满 - 推理服务对数据库的等待时间进一步拉长 - 前端超时堆积P99 持续飙高。值得强调的是模型推理服务的缓存设计里存在一个隐蔽的坑没有使用“本地缓存 分布式缓存 数据库”的三级降级策略。当 Redis 不可用时请求直接就打到数据库了没有任何中间缓存来挡住流量。这就像一层楼的消防通道只做了楼梯没做缓降器一旦楼梯堵了所有人都挤在一个出口。另外模型推理的缓存 key 还有一个特点很多 key 都是特征向量和模型中间结果数据体量大而且带有时间戳维度容易造成“缓存遗漏风暴”——一旦某个时刻大量 key 同时过期新的请求全部回源回源的压力又会进一步拖慢 Redis 的响应形成恶性循环。检查时我们发现部分 key 的 TTL 集中在同一个小时段过期这说明当初设置过期时间时没有加入随机偏移量。这是缓存雪崩的高危配置。4. 优化落地与预防措施4.1 Redis 集群侧的配置调整问题定位清楚之后优化动作分成了几个批次来落地。第一批针对 Redis 集群本身的稳定性。调整 AOF 重写策略。具体做法是把auto-aof-rewrite-percentage从 100 提高到 200同时把auto-aof-rewrite-min-size从 64mb 提高到 512mb。这么改的直观效果是降低 AOF 重写的触发频率。AOF 重写也不是每次都要抢主线程的资源Rewrite 过程本身是大文件操作对磁盘 IO 的消耗显著降低频率就能减少阻塞窗口。如果是重写期间的写入量较大的场景还可以考虑开启aof-use-rdb-preamble让重写后的 AOF 文件以 RDB 格式存储重写时会更快。然后是给主从切换留出合理的检测时间。cluster-node-timeout从默认的 15 秒调整到 5 秒。这个值减小后故障节点的判断会更加敏锐但也不能调得太低否则网络抖动会频繁触发主从切换导致集群不稳定。5 秒是实践里比较平衡的值。再操作一层把 Redis 节点从 IO 负载过高的宿主机迁移到独立的节点。具体做法是用redis-cli cluster setslot做节点迁移或者直接把 pod 重新调度到新的宿主机。同时给宿主机上的其他容器加了 IO 限流避免某些大流量容器把磁盘带宽全部吃掉影响 Redis。持久化方面我们对 AOF 的写入模式做了调整。原来用的是appendfsync everysec在 IO 抖动时仍然可能造成秒级阻塞。我们保留了这个模式但额外添加了no-appendfsync-on-rewrite参数这个参数在实际操作中要看具体版本是否支持。当 AOF 重写进行时不再执行 fsync减少磁盘竞争。需要注意这里隐含着一个权衡重写期间如果宕机可能会丢失更多数据但对于缓存场景来说可以接受。4.2 应用侧连接池参数与本地缓存兜底Redis 侧优化完之后应用侧的代码和配置也需要调整。这次我们踩了一个很典型的坑Lettuce 客户端默认的超时时间是 10 秒而 commons-pool2 的连接池设置里maxWaitMillis用的是默认值 -1无限等待。这意味着当 Redis 出问题时客户端会一直等着获取连接导致推理服务线程被长时间占用。模型的推理线程数量本来就不多被这么一占整个服务的吞吐量就急剧下降。连接池参数的最终配置如下spring.redis.lettuce.pool.max-active400 spring.redis.lettuce.pool.max-idle200 spring.redis.lettuce.pool.min-idle50 spring.redis.lettuce.pool.max-wait-1ms重点改了两点max-active从 200 提高到 400匹配现有业务高峰max-wait从 -1 改成 3000ms。等 3 秒拿不到连接就快速失败让请求走降级逻辑而不是无限卡住。这两个参数直接影响的是队列堆积和快速失败的能力。同时min-idle设置成 50避免突发流量时大量新建连接触发的 Redis 连接风暴。应用侧的第二层防护是本地缓存兜底。我们在推理服务里引入了 Caffeine 作为一级缓存模型特征数据本地缓存 30 秒推理结果的 common key 本地缓存 10 秒。这样即使 Redis 集群再次出现短暂的吞吐下降本地缓存也能挡住一部分请求不会全部打到数据库。第三层是降级开关。用CircuitBreaker给 Redis 读取操作打上熔断标记当错误率达到阈值时直接短路缓存读取走数据库回源通道同时限制回源的并发线程数。这里的关键思路是宁可让部分请求获取不到最新数据也不让整个服务因为缓存故障而宕掉。5. 常见问题速查与避坑清单5.1 故障排查速查表把这次问题排查的各个阶段整理成一个速查表下次再遇到类似情况可以按图索骥。排查方向关键检查点常用命令/手段本次故障中的状态服务自身GPU 利用率、batch 配置、线程池监控面板、服务日志正常排除调用链哪个环节耗时增长分布式追踪系统Redis 读取耗时飙涨Redis 慢查询是否有命令超过阈值slowlog get大量 GET/SET 耗时 1-3 秒Redis 连接数是否接近 maxclientsinfo clients连接数接近 4000Redis 内存碎片率、过期键占比info memory碎片率 1.8持久化AOF 重写是否触发、是否存在阻塞info persistenceAOF 重写期间磁盘 IO 饱和集群状态节点归属、哈希槽、状态cluster info/cluster nodescluster_state:fail主从切换过宿主资源磁盘 io、网络是否被邻居影响宿主机监控磁盘 util 持续 90%这张表的核心思路是先看现象、再看调用链、最后看资源瓶颈逐层排除不盲目动配置。5.2 实战避坑经验这次排查我踩了几个值得记录的坑写出来给大家提个醒。第一个坑是不要在生产环境随意执行monitor命令。故障当时我一度想用monitor看实时命令流还好被同事拦住了。monitor会把所有命令实时输出它会大幅增加 Redis 的负载在集群已经不稳定的时候用这个命令简直是雪上加霜。正确的做法是优先用slowlog配合redis-cli --stat看实时吞吐和延迟。第二个坑是调查大 key 和过期策略要坚持在低峰期进行。排查中我们发现有一个 list 类型的 key 存储了某用户的特征序列单 key 大小达到了 20MB。这类大 key 在执行删除或查看操作时也会引起长时间阻塞需要用redis-cli --bigkeys扫描出来的结果逐一处理。我们当时的处理方案是把这批大 key 拆分成多个小 key并且在过期时间上加了 5-10 分钟的随机偏移。这样即使同一批 key 被写入过期时间也不会集中在同一秒。第三个坑是关于主从切换后的数据一致性。我们的场景里缓存数据本身不需要强一致所以这个问题暂时没造成太大的业务影响。但如果缓存里存的是计费相关的数据主从切换后读不到旧 key 的问题就会变成一个真正的数据一致性事故。建议在业务层面做好缓存不可用时的回源策略并针对“主从切换瞬间的缓存穿透”专门做一层接口限流防止数据库被一次性打满。第四个坑是注意观察 Redis 客户端的连接池回收速度。故障结束后我们调整了连接池参数但还在持续监控连接数。发现max-active400在高峰期依然容易出现连接不够用的情况后来配合了连接池泄漏检测定期用info clients对比活跃连接数和空闲连接数的比例。连接池本身不会自动回收异常的连接如果业务代码里出现了连接未归还的逻辑错误会逐渐把连接池耗尽。还有一个容易被忽视的环节是Kubernetes 环境下 DNS 解析和网络策略的变更。Redis 集群的节点在 pod 重启后 IP 会变化如果服务端配置的 Redis 节点地址没有及时更新客户端会一直尝试连接旧地址导致短时间内的连接失败和超时。这次故障虽然发生在 Redis 本身的运行状态上但在排查时我们也遇到过 pod 重建导致的 IP 漂移问题。建议在容器化部署环境下优先使用 headless service 提供的稳定 DNS 名称来配置 Redis 集群地址避免 IP 变化带来的二次故障。6. 最后分享一点实际体会维护 Redis 集群这几年最大的感受是故障排查其实不是从 Redis 开始的而是从“这条请求的完整生命周期”开始的。推理服务响应变慢把锅扣在模型上是人之常情但真正的原因往往藏在更底层的资源竞争里。这次的宿主机磁盘 IO 问题如果只看 Redis 自身指标很容易误判成客户端连接数过多或者访问量突增只有把节点视角切换到宿主机视角才能看到 AOF 重写和日志写入在抢磁盘带宽这个真相。我个人建议每半年做一次 Redis 集群的压力测试和故障演练尤其是主动用redis-cli debug sleep模拟节点阻塞观察集群的主从切换业务流程能不能平稳度过。这次的故障真正的问题不在于 Redis 本身挂了而在于挂了之后的连锁反应没有对应的防御机制。缓存层、数据库层、本地缓存层三层之间的降级链路如果提前配好即使 Redis 再瞬断一次推理服务的 P99 也只是从 80ms 涨到 200ms 的问题而不是 3 秒。以上都是这次实际操作中遇到并验证过的做法。距离“永不故障”还很远但至少下一波故障来的时候能少了些手忙脚乱多了些按步骤处理的底气。