大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
缓存模式
这一课你将能做到
- 说清 Cache-Aside、Read-Through、Write-Through、Write-Behind 四种模式的读写路径与适用边界
- 亲手写出防穿透、防击穿、防雪崩的 Redis 读路径 Java 代码(含互斥锁与空值缓存)
- 为本站题目详情页设计「CDN + Redis + MySQL」三层缓存并给出 TTL 与失效策略
为什么要学
10 万日活的平台上,题目详情、排行榜、热门题单这些读请求占 90% 以上,全打 MySQL 就是花钱买崩溃。缓存是用内存的贵换数据库的命,是所有读多写少系统的第一杠杆。
面试缓存是必考区,且问法越来越细:「先删缓存还是先更新数据库?」「缓存和数据库不一致怎么办?」「穿透击穿雪崩分别怎么防?」——要求能背能画图,这课按这个标准写。
人话:缓存像你桌上的便签。Cache-Aside 是「先翻便签,没有再查资料,查完顺手抄一份贴桌上」;Write-Through 是「改资料必须同时改便签,永远一致但每次都慢一点」;Write-Behind 是「先改便签,攒一攒再同步资料,快但可能丢」。
先建立图像
四种模式的读写路径:
| 模式 | 读路径 | 写路径 | 一致性 | 适用 |
|---|---|---|---|---|
| Cache-Aside | 先缓存,miss 再查 DB 并回填 | 先写 DB,再删缓存 | 最终一致 | 通用业务(默认选它) |
| Read-Through | 缓存层代理,业务无感 DB | — | 同上 | 有封装好的缓存 SDK |
| Write-Through | — | 同步写缓存+DB | 强 | 写后读必须最新 |
| Write-Behind | — | 写缓存,异步批量刷 DB | 弱(可能丢) | 写爆炸但可容忍丢失(点赞计数) |
本站三层缓存全景:
GET /api/problems/1024
│
├─ ① CDN/浏览器:题目描述静态部分,TTL 1h(公开内容才放)
│
├─ ② Redis:problem:1024 → 完整 JSON,TTL 10min + 随机抖动
│ rank:weekly → ZSet 排行榜
│
└─ ③ MySQL:problem 表(buffer pool 天然是最后一层)
命中路径的延迟量级:CDN ~10ms(用户就近)→ Redis 0.5ms → MySQL 1-5ms。**每往下一层,延迟差 520 倍**,这就是分层的意义。
容量估算(10 万日活):
热门题目 2000 道 × 平均 30KB JSON ≈ 60MB
全量题目 5000 道 × 30KB ≈ 150MB —— Redis 4GB 实例绰绰有余
命中率目标 >95%,剩余 5% 回源 DB ≈ 175 QPS × 5% ≈ 9 QPS,单库轻松
跟着做
步骤 1:写出标准 Cache-Aside 读路径(含三防)
@Service
@RequiredArgsConstructor
public class ProblemCacheService {
private final StringRedisTemplate redis;
private final ProblemMapper problemMapper;
private final ObjectMapper objectMapper;
private static final Duration BASE_TTL = Duration.ofMinutes(10);
public ProblemDTO getProblem(long id) {
String key = "problem:" + id;
String json = redis.opsForValue().get(key);
if (json != null) {
// 空值哨兵:防穿透命中
return "".equals(json) ? null : parse(json);
}
return loadFromDb(id, key);
}
private ProblemDTO loadFromDb(long id, String key) {
// 防击穿:miss 后先抢互斥锁,只放一个线程回源
String lockKey = key + ":lock";
Boolean locked = redis.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
try {
if (Boolean.TRUE.equals(locked)) {
ProblemDTO dto = problemMapper.selectDetailById(id);
if (dto == null) {
// 防穿透:不存在的 id 缓存空串 60s,挡住恶意扫描
redis.opsForValue().set(key, "", Duration.ofSeconds(60));
return null;
}
// 防雪崩:TTL 加随机抖动,避免同一秒集体过期
Duration ttl = BASE_TTL.plusSeconds(
ThreadLocalRandom.current().nextLong(0, 120));
redis.opsForValue().set(key, toJson(dto), ttl);
return dto;
}
// 没抢到锁:稍等后重读缓存(别人正在回填)
Thread.sleep(80);
String json = redis.opsForValue().get(key);
return json == null || json.isEmpty() ? null : parse(json);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return problemMapper.selectDetailById(id); // 兜底直查
} finally {
if (Boolean.TRUE.equals(locked)) redis.delete(lockKey);
}
}
}
步骤 2:Redis 命令行验证每一步
# 查看缓存与 TTL(-2 不存在,-1 永不过期)
GET problem:1024
TTL problem:1024
# 手动制造击穿演练:删缓存瞬间并发请求
DEL problem:1024
# 观察互斥锁的瞬间存在
SET problem:1024:lock 1 NX EX 3 # NX=不存在才设置,EX=过期秒数
# 命中率监控
INFO stats # 看 keyspace_hits / keyspace_misses,命中率应 >95%
步骤 3:写路径——先更新 DB 再删缓存(含延迟双删)
面试经典题「先删缓存还是先更新库」。答案:先更新 DB,再删缓存(Cache-Aside 标准动作),因为「先删缓存」在删后写库前有个空窗,并发读会把旧值重新灌进缓存。
@Transactional
public void updateProblem(ProblemDTO dto) {
problemMapper.update(dto); // ① 先写库
redis.delete("problem:" + dto.getId()); // ② 再删缓存
// 高并发下为兜底「删后旧读回填」,可加延迟双删:
// 消息延迟 500ms 后再删一次(可用延迟队列/定时任务实现)
}
运营改题后想立刻生效(不等 TTL),就是这个 update 方法——主动失效优于被动过期。
步骤 4:排行榜用 ZSet 而不是缓存 JSON
# 判题 AC 时消费者加分(秒级延迟,最终一致)
ZINCRBY rank:weekly 100 "user:1024"
ZREVRANGE rank:weekly 0 9 WITHSCORES # Top10
ZREVRANK rank:weekly "user:1024" # 我的排名,O(logN)
排行榜的写频率高(每次 AC)读频率也高(全站刷新),缓存整页 JSON 会频繁失效,预聚合结构(ZSet)比页面缓存更合适——按数据形态选缓存结构,不是所有东西都存 String。
讲清楚要点
- Cache-Aside 是默认起点:业务代码自己管缓存,逻辑直白、可控。Read/Write-Through 需要缓存中间件支持,本地场景用 Spring Cache 注解近似实现。
- 三兄弟(穿透/击穿/雪崩)一句话记牢:穿透是查不存在的数据(缓存和 DB 都没有),击穿是单个热点 key 过期瞬间被集中轰击,雪崩是大批 key 同时过期或 Redis 整体宕机。对策分别是空值缓存/布隆过滤器、互斥锁/逻辑过期、TTL 抖动/多级缓存/集群高可用。
- TTL 永远加随机抖动:
base + random(0, 20%),一行代码消灭「整点同一秒全量过期」这一最常见的雪崩诱因。 - 主动失效优于被动过期:运营改了题却要等 10 分钟 TTL 才生效,是产品事故。所有写路径必须顺手删缓存,TTL 只是兜底。
- 缓存不是依赖而是优化,必须可降级:Redis 全挂时读路径要能直查 DB(自动降级 + 对 DB 限流),宁可变慢不能全挂。设计文档里写清「Redis 故障时系统行为」。
你可能会懵的地方
-
为什么是「删缓存」而不是「更新缓存」? 更新缓存有并发写覆盖风险(两个写请求交错导致缓存留旧值),且若缓存的值是聚合计算结果(如列表页),每次写都重算很浪费。删除后下次读自然回填最新值,语义简单安全。
-
布隆过滤器是什么,什么时候用? 一个「可能误判存在、绝不漏判不存在」的位数组结构,放在缓存之前拦截恶意扫描不存在的 id。数据量大(亿级 key)、恶意请求多时上;本站题目 id 才几万,空值缓存足够,别过度设计。
-
逻辑过期和互斥锁怎么选? 互斥锁保证不回源旧值但牺牲吞吐(排队);逻辑过期(value 里带过期时间,过期后由单线程异步刷新,先返回旧值)不阻塞但短期给旧数据。秒杀价格这类不能旧的字段用互斥锁,首页推荐这类可旧的数据用逻辑过期。
-
多级缓存(本地 Caffeine + Redis)什么时候需要? Redis 一次 ~0.5ms,单机 5000 QPS 也够快;但当单 key QPS 达到数十万(首页热门榜单),网络往返和 Redis 带宽都成瓶颈,才加本地层。代价是本地层失效广播(Redis pub/sub 通知各实例)复杂度——先看数字再上。
-
Write-Behind 丢数据怎么办? 它就是用可丢失换吞吐的模式,只用于可重建的数据(浏览计数、点赞数)——源头行为日志在 Kafka 里保留,丢了可以从日志重放恢复。用户提交记录这种绝不能用 Write-Behind 直写。
错法对照
| 错法 | 问题 | 正解 |
|---|---|---|
| 先删缓存再更新 DB | 空窗期旧值被回填,长期不一致 | 先更新 DB 再删缓存(+延迟双删兜底) |
| 所有 key 统一 TTL 10 分钟 | 整点集体过期 = 定时雪崩 | TTL 加 0~20% 随机抖动 |
| 查不到结果不缓存 | 恶意 id 扫描每次都穿透到 DB | 空值短 TTL 或布隆过滤器 |
| 缓存大对象整个列表不分页 | 大 key 序列化慢、淘汰阻塞 | 按页拆 key,单 value 控制在几十 KB 内 |
| 把判题结果缓存 10 分钟 | 用户看不到自己的最新结果 | 状态类读己之写直查主库,不进缓存 |
卡点急救
- Redis 内存持续增长不释放:检查是否设置了 TTL(
TTL key返回 -1 的都是隐患)、有没有大 key 没拆分(redis-cli --bigkeys);生产必须配maxmemory+ 淘汰策略(推荐allkeys-lru)。 - 命中率突然从 98% 掉到 60%:先看是否有批量运维操作(
FLUSHDB、脚本扫描)、Redis 是否主从切换过(切换瞬间全 miss);再核对 TTL 分布是否被改过。命中率是缓存的第一健康指标,必须有监控告警。 - 缓存与 DB 出现持续不一致:核对写路径是否所有入口都删了缓存(多个写入口漏删是常犯错误)、是否有延迟双删兜底;极端情况用「版本号」——value 带 version,读时校验 DB 版本决定是否强制回源。
面试常问
-
缓存穿透、击穿、雪崩的区别与对策? 穿透:查不存在的数据,缓存永远 miss——空值缓存、布隆过滤器、接口参数校验。击穿:热点 key 过期瞬间高并发回源——互斥锁重建、逻辑过期、热点 key 永不过期+主动刷新。雪崩:大批 key 同时过期或 Redis 宕机——TTL 抖动、多级缓存、集群哨兵、限流降级直查。答题时按「现象→根因→对策」三段说。
-
Cache-Aside 写路径为什么是先更新 DB 再删缓存? 先删缓存的问题是删后到写库完成前,并发读会把旧值回填且因缓存无 TTL 逻辑长期驻留;先写库再删,最坏情况是「删后读 miss 回填旧库值」的极小概率窗口,加延迟双删即可收敛。顺带能答出「更新缓存有并发覆盖问题」更佳。
-
怎么设计一个高命中率缓存方案?(以题目详情页为例) 给出分层:CDN 承接静态描述、Redis 存热点题目 JSON(TTL 抖动 + 写时主动失效)、空值缓存防穿透、命中率与延迟指标监控、Redis 故障降级直查 DB 限流。最后补容量估算(60MB 热点集)体现量化能力。
自测三问
- 四种缓存模式的写路径分别是什么?判题计分适合哪种?
- 三兄弟的根因和对策各是什么?互斥锁方案的代码要点有哪几步?
- 「先删缓存再更新 DB」在什么时序下会产生长期不一致?画时间线说明。
小结一句话
缓存是读路径的第一杠杆:模式选 Cache-Aside 起步,写路径「先写库再删缓存」,三兄弟各备对策,永远准备 Redis 挂掉时的降级路径。
延伸
- Redis 官方文档 — 数据类型与模式:ZSet/Hash 等结构权威参考
- 阿里《Java 开发手册》缓存篇:缓存规约的工业界共识
- Cache-Aside Pattern(Microsoft Azure 架构模式):模式定义出处
- 下一课:异步与消息——Kafka 削峰填谷、消费者组与幂等消费,判题队列的完整设计。