压测卡在 400 QPS 上不去
知识库问答系统三月中旬做上线前压测,目标 800 QPS,结果压到 400 就开始大量超时。看 Grafana 面板,网关 CPU 才 40%,数据库连接池很闲,但整体 P99 已经飙到 3.2s。
拿 async-profiler 抓了 60 秒火焰图,TIME_WAIT 和 socket read 占了大头,顺着调用栈往下追,发现问题在 embedding 服务:每次检索都要把用户 query 送去向量化,这个调用内网往返 30~50ms,而我们的 embedding 服务只部署了 2 个实例,被打满后开始排队。
先拆解一次检索的耗时构成
我加了一层埋点,把一次知识库问答拆成几段看(压测 400 并发,采样 1 万次):
| 阶段 | 平均耗时 | 占比 | 可缓存 |
|---|---|---|---|
| query 向量化(embedding) | 48ms | 11% | 是 |
| 向量库检索 | 62ms | 14% | 是 |
| Rerank 重排 | 95ms | 22% | 部分 |
| LLM 生成 | 215ms | 50% | 部分 |
| 其他(鉴权、日志) | 13ms | 3% | 否 |
embedding 单看占比不高,但它是同步阻塞且 QPS 完全等于请求量,是压垮 embedding 服务的直接原因。而且这里有个隐藏问题:用户 query 的重复度非常高。我统计了生产上历史一周的 87 万条 query,去重后只有 19 万条,重复率 78%。客服场景尤其明显,同一个问题不同用户问法几乎一模一样。
第一层:Embedding 缓存
这个最简单,key 是规范化后文本的哈希,value 是 float 数组。用 Redis 存的话要注意序列化——vector 是 768 维 float,JSON 存一份要 30KB 以上,序列化开销比计算还大。我们改成了直接存二进制:
@Service
public class CachedEmbeddingModel implements EmbeddingModel {
private final EmbeddingModel delegate;
private final RedisTemplate<String, byte[]> redis;
private final Cache<String, float[]> local; // Caffeine 二级缓存
@Override
public float[] embed(String text) {
String key = "emb:" + DigestUtils.sha256Hex(normalize(text));
float[] hit = local.getIfPresent(key);
if (hit != null) return hit;
byte[] cached = redis.opsForValue().get(key);
if (cached != null) {
float[] vec = toFloats(cached);
local.put(key, vec);
return vec;
}
float[] vec = delegate.embed(text);
redis.opsForValue().set(key, toBytes(vec), Duration.ofDays(7));
local.put(key, vec);
return vec;
}
private String normalize(String s) {
return s.trim()
.replaceAll("\\s+", " ")
.toLowerCase(); // 中文不受影响,英文 query 有用
}
}
几个细节:
- 本地二级缓存必须有。768 维 float 是 3KB,纯走 Redis 每次反序列化 0.4ms 左右,加上网络 0.3ms,攒起来也不少。加了 Caffeine 本地缓存(上限 5 万条,约 150MB)后,热 query 直接内存返回,耗时 0.02ms;
- key 里带模型版本号。我们中途换过一次 embedding 模型,老缓存没清,结果新旧向量混在一起,检索结果变得莫名其妙,召回率从 0.83 掉到 0.51。现在 key 格式是
emb:v2:{hash},换模型改版本号,老数据自然过期; - TTL 设了 7 天。embedding 本身不会变(除非换模型),理论上可以永久缓存,但内存有限,7 天是被 LRU 淘汰之外的兜底。
上线后 embedding 服务 QPS 从 400 降到 86,P99 从 48ms 降到 3.1ms(命中本地缓存的情况)。压测轻松上到 1100 QPS。
第二层:检索结果缓存
光缓存 embedding 还不够,向量检索本身 62ms 也很可观。于是加第二层:直接缓存"query → 召回的文档 ID 列表"。
这层的难点不在缓存,在失效。知识库是持续更新的,文档一改,相关的检索结果就变了,缓存必须失效。但我们没法知道"某个 query 的结果是否受这次文档更新影响"——这是个本质困难。
最先想到的方案是文档更新时清空所有检索缓存,粗暴但正确。问题是我们的知识库每天有 3000 多次文档变更,等于缓存永远处于清空状态,等于没缓存。
后来改成知识库版本号作为 key 的一部分:
String key = "retr:" + kbId + ":" + kbVersion + ":" + queryHash;
kbVersion 每次知识库有文档变更就自增,存在 Redis 里。这样:
- 知识库没更新时,检索结果可以一直命中;
- 更新后版本号变了,旧 key 自然访问不到(不用主动删除,避免删 key 的瞬时压力);
- 旧版本 key 靠 TTL 自然淘汰。
我们还做了个优化:版本号按知识库粒度而非全库粒度。我们有 40 多个知识库(客服知识库、产品手册、内部 Wiki……),它们互相独立。一开始版本号是全局的,任何一个库更新都会让所有库的缓存失效。改成每库一个版本号之后,命中率从 34% 涨到 61%。
还有一类特殊情况值得单独说:某些知识库更新极其频繁(比如实时库存相关的,每分钟变),版本号自增太快,缓存等于失效。我们对这类库做了版本号降级——按时间窗口取整,比如每 5 分钟才自增一次,用一点点数据新鲜度换缓存命中率。这个取舍要跟业务确认,我们是在页面上标注了"数据延迟不超过 5 分钟"。
第三层:LLM 结果缓存要谨慎
完整问答结果的缓存我们也试了,命中率 22%,收益一般,而且风险更高:
- 同一个问题在不同时间问,答案可能应该不同(比如"今天有什么优惠");
- 不同用户权限不同,能看到的知识库切片不同,缓存 key 必须包含权限维度;
- 缓存了错误的答案,会持续错很久。
我们最后只在一个场景开了这层缓存:FAQ 类的固定问答——运营明确维护了标准答案的问题列表,这些走缓存,命中率 89%,且运营修改标准答案时会主动触发缓存清除。其他场景一律不缓存 LLM 结果。
整体效果与踩过的坑
三层缓存上完,压测和线上的对比:
| 指标 | 缓存前 | 缓存后 |
|---|---|---|
| 支持 QPS(P99 < 500ms) | 400 | 1500 |
| 整体 P99 | 3200ms | 410ms |
| embedding 服务 QPS | 400 | 86 |
| 向量库 QPS | 400 | 156 |
| 单请求成本 | ¥0.0084 | ¥0.0031 |
踩的坑里有个值得一提的:缓存雪崩。第一版上线时所有 embedding 缓存的 TTL 都是固定 7 天,上线第七天的凌晨集中过期,Redis 压力瞬间翻倍,embedding 服务被打到超时。后来给 TTL 加了随机抖动(7 天 ± 12 小时),问题解决。
还有一个是本地缓存的一致性问题。我们有 8 个应用实例,每个实例有自己的 Caffeine 缓存。知识库更新时,Redis 里的版本号变了,但本地缓存里可能还有旧数据。我们的处理是本地缓存 TTL 只设 10 分钟,接受最多 10 分钟的数据延迟,这个延迟对知识库场景可以接受(真正的实时数据我们没走这套)。
留个问题
关于《向量数据的缓存策略与失效设计》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。