缓存模式
cache-aside、write-through、CDN、refresh-ahead。
缓存模式
这一课你将能做到
- 说清 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);
}
}
}