Appearance
缓存更新策略与常见坑:穿透、击穿、雪崩与一致性
回到总览:后台 / API / 数据层
相关模块:移动端与服务端数据库、Redis 缓存体系总览 · API 幂等性、超时重试与移动端数据最终一致性
一句话定义
缓存更新策略是指在服务端(Redis + MySQL)或移动端(Memory + Local Disk + Remote API)多层架构中,通过合理的读写模式(Cache-Aside / Read-Through)以及防范机制,解决缓存穿透、缓存击穿、缓存雪崩以及双写数据一致性问题的工程实践。
代码索引
为什么需要
- 为什么当热点新闻(Hot Key)的缓存 TTL 到期的一瞬间,如果有 10 万个并发请求同时涌入,会导致数据库(MySQL)瞬间崩溃?
- 一句话答:这就是典型的缓存击穿(Hotspot Invalid) ;热点 Key 过期后,海量并发请求同时发现缓存未命中,全部穿透去查数据库并尝试重建缓存,数据库连接池被瞬间挤爆。
- 为什么 10 年 Android 工程师必须了解缓存三大坑(穿透/击穿/雪崩)的治本之策?
- 一句话答:不仅后端 Redis 存在这三大坑,移动端本地缓存(Memory + SQLite)在面对大促或突发流量时同样存在缓存并发失效问题;掌握布隆过滤器、互斥锁与随机 TTL 才能在端到端架构上保障稳定。
底层机制
1. 缓存三大经典事故原理与防御方案
text
1. 缓存穿透 (Penetration) ───► 查询一个【根本不存在】的数据 (如 id = -1)
- 原因: 缓存没有,数据库也没有,攻击者高频查询绕过缓存直击 DB。
- 解法: 布隆过滤器 (Bloom Filter) 拦截 + 缓存空对象 (TTL 设短)。
2. 缓存击穿 (Breakdown) ───► 一个【超级热点 Key】刚好过期
- 原因: 单个热点 Key 过期的瞬间,海量并发请求同时穿透到 DB。
- 解法: 互斥锁 (Mutex/Deduplication) 仅允许一个线程查 DB + 逻辑不过期。
3. 缓存雪崩 (Avalanche) ───► 【大量 Key 同时过期】或 Redis 节点宕机
- 原因: 集中过期的 Key 导致大量请求同时转向 DB,压垮数据库。
- 解法: 给 TTL 加上随机抖动时间 (Random TTL) + Redis 高可用集群。2. 双写一致性常用模式:Cache-Aside Pattern (旁路缓存)
对应 Lab:cache-aside-simulation
缓存更新的最标准模式(读多写少场景):
text
[读操作] 先读 Cache ──► 命中则返回 ──► 未命中则读 DB ──► 写入 Cache 后返回
[写操作] 先更新 DB ──────────────────────────────────► 再【删除/失效】Cache (而非更新 Cache!)为什么是"先更新 DB,再删除 Cache"?
如果先删 Cache 再更新 DB,在并发读写下会导致读请求将 DB 的旧数据重新写入 Cache,造成永久数据不一致;先更新 DB 再删除 Cache 结合延迟双删能最大程度保障一致性。
用一个并发时序把"先删 Cache 再更 DB"的坑演示出来:
- 可能执行顺序:上图中读线程 T2 在 T1 删除缓存后、更新 DB 前,读到了旧值 V1 并写回缓存;此后缓存永远停在 V1,直到下次过期。
- 预期现象:用户看到的是旧数据(V1),且持续很久——比"更新缓存被旧值覆盖"更隐蔽,因为缓存是被"合法"写回的。
- 观察重点:先删后更的窗口期是"删 → 更"这段间隙;先更后删的正确顺序中,窗口期只是"更 → 删"间隙内读到旧 DB 值(缓存未命中回源),一旦删掉缓存后下次读就是新值,不一致只存在极短时间。
延迟双删(Delayed Double Delete) 再收尾:
java
public void updateData(String key, Object newVal) {
redis.del(key); // 1. 先删缓存
db.update(key, newVal); // 2. 更新 DB
// 3. 延迟 500ms~1s 再删一次:兜掉"读线程在窗口期写回旧值"的残留
executor.schedule(() -> redis.del(key), 800, TimeUnit.MILLISECONDS);
}- 观察重点:第二次删除的延迟要略大于"读线程回源写回"的耗时(通常几百毫秒),且删除失败要有重试与告警,否则兜底失效。
Android / Flutter / Web / Backend 对照
| 维 | 服务端 Redis | 移动端本地 (Memory + SQLite) |
|---|---|---|
| 缓存击穿防护 | Redis SETNX 互斥锁 / 逻辑异步续期 | 单例协程 Mutex 互斥查库 / SingleFlight |
| 缓存穿透防护 | 布隆过滤器 (BloomFilter) / 存空值 | 存空对象标志 (Null Object) |
| 缓存雪崩防护 | TTL 加上 1~5 分钟随机数 | 内存/磁盘缓存设置不同的 Expire 偏移 |
常见场景与代码实现
1. 移动端与服务端通用防击穿:互斥锁单例重建
java
// 移动端防击穿:当本地内存缓存失效时,仅允许单线程/协程去读 DB/网络
public String getHotDataWithMutex(String key) {
String data = memoryCache.get(key);
if (data == null) {
synchronized (this) { // 单例锁
data = memoryCache.get(key); // 双重检查 (Double-Check)
if (data == null) {
data = queryFromDatabaseOrNetwork(key); // 仅查一次
memoryCache.put(key, data);
}
}
}
return data;
}常见误配、事故后果与排障
1. 事故:后端批量设置缓存,TTL 全部写死为 3600 秒
- 误配原因:在定时任务批量刷新商品缓存时,执行
redis.set(key, val, 3600)。 - 后果:恰好在 1 小时后的同一秒,这批商品缓存集中失效,引发严重的缓存雪崩,导致数据库 CPU 瞬间冲到 100%。
- 排障与修法:在基础 TTL 增加随机数:
int realTTL = 3600 + new Random().nextInt(300)(3600~3900 秒随机分布)。
与相近概念对比
| 事故类型 | 问题根源 | 关键特征 | 防御硬核手段 |
|---|---|---|---|
| 缓存穿透 | 数据在 DB 和 Cache 中都不存在 | 针对非法/不存在的 Key | 布隆过滤器 / 存空值 |
| 缓存击穿 | 单个热点 Key 恰好到期 | 高并发请求集中击穿 | 互斥锁 / 逻辑不失效 |
| 缓存雪崩 | 海量 Key 集中到期或 Redis 宕机 | 数据库整体被压垮 | TTL 加随机数 / 集群熔断 |
对应实验
| Lab | 说明 | 源码 |
|---|---|---|
| cache-aside-simulation | Cache-Aside 读路径 / 穿透 / 击穿(互斥锁)/ 雪崩(随机 TTL)/ 双写一致性 全仿真 | CacheAsideSimulation.java |
复习检查题
缓存更新策略中,在更新数据时为什么推荐“删除缓存(Delete Cache)”而不是“直接更新缓存(Update Cache)”?
答:原因有两个:第一,在并发写场景下,如果两个写请求先后发生,直接更新缓存可能会因为网络顺序颠倒导致旧值覆盖新值(数据不一致);第二,很多缓存数据是经过复杂计算得出的,如果写频繁而读较少,每次写都更新缓存会浪费大量的计算开销。采用“删除缓存”属于懒加载模式,只有在真正被读取时才按需重新计算并写入缓存。
什么是布隆过滤器(Bloom Filter)?它在解决“缓存穿透”中起什么作用?
答:布隆过滤器是一种空间效率极高的概率型数据结构,利用高效的位数组(Bit Array)与多个哈希函数判断一个元素绝对不存在或可能存在。在请求到达缓存之前先通过布隆过滤器校验,如果判定元素绝对不存在,则直接拦截并返回,绝不放行去查询数据库,从而高效抵御黑客利用不存在的 Key 发起的缓存穿透攻击。
速记
- 穿透存空加布隆:数据不存在用布隆过滤器拦截,或缓存 short-TTL 空值。
- 击穿互斥加续期:热点 Key 到期用互斥锁仅让单线程重建,或设逻辑不过期。
- 雪崩 TTL 随机:过期时间必须加上随机抖动,防止海量 Key 集中失效。