LiteLLM网关Redis内存打满引发级联故障的复盘与优化实践
做生产环境的技术人,最怕的不是模型 provider 出故障,而是自家网关这一层先崩。那天凌晨,LiteLLM 网关的 5xx 率在十分钟内从 0.1% 冲到 67%,Redis 内存告警紧随其后,紧接着业务方反馈大批模型调用失败——一次典型的级联故障就这么炸开了。这篇复盘,我从头到尾梳理了 LiteLLM 网关依赖 Redis 做限流与缓存时,内存被打满后如何一步步传导成全站故障,以及恢复过程中沉淀下来的内存优化措施。如果你正在用 LiteLLM、one-api 这类 LLM 网关,或者打算给网关引入一个共享 Redis 状态层,这篇内容应该能帮你少踩几个坑。
1. 事故时间线:从一条预警到全站请求失败
1.1 凌晨 1 点的告警顺序
那天是周四凌晨,值班手机先响的是 Redis 内存告警:某个共享 Redis 实例的内存水位到了 86%。我当时看了一眼,心想“还能撑一会儿,白天再处理”,结果五分钟之后,第二条告警就上来了——LiteLLM 网关的 5xx 比例超过了 10%,并且还在往上跳。第三条是业务方在群里直接开麦:“大模型接口大面积超时,是不是上游挂了?”
告警顺序只是表象,真正让人紧张的是下面这几个数据:
- Redis 侧:
used_memory1.78GB,maxmemory2GB,mem_fragmentation_ratio1.84,keyspace里 db0 的 keys 284 万,其中带过期时间的 221 万,意味着有 63 万个 key 没有 TTL。 - LiteLLM 侧:请求错误率从 0.1% 涨到 67%,峰值 p95 延迟从正常时的 300ms 拉到 8 秒以上。
- K8s 侧:网关 Pod 开始出现重启,部分副本的健康检查失败,容器被自动杀掉再拉起来。
这三组状态单独看都“很严重但能解释”:Redis 内存高、网关报错、Pod 重启。但把它们放到同一段时间轴上,才发现是一条完整的故障传导链。当时我们并没有第一时间把三张看板的曲线叠在一起看,这也是第一轮排查走弯路的主要原因。
1.2 第一轮排查为什么走偏了
凌晨两点,任何人打开 LiteLLM 的日志,第一反应大概率和我一样:先怀疑模型上游。因为我们看到的大部分报错都带着 timeout、connection reset、5xx 这类字眼,非常像 OpenAI、Bedrock 这类模型服务商出了状况。于是我们先后做了三件事:检查模型服务商状态页、看是否触发了上游限流、重放几个失败请求。
这三件事做完,结论却对不上:上游状态页一切正常,重放请求也是成功的,但生产环境仍然在持续报错。更让人困惑的是,错误率曲线和 Redis 内存曲线几乎是同步往上走的。直到有人把 Redis 的INFO errorstats拉出来,看到里面有大量OOM command not allowed when used memory > 'maxmemory'的错误计数,我们才反应过来:不是模型方的问题,是我们自己托管状态的 Redis 先扛不住了。
现在回过头看,这个弯路其实完全可以避免。网关报错的第一现场确实像“上游故障”,但如果当时先把 Redis 的evicted_keys、INFO errorstats、以及网关 5xx 放在同一块看板上,链条一眼就穿起来了。这也是很多 LLM 基础设施团队容易踩的坑:LiteLLM 本身是“模型层的东西”,Redis 是“缓存层的东西”,运维习惯上会把它们分开看,但生产环境里它们早就是强绑定关系了。
2. 级联故障链路拆解:Redis 在 LiteLLM 架构里到底承压在哪
2.1 从架构说起:LiteLLM 这条路为什么要架一个 Redis
先讲清楚一个基本问题:LiteLLM 单实例跑的时候,限流计数、响应缓存都可以放本地内存,完全不需要 Redis。一上生产、一搞多副本,情况就变了。K8s 里三个网关 Pod 各自持有独立内存,如果限流状态放在各自进程里,同一个 API Key 在 Pod A 上已经用完了额度,在 Pod B 上还能继续调,分布式限流就形同虚设。所以要引入 Redis 作为共享状态层,让所有副本读写同一份计数、同一份缓存、同一份运行时配置。
这个架构本身没有错,同类 LLM 网关(比如 one-api)也是这个思路。但问题在于:共享状态层一旦出故障,影响范围就不再是“某个 Pod 的缓存慢了”,而是全量副本的请求都在等同一个 Redis。鸡蛋放在同一个篮子里,这个篮子一旦出问题,炸得会特别快。
我在这次事故里体会最深的一点是:引入 Redis 解决的问题是“分布式一致性”,但引入的故障面是全新的——Redis 的连接数、内存、慢命令、主从切换,任何一个环节出问题,LiteLLM 都会跟着出问题。而且 LLM 网关的流量模型和普通 API 网关不一样,单次请求携带的响应体动辄几千字节,这直接放大了 Redis 的内存压力。
2.2 内存去向拆解:哪些 Redis key 在悄悄吃内存
要搞清楚 Redis 为什么被打满,先得知道 LiteLLM 在 Redis 里到底存了什么。按我们当时的观测,主要有三类:
第一类是限流计数类 key,格式一般是litellm:ratelimit:<api_key>:<model>:<时间窗口>,value 很小,通常只有一两百字节,TTL 等于窗口期加一点余量。这类 key 看起来人畜无害,但窗口一多、Key 一分、调用一多,数量轻松涨到百万级。
第二类是响应缓存类 key,格式一般是litellm:cache:<prompt_hash>:<model>,value 是完整的模型响应。如果业务方用固定 prompt 做问答,这类 key 命中率很高,是好事;但如果调用方每次请求都带随机 trace_id,或者 prompt 本身抖动很大,缓存 key 的高基数问题就会非常严重——每来一个请求就新建一个缓存条目,却几乎没有命中。
第三类是业务方自己存进去的“外来户”。我们当时用的 Redis 是和另一个业务团队共用的,里面有大量没有设置 TTL 的 key,其中有一个 JSON key 的 value 达到了 1.2MB。这类 key 平时不起眼,但在 Redis 内存紧张的时候,就是压垮骆驼的最后一根稻草。
当时抓到的现场数据是这样的:
# Redis 侧(INFO memory / INFO keyspace) used_memory: 1784.62M used_memory_rss: 3281.94M maxmemory: 2048M maxmemory_policy: noeviction mem_fragmentation_ratio: 1.84 db0:keys=2843012,expires=2210432,avg_ttl=354avg_ttl=354意味着大量 key 很快就要过期,但数量实在太大,加上无 TTL 的大 value key,内存被顶到了上限。再往下看就会发现,Redis 的maxmemory_policy是 noeviction,也就是内存满了之后直接拒绝写命令——这一步是整个事故的起点。
2.3 三个放大器:内存告警如何传导成全站故障
内存打满本身只是 Redis 的问题,它之所以升级成全站故障,是因为后面还有三个放大器。
第一个放大器是 noeviction 策略下的写入拒绝。Redis 内存触顶后,LiteLLM 每次尝试写入限流计数或缓存,都会收到OOM command not allowed when used memory > 'maxmemory'这个经典报错。我们用的 LiteLLM 版本在 Redis 写失败时没有做好优雅降级,异常直接冒到了请求链路里,网关开始大面积返回 500 和 429。
第二个放大器是业务方的自动重试。OpenAI 官方 SDK 默认就有重试机制,一些业务方还自己包了一层高可用逻辑,最多重试 5 次,退避间隔只有 200ms、400ms、800ms。网关一报错,所有调用方几乎同时开始重试,重试流量再次打到网关,网关又要写 Redis,Redis 又拒绝写入,错误率被反复拉升,形成一个负向反馈循环。
第三个放大器是健康检查与 Pod 重启。网关线程池被 Redis 写超时拖住后,/health/liveliness探针响应变慢,部分 Pod 被杀掉重建。新 Pod 拉起来之后,流量被调度到存活副本上,单个副本的并发和连接数被瞬间抬高,存活副本也开始撑不住,雪崩就这么发生了。
整条链路拆开来看就是:共享 Redis 被业务大 key 和无效缓存顶满内存 → noeviction 策略拒绝写入 → LiteLLM 写限流计数失败 → 请求 500/429 → 客户端 SDK 重试 → 重试流量回到网关和 Redis → 连接数暴涨、线程池阻塞 → 健康检查失败、Pod 重启 → 流量漂移、单副本承压 → 全站故障。
3. 应急恢复实录:止血操作与降级取舍
3.1 我们当时按顺序做的 6 件事
故障处理有个原则:先止血,再定位。我们当时的操作顺序也基本顺着这个逻辑走下来了。
第一步,打开 Redis 控制台,执行INFO memory、INFO keyspace、INFO errorstats,确认了 maxmemory 打满和 OOM 报错计数在涨。这一步的核心目的是判断“Redis 是否还有救”,如果 errors 在涨但 CPU 不高,说明只是内存问题,清理 key 就能恢复;如果 CPU 已经打满,那就可能是慢命令风暴,操作要更激进。
第二步,让业务方临时停掉所有非核心的模型调用。这一步在半夜做有点得罪人,但非常有效。把 QPS 降下来之后,Redis 的写压力立刻缓解,至少能给后续操作争取时间。这里我的经验是:不要想着“我们不切流也能修好”,生产事故里时间是最贵的资源,先降流量永远不亏。
第三步,用redis-cli --scan --pattern "litellm:*"抓了一批 key 样本,再用MEMORY USAGE找出体型最大的几个。我们当场确认了几类目标:那个 1.2MB 的业务 JSON key,以及一批 key 数量巨大但命中率几乎为零的响应缓存。对于确认无害的 key,直接用UNLINK异步删除,避免DEL阻塞 Redis 主线程。
第四步,把maxmemory-policy从 noeviction 临时改成 volatile-lru。这里要注意:改这个配置之后内存不会立刻下降,Redis 是在后续写入时才触发逐出检查。但至少它让 Redis 从“拒绝写入”变成了“优先淘汰带 TTL 的 key”,给新的写请求腾出了一条活路。
第五步,在 LiteLLM 侧临时关闭了响应缓存功能。这是个应用层的止损:缓存本来就是可丢弃的数据,先把这块高频写入停掉,Redis 的压力能消掉一大半。关闭后网关配置 reload,进程不重启,避免再引发一次连接风暴。
第六步,等网关错误率回落后,逐步恢复业务流量,先放 10%,观察两分钟,再放 30%,确认稳定后全量恢复。这六步做完,距离第一条告警大概过了一个半小时。
3.2 为什么不建议第一时间重启 Redis
很多人在 Redis 内存爆掉之后的第一反应是“重启一下就好了”,这个思路在根因明确前非常危险。重启确实会清空内存里的数据,但内存里同时还有 LiteLLM 的限流计数和缓存数据。限流计数一旦清空,所有调用方都会被视为“新用户”,大量请求瞬间获得放行资格,上游模型服务商会被一股脑打过来的洪峰冲垮。
另外一个容易被忽略的问题是:如果根因没有消除——比如响应缓存还在开、那个没有 TTL 的大 key 还在——重启之后内存会以肉眼可见的速度涨回来,等于白操作一次,还白白搭上了一次可用性抖动。就算 Redis 开了 RDB 持久化,恢复出来的数据也可能再次把内存顶满,启动时甚至会因为加载数据触发新一轮的 OOM。
我们当天没有重启 Redis,就是因为先做了“降流量 + 清 key + 改淘汰策略”这三件事。只有当 Redis 本身出现严重阻塞、不可响应的时候,重启才应该进入候选方案。而且重启之前一定要确认上层应用具备重连能力,否则你这边 Redis 刚起来,LiteLLM 的连接池还停留在旧连接上,照样会报错。
3.3 fail-open 还是 fail-closed:这次事故给我们的教训
Redis 挂掉之后,网关应该怎么做?这次事故暴露了我们默认行为是 fail-closed——Redis 写失败就让请求直接失败。这保证了不会超卖上游额度,但也让全站可用性瞬间归零。反过来想,如果 fail-open,请求会直接放行到模型服务商,代价是上游可能被超售打挂。
我的结论是:不能一刀切。对于限流计数这类“写失败影响的是超额风险”的数据,可以 fail-open,并在网关本地做一份粗糙的内存计数兜底,同时打出告警。宁可短时间超卖,也不能让全站不可用。对于鉴权、配额扣减这类强一致数据,仍然要 fail-closed,因为放行带来的经济损失和合规风险远高于一次 500。
这次事件之后,我们把 LiteLLM 的 Redis 连接池、读写超时、异常处理路径逐一过了一遍,把不同场景下的 fail 策略明文写进了网关配置说明里。技术方案没有绝对的对错,但一定要让人在一分钟内能回答出“Redis 挂了网关会怎样”。
4. Redis 内存优化与防线重构:让网关不再被缓存拖垮
4.1 容量规划:先算清楚 Redis 到底该给多大
Redis 容量规划不能只凭感觉“给个 2GB 吧”,要按业务量粗算。我们后来重新梳理了一版估算公式:
内存预算 = 日峰值 QPS × 单请求 Redis 写入量 × 窗口 TTL 内保留的请求数系数 × 碎片系数举个例子:如果网关峰值 QPS 是 200,每次限流写入约 200 字节,1 分钟窗口内累计写入 1.2 万次,大约 2.4MB;1 小时窗口再算一遍,大约 144MB。两项相加再乘上 1.2~1.5 的碎片系数,大概 200~300MB。如果还开了响应缓存,再按“缓存条目数 × 平均响应大小”单独算一批。
当时我们最大的问题不是限流 key 太多,而是响应缓存 key 膨胀到了百万级,每条平均 3~6KB,光这一块就吃掉了差不多 1GB 内存。容量规划的另一个要点是给峰值预留足够的缓冲水位:maxmemory至少是日常 used_memory 峰值的 1.5 倍,并且要盯着used_memory_rss看——used_memory 只是逻辑占用,RSS 才是真实的物理内存压力,碎片率高了以后 RSS 会远超 used_memory。
4.2 TTL 治理与无效缓存识别
Redis 内存优化最基础也最见效的一件事,就是把 TTL 管起来。所有写入 Redis 的 key 都必须带过期时间,这是底线。带 TTL 的 key 即使数量多,也会自动滚出去;真正打死 Redis 的,永远是那些“活到永远的 key”。
怎么找无 TTL 的 key?生产环境用redis-cli --scan全量刷通常会拖垮 Redis,我建议在从节点或者低峰期跑,并且用脚本控制采样量。下面这个 Python 脚本的思路可以参考:
import redis r = redis.Redis(decode_responses=True) cursor = 0 sample = [] while True: cursor, keys = r.scan(cursor=cursor, match="litellm:*", count=500) for k in keys: if r.ttl(k) == -1: sample.append(k) if len(sample) >= 20: break if cursor == 0 or len(sample) >= 20: break print("\n".join(sample))这个脚本每 500 个 key 采样一次,拿到 20 个无 TTL 样本就停,压力可控。找到问题 key 后,用UNLINK异步删除,而不是DEL。
无效缓存的识别更值得说。我们当时发现响应缓存 key 数量巨大,但keyspace_hits极低,缓存命中率不到 10%。这说明大部分缓存建了也没人用,纯粹在烧内存。这类问题通常出在缓存 key 的生成逻辑上:如果调用方在 prompt 里拼了随机数、时间戳、trace_id,那么缓存 key 的基数会无限膨胀。解决的思路有两个:要么在网关层对 prompt 做归一化处理,要么干脆对这类流量关闭缓存。
4.3 淘汰策略怎么选:noeviction、allkeys-lru 还是 volatile-lru
Redis 的maxmemory-policy有几种常见选择,生产环境里这不是一个可以随手填的配置:
| 策略 | 行为 | 适用场景 |
|---|---|---|
| noeviction | 内存满后拒绝写入 | 数据强一致场景,但容易引发写失败和级联故障 |
| allkeys-lru | 全空间按 LRU 淘汰 | 纯缓存场景,不区分是否有 TTL |
| volatile-lru | 只淘汰带 TTL 的 key | LiteLLM 这类大部分 key 带 TTL 的场景 |
| volatile-ttl | 优先淘汰剩余时间最短的 key | 对数据新鲜度有要求 |
我们最终在恢复阶段选择了 volatile-lru,核心考虑是:LiteLLM 的限流计数和响应缓存本来就带 TTL,属于“可以牺牲、可以重建”的数据;而无 TTL 的业务大 key 虽然坑,但不应由 Redis 自动淘汰。volatile-lru 能把淘汰范围限制在带 TTL 的数据里,保住无 TTL 的“硬数据”。
需要提醒的是,淘汰策略是兜底,不是第一道防线。如果内存经常触发淘汰,说明容量规划已经失效了,该扩容就扩容,该治理就治理。别把allkeys-lru当成万能药,它会导致所有 key 都可能被牺牲,包括一些不应丢失的数据。
顺便说一下碎片。我们当时的mem_fragmentation_ratio是 1.84,说明大量短 TTL key 频繁创建、销毁,内存碎片非常严重。可以在 Redis 配置里开启activedefrag yes,让 Redis 在后台自动整理碎片;如果碎片率持续超过 1.5,还伴随 RSS 暴涨,就要考虑重启实例或者迁移数据了。
4.4 实例隔离与连接参数
这次事故最深刻的结论之一,就是共享 Redis 是定时炸弹。LiteLLM 的限流计数写得很频,响应缓存动不动上 GB,这两类数据都不适合和一个陌生业务共享同一个实例。事后我们把 Redis 拆成了两层:核心的 LiteLLM 状态用独立实例,业务缓存走另一个集群,彼此之间互相隔离。
如果实在做不到实例隔离,退一步也要做到“逻辑隔离 + 配额配额”。用不同的 db 编号分隔,同时给每个业务设置明确的 key 前缀约定,比如litellm:*、biz:xxx:*,这样至少可以通过INFO keyspace和--scan快速定位是哪一方的 key 在膨胀。
连接参数也非常关键。LiteLLM 连接 Redis 时,connect_timeout、socket_timeout、max_connections 这些参数不能全走默认值。我们当时的问题之一就是连接池过小,Redis 写慢的时候请求全部堵在池子里等连接,线程池被占满。建议把 socket_timeout 调到一个较小的值,比如 2 秒,宁可让单一请求快速失败走降级,也不能让一堆请求无限阻塞在 Redis 连接上。
高可用方面,生产环境至少要有主从+哨兵,或者直接用云上托管 Redis。主从切换这个动作本身也会造成几十秒的不可用,LiteLLM 侧要做好自动重连和重试。我们暂时还没做到完全无感切换,但这已经是下一步明确要补的功课。
4.5 监控告警这样做才算到位
这次事故之后,我把 Redis 相关监控重新梳理了一遍。Redis 侧最重要的几个指标是:used_memory / maxmemory(超过 80% 告警)、evicted_keys(持续大于 0 要告警)、rejected_connections(连接数触顶)、blocked_clients(客户端被阻塞)、connected_clients(突增说明流量异常)、instantaneous_ops_per_sec(每秒命令数)、INFO commandstats里的usec_per_call(慢命令指标)。
LiteLLM 侧要盯的是:5xx 比例、p95 延迟、限流命中次数、缓存命中率。特别是缓存命中率,它直接反映了响应缓存有没有在干活。如果缓存命中率走低而缓存 key 数量走高,说明在烧内存换空气,该处理了。
王炸级别的操作是把这些指标放到同一块看板上。我们这次走弯路的原因就是 Redis 看板归 Redis、网关看板归网关,两边没叠在一起。后来我把 Redis 内存、evicted_keys、LiteLLM 5xx 曲线画成同一条时间轴的关联图,再遇到类似情况,一分钟就能定位到是哪一边先出的事。
5. 复盘沉淀:排查工具与避坑清单
5.1 Redis 侧排查命令速查
踩过一次坑之后,我把常用的 Redis 排查命令整理成了一个速查表,贴在了团队文档里:
| 目的 | 命令 | 注意点 |
|---|---|---|
| 看内存与配置 | INFO memory | 关注 used_memory_rss、maxmemory、mem_fragmentation_ratio |
| 看 key 数量与 TTL 概览 | INFO keyspace | 对比 keys 和 expires,差值就是无 TTL key 的近似量 |
| 看错误类型统计 | INFO errorstats | 能直接看到 OOM 类错误计数,Redis 6.2+ |
| 找大 key | redis-cli --bigkeys | 尽量在从节点跑,避免拖慢主实例 |
| 看单个 key 内存占用 | MEMORY USAGE <key> | 定位具体大 value |
| 看慢命令 | SLOWLOG GET | 关注高耗时写入命令 |
| 看热点 key | redis-cli --hotkeys | 需要开启 LFU 淘汰策略,默认不可用 |
| 异步删除大 key | UNLINK <key> | 不要用 DEL,DEL 会阻塞主线程 |
这里特别说一句:MONITOR命令虽然能看到所有实时命令,但生产环境千万别随便开。它会显著降低 Redis 吞吐,曾经有人开着 MONITOR 排查问题,结果自己把 Redis 拖垮了。
5.2 写进作战手册的应对流程
事故复盘如果不落到流程上,下次大概率还会换个姿势再踩一遍。我们把这次事件抽成了一个标准应对流程,写进了运维作战手册:
- 确认影响面:拉出 Redis 内存、网关 5xx、Pod 重启数的关联图,快速判断是单点故障还是级联故障。
- 降流量:让非核心调用方先停手,把 QPS 降到安全水位。
- 看 Redis 指标:优先看
INFO errorstats、evicted_keys、SLOWLOG GET,判断是内存问题还是慢命令问题。 - 定位大 key:用
--scan+MEMORY USAGE找到可疑目标,确认无害后UNLINK。 - 止血调整:按情况调整
maxmemory-policy、关闭不必要的缓存、临时扩容实例规格。 - 观察恢复:错误率回落、延迟恢复正常后才逐步放流量,不要一次全量恢复。
- 复盘归档:写清楚时间线、决策点、依据和遗留改进项。
这个流程看起来简单,但每条背后都有代价。第 2 步“降流量”我们当时执行得不够果断,结果第 5 步怎么调都稳不住。现在我们把“是否降流量”的决策权下放给了值班同学,不需要等 leader 拍板。
5.3 一些常规文档不会写的经验
最后分享几条比较“私人”的经验。
第一条,先处理 Redis,再质问业务方。共享 Redis 里经常躺着别人的 key,内存爆了之后很容易互相甩锅。但无论这个 key 是谁的,第一要务是先让 Redis 恢复写入能力,恢复可用性之后再去清理业务关系和约定边界。生产事故现场不是追责的场合。
第二条,看内存不要只看 used_memory,一定要看 RSS。我们这次 used_memory 才 1.78GB,但 RSS 已经 3.2GB,碎片就让物理内存压力翻了一倍。如果只看逻辑内存,你会觉得“2GB 上限还有 200MB 余量”,但实际上机器早就吃紧了。
第三条,改动 Redis 配置之前必须确认主从同步和配置文件持久化。用CONFIG SET临时改的 maxmemory-policy,如果不写进配置文件,下次重启就回到旧值,等于白改。我当时改完之后特意确认了持久化,才放心继续后面的操作。
第四条,LiteLLM 这类 LLM 网关是相对较新的基础设施,故障模式还没被广泛认知,别指望社区文档把踩坑经验都写全。多花点时间做混沌演练,把 Redis 内存打满、Redis 宕机、连接池打爆这些场景在预发环境提前跑一遍,比事后复盘省钱得多。
踩过这次坑之后,我现在看到 Redis 内存告警的心态完全变了。以前第一反应是扩容,现在第一反应是打开INFO keyspace看还有多少 key 带着 TTL,用MEMORY USAGE抓几个大户,先搞清楚这些内存是“该花的”还是“泄漏的”。扩容能解决一时,但每次故障背后都有一个“为什么会涨成这样”的答案。希望这篇复盘能帮你少踩一个坑。