Skip to content

缓存更新策略与常见坑:穿透、击穿、雪崩与一致性 ​

回到总览:后台 / API / 数据层
相关模块:移动端与服务端数据库、Redis 缓存体系总览 · API 幂等性、超时重试与移动端数据最终一致性

一句话定义 ​

缓存更新策略是指在服务端(Redis + MySQL)或移动端(Memory + Local Disk + Remote API)多层架构中,通过合理的读写模式(Cache-Aside / Read-Through)以及防范机制,解决缓存穿透、缓存击穿、缓存雪崩以及双写数据一致性问题的工程实践。

代码索引 ​

对应 Lab:cache-aside-simulation · CacheAsideSimulation.java

为什么需要 ​

  • 为什么当热点新闻(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-simulationCache-Aside 读路径 / 穿透 / 击穿(互斥锁)/ 雪崩(随机 TTL)/ 双写一致性 全仿真CacheAsideSimulation.java

复习检查题 ​

  1. 缓存更新策略中,在更新数据时为什么推荐“删除缓存(Delete Cache)”而不是“直接更新缓存(Update Cache)”?

    答:原因有两个:第一,在并发写场景下,如果两个写请求先后发生,直接更新缓存可能会因为网络顺序颠倒导致旧值覆盖新值(数据不一致);第二,很多缓存数据是经过复杂计算得出的,如果写频繁而读较少,每次写都更新缓存会浪费大量的计算开销。采用“删除缓存”属于懒加载模式,只有在真正被读取时才按需重新计算并写入缓存。

  2. 什么是布隆过滤器(Bloom Filter)?它在解决“缓存穿透”中起什么作用?

    答:布隆过滤器是一种空间效率极高的概率型数据结构,利用高效的位数组(Bit Array)与多个哈希函数判断一个元素绝对不存在或可能存在。在请求到达缓存之前先通过布隆过滤器校验,如果判定元素绝对不存在,则直接拦截并返回,绝不放行去查询数据库,从而高效抵御黑客利用不存在的 Key 发起的缓存穿透攻击。

速记 ​

  • 穿透存空加布隆:数据不存在用布隆过滤器拦截,或缓存 short-TTL 空值。
  • 击穿互斥加续期:热点 Key 到期用互斥锁仅让单线程重建,或设逻辑不过期。
  • 雪崩 TTL 随机:过期时间必须加上随机抖动,防止海量 Key 集中失效。

站点构建时间:2026/8/24 23:43:17