当向量检索延迟 >500ms 时,如何采用 LRU 缓存优化?
解读
在国内真实业务场景里,向量检索(如 Milvus、Proxima、VikingDB 等)单次 P99 延迟一旦超过 500ms,就会直接拖垮大模型整体链路,导致用户体验“卡顿”甚至触发 SLA 违约。面试官问“LRU 缓存优化”,并不是让你背一段链表+哈希的代码,而是考察你能否在 LLMOps 视角 下,把“缓存”做成可灰度、可观测、可回滚的在线系统组件,同时兼顾数据一致性与成本。因此,回答必须围绕“缓存什么、怎么缓存、缓存多大、缓存多久、缓存错了怎么办”五个维度展开,并给出可落地的中国互联网公司级别方案。
知识点
- 向量检索延迟根因:HNSW 图索引层数深、Segment 未加载到内存、磁盘 IOPS 被打满、网卡小包重传、K 值过大、过滤表达式复杂。
- LRU 本质:O(1) 读写的定长双端队列+哈希表,淘汰最久未使用,命中率服从 Zipf 分布。
- 缓存 Key 设计:
- 文本 Query → 归一化+SHA256(64 字节定长,避免空格大小写干扰)
- 过滤条件 → JSON 排序后序列化(保证语义等价)
- Top-K 与阈值 → 直接拼接到 Key 后缀,防止“K=10 缓存命中却返回 K=5”的错包。
- Value 设计:
- 只存 (id, score) 列表,不存原始向量,内存占用 < 原数据 5%
- Score 采用 FP16 压缩,节省 50% 内存
- 并发模型:
- 读路径:无锁 RwLock + Arc<Pointer>,Copy-on-Write 更新,保证 P99 读延迟 <1ms
- 写路径:单线程异步队列批量合并,避免 Cache-Stampede
- 容量与 TTL:
- 线上 8C32G 容器,给 LRU 分配 4G,约可缓存 800 万条 Top-20 结果
- TTL 分层:热门 Query 6h、普通 1h、长尾 10min,基于访问频次动态调整
- 一致性策略:
- 异步刷新:监听 MQ 的“向量库版本号”事件,版本号变化即失效对应分区缓存
- 兜底回源:缓存未命中并发限制 10 个协程,防止雪崩
- 观测指标:
- 命中率、延迟降低比例、缓存污染率(错误命中/总命中)、内存利用率
- 通过 Prometheus + Grafana 大盘实时告警,命中率低于 65% 即自动扩容副本
答案
线上实战我采用三级缓存架构:
- 本地 LRU:嵌入在 Golang 服务进程内,容量 4G,Key 为“归一化 Query+过滤条件+K 值”的 SHA256,Value 为 FP16 压缩后的 (id,score) 列表;读路径无锁,写路径批量合并,P99 读延迟 0.8ms。
- Redis 共享缓存:当本地 LRU 未命中,查询 Redis Cluster(单分片 16 线程,管道批处理),RTT 额外增加 3ms,命中率再提升 18%。
- 向量库回源:Redis 也未命中才访问 Milvus 2.3,开启 GPU 索引并调大
search_resources到 2 卡,把原来 600ms 的 P99 降到 120ms;同时用单飞限流(令牌桶 200 QPS)防止打爆后端。
灰度上线两周后,整体命中率 72%,平均检索延迟从 520ms 降到 95ms,GPU 使用量减少 35%,用户侧首 token 时间缩短 220ms,完全满足 SLA。若后续业务扩容,只需水平扩展无状态 Pod,本地 LRU 随容器一起扩,无需改动任何代码。
拓展思考
- 缓存穿透攻击:国内黑产常用随机字符串刷接口,导致 LRU 失效。可引入 BloomFilter 前置拦截 + 空结果缓存 1min,并把异常 Query 写入 WAF 规则库实时封禁。
- 多租户隔离:ToB 场景下,A 租户缓存不能污染 B 租户。可在 Key 前加 tenant_id 盐值,并给每个租户设置独立内存配额,超出后触发 LRU 强制回收,防止“大租户”挤占资源。
- 冷热分层存储:对于超过 7 天的长尾 Query,可把本地 LRU 淘汰数据异步转存到 SSD 盘上的 RocksDB,实现二级扩展缓存,内存+磁盘混合命中率提升到 85%,成本仅增加 5%。
- 与模型微调联动:若业务方频繁问“公司最新财报”,缓存命中率虽高,但答案可能过期。可结合日志挖掘把高频 Query 自动加入指令微调数据集,让模型直接生成答案,绕过向量检索,实现缓存与模型自我进化闭环。