Skip to content

缓存架构设计 ​

缓存不是「加个 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