2. 缓存基础回顾:缓存的作用与常见模式

2.1 缓存的核心作用

在分布式系统中,缓存位于数据库与应用程序之间,通过将热点数据存储在高速存储介质(通常是内存)中,显著降低数据访问延迟并减轻后端数据库的负载。其核心价值体现在三个维度:

  • 降低延迟:内存访问延迟通常在微秒级,而磁盘 I/O 在毫秒级,缓存可将读操作延迟降低 1~3 个数量级。
  • 提升吞吐:缓存层可承受每秒数万至数十万次查询,有效分担数据库连接池与磁盘 I/O 压力。
  • 削峰填谷:在流量突发场景下,缓存吸收瞬时请求洪峰,防止数据库因连接耗尽而崩溃。
关键指标:缓存命中率是衡量缓存效率的核心指标。生产环境中,读密集型业务通常要求缓存命中率 ≥ 95%;低于 85% 时需排查缓存策略或数据预热逻辑。

2.2 三种主流缓存模式

不同的缓存模式决定了应用程序与缓存、数据库之间的交互方式,也直接影响缓存一致性的维护成本。下表对比了三种常见模式的核心差异:

模式 读操作流程 写操作流程 一致性维护 适用场景
Cache-Aside 应用先读缓存,未命中则读 DB 并回填缓存 应用先写 DB,再删除或更新缓存 应用层负责,需处理并发写冲突 通用场景,灵活性最高
Read-Through 缓存组件自动回填(应用不直接读 DB) 同 Cache-Aside 或由缓存代理 缓存层封装,对应用透明 读密集、缓存层可扩展
Write-Through 同 Read-Through 应用写缓存,缓存同步写 DB 缓存层保证强一致(写路径) 写后立即读、一致性要求高

2.2.1 Cache-Aside(旁路缓存)

Cache-Aside 是业界最广泛采用的模式,应用程序直接管理缓存与数据库的读写路径。其核心逻辑如下:

// 读操作
public String get(String key) {
    String value = cache.get(key);
    if (value == null) {
        value = db.query(key);          // 缓存未命中,查数据库
        cache.set(key, value, ttl);     // 回填缓存,设置过期时间
    }
    return value;
}

// 写操作
public void update(String key, String newValue) {
    db.update(key, newValue);           // 先更新数据库
    cache.delete(key);                  // 再删除缓存(或更新)
}
一致性陷阱:Cache-Aside 在并发写场景下存在「先删缓存后写 DB」的时序问题,可能导致脏数据。标准做法是「先写 DB 后删缓存」,并配合延迟双删或消息队列补偿。

2.2.2 Read-Through(通读缓存)

Read-Through 模式下,缓存层(如 Redis Cluster + 自定义加载器)接管了数据回填逻辑。应用程序只与缓存交互,缓存未命中时由缓存组件自动查询数据库并填充。该模式对应用层更透明,但要求缓存层具备数据加载能力。

2.2.3 Write-Through(通写缓存)

Write-Through 模式下,每次写操作都先写入缓存,再由缓存同步写入数据库。该模式保证了写路径的强一致性,但写延迟较高(需等待数据库确认),且不适合写频繁的业务。

三种缓存模式交互对比 Cache-Aside App Cache DB ① 读缓存未命中 ② 查 DB 并回填 ③ 写 DB → 删缓存 Read-Through App Cache DB ① 读缓存未命中 ② 缓存自动查 DB ③ 缓存回填并返回 Write-Through App Cache DB ① 写缓存 ② 缓存同步写 DB ③ 均成功后返回

2.3 缓存三大经典问题

缓存穿透、缓存击穿、缓存雪崩是分布式缓存运维中最常见的三类故障模式。理解其成因与应对策略,是设计高可用缓存系统的前提。

问题 现象 根因 典型解决方案
缓存穿透 请求绕过缓存直接打到数据库 查询不存在的数据(key 在缓存和 DB 中均无) 布隆过滤器、缓存空对象(短 TTL)
缓存击穿 热点 key 过期瞬间,大量并发请求直击 DB 单个热点 key 过期 + 高并发 互斥锁(Mutex)、逻辑过期、永不过期 + 异步刷新
缓存雪崩 大量 key 同时过期或缓存节点宕机,DB 被压垮 批量 key 同时过期 / 缓存集群故障 过期时间加随机偏移、缓存集群高可用(哨兵/集群)、限流降级

2.3.1 缓存穿透

当请求的 key 在缓存和数据库中都不存在时,每次请求都会穿透缓存直达数据库。攻击者可以利用此漏洞构造大量不存在的 key 进行恶意请求,导致数据库负载飙升。

防御手段:
  • 布隆过滤器:在缓存前加一层布隆过滤器,判断 key 是否存在,不存在则直接拒绝。
  • 缓存空对象:对查询结果为空的 key 也缓存一个占位符(TTL 设为 30~60 秒),防止重复穿透。

2.3.2 缓存击穿

缓存击穿特指某个热点 key 在过期瞬间,大量并发请求同时发现缓存失效,全部回源查询数据库,导致数据库瞬间压力过大。

推荐方案:使用互斥锁(如 Redis SETNX)保证只有一个线程去加载数据,其他线程等待或快速失败。对于读多写少的场景,可采用「逻辑过期」策略——缓存永不过期,但数据中携带过期时间,异步线程负责刷新。

2.3.3 缓存雪崩

缓存雪崩有两种典型诱因:一是大量 key 设置了相同的过期时间,在同一时刻集体失效;二是缓存节点宕机导致整个缓存层不可用。两种情况下,所有请求都会直接压向后端数据库。

工程实践:
  • 设置过期时间时,在基础 TTL 上增加 ±10%~30% 的随机偏移,避免批量失效。
  • 缓存集群必须部署哨兵或 Cluster 模式,保证故障自动切换。
  • 在应用层配置限流(如 Sentinel)和熔断降级策略,防止数据库被击穿。
缓存穿透 / 击穿 / 雪崩 对比 缓存穿透 请求 缓存 DB ① 缓存未命中(key 不存在) ② 直接查询 DB(无数据) ③ 每次穿透都压到 DB 缓存击穿 并发请求 热点 Key DB ① 热点 key 过期 ② 大量并发同时回源 ③ DB 瞬间压力暴增 缓存雪崩 大量请求 缓存集群 DB ① 大量 key 同时过期 ② 或缓存节点宕机 ③ 所有请求直击 DB

2.4 缓存一致性问题的根源

缓存与数据库之间的数据不一致,本质上是写操作时序与并发访问共同作用的结果。在 Cache-Aside 模式下,典型的冲突场景包括:

  • 先删缓存后写 DB:线程 A 删除缓存后,线程 B 读取旧数据并回填缓存,导致缓存中为旧值。
  • 先写 DB 后更新缓存:写 DB 成功但更新缓存失败,缓存仍为旧数据。
  • 并发写冲突:两个线程同时写 DB,后写 DB 的线程先更新缓存,导致缓存与 DB 最终状态不一致。