上半年我们把生成式推荐模块接到生产环境,最开始一个月几乎是“裸奔”状态:所有请求直连生成服务,一层缓存都不开。当时不是懒,是怕缓存把推荐内容弄“傻”。但现实打脸很快——生成式推荐和传统推荐不一样,它每次生成都要走模型推理,耗时高、成本高,流量稍一起来,资源账单和延迟曲线都直线飙升,逼得我们不得不认真考虑缓存这件事。而一旦认真开始设计缓存,又发现生成式推荐场景下的缓存根本不是“Redis加个TTL”这么简单,它牵涉到语义近似、一致性和高可用联动降级等一堆问题。这篇文章就是我对“基于 openYuanrong 的生成式推荐缓存高可用方向”做的完整验证记录,包括三级缓存结构设计、四道高可用防线、故障注入过程,以及验证期间踩过的坑。
我在团队里主要负责推荐服务的稳定性与性能优化,这次验证实际做了三周多。文章更适合正在做生成式推荐、或者准备把大模型生成能力放进推荐链路里的同学参考,尤其是那些和我一样,一开始以为“缓存就是KV+过期时间”的人。
1. 为什么选这个方向:生成式推荐里的缓存问题,比想象中更特殊
1.1 从“裸奔”到必须加缓存,只用了三周
接入 openYuanrong 框架后,我们把生成式推荐模块快速跑通,每天承担了大约30%的线上推荐流量。刚开始的架构相当原始——请求进来,经过召回,拼出上下文,直接交给生成服务推理,再返回结果。这个链路简单、干净、效果直观,但代价非常大。
我们用流量录制回放做过一次统计,发现一个扎心的事实:大约45%的请求,来自同一个用户、同一个场景在较短时间窗口内的重复或近似请求。比如用户进入推荐首页后连续下拉刷新,或者在一次会话里反复进入同一个活动页,这些请求的输入上下文高度相似,但每次都会完整地跑一遍生成推理。
生成服务那边,单次推理的平均耗时在850毫秒左右,P99能到2.8秒。一台推理节点的有效吞吐非常有限,大概只有个位数到两位数QPS。也就是说,在没有缓存的情况下,我们每处理一次重复请求,都在花真金白银购买算力,同时还在拖慢整体延迟。三周之后,成本压力直接把我们推到缓存方案的设计桌前。
1.2 生成式推荐缓存和传统推荐缓存差在哪里
我不止一次听人说“推荐场景的缓存很成熟,直接照搬精排缓存那套不就行了”。但这个想法在生成式推荐里是行不通的,两者的差别非常本质。
传统推荐的精排结果往往依赖离线预计算的特征和规则,同一个用户在短时间内的请求特征变化不大,所以缓存命中率可以做得非常高,失效策略也相对简单——定时更新或者用户行为触发即可。
而生成式推荐的结果是由模型在线生成的,输出本身带有不确定性,同一套参数在不同时刻可能产出不同结果。用户的兴趣状态、短期行为、场景上下文都在变化,这意味着:
- 缓存键不能只按“用户ID+场景”这种粗粒度来构造,否则会命中大量过时结果;
- 精确匹配的KV缓存只是基础,真正能提升命中率的是语义近似匹配;
- 缓存失效不能只依赖TTL,用户的即时行为反馈必须能主动作废缓存。
传统推荐里“命中缓存”几乎等于“返回正确答案”,生成式推荐里“命中缓存”只意味着“返回一个仍然合理的答案”。这中间多了几层判断,也是我们这次验证的重点。
1.3 这次验证要回答的三个问题
明确了方向之后,我们没有急着堆功能,而是先把验证目标锁定在三个问题上:
第一,缓存能不能在不明显牺牲推荐效果的前提下,把资源成本打下去?具体来说,我们要看缓存能把生成服务的压力降低到多少,同时用户感受到的内容新鲜度还能不能接受。
第二,当缓存层本身出故障时,系统能不能扛得住?比如Redis宕机、热点key过期、穿透流量打过来、生成服务突然超时,这些场景下推荐接口不能整体挂掉,要能给出降级结果。
第三,引入语义缓存之后,一致性和多样性会不会失控?生成式推荐引以为傲的就是结果动态化、千人千面,如果缓存复用过度,用户连续看到的都是相似内容,那省下的算力就没有意义了。
这三个问题合起来,是我理解的“缓存高可用方向验证”的全部内涵。不做花哨的架构,先把这些问题用数据和故障实验回答清楚。
2. 缓存分级与基础设计:最终定了三级结构
基于 openYuanrong 框架提供的能力,我们把缓存做成了三级结构:进程内热点缓存、Redis参数级缓存、语义缓存。每一级负责一个层面的复用,越往上越靠近请求入口,越往下越需要更严格的判断。
2.1 第一级:进程内热点缓存,挡最碎最急的流量
第一级是进程内热点缓存,选型上直接用了Caffeine。这一层解决的是“同一台机器上,短时间内反复出现相同请求”的场景,比如推荐页下拉刷新时,用户在滑动恢复的瞬间可能会有重复请求打到同一实例。
设计上我们没有搞复杂,核心参数如下:
- 最大容量:50万条,超过后按照LRU淘汰;
- TTL:8秒,短而克制;
- 缓存键:请求参数标准化后的哈希值。
为什么TTL只放8秒?因为用户的最新行为一定要快速透传到推荐结果里。如果本地缓存时间太长,用户刚刚点过“减少此类内容”,下一秒刷新却还是旧结果,这种体验非常撕裂。
这块的收益相当可观——约28%的请求在进程内就命中了,连Redis都不用走。而且这一层天然摊平了Redis层的空窗期,为后面的高可用设计减轻了不少压力。
2.2 第二级:Redis参数级缓存,承载稳定结果的复用
第二级是Redis参数级缓存,承载的是“较短时间内不会改变的结果复用”。这里的缓存键设计是个学问,不能简单用用户ID拼接场景,我们把能想到的维度都纳入了键的构造:
缓存key = "reco:{uid}:{scene}:{ctx_hash}:{biz_ver}:{content_fp}" value = { payload, gen_model_version, content_fingerprint, result_ts }ctx_hash是召回候选和prompt上下文做标准化后计算的哈希;biz_ver是用户业务版本号,比如收藏版本、屏蔽版本;content_fp是生成结果的内容指纹,用于后续内容去重和质量追踪。
TTL我们设置的是300秒加随机离散化,也就是实际过期时间在240到360秒之间浮动。为什么加随机?这里先埋个伏笔,后面第3章讲雪崩防护时会细说。
Redis层还有一个重要的主动失效逻辑:当用户产生收藏、取消、屏蔽、滑走等关键事件时,我们会按用户维度做前缀删除,让缓存立刻失效。经验是:事件维度要拆细,不要所有行为都触发全局版本号递增,否则任何一个轻微操作都会导致大量缓存miss。
2.3 第三级:语义缓存,回应用户的“相似意图”
第三级是所有设计里最有生成式推荐特色的一层:语义缓存。
原理不复杂。我们把用户的请求上下文和召回结果向量化,然后在一组已经缓存的结果里做近邻检索,当当前请求与某条缓存记录的向量距离低于阈值时,直接把那条生成结果复用来返回。
为什么需要这一层?因为生成式推荐里真正完全等价的并发请求其实比例有限,但语义相似的请求非常多。用户连续下拉刷新的过程中,前后几次请求的意图边界经常是模糊的——前一条想看美食,下一条还在美食边缘试探,这种细微差异对模型来说可能敏感,但对用户来说,返回相似但不完全相同的候选集合,体验上几乎无差别。
语义缓存用Faiss做向量检索索引,阈值经过多轮调优,最终定在相似度0.92以上才会命中。同时在每条缓存记录上维护一个引用计数,单条缓存结果最多复用8次,第9次起强制重新生成,避免内容过度重复。
值得一提的反而是 openYuanrong 框架在这里的配合。它内部有一套标准化的请求上下文管线,召回候选、prompt模板、随机种子、采样温度这些作为上下文对象在中间件中流转,这让我们得以把“上下文归一化”做在框架层——生成时保留随机性,但缓存键只取标准化后的语义部分,这样语义缓存才能稳定工作。
2.4 一致性策略:版本号加主动失效,兜底还是TTL
三级缓存都具备之后,紧接着要解决一致性问题,否则前面省下的算力会在用户投诉里赔光。
我们的策略分两层。第一层是主动失效,刚才提到的业务版本号机制会把用户的关键行为映射到缓存key的变化上,行为发生,key就变,结果必然重新生成。第二层是TTL兜底,即使没有任何行为事件,缓存也会在几百秒后自然过期,保证最终回报到当前模型状态。
其实一致性在生成式推荐里有一点“灰色地带”:传统推荐如果缓存和最新状态不一致,通常就是bug;生成式推荐因为模型结果本身是概率输出,我们判断一致性的标准是“结果是否仍然合理”,而不是“结果是否严格最新”。基于这个原则,我们把TTL作为兜底,而不是作为主要失效手段——事实证明这个取舍方向是对的。
3. 高可用防护设计:四条防线,一条都不能少
缓存只是性能手段的说法,在生成式推荐里完全不够用。缓存一旦挂掉,等于把全部流量瞬间导给后端生成服务,那比没有缓存更可怕。所以我们把高可用拆成四条防线:穿透、击穿、雪崩、过载联动保护。
3.1 防线一:穿透防护,布隆过滤器不是终点
缓存穿透是最常见的高危场景:大量请求的key在缓存和数据库里都不存在,每个请求都会一路打穿到生成服务。生成式推荐里最容易出现这种情况的是伪造用户ID、已删除的Item ID、以及恶意刷量的长尾请求。
布隆过滤器是第一道闸门,我们在入口处维护合法用户ID和合法ItemID的集合,请求的ID根本不存在时,直接在布隆过滤器就拦截掉了。但布隆过滤器不是终点,它有误判率,而且对“存在但当前无结果”的请求无能为力。因此我们在Redis里对负结果也做了短TTL缓存,时间定为30秒,让一段时间内重复的穿透请求直接命中空结果。
这里有一个实操细节:布隆过滤器务必按ID类型分别建,不要把所有ID塞进一个大集合。不同ID空间的长度和分布差异很大,混在一起会显著增加误判率,还可能误杀正常用户。
3.2 防线二:击穿防护,互斥锁还是逻辑过期
缓存击穿指的是热点key在过期的一瞬间,大规模并发请求同时发现缓存没有,然后全部冲向后端生成服务。在生成式推荐里这个问题被放大了——后端生成一次要800多毫秒,并发一多,推理集群直接被打满。
最直观的解法是互斥锁:只有第一个请求去触发生成,其他请求在锁上等待,等缓存刷新后再读。听起来合理,但我们实测之后发现,在生成耗时这么高的场景下,等待线程会大量堆积,反而造成下游更大的压力,甚至把生成服务的队列打爆。
最终我们采用了“逻辑过期+后台刷新”的方案:
- 缓存记录不物理过期,但记录上一次刷新的时间戳;
- 允许返回的最老时间窗口是TTL的1.2倍;
- 后台预热任务负责在TTL到期前5秒触发刷新;
- 如果后台刷新失败,接口仍返回旧值并打上stale标记,同时触发告警。
这个方案的核心思想是“宁可旧,不可挂”。在线推理场景下,返回一个几秒前的合理结果,远比请求超时或全面崩溃要好。
3.3 防线三:雪崩防护,TTL离散化与多级叠浪
缓存雪崩和击穿的区别在于:击穿是单个热点key,雪崩是大批量key同时过期。生成式推荐里这个问题有一个隐蔽来源——同一场景、同一批用户的热门请求,几乎在同一时间被写入缓存,如果TTL设得一样,那它们也会在同一时间全部过期。
我们做了两件事。第一,所有TTL都做随机离散化,在基础值上浮动±20%,让过期时间点自然错开。第二,对热门场景做定时扫描,在批量过期前30秒对预计到期的key做预热刷新,相当于把雪崩提前消解在后台任务里。
加上第一级进程内缓存的存在,实际雪崩影响被进一步摊平:即使Redis层有一批key同时过期,每台机器上的本地缓存仍能扛住一部分流量,给后台刷新争取时间。多级缓存叠加本身就是一道天然的缓冲。
3.4 防线四:生成侧过载保护,缓存层要做“可退缩的盾”
最后一道防线不是保护缓存本身,而是保护后端生成服务。缓存层在生成服务不可用的时候,不仅不能继续放流量进去,还要把自己降级成一个“保守结果提供者”。
我们设计了一个简单的状态机:
- Full:缓存和生成服务都正常,正常工作;
- Reduced:生成服务错误率或P99超过阈值后切换,缓存只读不更新,所有写缓存行为暂停;
- CacheOnly:生成服务被判定为不可用时切换,关闭生成调用,所有请求直接返回缓存中的stale结果,没有缓存则返回降级文案。
状态切换不能太灵敏,否则容易在故障边缘反复抖动。我们给状态切换加了冷却期——从Full到Reduced至少要观察10秒,反向恢复也要连续3次探活成功才允许切回。
这套防线在后面的故障注入里经受住了考验,也是我认为整个项目中最有价值的设计之一。
4. 验证过程全记录:基线、压测与故障注入
4.1 先有基线,才有结论
任何高可用验证都要从基线开始,不然压测数据没有参考意义。
我们的验证环境是这样搭的:openYuanrong 部署3台生成推理节点,缓存集群一组,压测机采用线上流量录制回放,最大程度还原真实请求分布。整个链路预先把三级缓存全部关掉,跑出了一个“裸奔基线”:
- 平均延迟:950ms;
- P99延迟:2.8s;
- 吞吐上限:约260 QPS;
- 生成服务CPU在压测期间稳定打满。
这个基线数据让我们心里有数:如果缓存方案有效,吞吐至少要翻几倍;如果高可用调度生效,故障期间P99不能突破1.5秒。
随后打开三级缓存,同样流量再跑一轮,数据是:
- 一级进程内命中率:28%;
- 二级Redis命中率:17%;
- 三级语义缓存命中率:12%;
- 整体P99降到470ms;
- 吞吐能力提高到1700 QPS以上。
对照基线,结论很直观:缓存方向完全正确,资源成本可以打掉一大截。
4.2 故障注入清单与验证结果
光看性能提升还不够,高可用方向必须用故障来检验。我们设计了一张故障注入清单,每个场景跑三遍,取稳定结果:
| 故障场景 | 注入方式 | 预期行为 | 验证结果 |
|---|---|---|---|
| 缓存主节点宕机 | 直接kill Redis主进程 | 客户端fail-fast,请求绕行生成服务,接口不挂 | 通过,RTO从分钟级降到秒级 |
| Redis网络抖动 | 用tc工具注入100ms延迟 | 缓存客户端快速失败,不走重试拖死线程 | 通过,P99峰值控制在1.1s |
| 生成服务模拟超时 | 在推理节点注入3000ms延迟 | 状态机切到Reduced/CacheOnly,返回stale结果 | 通过,成功率保持在99.9% |
| 伪造穿透流量 | 压测机额外发送5000个不存在用户ID | 布隆过滤+负结果缓存兜底 | 通过,生成服务负载无明显抬升 |
| 热点key集中过期 | 构造同一场景1万条同TTL记录 | 离散化和后台预热生效,无雪崩 | 通过,缓存TPS曲线平稳 |
其中Redis网络抖动那个场景最刺激。第一次跑的时候,我们发现虽然Redis不可用,但客户端竟然在无限重试,导致线程池耗尽,整个推荐接口都跟着挂了。这是我们后来在第5章要讲的第一个大坑,在这里先埋个引子。
4.3 命中率不是越高越好
验证走到后面,我们意识到一个和直觉相反的结论:缓存命中率不是越高越好。
语义缓存尤其明显。它的命中率在压测中一路走高,从12%涨到接近30%,但与此同时,内容去重率开始恶化——同一用户连续几次请求拿到的结果几乎一样,推荐列表完全失去了探索感。用户反馈数据虽然没有断崖式下跌,但点击率出现了可观测的下降趋势。
我们后来加了去重预警指标:单个用户在一个会话内,语义缓存命中次数占总请求次数的比例超过8%,就要触发告警并临时调低语义缓存的引用次数上限。经过调优,最终把语义命中率控制在12%左右,内容重复出问题的比例压到了0.5%以下。
这个经历告诉我们:在生成式推荐的缓存设计里,效果约束和性能优化必须放在同一个天平上称。
5. 验证期间踩过的坑与排查链路
5.1 语义缓存阈值过低:命中越多效果越差
第一个坑出现在语义缓存的阈值调整上。初期为了追求高命中率,我们把相似度阈值从0.92放宽到了0.85,结果命中率确实上去了,但是很快收到测试反馈:推荐内容“跑偏”了。
最典型的一个案例是:用户A刚屏蔽了某类内容,但下一个语义相近但意图相反的请求仍然命中了旧缓存,把用户明确不想要的内容又推了回去。排查链路是这样的:
先看命中分布,发现所有异常case都集中在语义缓存;接着抽样打印每条命中记录的相似度得分,确认大量命中发生在0.88到0.92这个区间;再对比用户行为事件,发现被误命中的记录都没有参与用户屏蔽版本号的变化。
根因很清楚:语义相似度只衡量了上下文向量的距离,没有考虑用户自身的排斥信号。修复方式是在语义缓存命中后加一道轻量校验层——只做粗粒度标签的一致性判断,比如内容分类与用户近期屏蔽分类是否冲突,冲突就拒绝命中。轻量分类器的开销只有几毫秒,却拦住了绝大多数误命中。
5.2 本地缓存与Redis缓存叠加后,内容变“旧”了
第二个坑是两级缓存叠加导致的“旧内容滞留”。现象是:用户主动点了“减少此类内容”之后,下一次刷新仍然看到了旧结果,而且持续了十几秒。
第一反应是Redis主动失效没生效,结果查日志发现Redis的失效逻辑是对的,问题出在第一级进程内缓存。用户行为事件虽然出发了Redis前缀删除,但本地Caffeine缓存没有收到任何通知,于是8秒TTL内,用户拿到的还是旧结果。
修复方式是把业务版本号加入到本地缓存的key中,同时在框架中间件里监听用户行为事件,由事件驱动本机缓存失效。这里有一个现实约束:本机缓存删除只能删当前实例的条目,集群里其他实例的条目还是要靠TTL兜底,所以本地TTL最开始的8秒设计是合理的选择,不能为了追求绝对一致而把本地缓存改成极短TTL,那样第一级的价值就没了。
5.3 缓存读取超时把整个链路拖死
这个坑是故障注入时最严重的一次事故。
Redis网络抖动场景刚开始,我们观察到的现象不是延迟升高,而是整个推荐接口在30秒内全军覆没,成功率直接掉到30%以下,比没有缓存时还惨。
查线程池快照发现,所有请求线程都阻塞在Redis读取上——客户端设置了超时重试,每次失败重试3次,而每次重试又要等待网络超时,一条请求光在缓存读取上就耗费了几秒钟。后端生成服务并没有被打挂,是缓存客户端把整个线程池拖垮了。
根因不复杂:我们把缓存当成了“可靠组件”来对待,却忘了缓存本身也需要自我保护。修复就三句话:缓存客户端连接超时设为50ms,读超时设为100ms,不做重试。缓存读失败直接视为缓存缺失,走无缓存路径,同时上报降级指标。这是整个验证里最值钱的经验之一:缓存链路上的每一个环节,都必须以快速失败为默认行为。
5.4 降级状态机的恢复逻辑缺失
最后一个坑出现在故障恢复阶段。在一次生成服务超时演练中,状态机正确切到了CacheOnly,系统稳定返回stale结果,看起来一切正常。但等生成服务恢复正常后,系统却没有立刻回到Full状态,仍然在CacheOnly模式下运行了好几分钟。
根因是状态机的恢复条件没有设计完整:进入降级的条件写得很细致,但恢复逻辑只判断了“生成服务探活成功”,没有做全链路回归验证。于是探活成功了,缓存层依然认为自己还在降级模式。
修复方式是在恢复路径上增加了连续三次探活、一次真实生成调用检验、再经过10秒冷却期,才能切回Full。这个细节让整个状态机从“只能降下去”变成了“能降能升”,高可用的能力才算闭环。
6. 方向验证结论与后续可做的事
6.1 三个结论
三周验证跑下来,我们确认了几件事。第一,生成式推荐的缓存高可用方向完全可行,三级缓存结构配合四道防线,可以把P99从2.8s降到470ms,吞吐提升6倍以上,同时故障注入下接口成功率能维持在99.9%以上。
第二,语义缓存是生成式推荐区别于传统推荐的关键增益,但使用门槛比想象的高,它必须配合结果去重预警和意图一致性校验,否则会带来体验反噬。
第三,高可用不是单点设计,而是一条联动链路。从布隆过滤器到TTL离散化,从逻辑过期到状态机降级,每一层都依赖其他层次配合。尤其不要忽略缓存客户端自身的快速失败——这是代价最低、收益最高的一道防线。
6.2 后续可以扩展的三个方向
验证结束不代表这块可以放着不管,我们已经在计划三个后续方向。
第一个是语义缓存的个性化预算。基于用户维度设置每日允许的语义缓存命中配额,让养成系用户和重度刷用户拿到不同比例的新鲜度,而不是一刀切地限制引用次数。
第二个是缓存一致性的版本号广播。目前行为事件只走本机失效,后续可以走消息总线做集群级主动失效,配合本地缓存的短TTL,把一致性问题几乎消灭在源头。
第三个是基于生成代价的缓存资源优先级。给高成本的推断请求分配更多缓存预算,低成本的请求则降低缓存优先级,把有限的缓存空间用在算力节省最明显的那部分流量上。
如果让我用一句话概括这次验证最大的收获,我会说:生成式推荐里的缓存从来不是一个性能优化附属品,而是一套必须和模型服务共同设计的高可用基础设施。它真正的难点不在缓存本身,而在于“什么时候该信任缓存、什么时候该果断放弃缓存”——这个边界,需要用故障演练去反复摸出来,而不是靠拍脑袋设计出来。