前阵子团队做方向预研,我们接到一个挺有意思的任务:在 openYuanrong 这个开源推荐服务平台上,验证一下生成式推荐场景下缓存高可用方向能不能走通、值不值得投入。听上去就是把“推荐、缓存、高可用”三个词拼在一起,真正动手之后才发现,这三件事撞在一起,产生的组合问题是单看任何一项技术都解决不了的。这篇就当一次完整的验证实践记录,把我们怎么定目标、怎么设计、怎么压测、怎么把缓存真的搞挂又救回来,完整写一遍。
做这类预研最怕的就是“方案很丰满,落地很骨感”。所以这一次我们从一开始就定死了原则:不追求一步到位的完美架构,只看两件事——这套缓存方案在生成式推荐链路里能不能显著降本提效,以及在各种故障场景下能不能扛得住、恢复得了。如果你也在评估类似方向,这篇内容可以省掉你至少两轮试错。
1. 生成式推荐场景下,缓存为什么从“优化项”变成了“生命线”
1.1 生成式推荐到底改变了什么
要理解缓存的作用,先得看清生成式推荐和传统推荐的本质区别。传统推荐系统很像食堂的营养套餐:营养师提前把菜配好,你到窗口直接端走,结果基本稳定。生成式推荐更像私厨现做:你说了忌口、偏好、甚至心情状态,厨师当场开火,端出来的菜每次都不一样。
这个比喻落到工程上,就是几个让缓存工程师头疼的变化。
第一个变化是推荐结果从“稳定的列表”变成了“动态的内容”。用户拿到的不再只是 item_id 排序,而是带摘要、带解释、带个性化调性的文本。同样的商品,不同用户看到的描述文案完全不同,同一个用户在不同上下文里看到的推荐理由也不同。这意味着缓存值的分布被大幅打散。
第二个变化是请求维度变了。传统推荐的 key 通常是 userId 或 userId+itemId,直接查稠密倒排和粗排结果。生成式推荐里,请求里往往带着自然语言描述、用户当前会话上下文、风格参数,这些信息组合起来几乎是无界的。我们内部统计过,如果简单粗暴地把完整请求原文拼成 key,线上一天的 key 数量会比传统推荐多出两个数量级。
第三个变化是热度分布变了。传统推荐有明显的头部流量,少数头部 item 覆盖大量请求。生成式推荐虽然也有热门话题,但请求的表达方式千差万别,导致即便大家问的是同一个事,落到底层 API 的参数也可能完全不同,中间层的缓存几乎打不中。
这三个变化叠加起来,结论就一句话:我们不能照搬传统推荐那套“算好结果直接缓存”的思路,得重新思考缓存怎么设计。
1.2 缓存失效的代价被放大了多少倍
如果只是命中率变低,缓存顶多算“变难用了”,但生成式推荐把缓存失效的代价也抬高了。
首先看时延。我们压测环境里,一次典型的生成式推荐请求,从编排层到召回、再到生成服务完成推理,P99 能到 1200ms 甚至更高。传统推荐同样场景下基本能压在 100ms 以内。缓存如果命中,我们可以把这个时间收回大半;一旦缓存失效,所有请求就全部打到后端的生成服务上,用户体验直接回到“转圈等结果”的状态。
其次看成本。生成式服务的算力成本远不是普通 RPC 能比的。一次推理消耗的 GPU 时间和资源消耗,大约是传统排序服务的几十倍。缓存命中率每下降一个百分点,后端需要支撑的推理并发就会肉眼可见地往上涨。我们当时粗略估算过,如果缓存命中率从 50% 掉到 30%,相同业务压力下,生成服务需要扩容约 40%,这个成本差异已经不是优化层面的话题,而是预算层面的话题。
所以“缓存高可用”在这个场景里,不是缓存的可用性,而是整个推荐链路的生命线。缓存挂了,不只是变慢,而是直接变成高成本雪崩。
1.3 验证目标:不追求完美,先确认方向可不可走
这类预研项目最容易翻车的地方,是没有量化目标就开跑。我们这次一开始就把验证目标写成了几行能打勾的指标。
- 缓存整体命中率:在混合流量模型下不低于 45%,其中语义层命中不低于 20%;
- 请求性能:开启完整两级缓存后,P99 应稳定低于 150ms;
- 故障恢复:单点缓存故障场景下,服务可用率不低于 99.9%,RTO 不超过 30 秒;
- 降级路径:后端生成服务超时或失败时,应能自动降级到简化推荐结果,而不是直接报错。
这些数字不是拍脑袋拍的。混合流量模型里,有相当比例的长尾请求本身就不适合缓存,所以命中率目标定得不算激进。P99 150ms 是根据产品侧“生成式推荐也不能慢过传统推荐太多”的要求反推的。至于 RTO 30 秒,我们参考的是同类推荐平台内部对核心链路的恢复预期。
目标定清楚之后,后续所有方案和取舍都围绕这些指标展开,不再被新想法带跑。
2. 高可用验证方案设计与缓存选型,我们是这样拆的
2.1 总体链路:先把缓存插到正确的位置
openYuanrong 的请求链路大概是这样的:客户端请求先到网关,然后进入推荐编排层,编排层负责召回、排序、重排,最后调用生成服务产出结果。我们没有去改动 openYuanrong 的核心编排逻辑,而是在编排层和生成服务之间插入了一个独立的缓存中间层。
之所以选这个位置,是因为生成结果是整个链路里成本最高、最慢、且可复用性相对较弱的一环。缓存如果放在应用层更前面,拦截的是请求,但对生成服务没有直接保护;缓存如果放在更靠近客户端的 CDN 层,对个性化内容又太粗糙。卡在生成服务前面,正好能把“已算过的结果”挡住,让后端只处理真正的新请求。
我们当时实现了三个分支路径:
- 缓存命中:直接从缓存层返回结果,不触及生成服务;
- 缓存未命中:由缓存服务回源调用生成服务,拿到结果后回填,再返回给上游;
- 降级路径:生成服务超时或异常时,返回提前准备好的简化推荐结果,保证请求不失败。
整体上是一个很典型的两级缓存加降级的组合。这里有个要点:降级路径不是可选项,而是高可用方案里必须的一部分。因为一旦生成服务出问题,缓存层如果还在傻等回源,整个链路照样会被拖死。
2.2 选型解析:为什么是本地缓存加分布式缓存的组合
缓存常被提起的同类方案有不少,比如 MyBatis 缓存、Spring 三级缓存原理,本质上都是多级缓存的思想,我们在业务系统里做的也是同一件事:一级不够,就再加一级,每级解决不同的问题。
我们最终选择的是 L1 本地进程内缓存 + L2 Redis Cluster 分布式缓存。
L1 用进程内缓存组件,比如 Caffeine 这一类的,保存最近被高频访问的结果。这样做有三个理由:一是访问路径最短,内存里取数据是微秒级,比任何网络请求都快;二是当 L2 Redis 发生故障时,L1 还能继续兜底一段时间;三是生成式推荐里确实存在一小部分高热门请求,这些内容放在进程内最划算。
L2 用 Redis Cluster,负责承载全量可缓存结果。它解决的不仅是容量问题,还有多副本一致性。本地缓存只有单机有,换了实例就丢了,但分布式缓存是全局共享的,任何节点都能命中。
为什么不直接用 Redis 扛全部?因为纯 Redis 的每次访问多一次网络开销,在高并发场景下,P99 会明显比本地命中高。为什么不只用本地缓存?因为容量有限,且实例之间互相没有副本,一台机器重启,自己的缓存全没了,冷启动压力会集中到后端。
这个组合很像外卖店:L1 是柜台前的冷藏柜,放最常卖的货;L2 是后厨总仓,放所有备料。冷藏柜里没有了才去总仓拿,总仓也没有才临时采购,也就是回源生成服务。
2.3 缓存键、TTL 与语义缓存:生成式场景最挠头的设计点
缓存设计里,键和 TTL 是最容易“让人想当然”的部分。在生成式推荐里,这两块的坑尤其多。
先说缓存键。我们最开始就踩了“直接把请求原文拼进去”的坑,结果命中率不到 15%。后来改成组合 key:用户 ID 加上下文指纹加风格参数加时间窗口。这里的核心思路是,用户画像和会话表达会变化,但真正决定内容差异的关键维度并没有那么多。我们把高相关维度保留,低相关维度从 key 里剥离,让相似请求能命中。
再说说语义缓存。这个是生成式推荐特有的一种思路:不要求请求完全相同,而是要求请求语义相似。我们给每个生成结果计算了一个 embedding 向量,新请求来了先算请求语义指纹和已有缓存结果的相似度,超过阈值就直接复用。验证下来命中率提升了差不多 7 个百分点,但代价也不小——相似度计算本身有开销,而且 embedding 模型需要额外维护。它更适合“同一个话题不同的问法”这类场景,不适合硬套在所有请求上。
TTL 这块我们做了分层,不能一把尺子量所有数据。
| 请求类型 | TTL 策略 | 设计理由 |
|---|---|---|
| 用户主动刷新、即时会话 | 30 秒 | 时效性要求极高,结果需要快速反映用户最新行为 |
| 常规列表推荐 | 5 分钟 | 平衡新鲜度和命中率,避免结果过度陈旧 |
| 运营活动专属内容 | 不缓存 | 活动结果通常带临时策略,缓存容易引发版本混乱 |
| 长尾冷门请求 | 30 分钟 | 生成频率极低,适当延长有效期提升复用价值 |
TTL 分层的直接好处,除了控制新鲜度,还有一个附加收益:避免所有 key 在同一时间窗内集体过期,从源头降低缓存雪崩的概率。
3. 实操实录:基线压测、故障注入与恢复演练
3.1 环境准备与基线数据
验证环境我们按生产规模的 1/10 左右搭的。核心配置是三台缓存中间服务节点、六节点 Redis Cluster(三主三从)、八台 openYuanrong 业务节点、两台压测机。流量模型按真实线上比例混合:60% 文本推荐请求、25% 内容摘要请求、15% 列表重排请求。压测 QPS 按业务峰值 8000 再上浮 50%,打到 12000。
先跑一遍没有缓存的基线,结果在意料之中:P99 约 1200ms,后端起播 QPS 一直在高位,CPU 和推理资源都顶着警告线走。
然后逐步开启缓存。只开 L2 时,P99 降到 220ms,命中率约 32%。再叠加 L1,P99 稳定在 95ms 上下,整体命中率到了 47%。这个阶段的目标全部达成,但我们很清楚,这只是“顺风局”。真正能测出方案成色的,是把缓存打挂之后的“逆风局”。
3.2 故障注入:别怕,真的把缓存搞挂一次
这一步是整个验证里信息量最大的部分。我们设计了几类典型故障场景,逐个打了一遍。
第一个场景是 Redis 主节点故障。我们直接停掉了其中一个主节点的进程,看会发生什么。结果 Redis Cluster 自身的故障切换机制在十几秒内完成了主从切换,期间部分 key 访问报错。但因为 L1 本地缓存一直在正常工作,真正打到 L2 的请求占比不高,整体服务可用率没有跌破指标。这里验证出一个非常关键的事实:多级缓存结构里,L1 不仅能提性能,更是 L2 故障时的缓冲垫。
第二个场景是网络延迟注入。我们在缓存中间服务里人为给 L2 调用增加了 200ms 延迟,模拟缓存服务“没挂但很慢”的状态。这个场景比直接宕机更容易被忽略,也更阴险。因为我们预先设置了 80ms 的超时阈值,缓存调用一旦超时就直接走降级路径,绕过后端生成服务,结果反而没有出现大面积故障。反而暴露了一个新问题:降级路径触发后,监控里的日志量暴增了十几倍,差点把日志系统打满。这个教训我们后面单说。
第三个场景是大面积缓存过期。我们模拟了 30% 的 key 在同一个时间段集中到期,P99 瞬间冲到 900ms 以上,当时顺手把 TTL 加上了随机抖动,再跑同一场景,P99 回到 180ms。抖动算法就是在原始 TTL 基础上加一个随机扰动区间,比如正负 30%,让过期时间不再整齐划一。
第四个场景是缓存服务自身内存被打满。我们故意用一个无限增长的 key 集合把 L1 进程内存逼到 OOM。这个场景验证了容量上限和淘汰策略的必要性。后来我们给 L1 设置了最大容量和淘汰策略,明显缓解了内存压力,也顺带验证了淘汰策略在压力下的表现。
3.3 恢复演练与指标判定
故障注入不是为了看它挂,而是为了看它怎么恢复。我们把每个场景的恢复动作固化成了流程:先判断故障发生层,再把 L1 切换成只读模式;随后评估是否需要临时扩容后端生成服务,等待 L2 连接恢复;最后做一次热 key 预加载,把故障期间缺失的热数据提前拉回。
每个场景跑完之后,我们都按之前定的指标打一次分。
| 故障场景 | 服务可用率 | RTO 实测 | 命中率影响 | 是否触发降级 | 验证结论 |
|---|---|---|---|---|---|
| Redis 主节点宕机 | 99.96% | 约 15 秒 | 无明显影响 | 部分触发 | 通过 |
| L2 网络延迟 | 99.92% | 约 20 秒 | 短暂下降 | 全面触发 | 通过 |
| 大面积 key 过期 | 99.85% | 约 40 秒 | 明显下降 | 部分触发 | 需优化后复测 |
| L1 内存打满 | 99.10% | 需重启恢复 | 影响严重 | 未配置 | 需加容量治理 |
| 后端生成服务超时 | 99.65% | 约 25 秒 | 不受影响 | 全面触发 | 通过 |
只有真实挂过一遍,你才会发现很多“看起来没问题”的地方实际很脆弱。比如大面积 key 过期那次,如果我们没做随机抖动,雪崩几乎是必然事件,而这个问题在做方案设计时是很容易被忽略的。
4. 验证过程中最让人抓狂的四个缓存坑
4.1 穿透、击穿、雪崩:老问题在新场景里的新表现
这几个概念不新鲜,但生成式推荐让它们的表现形式更隐蔽。
缓存穿透,指的是请求查了一个根本没有的数据,每次都直接打到后端。在传统推荐里,这种情况多发生在恶意刷不存在的商品 ID,比较容易识别。在生成式推荐里,用户的自然语言请求五花八门,很多请求本身就是无聊的长尾问题,后端生成服务会返回空结果或兜底结果。如果不对空结果做缓存,同一类问题会反复打到生成服务。我们的解法是做空值缓存,给空结果设一个很短的 TTL,比如 60 秒,期间同样请求直接返回空。
缓存击穿,指某个热点 key 过期瞬间,大量并发请求同时回源。生成式场景里,一个热点话题可以在几分钟内把一条原本没人访问的请求变成全网热点。我们用的老办法,回源时加互斥锁,同一时间只允许一个请求去生成服务回填数据,其他请求先等一下,等那个请求写完缓存再读。
缓存雪崩,指大量 key 同时过期。解决方案就是前面说的 TTL 随机抖动,再加上多级缓存兜底。这几个方案的组合效果,后来成了我们对外分享时最常用的一张图:左边四个坑,右边四条对策。
| 问题 | 典型症状 | 我们采用的解法 | 验证效果 |
|---|---|---|---|
| 穿透 | 空请求反复打后端 | 空值缓存 + 短 TTL | 穿透流量下降 90% 以上 |
| 击穿 | 热点 key 过期后回源风暴 | 互斥锁 + 单飞 | 回源并发被限制到个位数 |
| 雪崩 | 短时间大批量过期 | TTL 随机抖动 | 峰值 P99 从 900ms 降至 180ms |
| 热点拥塞 | 热点请求打爆单机 | L1 本地缓存优先 | 热点请求不进网络,瞬时命中 |
4.2 生成结果一致性:版本号与异步刷新
缓存高可用不只是“缓存不挂”,还要保证缓存里的内容是对的。生成式推荐里内容时效性强,用户点击了“不感兴趣”之后,缓存里如果还是旧结果,用户会觉得这个推荐“听不懂人话”。
我们给缓存结果设计了版本号机制。推荐编排层会定期推送全局策略版本号和用户特征版本号,缓存服务命中时先比对版本号,再决定是否直接返回。版本不匹配的缓存结果直接视为失效,触发回源。
这套机制在验证中表现得不错,但随之出现一个新问题:版本号频繁变化会导致缓存快速失效,命中率从 47% 掉到 33%。后来我们的解法是异步刷新而不是同步失效。具体做法是:版本号有变化时,不直接清空所有 key,而是把相关 key 放进一个失效队列,由后台任务在几秒内慢慢刷掉。这样既保证了最终一致性,又不会对命中率和后端请求量造成尖峰冲击。
4.3 内存治理与缓存目录、存储介质优化
缓存服务最容易被忽视的问题,就是内存到底吃多少。我们的 L1 刚开始做的时候没有设最大容量,跑了一段时间,一个节点内存直奔 8GB,逼近容器上限。后来给 L1 设置了容量上限,并配置了淘汰策略。对比下来,TinyLFU 类的策略比简单 LRU 更能保留热点流量,命中率在容量不变的情况下提升了约 5%。
另一个容易被忽略的细节是存储介质。我们当时把 L1 的持久化目录和部分磁盘缓存目录落在了 SSD 上,而不是默认的机械盘,冷启动回填速度明显更快。这个经验其实和普通电脑端清理系统缓存是同一个逻辑:缓存目录放在不同的物理介质上,写入和读取的性能差异是很直观的。服务端也一样,容器挂载的磁盘类型会影响缓存组件的冷启动表现。
内存治理这块我们的最终结论是:缓存容量不是越大越好,而是要在“命中收益”和“故障影响范围”之间找平衡。容量越大,故障时丢失的活跃数据越多,恢复时间越长,所以必须有上限、有淘汰、有监控。
5. 验证结论与落地建议
5.1 验证结论:方向成立,但有两个地方要持续打磨
这一轮验证做完,我们给出的结论是:openYuanrong 的生成式推荐场景下,缓存高可用方向整体可行,值得投入资源继续推进。
具体来说,三个核心方向是成立的:
- 两级缓存架构能同时解决性能和故障缓冲两个问题,L1 在 L2 故障时提供的保护远超预期;
- 语义缓存在“同话题不同问法”的场景下收益明确,整体命中率贡献约 7 个百分点;
- 降级路径是真正避免故障变成事故的关键,必须从设计第一天就纳入。
但有两个地方不能视作已经解决。一个是语义缓存的 embedding 相似度计算开销偏大,在高 QPS 下会吃掉不少 CPU,目前只适合在做最热流量上;另一个是版本号失效的粒度还不够细,全量版本号太粗,容易误杀正常缓存,后续需要细化到业务模板级别。
5.2 落地建议:分三步走,别想着一步到位
如果你们团队也要做类似验证,我的建议是分三个阶段推进,每个阶段都有明确的交付物。
第一阶段先上基础盘:L1 加 L2 两级缓存、空值缓存、TTL 抖动、降级路径。这个阶段能解决八成问题,投入也最小,适合做第一版上线。第二阶段加语义缓存和版本号异步刷新,这个阶段开始触及生成式推荐的个性化本质,需要配合模型团队做请求语义指纹的接入。第三阶段做跨地域多活和容量自动伸缩,等业务量真上来了再考虑,提前做容易陷入过度设计。
5.3 影响范围与后续可扩展方向
这套验证的影响范围其实不只在缓存层。它直接决定了几个方面的决策:生成服务需要按什么容量规划扩容、编排层要预留哪些扩展接口、监控体系需要新增哪些指标。我们把命中率、降级触发次数、版本号失效占比都列进了核心看板,因为这组指标能同时反映性能和成本两个维度的健康度。
后续可以扩展的方向,我个人最看好两个。一个是模型侧的 KV Cache 和业务侧缓存配合,大模型推理过程中本身有状态缓存可以复用,和业务缓存结合起来可以进一步降低生成成本。另一个是语义缓存的覆盖率提升,如果把 embedding 模型做蒸馏和量化,推理成本降下来之后,这个方向的收益空间还很大。只有把这些点都补齐,生成式推荐的高可用缓存体系才算真正闭环。
整个验证做下来,我最大的体会是:高可用不是靠模板方案堆出来的,而是靠把故障场景一个个列出来、打一遍、记录一遍养出来的。缓存方案写得再漂亮,不如真的让它挂一次看看会发生什么。最后再给一条实操建议:别把缓存服务当黑盒,日志和监控指标一定要留全。我们这次如果没在故障注入时对比完整日志,根本发现不了降级路径会连带打爆日志系统这类问题。做高可用验证,记录和复盘比方案本身更值钱。