缓存架构设计
缓存不是「加个 Redis」,而是在每一层决定放什么、什么时候失效、失效了怎么办。 这篇按「放哪 → 怎么读 → 怎么保证一致 → 出问题怎么修」四步讲。
一、缓存放哪一层
浏览器缓存 → CDN → 网关/接入层 → 本地缓存(Caffeine) → 分布式缓存(Redis) → DB
↑静态资源 ↑静态+动态 ↑鉴权/限流结果 ↑热点数据、字典 ↑共享状态浏览器缓存 → CDN → 网关/接入层 → 本地缓存(Caffeine) → 分布式缓存(Redis) → DB
↑静态资源 ↑静态+动态 ↑鉴权/限流结果 ↑热点数据、字典 ↑共享状态| 层次 | 典型内容 | 收益 | 风险 |
|---|---|---|---|
| 浏览器 | 静态资源(JS/CSS/图片) | 省掉整个网络往返 | 更新不及时(要靠 hash/版本号) |
| CDN | 图片、视频、大文件 | 就近访问,抗量 | 回源、刷新延迟 |
| 本地缓存 | 配置、字典、热点少量 | 纳秒级,无网络 | 多实例不一致 |
| Redis | 会话、热点、计数器、锁 | 毫秒级,共享 | 网络、容量、一致性 |
多级缓存的价值:本地缓存挡住 80% 的重复读,Redis 挡住剩下 15%,只有 5% 落到 DB。
代价:每一级都要处理失效。本地缓存尤其难 —— 多实例之间没有共享,只能靠「短 TTL + 广播失效」兜。
二、四种缓存模式
| 模式 | 读 | 写 | 适用 |
|---|---|---|---|
| Cache-Aside(旁路,最常用) | 先读缓存,miss 读库并回填 | 更新库,再删缓存 | 绝大多数业务 |
| Read-Through | 缓存层负责回源 | — | 缓存组件支持时 |
| Write-Through | — | 写缓存,缓存同步写库 | 写少读多、要求强一致 |
| Write-Behind | — | 写缓存即返回,异步刷库 | 写极多、可容忍丢数据 |
为什么是「删缓存」而不是「更新缓存」:
- 更新缓存在并发下容易写进旧值(两个线程乱序写库,却按顺序写缓存);
- 删缓存让下次读自然回填,逻辑更短、出错面更小。
三、缓存三大问题(面试必答)
1. 穿透:查不存在的数据
现象:请求一个根本不存在的 key,缓存永远不命中,每次都打到 DB。
| 解法 | 说明 | 代价 |
|---|---|---|
| 缓存空值 | 查不到也存 null,TTL 短(如 60s) | 占空间、可能短暂不一致 |
| 布隆过滤器 | 前置判断「可能存在」,不存在直接返回 | 有假阳性、删除麻烦 |
| 参数校验 | id ≤ 0、格式不对直接拒 | 只能挡一部分 |
2. 击穿:热点 key 过期瞬间
现象:一个热点 key 到期,瞬间上万请求同时打到 DB。
| 解法 | 说明 |
|---|---|
| 互斥锁重建 | 只让一个线程回源,其余等待或返回旧值 |
| 逻辑过期 | 不设 TTL,值里带过期时间;发现过期后异步刷新,先返回旧值 |
| 热点永不过期 | 靠主动更新 |
java
// 互斥锁版(最常用)
String v = redis.get(key);
if (v == null) {
if (redis.setnx(lockKey, "1", 10)) {
try {
v = loadFromDb(key);
redis.set(key, v, ttl);
} finally {
redis.del(lockKey);
}
} else {
Thread.sleep(50);
return get(key); // 等别人填好再读
}
}// 互斥锁版(最常用)
String v = redis.get(key);
if (v == null) {
if (redis.setnx(lockKey, "1", 10)) {
try {
v = loadFromDb(key);
redis.set(key, v, ttl);
} finally {
redis.del(lockKey);
}
} else {
Thread.sleep(50);
return get(key); // 等别人填好再读
}
}3. 雪崩:大量 key 同时过期 / 缓存整体挂
| 解法 | 说明 |
|---|---|
| TTL 加随机抖动 | ttl = base + random(0, 300s),避免同时失效 |
| 高可用 | Redis 主从 + 哨兵/Cluster,避免单点 |
| 限流降级 | 缓存挂了直接限流 + 返回兜底数据,别让 DB 被压死 |
| 多级缓存 | 本地缓存顶一层 |
四、缓存与数据库的一致性
这是最容易被追问的点。先明确:除非用分布式事务,否则做不到强一致,只能做到最终一致。
常见方案的取舍
| 方案 | 一致性 | 问题 |
|---|---|---|
| 先更新库,再删缓存(Cache-Aside) | 最终一致 | 删除失败会脏读;并发下仍有极小概率不一致 |
| 先删缓存,再更新库 | 更差 | 删完到更新完之间,读请求会回填旧值 |
| 延迟双删 | 较好 | 更新库后睡 500ms 再删一次,覆盖并发回填窗口 |
| 订阅 binlog 异步删(Canal) | 较好、业务无侵入 | 有延迟,要处理顺序 |
| 分布式锁串行化 | 强一致 | 性能差,只在关键场景用 |
工程上的常用组合:
写:更新 DB → 删缓存 → 发一条 MQ(延迟 500ms 再删一次)
读:读缓存 → miss 读 DB → 回填(TTL + 随机抖动)
兜底:对账任务每天比对关键数据写:更新 DB → 删缓存 → 发一条 MQ(延迟 500ms 再删一次)
读:读缓存 → miss 读 DB → 回填(TTL + 随机抖动)
兜底:对账任务每天比对关键数据为什么删两次:第一次删除后,可能有旧读请求在删除前已经读到旧值、正要回填;延迟第二次删除能把这个回填清掉。
五、热点 key 与大 key
热点 key
现象:单个 key 的 QPS 打满一个 Redis 节点(Redis Cluster 按 key 分片,热点无法分散)。
| 解法 | 说明 |
|---|---|
| 本地缓存 | 最短路径,最有效 |
| key 打散 | hotkey:{0..9} 复制 10 份,随机读一份 |
| 读写分离 | 热点读走从节点 |
| 限流 | 保护性兜底 |
大 key
判定:String > 10KB、集合元素 > 5000 个。
危害:单次操作阻塞 Redis 主线程(Redis 单线程),网络拥塞。
治理:拆分(hash 分桶)、压缩、改用其他存储(如把大 JSON 放对象存储,缓存只存引用)。
六、缓存容量与淘汰
| 项 | 建议 |
|---|---|
| 容量 | 按「热点数据量 × 1.5」估,别把全量数据塞进缓存 |
| 淘汰策略 | allkeys-lru(通用)或 volatile-lru(只淘汰有 TTL 的) |
| 过期策略 | 惰性删除 + 定期抽样删除(不是精确定时) |
| 监控 | 命中率、内存使用率、慢查询、大 key |
命中率低于 80% 就要查原因:key 设计不对、TTL 太短、还是根本没热起来。
七、什么时候不要用缓存
- 数据写多读少(缓存只会增加不一致);
- 数据强一致要求(如账户余额的扣减,缓存只能做只读副本);
- 访问极度分散(每个 key 只读一次,缓存纯属浪费内存)。
八、速查
| 问题 | 答案 |
|---|---|
| 为什么删缓存而不是更新 | 并发下更新缓存会写旧值,删缓存让下次读自然回填 |
| 穿透怎么解 | 缓存空值 + 布隆过滤器 + 参数校验 |
| 击穿怎么解 | 互斥锁重建 / 逻辑过期 |
| 雪崩怎么解 | TTL 随机抖动 + 高可用 + 限流兜底 |
| 一致性怎么保证 | 更新库→删缓存→延迟双删 / 订阅 binlog,最终一致 + 对账 |
| 热点 key 怎么解 | 本地缓存 + key 打散 + 读写分离 |
| 大 key 怎么治 | 拆分、压缩、只存引用 |
| 命中率多少算健康 | 80% 以上;低于就要查 key 设计和 TTL |
Cao Note