跳到主要内容

分布式-缓存更新策略

分布式-缓存更新策略

所有缓存更新策略都基于两种最根本的模式演变而来:读时扩展(Cache-Aside) 和 写时扩展(Write-Through)

策略模式核心逻辑与读写流程优点缺点一致性保障
Cache-Aside (旁路缓存)读流程
1. 读缓存,命中则返回。
2. 未命中则读数据库。
3. 将数据写入缓存。
写流程
1. 更新数据库。
2. 删除缓存
1. 缓存按需填充,内存效率高。
2. 实现简单,普适性强。
3. 业务逻辑直接控制缓存。
1. 缓存不一致窗口(见下文详解)。
2. 首次请求或缓存失效时,存在缓存穿透/击穿/雪崩风险。
最终一致性(存在不一致窗口)
Write-Through (直写)读流程
1. 读缓存(缓存应总有数据)。
写流程
1. 同时更新缓存和数据库。
2. 缓存组件负责将写入同步到数据库。
1. 缓存数据永不过期,始终“新鲜”。
2. 读性能极致(缓存命中率100%)。
1. 写延迟增加(需写两份)。
2. 写操作频繁时,缓存资源浪费。
3. 需要缓存组件支持复杂的写逻辑。
准强一致性(缓存与DB同时更新)

Write-Behind (异步回写) 可视为 Write-Through 的变种,它在更新缓存后异步批量写入数据库,牺牲一致性换取极高的写吞吐。

1、Cache-Aside : 实践最多

先更新数据库,再删除缓存,这是更安全的做法。即使第二步(删缓存)失败,最坏情况是下次读到旧数据(可通过缓存过期或重试补救)。若“先删缓存”,在并发读写下,旧数据极易被写回缓存,造成长时间不一致。

核心并发陷阱
假设线程A读、线程B写:

  1. 缓存恰好失效,A读数据库(得到旧值V1)。
  2. B更新数据库为新值V2,并删除缓存。
  3. A将读到的旧值V1写入缓存。
    结果:缓存中是V1(旧),数据库是V2(新),数据不一致,直到下次缓存失效。

解决方案

  • 延迟双删:更新DB后,休眠一段时间(如主从延迟+业务处理时间),再次删除缓存。
func updateData(key, newValue) {
redis.delete(key); // 第一次删除(可选,激进清理)
db.update(key, newValue); // 更新数据库
Thread.sleep(500); // 等待可能正在进行的旧数据加载完成
redis.delete(key); // 关键:第二次删除
}
  • 设置较短的缓存TTL:作为最终兜底,即使出现不一致,也会很快过期。
  • 使用分布式锁:在“读数据库并写缓存”和“更新数据库并删缓存”时,对同一Key加锁,保证串行化。性能损耗大,需慎用。
  • 禁用缓存:写数据库时,打开开关,将读数据库更新缓存禁用。写数据库完成后,关闭开关,读数据库可以更新缓存。

以上方案,都无法从原则上避免数据不一致。因为并发情况下,可能因为网络原因,先读的数据,最后进行更新缓存。只能降低这种概率

2. Write-Through:将复杂性转移至缓存层

此模式下,应用将缓存视为主要数据存储。每次写操作都必须通过缓存,由缓存负责写入数据库。

  • 实现关键:通常需要一个实现了 Write-Through 逻辑的智能缓存客户端或中间件。例如,使用支持 Write-Behind 的分布式缓存库(如 Ehcache 配置 write-behind)。
  • 优点:对应用层透明,读写逻辑简单。非常适合读多写少且对一致性要求较高的配置型数据。
  • 缺点:所有写请求都会穿透到DB,数据库写入压力大。如果缓存集群宕机,会阻塞所有写操作。

3、Cache-Aside + Binlog监听 (终极解耦方案)

这是目前大型互联网公司最推崇的架构,它结合了两种模式的优点:

  1. 读路径:采用标准的 Cache-Aside。业务代码只负责读缓存和读数据库。
  2. 写路径业务代码只更新 MySQL。完全不再负责操作缓存。
  3. 同步路径:部署一个独立的数据同步服务(如使用 Canal、Debezium、Maxwell),实时监听 MySQL 的 Binlog。当监听到数据变更时,此服务负责删除或更新 Redis 中对应的缓存。

优点

  • 业务代码极度简洁:开发者只需关心数据库,无需在业务逻辑中掺杂缓存操作。
  • 彻底解耦:缓存同步成为独立的基础设施,由专门团队维护。
  • 高可靠性:基于 MySQL 主从复制的 Binlog,本身就是高可靠的。
  • 支持多缓存、多目标:可以轻松地将一份数据变更同步到 Redis、ES 等多个下游系统。

缺点:

  • 需要额外开发缓存同步的基础设施,不太适合小团队

总结:

选择缓存更新策略,本质是在 性能、一致性、复杂性 之间做权衡。

  • 追求简单快速上线:从 Cache-Aside 开始,使用“先更新DB,再删除缓存”,并为缓存设置合理的TTL。
  • 追求架构清晰与强最终一致:逐步过渡到 Cache-Aside + Binlog监听 的组合,这是面向未来的架构。
  • 追求极致读性能,可接受写延迟:考虑 Write-Through,但需评估数据库的写负载能力。
  • 追求极致写吞吐,可接受数据丢失风险:考虑 Write-Behind

无论选择哪种,都必须遵从一下规则:

  1. 缓存一定要设置过期时间:这是防止缓存“永久”脏数据、保证最终一致性的最后防线。
  2. 数据库是唯一信源:任何方案的设计前提都是,在发生争议时,以数据库中的数据为准。
  3. 保证操作幂等性:删除缓存、监听Binlog更新缓存等操作,必须支持安全的重试,防止网络抖动导致重复操作引发问题。

对于绝大多数业务,一个 “TTL + 重试删除 + 延迟双删” 的组合,已经能提供一个足够好的最终一致性保证。 如果不够再加一个禁用缓存的开关,可以解决99%的场景。

缓存雪崩/穿透/击穿

缓存雪崩:redis key大批量过期,大量 cache key 在同一时刻过期,那么这一瞬间纷涌而至的大量读请求都会因为 cache 数据 miss 而集体涌入到 db 中,导致 db 压力陡增.

缓存击穿:redis 热点 key 过期,直接请求db

缓存穿透:读操作频繁请求 db 中不存在的数据,那么该数据自然也无法写入 cache,最终所有请求都会直击 db,导致 db 压力较大.

缓存穿透:

1、缓存空值 / 默认值(最简单,推荐)

2、布隆过滤器(Bloom Filter,防恶意攻击首选),

布隆过滤器是一种空间效率极高的概率型数据结构,用于判断 “一个元素是否一定不存在”

  • 提前将 DB 中所有存在的 Key加载到布隆过滤器;
  • 所有请求先过布隆过滤器,若过滤器判定 “Key 不存在”,直接返回,不查缓存和 DB;
  • 若判定 “Key 可能存在”,再查缓存→DB。
核心要点:
  • 布隆过滤器无删除操作(删除会导致误判),新增 Key 需同步更新过滤器;
  • 存在极小误判率(判定存在但实际不存在),可通过调整参数降低;
  • 适合防恶意攻击、大量非法 Key 的场景。
3、接口层参数校验(前置拦截)

在接口入口做参数合法性校验,过滤非法 Key(如用户 ID 为负数、非数字、长度异常),从源头拦截穿透请求。

缓存击穿:

1:互斥锁(Mutex,本地 / 分布式锁,推荐)

缓存未命中时,加锁只允许一个请求去 DB 查询并更新缓存,其他请求等待锁释放,直接读取缓存,避免大量请求同时打 DB。

分类:

  • 本地锁(sync.Mutex):适合单机部署,性能高,无网络开销;
  • 分布式锁(Redis Redlock、ZooKeeper):适合集群部署,保证跨节点的互斥。

2:热点 Key 永不过期(逻辑过期,高并发首选)

不设置缓存 Key 的物理过期时间,而是在缓存值中存储逻辑过期时间

  • 缓存中存储:{value: "商品数据", expire_time: 1712345678}
  • 请求命中缓存后,检查逻辑过期时间:
    • 未过期:直接返回;
    • 已过期:异步后台 Goroutine去 DB 更新缓存,当前请求直接返回旧数据(不阻塞)。

核心要点:

  • 保证请求无阻塞,高并发下性能极高;
  • 存在短暂数据不一致(旧数据),适合对一致性要求不高的场景(如商品详情、热搜)。

3:多级缓存(本地缓存 + 分布式缓存)

本地缓存存储热点 Key,分布式缓存(Redis)失效时,先查本地缓存,再查 DB,减少 DB 压力。

缓存雪崩:

1:过期时间随机化(解决 “大量 Key 统一过期”,首选)

给每个缓存 Key 的过期时间加随机偏移,避免所有 Key 在同一时间过期:

2:多级缓存(本地缓存 + 分布式缓存,高可用核心)

构建本地缓存(Caffeine/Guava)+ 分布式缓存(Redis) 两级缓存:

  • 一级:本地缓存(内存,速度极快,无网络开销);
  • 二级:分布式缓存(Redis,高可用集群);
  • 请求流程:本地缓存→分布式缓存→DB;
  • 分布式缓存宕机时,本地缓存仍能扛压,避免请求直打 DB。