LiteLLM网关与Redis缓存故障复盘:从内存溢出到限流崩溃的排查与治理
大约周五下午 14 点,我正在调整新上线的模型路由策略,同事在 IM 里丢过来一句:“LiteLLM 网关的错误率不对,499 和 504 变多了。”我打开 Grafana,第一眼看到的不是网关本身的 CPU 有多高,而是 Redis 的内存曲线像一条被拉直的钓鱼线——从 8GB 一路快速爬升,离 maxmemory 只剩不到 2GB。那台 Redis 我们用了近一年,一直承载 LiteLLM 代理的响应缓存、限流计数,还兼着其他应用的分布式锁和会话缓存。谁也没想到,它会在一个看似普通的周五下午成为整个事故的起点。
这篇文章是这次生产故障的完整复盘。从告警出现开始,到一步步溯源到根因,再到 Redis 内存治理和后续防复发设计,我会把所有关键细节都写出来。如果你正在用 LiteLLM 统一接入 Bedrock、OpenAI 这类上游,又把它和 Redis 绑在一起当缓存和限流后端,这篇内容应该能帮你少踩几个坑。
1. 故障时间线与第一现场
1.1 周五下午的告警风暴
先把时间线拉出来,方便你对照当时现象一步步理解后面的判断。整个过程大约 55 分钟,从出现异常到完全不可用,节奏非常快。
| 时间 | 现象 | 我们当时的判断 |
|---|---|---|
| 14:05 | 某业务方上线离线批量任务,网关整体 QPS 从 30 冲到 400+ | 正常业务高峰,未触发告警 |
| 14:32 | 网关延迟 p99 从 1.2s 变为 4s+,部分客户端开始收到 429 | 怀疑 LiteLLM 路由配置或上游限流 |
| 14:47 | 499/504 激增,3 个网关副本中有 2 个健康检查失败 | 怀疑网关实例资源不足,准备扩容 |
| 15:00 | Redis 写入开始直接报 OOM,所有走网关的请求大面积失败 | 终于发现共享 Redis 内存已经打满 |
这个顺序很典型:先出现轻微变慢,然后错误率抬升,紧接着健康检查失败,最后底层存储爆掉。但如果当时第一眼就去查 LiteLLM 的配置文件,或者去压测上游供应商的限流,大概率会绕很久。
1.2 影响范围与第一时间行动
这次故障影响的不只是单个业务。我们内部几乎所有需要调用大模型的服务都挂在同一个 LiteLLM 网关上,包括客服摘要、数据分析、内部知识库问答,还有那个刚上线的批量任务。走同一个网关,共享同一个 Redis。
当时现场的行动其实只有三步。第一步,拉了一个三人小群:网关负责人、Redis 负责人、业务方负责人。第二步,由业务方确认批量任务可以暂停,立刻暂停该任务的调度——这起到了止损作用。第三步,我们在群里达成共识:先不碰 Redis,不搞重启,先观察内存曲线是否回落。
为什么强调“先别动 Redis”?因为那台实例上还挂着其他业务的分布式锁和会话缓存,一旦重启 Redis 或者执行FLUSHALL,锁全部丢失,会造成比网关超时更严重的重复调度和脏数据问题。这也是后面我们坚持拆分实例的重要原因。
2. 从网关日志到 Redis:线索的完整链路
2.1 LiteLLM 网关日志里最有价值的三类报错
事故中最容易踩的坑,是一上来就打开全量日志漫无目的地刷。正确做法是按错误类型聚合,先看 error code 的分布,再看每种类型对应的原始日志片段。我们当时过滤后发现真正有价值的报错只有三类:
第一类,Redis 连接异常,来自 LiteLLM 的 cache 层:
cache: ConnectionError: Error while connecting to Redis第二类,限流相关的 429,即使当前流量远没达到网关配置的 RPM 上限:
ratelimit: RateLimit exceeded, token limit: 0 / 100000第三类,上游调用超时:
litellm.proxy: TimeoutError: Request timed out after 120s这三类报错如果单独看,很容易得出三个完全错误的结论:Redis 网络有问题、限流配置配错了、上游供应商响应慢。但放在一起看,有一个共同的交汇点——所有异常都发生在与 Redis 交互的环节。限流计数需要写 Redis,缓存读写需要 Redis,甚至连上游调用超时的根因,也是因为请求在 LiteLLM 内部线程池里排队等 Redis 操作完成,根本没到上游那一步。
2.2 Redis 内存指标与 key 分布给的第一击
确认方向后,我们用redis-cli INFO memory看了一眼,数字非常吓人:
used_memory: 15664209920 # 14.6GB used_memory_rss: 23674880000 # 22GB mem_fragmentation_ratio: 1.51 maxmemory: 16106127360 # 15GBused_memory 已经超过 14.6GB,距离 maxmemory 只剩不到 500MB。更让人警觉的是 mem_fragmentation_ratio 到了 1.51,说明除了实际数据,内存碎片也在占用大量 RSS。
再看 keyspace:
db0: keys=1843201, expires=1081344, avg_ttl=1023s接近 184 万个 key,但只有 108 万个设置了过期时间,这意味着有大量 key 是“永生”的。avg_ttl 只有 17 分钟,说明很多 key 其实已经可以淘汰,但由于过期 key 清除是惰性的,如果没有命中和写入触发,它们会一直躺在内存里占地方。
我们随后用redis-cli --bigkeys扫了一遍,发现字符串类型占据绝对大头,里面混着大量 LiteLLM 的响应缓存 key。最大的 key 超过 2MB。单个 key 本身不算致命,但几十万个 100KB 以上的大 key 叠加在一起,内存涨起来是肉眼可见的。
2.3 上游与业务方流量为什么被排除
在锁定 Redis 之前,我们花了大约 10 分钟排查上游和业务方。过程不复杂,但值得说一下排除的逻辑。
上游 Bedrock 侧,我们通过供应商控制台和网关的 upstream metrics 同时观察,延迟曲线和错误率都很平稳,说明上游没有成为瓶颈。业务方那边,确认批量任务的 QPS 虽然高,但远低于网关设计的承载上限,所以不是流量攻击,也不是网关并发能力不够。网络设备层面也做了丢包检查,确认没有传输问题。
到这一步,范围已经收窄到“网关 + Redis 的联动关系”上。后面复盘时我们发现,实际上还有一个隐藏因素:批量任务的 prompt 内容高度重复,但每次请求的 metadata 不同,导致 LiteLLM 生成的缓存 key 不完全一致,响应缓存几乎没有命中率,却在持续写入新 key。这是典型的“缓存穿透式写入”——不是读不到,而是不断写新值。
3. “级联”是怎么炼成的:三层放大机制复盘
3.1 第一层:缓存写满后限流计数和计量先崩
我们需要先理解 LiteLLM 网关在正常情况下的 Redis 角色。它同时承担了三类数据,全部放在同一个 Redis 里。
第一类是响应缓存,也就是 prompt/response 缓存,value 大,写入频繁。第二类是限流计数,LiteLLM 的 token 限流基于滑动窗口,每次请求都要对 Redis 做 INCR 或窗口记录。第三类是预算计量,记录每个 key 的消耗金额,每笔调用都要写。
当 Redis 达到 maxmemory,并且当前的maxmemory-policy是noeviction时,所有写操作都会被拒绝,直接在客户端报 OOM error。响应缓存写失败时,LiteLLM 有可能降级继续向上游转发请求,但限流计数和预算计量写失败,请求就没办法继续走完正常流程,只能返回 429 或 500。
这里有一个反直觉的点:缓存不可用反而让情况更严重。因为每次请求的缓存读取都失败了,LiteLLM 认为“没缓存”,于是全部穿透到上游供应商。上游的 TPM/RPM 配额本来就在被正常消耗,一下子多出几倍的突发令牌请求,很快引起上游限流错误。于是客户端看到的不再只是网关错误,还有上游的限流错误,两边一起爆发,形成了第一层放大。
3.2 第二层:Redis 慢了,网关线程池就死
LiteLLM 网关本身是 Python 异步框架,所有 Redis 操作都是异步任务。当 Redis 由于 OOM 导致命令处理排队时,每个 Redis 操作的响应时间从微秒级变成秒级,最直接的影响就是事件循环被这些等待占满。
事件循环被占满之后,最怕的事情发生了:健康检查接口也开始响应缓慢。Kubernetes 的 readinessProbe 连续三次失败就会把实例从 Service Endpoint 中摘掉。我们当时有 3 个网关副本,其中 2 个被摘除,所有流量集中到剩下的 1 个副本。
问题在于,剩下的那个副本依赖的还是同一个 Redis 实例。它没有变得更快,反而因为承接全部流量,积压得更严重。健康检查继续失败,负载均衡器开始对探活产生超时,新的请求开始被拒绝。
这一层是整场事故里最危险的部分。很多人以为负载均衡会把流量分散到健康节点,但实际上当所有节点都共享同一个故障存储时,“健康”只是表象。
3.3 第三层:重试风暴与共享实例的连带故障
客户端看到 499 和 504 后,会自动重试。我们事后从日志统计,平均每个失败请求被客户端重试了 2.3 次。每一次重试,都会再次触发 Redis 写入或读取操作,而 Redis 此时已经满内存、高 CPU、命令排队,任何一个操作都在加重它的负担。
更麻烦的是,这台 Redis 上还有其他业务的分布式锁和会话缓存。那些业务并不关心 LiteLLM 出了什么问题,它们只知道自己的 Redis 操作变慢了,于是开始报错、重试、重建锁。Redis 的 CPU 占用进一步升高,内存碎片进一步加剧。整个实例进入了一种“还活着,但每次操作都在超时边缘”的半死状态。
这种半死状态比完全宕机更难受,因为监控图上看起来 CPU 还有余量、网络还在通,让人一次次误判成网络抖动或偶发超时。
3.4 为什么当时“重启 Redis”绝对不行
现场一度有人提议重启 Redis,我们反复拦住了。不是因为重启没用,而是因为重启会造成更严重的次生伤害。
第一,重启后缓存清空,所有瞬时流量都会穿透到上游供应商,TPM/RPM 配额会在几分钟内被打满,上游从正常变成限流,错误率不会降反而会升。第二,分布式锁会全部丢失,依赖锁的定时任务可能同时多节点执行,造成重复写入和脏数据。第三,当时 Redis 的 maxmemory-policy 还是 noeviction,即使重启成功,内存照样会在短时间内再次写满。
真正的止损不是重启,而是先把异常流量源停掉——也就是那个批量任务。任务暂停后,Redis 写入量骤降,内存曲线开始缓慢往下走。到 15:30 左右,内存降到 12GB 以下,LiteLLM 的错误率开始回落。虽然业务还没有完全恢复,但至少系统稳住了。
这之后才进入真正的治理阶段。
4. Redis 内存优化手术:从止血到长期治理
4.1 第一刀:给缓存一个合理的时间边界
首先要改的是 TTL。我们当时的 LiteLLM 配置里,cache_params.ttl设置的是 3600 秒。在低频业务下这没问题,但批量任务这种高并发场景下,1 小时内的存留 key 会快速积累。我们把 TTL 从 3600 调整到 300 秒,观察几天后发现缓存命中率并没有明显下降。
原因是同类请求的重复集中在很短的时间窗口里,通常是几分钟以内,而不是几十分钟以后。TTL 调短后,至少内存不会把“冷数据”留在原地。
同时修改 Redis 的 maxmemory-policy,从noeviction改为volatile-lru。这个策略只淘汰设置了 TTL 的 key,不会动那些不带过期时间的分布式锁和业务持久化 key。这是相对安全的选择:
maxmemory 16gb maxmemory-policy volatile-lru maxmemory-samples 10maxmemory-samples提高到 10,是为了让 LRU 近似算法更准确,避免刚写进去的热点 key 被淘汰。
这里有一个细节:如果直接用allkeys-lru,当内存压力来时,Redis 会优先淘汰空闲的锁 key,导致分布式锁逻辑彻底乱掉。所以我才强调用volatile-lru。
4.2 第二刀:把“公共水库”拆成“专用水池”
共享 Redis 最大的问题不是容量不够,而是劣化会互相传导。一个业务把内存写满,连累所有依赖同一个 Redis 的其他应用。所以长期方案必须拆分。
我们最终把 Redis 分成了两个实例:
- 原实例:保留分布式锁、会话缓存等业务侧数据,容量 8GB。
- 新增实例:专门给 LiteLLM 使用,承担响应缓存、限流计数、预算计量,容量 8GB。
LiteLLM 侧只需要在配置里指定独立的 Redis 地址:
litellm_settings: cache: true cache_params: type: redis host: 10.0.10.12 port: 6379 password: "xxxx" ttl: 300限流和计量相关配置也指向同一个新实例。这样即使 LiteLLM 把新实例写爆,也不会影响业务侧分布式锁。反过来,业务侧出现异常流量,也不会拖垮网关。
拆分后的效果很明显:原实例内存从 14.6GB 降到 4GB 左右,LiteLLM 专用实例在高峰期也稳定在 6GB 以内。
4.3 第三刀:大 Key 治理与缓存瘦身
拆分实例只是解决了耦合问题,LiteLLM 专用实例如果还是同样的使用方式,早晚也会被写满。所以接下来是针对大 key 的治理。
先用redis-cli --bigkeys做第一轮扫描,定位到最大的 key。再用脚本找出 top 100 大 key 的分布规律。我这里提供一个简单的 Python 脚本思路,用 SCAN 配合 MEMORY USAGE:
import redis r = redis.Redis(host="10.0.10.12", port=6379, password="xxxx") results = [] for key in r.scan_iter(match="*", count=5000): size = r.memory_usage(key) if size and size > 1024 * 100: # 大于100KB results.append((key.decode("utf-8", "ignore"), size)) results.sort(key=lambda x: x[1], reverse=True) for k, v in results[:50]: print(v // 1024, "KB", k[:120])扫完以后我们发现,最大的 key 是某几个超长 prompt 的完整调用记录,value 里保存了完整的请求和响应文本。针对这些大 key,我们做了三件事:
第一,对缓存 value 做 gzip 压缩。LiteLLM 的 value 大多是可压缩的 JSON 文本,压缩后平均节省超过 70% 空间。压缩确实会带来一点 CPU 开销,但在资源充足的情况下可以接受。
第二,裁剪缓存字段。只保留代码真正会用的内容,比如 response text 和基础 usage,去掉 annotations、system_fingerprint 这类元数据。这个收益很直接,单条记录大小直接降了一个量级。
第三,超长 prompt 不参与缓存。超过 2KB 的 prompt 生成 cache key 很容易撑爆 Redis,而且这类请求基本不会完全重复,缓存命中率极低。我们在 LiteLLM 前的业务逻辑里加了判断,超长请求直接跳过缓存层。
4.4 LiteLLM 侧降级开关与客户端重试治理
Redis 治理做得再好,也需要回答一个问题:如果 Redis 再次故障,LiteLLM 网关应该怎么办?
LiteLLM 本身支持缓存降级,也就是 cache 后端不可用时,直接透传上游,不让缓存成为请求链路上的强依赖。这个开关需要确认版本和配置项,重点是测试缓存 Redis 彻底不可用时,请求不会跟着抛异常,而是正常转发到上游。
另外,客户端重试策略也必须管住。我们当时的重试风暴让故障多持续了至少 10 分钟。现在内部约定是:
- 503 可以重试,最多 2 次,每次退避至少 500ms。
- 429 不做盲目重试,优先检查配额和限流。
- 499 不重试,因为是客户端主动断开。
网关这一侧,我们还给每个业务方配置了独立的 QPS 上限。单一业务方的异常流量不再可能导致整体资源被打满,这也就是很多人常说的“流量隔离”。当时复盘时我们还对比过 One API 和 LiteLLM 在并发场景下的表现差异,但最终的结论是:网关选型不是根因,共享 Redis 缺乏容错和隔离才是根因。换网关解决不了存储层的脆弱性。
5. 防复发:监控、压测与故障演练
5.1 给 Redis 内存建立“提前量”
Redis 内存监控不能只看当前值,要看增长斜率。内存从 8GB 涨到 14GB 只用了 45 分钟,如果只在达到 90% 时告警,留给处理的时间窗口非常短。
我们现在的告警体系分四级:
| 级别 | 触发条件 | 处理方式 |
|---|---|---|
| P4 | used_memory 超过 maxmemory 的 60% | 观察趋势,不介入 |
| P3 | 超过 75% 且内存增速持续 10 分钟 | 拉群确认流量来源 |
| P2 | 超过 85% | 停止非核心任务,准备摘流量 |
| P1 | 超过 92% | 立即降级缓存,限制写入 |
PromQL 表达式大致是这样:
redis_memory_used_bytes / redis_memory_max_bytes * 100同时监控mem_fragmentation_ratio,超过 1.5 就要主动执行memory purge或安排重启维护窗口,避免内存碎片把 RSS 推到危险区间。
5.2 网关和依赖链路要有自己的监控面
Redis 内存告警只是最后一道防线,前面几道防线在网关侧。
LiteLLM 网关的几个关键指标必须单独画出来:请求排队时间、连接池等待时间、健康检查成功率、上游 TPM 余量。其中最有价值的是请求排队时间,它几乎在 Redis 变慢的同时就会立刻抬升,比内存告警至少早 5 分钟。
我们现在的做法是“三合一”告警:Redis 内存、网关排队时间、错误率三个指标同时异常才触发 P1。单一指标波动不惊动,这极大减少了误报。
5.3 故障演练与团队共识
最后是演练。光有监控还不够,必须验证“Redis 挂了之后 LiteLLM 会不会跟着挂”。我们每个月挑一个低峰期做一次混沌演练,方法很简单:用CONFIG SET maxmemory 2gb临时把实例内存压到极限,观察网关行为。
第一次演练就发现了一个问题:在noeviction策略下,Redis 满内存后,LiteLLM 的限流计数写失败会导致大量请求直接返回 500。后来调整了降级配置并重写了限流异常的兜底逻辑,第二次演练时网关已经能在 Redis 异常时保持转发,只是响应缓存短暂失效。
演练的另一个作用是推动团队共识。现在新服务要接入共享 Redis,必须先提交一份容量评估表,写明预估 QPS、单 key 大小、TTL、增长斜率。不填这张表,代码评审就不放行。这个习惯已经被写进了我们内部的接入规范。
写在最后的一点体会
这次事故最让我印象深刻的不是 Redis 会满,而是“网关加共享 Redis”这种架构里,故障的放大是分层的。先缓存穿透,再线程池阻塞,最后负载均衡补刀,每一层都在让情况更糟。事后我们常开玩笑说,Redis 内存其实没多少钱,但故障之后的开会成本贵得多。
我现在每接一个中间件,第一件事就是查它的默认容错行为和默认 TTL,而不是先跑通功能。网关这类基础组件更是如此。最值得问的问题永远是:如果它依赖的 Redis 挂了,你的应用是跟着挂,还是降级继续跑?希望这篇复盘能帮你把这个问题想清楚,最好是在故障发生之前。