缓存架构
缓存架构
缓存是用「内存换读性能」的核心手段:把热点数据放在离用户更近、更快的存储里,让绝大多数读请求不落到数据库。代价是一致性、容量与失效需要精心设计。
1. 缓存的收益与代价
命中缓存的读取通常在亚毫秒级,而未命中的请求要穿透到数据库。对于读多写少的系统,缓存能把 DB 的读压力降低一到两个数量级。但要清楚它的代价:
| 收益 | 代价 |
|---|---|
| 读延迟大幅下降 | 缓存与 DB 的数据一致性问题 |
| DB 负载下降,可扛更高 QPS | 需要处理穿透/击穿/雪崩 |
| 保护后端不被打垮 | 内存成本、失效策略复杂 |
2. 缓存模式
2.1 Cache Aside(旁路缓存,最常用)
应用代码自己读缓存、查库、回写缓存:
读:先查缓存 → 命中则返回
→ 未命中则查库 → 回写缓存 → 返回
写:更新数据库 → 删除缓存(而不是更新缓存)public Product getProduct(Long id) {
String key = "product:" + id;
String cached = redis.opsForValue().get(key);
if (cached != null) {
return JSON.parseObject(cached, Product.class);
}
Product db = productMapper.selectById(id); // 查库
if (db != null) {
redis.opsForValue().set(key, JSON.toJSONString(db), 30, TimeUnit.MINUTES);
}
return db;
}为什么是「删除缓存」而不是「更新缓存」?
- 更新缓存可能产生脏数据:两个并发更新按不同顺序写缓存和 DB,缓存可能与 DB 不一致;
- 删除缓存更简单、更安全,下次读时自然从 DB 重建。
2.2 Read/Write Through(读写穿透)
缓存在应用与 DB 之间充当代理,应用只与缓存交互,缓存的实现负责读写 DB。对应用透明,但需要缓存组件本身支持(如部分分布式缓存框架)。
2.3 Write Behind(异步写回)
应用只写缓存,缓存异步批量刷回 DB。写性能极高,但存在数据丢失风险(缓存宕机时未刷回的数据丢失),适合对丢失不敏感的计数、日志类场景。
| 模式 | 谁管 DB | 一致性 | 写入性能 | 适用 |
|---|---|---|---|---|
| Cache Aside | 应用 | 中 | 中 | 通用首选 |
| Read/Write Through | 缓存组件 | 中 | 中 | 有透明代理需求 |
| Write Behind | 缓存组件 | 弱 | 高 | 计数、可丢场景 |
3. 缓存与数据库的一致性
这是缓存最核心也最容易出错的地方。
3.1 先更新 DB 还是先删缓存?
| 顺序 | 问题 |
|---|---|
| 先删缓存,再更新 DB | 删除后、更新前有读请求,会把旧值回填缓存,之后一直用旧值 |
| 先更新 DB,再删缓存 | 相对更好,但在极端并发下仍可能不一致 |
业界普遍推荐「先更新数据库,再删除缓存」,因为它的不一致窗口更小、更可控。
3.2 极端并发下的不一致
即便「先更新 DB 再删缓存」,仍可能出现:
线程A(写):更新DB = 新值
线程B(读):读缓存未命中 → 查DB拿到 旧值(此时A还没删缓存)
线程A(写):删除缓存
线程B(读):把 旧值 写回缓存 ← 缓存里是旧值,但 DB 已是新值应对手段:
- 延迟双删:更新 DB 后删缓存,再延迟一段时间(如 500ms)二次删除,把并发读可能回填的旧值清掉;
- 版本号/时间戳:缓存值带版本,回写时比对版本,旧版本不覆盖;
- 给缓存设短过期时间:即使不一致,也能在 TTL 后自动收敛——最终一致的兜底;
- 订阅 binlog(Canal)异步删除缓存:与业务解耦,顺序更可靠。
心态
缓存一致性很难做到「绝对强一致」。多数业务要的是最终一致:接受极短窗口的脏读,用过期时间兜底。对强一致要求极高的数据(如账户余额),不要走缓存,直接查库或用其他机制。
4. 三大经典问题
4.1 缓存穿透(查不存在的数据)
问题:请求大量查询根本不存在的 key(如恶意用不存在的 ID 刷接口),缓存永远不命中,全部压到 DB。
方案:
- 缓存空值:查不到也缓存一个空标记(短 TTL),挡住重复请求;
- 布隆过滤器(Bloom Filter):在缓存前加一层,快速判断「key 一定不存在」直接拒绝。
// 缓存空值示意
Product db = productMapper.selectById(id);
if (db == null) {
// 缓存空值,TTL 设短,避免长期占用与脏数据
redis.opsForValue().set(key, "", 60, TimeUnit.SECONDS);
return null;
}布隆过滤器特点:空间效率极高,但有假阳性(可能误判存在)、不会假阴性(说不存在就一定不存在),且标准的布隆过滤器不支持删除。
4.2 缓存击穿(热点 key 失效)
问题:某个热点 key 突然过期,大量并发请求同时发现未命中,一起去查 DB 并重建缓存,瞬间把 DB 打爆。
方案:
- 互斥锁重建:只允许一个线程去查库重建,其余等待;
- 逻辑过期:缓存不设物理 TTL,而在 value 里存一个逻辑过期时间,过期后由一个线程异步重建,其他线程先返回旧值。
// 互斥锁重建示意
String cached = redis.opsForValue().get(key);
if (cached != null) return parse(cached);
String lockKey = "lock:rebuild:" + id;
if (redis.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) {
try {
// 只有一个线程查库并回填
Product db = productMapper.selectById(id);
redis.opsForValue().set(key, JSON.toJSONString(db), 30, TimeUnit.MINUTES);
return db;
} finally {
redis.delete(lockKey);
}
} else {
// 其他线程短暂等待后重试,避免直接打库
Thread.sleep(50);
return getProduct(id);
}4.3 缓存雪崩
问题:大量 key 在同一时刻集中过期,或缓存服务整体宕机,导致请求全部涌向 DB。
方案:
- 过期时间打散:在基础 TTL 上加随机抖动;
- 多级缓存:本地缓存(Caffeine)+ 分布式缓存(Redis),一层失效还有另一层;
- 缓存高可用:Redis 哨兵/集群,避免单点宕机;
- 限流降级兜底:DB 前面加限流,缓存挂时降级返回兜底数据;
- 预热:活动前把热点数据提前加载进缓存。
// 过期时间打散
long ttl = 30 * 60 + ThreadLocalRandom.current().nextInt(300); // 30分钟 + 0~300秒抖动5. 多级缓存
浏览器缓存 → CDN → Nginx 本地缓存 → 应用本地缓存(JVM) → 分布式缓存(Redis) → DB- 本地缓存(Caffeine/Guava):纳秒级,无网络开销,但容量受限、多实例间不共享,适合极热、可容忍短暂不一致的数据。
- 分布式缓存(Redis):共享、容量大,但有网络往返。
- 组合使用:本地缓存挡最热的数据,Redis 挡大部分,DB 只兜底。
代价是失效更复杂:更新时要同时失效多级,且各实例本地缓存之间不共享,通常靠消息广播或短 TTL 收敛。
6. 实践要点与常见坑
- 缓存大对象/大 key:单个 value 过大,读写慢、易阻塞,要拆分。
- 无过期时间:数据会永久陈旧,也可能撑爆内存。
- 缓存所有数据:只缓存热点,冷数据进缓存是浪费。
- 更新缓存而非删除:在高并发下更易产生脏数据。
- 忽略热 key:单个 key 的 QPS 过高会压垮单个 Redis 分片,需本地缓存或热 key 探测。
- 把缓存当数据源:缓存是加速层,不是唯一存储,任何时刻都要假设它会失效。
7. 小结
- Cache Aside(先更新 DB 再删缓存)是通用首选;Write Behind 快但有丢失风险。
- 一致性难以做到绝对强一致,用延迟双删 + 过期兜底 + 版本号逼近最终一致。
- 穿透用空值缓存/布隆过滤器,击穿用互斥重建/逻辑过期,雪崩用 TTL 打散/多级缓存/高可用。
- 多级缓存能显著提升抗压能力,但失效策略更复杂。
- 强一致要求的数据别走缓存。