分布式-缓存更新策略
分布式-缓存更新策略
所有缓存更新策略都基于两种最根本的模式演变而来:读时扩展(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写:
- 缓存恰好失效,A读数据库(得到旧值V1)。
- B更新数据库为新值V2,并删除缓存。
- 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监听 (终极解耦方案)
这是目前大型互联网公司最推崇的架构,它结合了两种模式的优点:
- 读路径:采用标准的 Cache-Aside。业务代码只负责读缓存和读数据库。
- 写路径:业务代码只更新 MySQL。完全不再负责操作缓存。
- 同步路径:部署一个独立的数据同步服务(如使用 Canal、Debezium、Maxwell),实时监听 MySQL 的 Binlog。当监听到数据变更时,此服务负责删除或更新 Redis 中对应的缓存。
优点:
- 业务代码极度简洁:开发者只需关心数据库,无需在业务逻辑中掺杂缓存操作。
- 彻底解耦:缓存同步成为独立的基础设施,由专门团队维护。
- 高可靠性:基于 MySQL 主从复制的 Binlog,本身就是高可靠的。
- 支持多缓存、多目标:可以轻松地将一份数据变更同步到 Redis、ES 等多个下游系统。
缺点:
- 需要额外开发缓存同步的基础设施,不太适合小团队
总结:
选择缓存更新策略,本质是在 性能、一致性、复杂性 之间做权衡。
- 追求简单快速上线:从 Cache-Aside 开始,使用“先更新DB,再删除缓存”,并为缓存设置合理的TTL。
- 追求架构清晰与强最终一致:逐步过渡到 Cache-Aside + Binlog监听 的组合,这是面向未来的架构。
- 追求极致读性能,可接受写延迟:考虑 Write-Through,但需评估数据库的写负载能力。
- 追求极致写吞吐,可接受数据丢失风险:考虑 Write-Behind。
无论选择哪种,都必须遵从一下规则:
- 缓存一定要设置过期时间:这是防止缓存“永久”脏数据、保证最终一致性的最后防线。
- 数据库是唯一信源:任何方案的设计前提都是,在发生争议时,以数据库中的数据为准。
- 保证操作幂等性:删除缓存、监听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。