缓存一致性

背景

缓存 + 数据库 的经典架构里,缓存通常作为加速层来缓解数据库压力。
但在更新数据时,如果操作顺序处理不当,就可能导致缓存与数据库之间出现短暂的不一致。

常见的更新顺序有两种:

  1. 先写数据库,再写缓存

    • 优点:简单直观,能保证数据库最终一致性。
    • 缺点:在删除缓存和数据库更新之间的时间差,可能有请求先查到老数据,一致性弱。
  2. 先写缓存,再写数据库

    • 优点:能一定程度上减少脏读。
    • 缺点:如果数据库更新失败,就会丢掉缓存,反而更糟。

因此我们大多时候会选择先写数据库再写缓存。

删除与更新

在写缓存的时候我们可以选择删除或者更新,大多数时候我们会选择更新,因为[1]:

删除缓存 (Cache-Aside) 更新缓存
复杂程度 简单 复杂
幂等性 天然幂等(无论怎么删,最终结果都是缓存被删除了) 非幂等操作
并发写安全 安全 (删除顺序不影响最终状态) 不安全 (并发更新可能导致缓存数据错乱或覆盖,即使是redis也只保证单个命令是安全的)
效率 高 (直接删除,不关心数据) 低 (可能频繁更新一个后续无人读取的值,消耗CPU和带宽,特别是当你在维护一个复杂的缓存时)
数据最终一致性 更易保证 (删除不依赖数据) 更难保证 (依赖数据,若顺序错误可能脏甚至是错误)

缓存失效策略(Cache-Aside / Lazy-Loading)

写数据库,删除缓存,下次请求重建缓存,简单可靠。

双删

双删也是经常提到的办法,属于一种增强版的写数据库后删除缓存,能够减少不一致窗口期。

在更新数据库之后,执行两次缓存删除操作

事务A 事务B
更新数据库
删除缓存(第一次)
访问数据
缓存未命中,读取数据库
返回数据库中的数据
延时一段时间后删除缓存(第二次)

第二次删除缓存通常会延迟,目的是解决以下问题:

事务A 事务B
写数据库 val = 37
写数据库 val = 42
写缓存 val = 42
写缓存 val = 37
  1. 延迟时间的选择
    延迟需要大于业务中可能的并发请求处理时间。
  2. 幂等性
    删除缓存操作需要是幂等的,即多删几次不会有副作用。

缺陷

弱一致性

  • 在第一次删除和第二次删除之间,如果有读请求访问数据,还是可能会从数据库加载旧数据回填缓存 → 读到脏数据
  • 因此双删保证的只是 最终一致性无法保证请求级别的强一致性

令人纠结的时间

  • 延迟太短:第二次删除可能还没覆盖到并发读回填的缓存,最终一致性可能都无法保证(长时间或永久脏缓存)。
  • 延迟太长:第二次删除的间隔内,缓存可能被大量请求读写回填,脏数据存在的时间过长(脏数据多)。

惊群效应(Thundering Herds)

  • 频繁的缓存失效可能会导致惊群,会有多个请求导致请求访问数据库

实际上,延迟双删更多是工程妥协:在读多写少、对短时间不一致可容忍的业务场景下适用(个人感觉有点骚操作,要么就别这么写,要写就干脆写更稳妥的租约或版本号方案)。

租约(Lease)

这是由 Meta 分享的方案。

请求 缓存 token 租约
请求 A 访问缓存,等待缓存更新 失效,触发更新 创建 token 与失效的缓存的 key 绑定 租期开始
请求 B 访问缓存,等待缓存更新 失效 在租期中,获取和 A 共享的 token 租期中
请求 C 访问缓存,等待缓存更新 失效 在租期中,获取和 A 共享的 token 租期中
请求 A,B,C 获取最新缓存 更新完毕 token 失效 租期结束

这里的 A 作为更新者触发了缓存的更新,其他的请求在租期内时会等待更新完毕。A,B,C 会拿着 token 等待缓存更新完毕。

等待

请求 A,B,C 等待时一般会使用带抖动(避免惊群效应)的指数退避算法进行主动重试。

为什么不用其他的方法?

  1. 立即重试:这样会加重了访问压力。
  2. 固定间隔重试:可能导致周期性的压力和惊群。
  3. 线性退避:分散负载减少冲突的效果不如指数退避。
  4. 随机重试:缺乏不断增长的延迟,无法应对如微服务一段时间不可用或是数据库错误这种时间较长的场景。

版本号(Version)

这是由 Uber 分享过的方案

数据库每行数据都有一个时间戳作为版本号。数据变更时时间戳会被更新。数据写入缓存时会将要写入的版本号和缓存中已有的版本号进行比较,更新的版本号才会被写入缓存。

原子化操作 redis

使用 redis eval 执行 lua 脚本,脚本可以原子[2]化的执行操作:

  1. 读取缓存已有的版本号
  2. 比较已有的版本号和想要写入的版本号
  3. 当要写入的数据的版本号大于已有版本号时,写入数据和版本号至内存
1
2
3
4
5
6
7
8
9
10
11
local key = KEYS[1]
local value = ARGV[1]
local current_version = ARGV[2]
local version_key = 'version:'..key
local version_value = redis.call('get', version_key)
if version_value == false or version_value < current_version then
redis.call('mset', version_key, current_version, key, value)
return {value, true}
else
return {false, false}
end

变更数据捕获(Change Data Capture,CDC)

变更数据捕获是一种用于捕获和记录数据库中数据变化
的机制。当数据库数据发生变化(增删改)时,不通过业务代码或是主动查询/触发,而是直接在数据库或日志层面去自动捕获这种变化,并将内容记录下来给其他系统使用。

例如 Uber 基于 每个集群的 mysql binlog 实现了 CDC [3]。

总结

维度 删除(单删) 延迟双删 版本号机制(Version-based / 乐观锁) 租约机制(Lease-based / 悲观锁)
核心思想 更新 DB 后直接删缓存,依赖后续请求重建 更新 DB 后先删缓存 → 延迟一段时间再删一次,降低并发不一致风险 乐观假设冲突少,操作前不阻止并发 悲观独占,操作前获取租约确保独占性
并发处理 并发写不管,可能产生脏数据 并发写允许,延迟的第二次删除尽量覆盖不一致场景 并发写允许,提交时检查版本号 → 冲突失败需重试 租约持有者独占资源,其他客户端需等待或抢占
理论实现复杂度 :删缓存即可 低-中:删缓存 + 延迟调度机制即可 :只需在数据表加一个 version 字段,更新时比对版本号即可 :要设计租约过期、释放、续租等逻辑
工程实现复杂度 :最常见做法,但一致性弱 低-中:需要可靠的延迟任务机制(消息队列 / 定时任务),处理好失败重试 :分布式缓存场景下,需要 CDC 捕获数据库变更,还要用 Redis + Lua 脚本保证缓存原子更新,整体链路复杂 :可以依赖 Redis Redlock、ZooKeeper、Etcd 等现成分布式锁库,流程直接(获取租约 → 执行业务 → 释放租约)
数据一致性 弱一致性:高并发下可能缓存和 DB 不一致 较强一致性:大多数情况下能避免不一致,但仍可能有极端并发问题 最终一致性,由冲突重试保证 独占期间强一致性
适用场景 对一致性要求不高的业务,如商品详情 对一致性要求高但容忍极少数异常的业务 高读低写、冲突少、操作快 写冲突高、关键资源操作、长事务或分布式锁
故障处理 缓存丢失影响性能,不影响数据正确性 延迟任务失败可能导致不一致,需要补偿 冲突重试 租约过期 → 可被抢占,避免死锁
优点 实现简单,性能好 简单扩展单删思路,提高一致性 轻量、无需租约管理 写操作冲突少,操作安全,可控并发
缺点 高并发下不一致概率大 延迟时间不好把握,仍可能有小概率不一致 冲突重试成本高 租约管理复杂,过期设置敏感(例如租期太短可能导致操作频繁中断),依赖外部锁系统

另请参阅

Scaling Memcache at Facebook

meta-server-design

How Uber Serves Over 40 Million Reads Per Second from Online Storage Using an Integrated Cache

脚注

[1] 删除是 KISS原则
YAGNI原则
的体现。简单并不是简陋,好的设计是对需求的精准把握,我们作为设计师要从需求考虑,找出需求中的关键因素。

[2] redis 执行 lua 并非完全的原子性,详情请见 redis lua

[3] Flux 会 tail MySQL binlog,监视数据库的数据变更实现 CDC(使用 binlog 是一个较好的选择,这样不会让CDC被未提交的脏事务影响)。

Flux 是 Uber 的一个中间件,它不止会把数据发送给CDC还会发送给 replication, materialized views, data lake
ingestion,还会为验证集群中节点之间的数据一致性提供支持。