最近刚把基于 openYuanrong 的生成式推荐缓存高可用验证跑完一轮,回头整理下整个方向的思考过程和实操细节。生成式推荐和传统推荐最大的不同在于,推理链路的单位成本上了一个量级,缓存早已不是"加个 Redis 提速"这么简单,而是直接关系到服务在异常场景下能不能扛住、能不能快速恢复。这个项目我们聚焦的是"缓存高可用方向验证",不是做性能调优,更不是改推荐算法,而是围绕缓存的架构形态、故障注入、降级链路和数据一致性这几个维度,把缓存这块从"能用"推到"扛得住事"。
先说结论:验证做完之后,我们把缓存从单一 Redis 主从结构,改成了本地缓存、分布式缓存、兜底计算三级联动,故障注入场景下的可用性从 92% 拉到了 99.5% 以上,缓存命中率稳定在 91% 左右。下面把设计思路、实操步骤、踩过的坑都展开讲清楚。
1. 为什么生成式推荐场景要把缓存高可用当回事
1.1 生成式推荐与传统推荐的本质差异
传统推荐系统的缓存压力主要集中在物品特征、用户向量、召回列表这些相对固定且可预热的 KV 数据上。特征维度再多,本质上还是"查表",即使缓存全部失效,后端服务也能用离线算好的结果兜底,只是慢一点、老一点。
生成式推荐完全不一样。用户请求进来之后,需要实时组装上下文、拼接用户行为序列、调用生成模型产出候选内容,再经过排序、重排、解释生成等环节才能返回。这条链路里任何一个环节出现高延迟或者不可用,用户端感受到的不是"推荐不够新",而是"一直在转圈"甚至"直接报错"。更麻烦的是,生成式模型的实时推理成本很高,如果缓存不可用导致大量请求同时穿透到推理服务,GPU 资源会被瞬间打满,GA 和成本同时失控。
所以在生成式推荐这个场景里,缓存承担的角色发生了根本变化——它不再只是降低延迟的加速器,而是整个系统在面对流量洪峰和下游故障时的第一道防线。这道防线如果本身不可靠,上游的每一次降级都会演变成雪崩。
1.2 这个验证项目到底验证什么、不验证什么
项目启动的时候我们做了很严格的边界定义,避免把验证做成一锅粥。核心验证三个方向:
第一,验证缓存架构在高压力、多故障叠加的情况下,能不能保证请求仍然有可用的响应途径。第二,验证降级链路是否平滑,缓存失效后系统是优雅降级到计算兜底,还是直接抛错被上游打死。第三,验证故障恢复后缓存能否自愈,一致性怎么保证,有没有出现脏数据长期存活的情况。
明确不做的也有三块:不优化推荐算法本身的精度,不调整生成模型的推理逻辑,不做无上限的性能压测。重点是把缓存基础设施的稳定性与可用性讲清楚,让业务方知道在什么故障级别下推荐服务可以承诺什么级别的可用性。
做这种方向验证最忌讳的就是目标发散。我们项目组有个工程师一开始想顺手把缓存序列化协议也换了,被我拦下来了。验证项目的价值不在于引入多少新技术,而在于对现有架构在高可用维度上的短板做出准确判断,并用数据支撑后续的架构决策。
2. 缓存体系设计与选型背后的思考
2.1 多级缓存架构的搭建逻辑
第一轮架构评审时,我们讨论过两个方案。方案 A 是只依赖 Redis 集群,靠主从切换和哨兵保证高可用,认为客户端缓存命中率很高,加本地缓存收益不明显。方案 B 是走本地缓存、Redis、兜底计算三层结构。最终选了 B,核心原因在于故障隔离的粒度。
Redis 本身高可用做得再完善,节点切换期间依然有不可写或者读延迟增大的窗口,而且网络抖动导致的超时在生成式推荐这种大流量场景里会被放大。方案 A 本质上把缓存高可用完全押在 Redis 一个环节上,这在传统推荐场景可以接受,在生成式推荐场景风险太高。
方案 B 的逻辑是:进程内本地缓存作为第一层,承担最高频的热点 KV 读取,毫秒级响应且不依赖网络;Redis 作为第二层,承接分布式共享缓存和跨实例的一致状态;第三层是兜底计算,当两层缓存都不可用时,直接走轻量化的实时推理路径。三层协作下,任何一层损坏都不至于让请求无路可走。
这个思路和很多团队讨论的"Spring 三级缓存原理"有相似之处,都是通过多级缓存隔离不同生命周期对象,但生成式推荐场景下的三级缓存更强调故障降级顺序,每一级被击穿之后的行为是预设好的,不能在运行时才临时决策。
2.2 Key 设计与治理策略:KV 缓存不只是存数据
缓存治理的第一步从来不是写代码,是设计 Key。我们给生成式推荐缓存定义了统一命名规范,主要包括四类 Key:
- 用户上下文 Key:前缀
ctx:,存储用户的短期行为序列、实时偏好信号,TTL 设定为 30 分钟。 - 会话态 Key:前缀
ss:,存储会话内的多轮交互状态,生成式推荐对会话内连续性要求很高,这类 Key 的 TTL 一般是 1 小时。 - 候选知识库 Key:前缀
kb:,存储可复用的知识片段和召回候选集,这类数据变化频率低,TTL 可以放到 12 小时。 - 业务结果缓存 Key:前缀
rs:,存储最终生成的推荐结果,TTL 控制在 10 分钟内,主要应对短时间热点冲击。
除了命名,我们对 Value 做了严格的大小限制。生成式推荐的结果往往包含介绍文本、推荐理由、候选 ID 列表,序列化之后很容易超过 100KB。大 Key 是 Redis 稳定性的隐形杀手,一个 200KB 的 Key 在主从同步和持久化时都会放大开销,网络抖动时更容易出问题。
实操中我们把超过 50KB 的结果拆成两类:核心结果摘要走 Redis,完整内容走对象存储加 CDN,Redis 里只存摘要和对象地址。这个改造让 Redis 的内存水位下降了 37%,大 Key 扫描结果从 41 个降到了 3 个。
2.3 为什么选 Redis 而不是全部押注本地缓存
本地缓存(如 Caffeine)的读取速度远超 Redis,为什么还要保留第二层?因为本地缓存有一个致命短板:数据一致性只能通过失效广播来处理,一旦服务实例多,广播成本指数级上升,处理不好就是每台机器各持一份过期数据。
在生成式推荐场景里,用户上下文和会话状态是强一致的,同一用户的请求如果被负载均衡分发到不同实例,各实例本地缓存不一致就会导致推荐结果前后矛盾。比如用户上一轮还在聊户外装备,下一轮请求命中另一台机器,结果又推了办公用品,这对生成式推荐是灾难性的体验。
所以我们的策略很明确:允许本地缓存存在极小窗口的不一致,但只限知识库类和结果类数据,可以容忍 30 到 60 秒短暂过期;用户上下文和会话状态必须强一致,直接走 Redis 或者直连存储。这个牺牲换来了缓存命中率和稳定的高可用性。
选型不是非此即彼。把 Redis 完全换成纯本地缓存不现实,把本地缓存去掉又在故障场景下缺少兜底。多级协同、分级容忍不一致,才是生成式推荐缓存架构的正解。
3. 高可用验证实操全流程
3.1 故障注入与压测方案:模拟真实爆炸场景
验证不能只在"理论上应该可用"这个层面打转,要真的把故障扔进系统里观察反应。我们的故障注入计划分了四个场景,每个场景背后对应一类真实风险:
场景一:Redis 主节点宕机,观察哨兵切换期间服务表现。这个场景最容易出问题的是客户端连接池在切换过程中的报错速度。我们在注入脚本里直接杀掉 Redis 主进程,同时保持推荐 QPS 在峰值水平。
场景二:Redis 网络抖动,人为在主机上执行 200ms 延迟注入,模拟机房内链路秒级闪断。这个场景比宕机更隐蔽,Redis 本身没挂,但每次请求都要等超时才会降级,请求堆积速度很快。
场景三:缓存雪崩,把一大批 Key 的过期时间设成同一时间点,验证是否会因为集中失效打垮后端推理服务。
场景四:缓存穿透,直接用不存在的用户 ID 和会话 ID 发起请求,验证空结果有没有被缓存,以及布隆过滤器是否正常拦截。
每个场景压测 30 分钟,观察指标包括:请求成功率、P99 延迟、CPU 水位、后端推理服务排队数、Redis 内存波动、降级触发次数。所有指标采集都在 Prometheus 上实时看板,避免压测结束面对一堆离线日志无从下手。
3.2 降级链路与兜底策略:故障下的服务姿态
故障注入结束后,我们整理出了三条关键降级链路:
第一级降级,Redis 读取超时或者连接池耗尽时,自动转向本地缓存读取。本地缓存命中的情况下,降级对调用方是透明的,只在上行日志打标记。这里必须限制降级线程并发数,避免大量线程同时阻塞在超时上,我建议用信号量控制在 20 以内。
第二级降级,本地缓存未命中,但用户会话态可以从持久化存储恢复时,走"半实时"路径。这一级不再等待分布式缓存锁,直接读主存储的用户上下文,代价是延迟从 5ms 涨到 40ms 左右,但避免了请求失败。
第三级降级,主存储也异常时,启用最保守的策略——基于用户静态画像和热门知识库内容生成推荐,不读实时上下文,只保证用户拿到合理内容,不保证个性化程度。这一级延迟最大,但至少不会给用户"服务不可用"的体验。
三级降级执行的关键在于熔断器状态机的设定。我们用了个简单的三态模型:关闭、打开、半开。连续 20 次缓存操作失败则熔断打开,此后 10 秒内不再请求 Redis,直接走降级路径;10 秒后进入半开状态,放 5% 的流量探测 Redis 是否恢复,恢复则复位,不恢复则重新熔断。这个模型的好处是参数少,便于快速调整,不至于把监控调教本身变成一个大工程。
3.3 数据一致性验证:高可用不能高到数据稀碎
高可用和一致性往往是一对矛盾。缓存高可用验证里最容易翻车的就是故障恢复后的数据不一致。我们的验证方法比较朴素:构造一个带版本号的生成任务,每写一次数据版本递增,缓存里和存储里各有一份版本号,定期对比。
具体到缓存更新策略,我们没有选择删除缓存后写库的方式,而是用了 Cache-Aside 的改良版:更新存储后,把旧缓存标记为待失效,然后通过消息队列异步删除 Key。删除失败会触发重试,重试超过三次则打入死信队列,人工介入清理。
这套方案避免了一个常见坑:直接删除缓存再更新数据库的窗口期问题。如果先删缓存后写库,另一个请求刚好在删缓存和写库之间读数据,就会把旧数据重新塞回缓存,造成永久性脏数据。
实际验证下来,我们引入的版本号机制把最终一致性时间从秒级压缩到毫秒级,靠的不是复杂度,而是 Commit Log 的回放机制。消费端从本地日志里定时回放未确认的更新请求,即便消息队列短暂不可用,缓存也能在分钟级内收敛回正确状态。
3.4 监控指标与阈值设计
验证项目要看数据说话,监控指标不能贪多,要直接反映高可用状态。我重点盯五个指标:
- 缓存命中率(整体命中率,以及热点 Key 命中率分开看)。
- 各级降级触发次数,每秒由缓存降级走向计算托底的请求数。
- Redis 慢查询数,超过 20ms 的操作就是慢操作。
- 连接池使用率,超过 70% 就要关注。
- 缓存穿透量级,空结果缓存写入频率和布隆过滤器的拦截率。
阈值设定方面,我推荐用动态基线而不是拍脑袋固定值。比如说降级触发次数,白天高峰和凌晨低谷天然不同,直接定一个固定阈值会出现白天误报、凌晨漏报的情况。我们用近 7 天同时间段数据的 P95 值作为动态基线,超过基线 1.5 倍直接告警。
这套监控体系落地之后,最大的收获不是"能发现故障了",而是"能证明故障没有发生"。高可用验证的本质是建立信任,信任来自数据,不是来自代码评审时的口头承诺。
4. 常见踩坑与排查实录
4.1 缓存穿透、击穿、雪崩,三个兄弟分开治
这三个问题几乎每个做缓存的人都会碰到,但很多团队混为一谈、一把梭哈加锁。实际上各自的解法重点完全不一样。
缓存穿透,指的是大量查询不存在的 Key,直接打到存储层。我们实验中最快的解法是两板斧:布隆过滤器拦截非法 Key 请求,以及空结果也写缓存,但 TTL 压缩到 30 秒。布隆过滤器要注意误判率,通常设置在 1% 左右,过大等于没拦,过小内存开销又失控。空值缓存一定要设置短 TTL,原因很直接——这个值本身是无效数据,如果 TTL 过长,真实数据来了之后可能还查不到。
缓存击穿,指一个热点 Key 在过期瞬间,大量并发请求同时打到后端。针对生成式推荐的高频 Key,我们不用互斥锁,用分布式信号量做限流+重建,热点 Key 过期时只允许一个请求去重建缓存,其他请求要么等待要么直接走降级路径。对比过"加锁"和"信号量"两种方案,加锁路容易把线程阻塞在 Redis 命令上,信号量方案更可控。
缓存雪崩,通常是因为大量 Key 同一时刻过期,或者缓存节点整体宕机。应对措施分两层:一层是在 Key TTL 上加入随机抖动,基础 TTL 基础上增加 5% 到 15% 的随机偏移,让过期时刻均匀散开;另一层是多级缓存和限流兜底,当 Redis 完全不可用时,本地缓存仍然能扛住一部分热点流量。雪崩场景最容易误判的是把后端推理服务的排队变化误认为是算法问题,其实根源在 Key 集中过期,排查看监控时一定要先过滤缓存的过期事件。
4.2 缓存膨胀、序列化、连接池泄漏,线上治理实录
高可用验证期间我们处理过不少与缓存本身相关的问题。最典型的是缓存 Key 无限膨胀。生成式推荐会话里,用户每轮对话都会产生新的上下文 Key,我们最初没有做会话维度汇总清理,结果 Redis 内存三天涨了 4GB,直接触发内存淘汰策略,开始随机淘汰 Key,导致在线命中率骤降。
解决办法是加了一个轻量级清理任务,每 10 分钟聚合一次会话维度的 Key 过期时间,超过 60 分钟未活跃的会话整体续期检查,逻辑上把冗余 Key 批量删除。这个任务本身很轻,几乎没有 CPU 开销,但带来了 15% 内存回落。
序列化问题也值得单独提。生成的推荐结果里有大段文本,如果统一用 JSON 序列化,GC 压力和高网络带宽消耗都会变成隐患。我们把结构化部分用 Protobuf 序列化,文本摘要单独压缩存储,比全量 JSON 节省了约 62% 的空间,反序列化的 CPU 开销也下降了大约 30%。
连接池泄漏是线上最隐蔽的问题。我们前期使用 Jedis 连接池时,曾因个别业务线程在异常分支里没有归还连接,导致连接池满,大量请求排队。排查这类问题的方法是看连接池活跃数和等待线程数的比值,如果等待线程持续 > 活跃连接,基本就是泄漏。修复后我们在所有 try-with-resources 场景强制封装了归还逻辑,这个问题再没出现过。
4.3 AI 缓存命中与生成式推荐的新挑战
AI 场景下的缓存命中和传统 KV 缓存有些新规律,我们观察到的现象值得分享。
生成式推荐里,不同用户的推荐结果往往差别巨大,但同一用户短时间内的请求却高度相似。有效的缓存策略从"按 Key 精确命中"扩展为"按语义相似度模糊命中"。我们尝试在用户上下文变化不大的情况下,直接用最近一次会话结果做续接,命中率可以额外提升 8 到 10 个百分点。这个方向还在验证阶段,但初步结论是:在生成式推荐的高质量缓存里,语义命中和精确命中同样重要。
另一个问题是缓存失效的粒度。把整个推荐结果做成一个 Key,任何上下文信号变化都会导致全部失效。我们后来做了拆分:推荐候选集单独缓存,用户反馈信号单独缓存,最终聚合时再拼接。这样用户的一次点击只影响反馈信号部分的失效,候选集仍然可以被复用,命中率显著提升。
长会话场景也带来新的隐患。会话等待时间过长时,服务端为节省资源可能会主动清理会话缓存,用户再请求时就会全部落空。我们专门设计了会话心跳机制,活跃用户在会话期间每 5 分钟续一次 TTL,不活跃则自然过期。这个机制上线后,长会话场景下的缓存穿透率下降了近半。
5. 复盘和可复用的建议
5.1 验证流程的方法论沉淀
这套高可用方向验证跑完后,我们把整个流程沉淀成了文件夹:故障注入脚本、监控看板配置、降级策略文档、复盘报告,全部放进项目组的内部知识库。团队后续做其他业务的缓存高可用验证,可以直接拉这套模板改参数,不用从零开始。
如果要用一句话总结这套方法论,那就是:方向验证不是指标炫技,而是要回答清楚"当某个环节坏了,系统整体还能提供什么级别的服务"这个问题。带着问题做故障注入,比漫无目的地压测有意义得多。
5.2 直接可以抄的落地建议
具体执行上,有几个建议值得直接采纳:
故障注入的节奏要由浅入深,先在测试环境注入小抖动脉冲,再逐步加到全故障级别,切忌第一次就炸掉生产环境,那样只能收获事故报告而不是验证报告。先做小实验,让团队建立对降级链路的直观认知再上强度。
每一个降级方案的配置都要做到"可动态调整"。比如熔断阈值后端的算法、超时时间的设定,一定要走配置中心,不能写死在代码里。因为故障场景千变万化,固定值就是定时炸弹。
监控看板要区分"报警"和"可观测"两个层次。报警指标要少而精,每个报警都有明确的止损动作;可观测指标可以多,用于事后复盘。如果把所有指标都设为报警,团队的告警疲劳会让真正重要的消息被淹没。
文档和演练要结合。高可用验证做完后,至少安排一次全员故障演练,验证书写的降级步骤和实际操作是否一致。我们第一次演练时就发现,文档里写的"切换备用配置"在实际系统上需要额外授权,流程完全走不通。这类问题只有靠演练才能暴露,单纯依赖评审发现不了。
5.3 后续可以扩展的方向
这个验证项目只是第一步,后续有两个方向值得深入。一是把语义缓存命中做成更通用的服务,从推荐场景扩展到对话式交互场景;二是引入自适应缓存预热机制,在热点 Key 即将失效前提前重建,进一步压缩击穿窗口。
每个方向的推进都需要新的数据和新的故障场景来验证,缓存高可用这条路没有终点。把这些实践记录下来,也算是对项目组连续几轮高强度复盘的一个交代。希望对正在规划类缓存验证的团队有参考价值,也欢迎大家在实际场景中跑出自己的数据再来交流。