AI 时代的缓存架构设计
你真的会用缓存吗?
多级缓存 L1+L2、语义缓存去重、Cache-Aside vs Write-Through 选型——这道高频场景题,99% 的候选人只答了个壳
LLM 让缓存重新成为核心命题
在传统 Web 场景,缓存的目标很简单:降低数据库压力、加速响应。但进入 AI 工程化时代,缓存面临三个全新挑战:
- LLM 调用成本高昂:GPT-4o 一次对话 0.01 美元起步,相同语义的问题被反复调用,直接烧钱。
- Embedding 计算昂贵:每次向量化一段文档需要调用 Embedding API,批量场景下 I/O 和费用都不可忽视。
- 生成内容不是精确值:传统缓存基于 key 完全匹配,但用户问"茅台今天涨了多少"和"今天茅台股价如何"是同一个语义——必须用向量相似度做模糊命中。
面试官怎么问?
我们的 AI 客服系统日均 500 万次问答,LLM 调用成本每月 20 万元。
你来设计一套缓存方案:要求既能命中语义相同的问题,也能保证数据一致性,同时缓存层本身不能成为单点瓶颈。
追问 1:缓存击穿、缓存穿透、缓存雪崩,在 AI 推理场景下有什么特殊表现?你会怎么处理?
追问 2:Write-Through 和 Cache-Aside 在 LLM 结果缓存里哪个更合适?为什么?
考察维度
三层缓存架构:L1 → L2 → L3(语义层)
↓
┌─────────────────────────────────────────────────────┐
│ L1 本地缓存 Caffeine(JVM Heap,~200MB) │
│ 命中率 ~40%,延迟 <1ms,无网络开销 │
└─────────────────────────────────────────────────────┘
↓ MISS
┌─────────────────────────────────────────────────────┐
│ L2 分布式缓存 Redis Cluster(精确 key 匹配) │
│ 命中率 ~35%,延迟 1~5ms,支持集群扩展 │
└─────────────────────────────────────────────────────┘
↓ MISS
┌─────────────────────────────────────────────────────┐
│ L3 语义缓存 pgvector / Qdrant(向量相似度匹配) │
│ 命中率 ~20%,延迟 10~30ms,覆盖语义等价问题 │
└─────────────────────────────────────────────────────┘
↓ MISS
调用 LLM(兜底)→ 异步回写三层缓存
选型矩阵:何时用哪种策略
| 缓存策略 | 适用场景 | 一致性 | 复杂度 | AI推理场景推荐 |
|---|---|---|---|---|
| Cache-Aside | 读多写少,数据库为主 | 最终一致 | 低 | ✅ 推荐(LLM结果只读) |
| Write-Through | 写操作多,强一致需求 | 强一致 | 中 | ❌ LLM无"更新"语义 |
| Write-Behind | 高写吞吐,允许短暂丢失 | 弱 | 高 | ⚠️ 日志异步落库可用 |
| 语义缓存 | NLP/Chat,语义等价问题 | 最终一致 | 高 | ✅ AI场景核心武器 |
| Refresh-Ahead | 热点数据,TTL快到期时提前刷 | 最终一致 | 中 | ✅ 热门问题预热有用 |
L1 Caffeine + L2 Redis 二级缓存
@Service public class TieredCacheService { // L1: 本地 Caffeine 缓存,最多 5000 条,10分钟过期 private final Cache<String, String> l1Cache = Caffeine.newBuilder() .maximumSize(5000) .expireAfterWrite(10, TimeUnit.MINUTES) .recordStats() // 开启命中率统计 .build(); @Autowired private StringRedisTemplate redisTemplate; private static final Duration L2_TTL = Duration.ofHours(2); private static final String KEY_PREFIX = "llm:chat:"; /** * 读取:L1 → L2 → Loader(LLM调用) * Cache-Aside 模式,找不到就调loader并回写 */ public String get(String cacheKey, Supplier<String> loader) { // 1. 查 L1 String val = l1Cache.getIfPresent(cacheKey); if (val != null) { Metrics.counter("cache.hit", "layer", "L1").increment(); return val; } // 2. 查 L2(Redis) String redisKey = KEY_PREFIX + cacheKey; val = redisTemplate.opsForValue().get(redisKey); if (val != null) { Metrics.counter("cache.hit", "layer", "L2").increment(); l1Cache.put(cacheKey, val); // 回填 L1 return val; } // 3. L1/L2 都 MISS:调用 LLM(兜底) Metrics.counter("cache.miss").increment(); val = loader.get(); // 4. 异步回写 L1 + L2,不阻塞主链路 String finalVal = val; CompletableFuture.runAsync(() -> { redisTemplate.opsForValue().set(redisKey, finalVal, L2_TTL); l1Cache.put(cacheKey, finalVal); }); return val; } /** 主动失效(知识库更新时调用)*/ public void evict(String cacheKey) { l1Cache.invalidate(cacheKey); redisTemplate.delete(KEY_PREFIX + cacheKey); } /** 暴露 L1 命中率给监控 */ public CacheStats l1Stats() { return l1Cache.stats(); } }
CompletableFuture.runAsync)避免 LLM 响应链路被 Redis 写操作拖慢。生产环境中建议用独立线程池隔离,防止 Redis 慢查询影响主线程。L3 语义缓存:向量相似度命中
核心思路:将用户问题 Embedding 成向量,存入 pgvector;新请求来了,先做 ANN(近似最近邻)检索,相似度超过阈值则直接返回历史答案。
@Service public class SemanticCacheService { @Autowired private EmbeddingModel embeddingModel; @Autowired private JdbcTemplate jdbc; // pgvector // 相似度阈值:越高越严格,建议 0.92~0.95 private static final double SIMILARITY_THRESHOLD = 0.93; // 缓存 TTL:AI 生成内容有时效性 private static final Duration CACHE_TTL = Duration.ofHours(6); public Optional<String> lookup(String question) { // 1. 将问题向量化 float[] qVec = embeddingModel.embed(question).toFloatArray(); // 2. 使用 pgvector 余弦相似度(<=> 表示 cosine distance) var sql = """ SELECT answer, (1 - (embedding <=> ?::vector)) AS similarity FROM semantic_cache WHERE created_at > NOW() - INTERVAL '6 hours' AND (1 - (embedding <=> ?::vector)) >= ? ORDER BY similarity DESC LIMIT 1 """; return jdbc.query(sql, (rs, n) -> rs.getString("answer"), toPgArray(qVec), toPgArray(qVec), SIMILARITY_THRESHOLD ).stream().findFirst(); } public void store(String question, String answer) { float[] vec = embeddingModel.embed(question).toFloatArray(); jdbc.update(""" INSERT INTO semantic_cache (question, embedding, answer) VALUES (?, ?::vector, ?) ON CONFLICT DO NOTHING """, question, toPgArray(vec), answer); } /** 超参数自适应:根据历史精准度自动调阈值 */ @Scheduled(cron = "0 0 3 * * *") public void autoTuneThreshold() { // 统计最近7天的"误命中率",超过 5% 则上调阈值 // 低于 1% 则适当下调,扩大命中范围 // 实现略,可结合用户反馈表实现监督式调优 } private String toPgArray(float[] arr) { // float[] → "[0.12,0.34,...]" pgvector字面量 StringBuilder sb = new StringBuilder("["); for (int i = 0; i < arr.length; i++) { if (i > 0) sb.append(','); sb.append(arr[i]); } return sb.append(']').toString(); } }
pgvector 建表 DDL
-- 安装扩展 CREATE EXTENSION IF NOT EXISTS vector; CREATE TABLE semantic_cache ( id BIGSERIAL PRIMARY KEY, question TEXT NOT NULL, embedding vector(1536), -- text-embedding-3-small 维度 answer TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), hit_count INT DEFAULT 0 ); -- HNSW 索引:高召回率 ANN 搜索(pgvector 0.5+) CREATE INDEX idx_semantic_embedding ON semantic_cache USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64); -- 定时清理超期缓存(避免无限膨胀) DELETE FROM semantic_cache WHERE created_at < NOW() - INTERVAL '7 days';
AI 场景三大缓存故障:击穿 / 穿透 / 雪崩
场景一:缓存击穿(热点问题过期)
某个超热问题(如"DeepSeek 怎么用")突然 TTL 到期,大量并发请求同时打到 LLM,Token 费用瞬间暴增。
public String getWithMutex(String key, Supplier<String> loader) { String val = redisTemplate.opsForValue().get(key); if (val != null) return val; String lockKey = "lock:" + key; boolean locked = Boolean.TRUE.equals( redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(30))); // 30s 自动释放,防死锁 if (locked) { try { // 获锁成功:调 LLM,写缓存 String result = loader.get(); redisTemplate.opsForValue().set(key, result, Duration.ofHours(2)); return result; } finally { redisTemplate.delete(lockKey); } } else { // 未获锁:短暂等待后重试(避免忙等) LockSupport.parkNanos(Duration.ofMillis(50).toNanos()); return getWithMutex(key, loader); // 递归重试 } }
场景二:缓存穿透(无意义的问题)
恶意用户发送大量随机问题,全部 MISS,每次都打到 LLM。解法:布隆过滤器 + 空值缓存双保险。
// Guava BloomFilter 预热合法问题指纹 private final BloomFilter<String> bloomFilter = BloomFilter.create( Funnels.stringFunnel(Charsets.UTF_8), 1_000_000, // 预期100万条 0.001 // 误判率 0.1% ); public String safeGet(String question, Supplier<String> loader) { // Bloom 未命中:问题从未出现过,直接拒绝或返回默认回复 if (!bloomFilter.mightContain(question)) { return "对不起,暂时无法回答该问题"; } String val = redis.get(question); if (val != null) { // 缓存了空值(之前LLM也不知道答案) if ("NULL".equals(val)) return "暂无答案"; return val; } String result = loader.get(); // 即使是空答案也缓存,防止重复穿透,TTL 短一些 redis.set(question, result != null ? result : "NULL", result != null ? Duration.ofHours(2) : Duration.ofMinutes(5)); bloomFilter.put(question); // 登记新问题 return result; }
场景三:缓存雪崩(批量 TTL 同时过期)
- TTL 随机抖动:
TTL = 基础时长 + random(0, 30min),打散过期时间 - 热点永不过期:Top 1000 热点问题不设 TTL,知识库更新时主动 evict
- Refresh-Ahead 提前刷新:TTL 剩余 20% 时异步触发刷新,不等真正过期
private Duration jitterTTL(Duration base) { // 基础TTL ± 30分钟随机抖动 long jitter = ThreadLocalRandom.current().nextLong( -30 * 60, 30 * 60); return base.plusSeconds(jitter).abs(); }
串联三层缓存:完整的 ChatService
@Service public class AiChatService { @Autowired private TieredCacheService tieredCache; // L1+L2 @Autowired private SemanticCacheService semanticCache; // L3 @Autowired private ChatClient chatClient; // Spring AI @Autowired private MeterRegistry metrics; public String chat(String userId, String question) { // 生成精确 key(含用户维度隔离) String exactKey = "u:" + userId + ":q:" + DigestUtils.md5DigestAsHex(question.getBytes()); // 1. L1 + L2 精确命中(毫秒级) String cached = tieredCache.get(exactKey, () -> { // 2. L3 语义命中(10~30ms) Optional<String> semanticHit = semanticCache.lookup(question); if (semanticHit.isPresent()) { metrics.counter("cache.hit", "layer", "L3").increment(); return semanticHit.get(); } // 3. 全部 MISS:调用 LLM metrics.counter("llm.call").increment(); String answer = chatClient.prompt() .user(question) .call() .content(); // 4. 异步写入 L3 语义缓存 CompletableFuture.runAsync(() -> semanticCache.store(question, answer)); return answer; }); return cached; } }
这些细节让你与众不同
softValues() 替代 maximumSize,JVM 内存压力大时自动驱逐,无需手动调 size,更适合容器化部署例如"今天 A 股涨了多少"这类与时间强相关的问题,不应进入语义缓存,否则昨天的答案会命中今天的问题。
解法:在 Embedding 之前做意图分类(时效型 vs 知识型),时效型问题绕过语义缓存直接调 LLM,知识型问题才走 L3。这一点 90% 候选人答不出来。
面试官还会怎么追问?
Q1 L1 和 L2 如何保持一致性?节点重启怎么处理? L1 是进程级缓存,服务重启后 L1 清空,此时请求打到 L2(Redis),随着请求进来逐步回填 L1——这是"懒加载"式一致性。如需快速预热,可在启动时从 Redis 热点 Key 批量 warmup 到 L1(Caffeine putAll)。Q2 多实例部署时 L1 缓存不一致怎么办? L1 只做读加速,不保证多节点一致性。对一致性要求高的数据(如会话状态)不放 L1,只放 L2(Redis)。对一致性要求低的热点知识(TTL 10min 以内可接受误差),L1 各自独立,过期自动同步。 Q3 语义缓存的 Embedding 模型换了怎么迁移? 必须全量重建索引。工程上的做法:新旧模型并行运行一段时间,逐步将 semantic_cache 表中的 embedding 字段用新模型更新;或者新建一张 semantic_cache_v2,双写切流量,确认无误后切换。 Q4 Spring Cache 注解(@Cacheable)和手动缓存选哪个? @Cacheable 适合简单场景,不支持多层缓存联动和语义缓存。AI 场景建议手动管理,更灵活:可以控制回填时机(异步 vs 同步)、阈值调优、分层策略。@Cacheable 的 CacheManager 也可以接 Caffeine,但 L3 语义层必须手写。 Q5 如何衡量缓存方案的投资回报率(ROI)? 核心指标:(LLM调用减少量 × 单次Token费用)÷(Redis + pgvector 运维成本 + 开发人天)。命中率每提升 10%,每月节省费用可量化为具体金额,向面试官展示成本意识是 P7+ 的加分项。
面试答题思路速记
这道题考的不是"你用过 Redis 没",而是你对 AI 工程成本的认知和分层设计能力。
- 先说三层架构(L1 Caffeine / L2 Redis / L3 语义缓存),体现分层思维
- 明确 Cache-Aside 是 LLM 场景首选,说出为什么(Immutable Response 无更新语义)
- 三大故障逐一说解法:击穿→互斥锁,穿透→布隆+空值,雪崩→TTL 抖动+热点永不过期
- 语义缓存要提相似度阈值、时效性意图过滤两个深度点
- 最后量化 ROI,体现业务感知:命中率 × Token 单价 = 每月节省费用
夜雨聆风