工程实践

缓存与数据库一致性:从 Cache Aside 到布隆过滤器

从一个读多写少的业务接口出发,推演双写方案为何失败、Cache Aside 为何成为主流,再到击穿、雪崩、穿透三类失效场景与布隆过滤器的取舍——一条主线串起缓存一致性背后的完整决策链。

缓存RedisCache Aside布隆过滤器高并发

给系统加一层缓存,听上去只是「查得更快」这么简单,但只要写操作一介入,一致性问题就会接踵而至。本文用一条主线把这些问题串起来:只要缓存与数据库是两个独立系统、又不做分布式事务,就不存在绝对的强一致,所有方案都只是在缩小不一致的窗口。 理解了这个前提,从双写方案的失败,到 Cache Aside 成为主流,再到击穿、雪崩、穿透的应对,几乎都能顺势推导出来。

本文整理自一次围绕「缓存与数据库一致性」的系统学习,重新组织为一篇完整的技术梳理。文中标注的「常见误区」,是这一主题下几乎每个人都会遇到的思维陷阱,特意保留下来作为对照。


一、问题的起点:一个真实的业务场景

考虑一个跑步类应用的用户主页接口,需要返回昵称、头像、月度跑量、会员状态四项数据。这个接口在每次打开应用时都会被调用,QPS 很高;而它背后的数据往往几天才变更一次。这是典型的读多写少场景,也正是缓存最该介入的地方。于是引入 Redis:

读:先查 Redis → 命中则直接返回 → 未命中则查 MySQL,并写回 Redis
写:更新 MySQL → 此时 Redis 该如何处理?

整篇文章要回答的,正是写操作发生时的那一个问号:数据库更新之后,缓存究竟该怎么办。所有的复杂性都由它衍生而来。

二、两种直觉方案为何失败

2.1 双写:更新数据库,再更新缓存

最自然的想法是「数据库和缓存都更新一遍」。但只要两个写请求并发修改同一个 key,就可能出现下面的时序——例如用户手快,连续两次修改了昵称:

A:更新 MySQL = "西风"
B:更新 MySQL = "西风工坊"     ← 数据库最终值为 B,正确
B:更新 Redis = "西风工坊"
A:更新 Redis = "西风"          ← A 的网络慢了一步,反而后写入

结果:MySQL 是「西风工坊」,Redis 是「西风」,脏数据会一直存活到 TTL 到期。

常见误区一:把病根归结为「网络抖动导致时序错乱」。

网络抖动只是诱因,不是病根。反问一句便知:为什么 MySQL 自身不惧并发写?因为数据库内部有锁与事务,能为并发操作排出一个确定的顺序;Redis 单独来看同样如此。真正的病根在于——「写数据库」与「写缓存」是横跨两个独立系统的两次操作,没有任何机制保证两个系统各自排出的顺序是同一个顺序。 MySQL 中 A 先 B 后,Redis 中 B 先 A 后,各自内部都合法,合起来就是脏数据。一句话:双写不原子,顺序无保证。

常见误区二:认为这种乱序「大概率发生」。

量词需要精确。双写乱序要求两个写请求并发修改同一个 key,对昵称这类低频写入的数据其实并不常见。准确的表述是:它的触发概率比 Cache Aside 高得多,且一旦发生便没有自愈能力——是「概率更高 + 后果持久」,而非「大概率」。在严谨的技术表述里,量词是否精确,往往直接决定一个判断成不成立。

2.2 先删缓存,再更新数据库

换个思路:写操作先删缓存,再更新数据库。这同样有一段危险窗口:

写:删除 Redis
                读:查 Redis → 未命中(刚被删)
                读:查 MySQL → 读到旧值(写操作尚未提交)
写:更新 MySQL = 新值
                读:把旧值写回 Redis   ← 脏数据形成

删缓存与更新数据库之间的窗口里,只要来一个读请求就会中招,同样是长期脏数据。

业界流传的延迟双删,正是给这个方案打的补丁:更新完数据库后等待数百毫秒,再删一次缓存,把窗口期内可能被灌回去的旧值清掉。但「等待多久」缺乏可靠依据,只能凭经验拍板,属于治标不治本。了解它是一个补丁即可,不建议作为生产环境的主方案。

三、Cache Aside:先更新数据库,再删缓存

主流方案是先更新数据库,再删除缓存,即经典的 Cache Aside 模式。它并非没有理论上的翻车时序:

读:查 Redis → 未命中(缓存恰好刚过期)
读:查 MySQL → 读到旧值
            写:更新 MySQL = 新值
            写:删除 Redis(此时缓存本就是空的)
读:把旧值写回 Redis     ← 脏数据形成

但它要求同时满足三个条件:

  1. 读请求发生时,缓存恰好失效;
  2. 读请求查库之后,写操作进来并完成「更新库 + 删缓存」的整套动作;
  3. 读请求的回填动作,要落在写操作整套动作之后才完成。

概率极低的关键在第 3 条:读操作(毫秒级)通常远快于写操作(涉及事务与落盘,往往几十甚至上百毫秒)。 触发翻车要求一个快操作跑输一个慢操作——快马跑输慢马,现实中概率极低。工程上的态度因此很务实:接受这个极小概率,再用 TTL 兜底,即便真的中招,脏数据也最多存活到 TTL 结束。

四、为什么是「删」而不是「更新」:惰性加载的哲学

Cache Aside 选择删除而非更新缓存,背后有一条值得单独拎出来的设计原则:

删除是把「构建缓存的成本」推迟到真正有人读取的那一刻才支付(lazy loading);而更新是每一次写入都预先支付一次可能根本没人消费的成本。

算一笔账。即便在写多读少的极端情形下——某个 key 一分钟被写 100 次,却只被读 2 次:

  • 更新缓存:100 次计算新值 + 100 次写入 Redis,其中最多 2 次的结果会被真正读到,98% 的缓存写入是纯粹的无用功。如果缓存值还需要回查数据库做聚合计算,浪费会更加夸张。
  • 删除缓存:100 次删除(操作极轻),只有那 2 次读发生时才回源加载。按需加载,读几次就算几次。

这与惰性求值(如生成器的按需计算)是同一种思想:不到用时不计算。

常见误区三:认为「缓存删除后,每一次读取都会直击数据库」。

严格来说,只有删除之后的第一个读请求会穿透到数据库,它完成回填后,后续读又重新命中缓存。只有在超高并发下,大量读请求同时挤进「回填尚未完成」的窗口,才会集体压向数据库——这正是后文要讨论的缓存击穿

「短 TTL 兜底」为什么不是好主意

有人会想:既然双写乱序难以根治,那就加一个短 TTL(比如 5 秒)兜底,脏数据最多活 5 秒,有界可控。代价是什么?

TTL 兜底的时长,和脏数据的存活上限,是同一个数字。 想让脏数据最多存活 1 秒,TTL 就得设成 1 秒,回源频率随之暴涨,缓存命中率随即崩盘。这个方案的本质,是拿命中率去换一致性——可我们引入缓存的初衷正是命中率,这等于在拆自己的地基。

兜底窗口越短,缓存越没有价值。两个目标被绑在同一个旋钮上,此消彼长,是一场零和博弈。

五、删缓存失败了怎么办

还有一种情况:MySQL 更新成功,删 Redis 时网络抖动导致删除失败——缓存里就是旧值,一直到 TTL 到期。两种加固手段:

  1. 消息队列重试:删除失败就把任务投进 MQ,由消费者不断重试直到删成功。缺点是业务代码里混入了缓存维护逻辑,职责不够纯粹。
  2. 订阅 binlog(Canal 方案):业务代码只负责写 MySQL,完全不碰缓存;另起一个服务订阅数据库 binlog,监听到变更就删除对应缓存。它有两个突出优点——业务与缓存彻底解耦,且 binlog 记录的是数据库真实的提交顺序,天然规避乱序。代价是要多维护一套基础设施,并引入一定的异步延迟(正常负载下通常在亚秒级,binlog 积压时才会升到秒级)。这一方案在国内中大型团队中应用相当广泛。

六、缓存的三类失效场景:击穿、雪崩、穿透

6.1 击穿:热点 key 失效的瞬间

某个热点 key(比如首页热门榜)QPS 高达一万,在它 TTL 到期的一瞬间:一万个读请求同时未命中,同时冲向 MySQL。高并发瞬间从快路径跌落到慢路径,数据库直接被打爆。

常见误区四:第一反应是对数据库查询做限流排队。

排队只是把「一万个请求挤垮数据库」变成「一万个请求拖垮数据库」——数据库仍要执行一万次查询,只是从「挤死」变成「慢死」,用户端则集体超时。真正的关键在于:这一万个请求要的是同一份数据,凭什么查一万次?

解法一:互斥锁(singleflight)。 缓存未命中时,只放行第一个抢到锁的请求去查库回填,其余 9999 个请求原地等待数十毫秒后重新读缓存——此时数据已经就绪。一万次查库因此收敛为一次。它与排队的本质区别在于:排队是「串行化一万次劳动」,singleflight 是「一个人劳动,九千九百九十九个人吃现成的」。Go 官方在扩展库 golang.org/x/sync 中就提供了同名的 singleflight 包,可直接复用。

解法二:逻辑过期(永不物理过期)。 在 Redis 层面不设 TTL,让数据物理上永远存在;把过期时间作为一个字段写进 value:{data: {...}, expire_at: 1720340000}。读请求发现逻辑上已过期时,照样返回旧数据,同时仅由抢到锁的那一个请求触发异步刷新。它的妙处在于:任何时刻缓存里都有值可返回,「未命中」这个状态被彻底消灭,击穿也就无从谈起。代价是用户可能读到几秒钟的旧数据——这又是一次拿一致性换可用性的取舍,而热门榜晚几秒更新,通常没人在意。

同一思路家族里还有缓存预热:大促之前主动把热点数据灌进缓存并续期,不给它自然过期的机会。

6.2 雪崩:大规模的击穿

典型成因是:凌晨批量导入一批数据,统一设置 TTL = 24 小时,第二天凌晨海量 key 在同一瞬间集体失效,雪层整个塌下来。更极端的情况是 Redis 直接宕机。

雪崩本质上就是大规模的击穿,是同一个物理过程的不同量级。 区别只在一个字:

  • 击穿:一个点被打穿,数据库挨的是一根针;
  • 雪崩:整个面塌下来,数据库挨的是一堵墙。

量级不同却要单独命名,是因为解法不同

  1. 打散过期时间:给 TTL 加随机抖动,如 24h + random(0, 30min),让 key 错峰失效。这个解法对击穿毫无意义(单个 key 打散给谁看?),是雪崩专属。
  2. 高可用架构:哨兵、集群,别让 Redis 整个挂掉。
  3. 降级与限流兜底:真的塌了,宁可让一部分请求快速失败,也不能让数据库被拖垮、进而连累所有业务。洪水一旦成形,限流就是最后一道堤坝。

一句话记忆:击穿看 key,雪崩看面;击穿加锁,雪崩打散。

6.3 穿透:查询根本不存在的数据

设想有人拿随机生成的、数据库里根本不存在的 user_id 疯狂打接口。病根在于 Cache Aside 的回填逻辑:只有查库有结果才回填缓存;数据库里本就没有的数据,缓存永远填不上。 于是这个 key 上的防御是一个空洞,每一发请求都直击数据库。

解法一:缓存空值。 查库发现不存在时,就往 Redis 写一条 12345 → NULL,并设一个较短的 TTL(比如 60 秒)。下次同样的请求命中缓存直接返回空,数据库无损。两个细节:

  • 为什么是「缓存空值」而不是「直接拒绝」?因为「现在不存在」不代表「永远不存在」——这个用户可能明天就注册了。空值缓存的 TTL,恰好就是「新数据出现后多久能被看见」的不一致窗口,一切仍是取舍。
  • 软肋:攻击者的 ID 是随机生成的,几乎不重复。缓存了 12345 的空值,他下一发就打 67890——空值缓存一条都命不中,而且每打一发,Redis 里就多一条垃圾,内存反而被撑爆。空值缓存能防住「重复查询同一个不存在的 key」,防不住「每次都查一个新的」。

解法二:布隆过滤器(Bloom Filter)。 它把问题转化为:在请求触及 Redis 和 MySQL 之前,用极小的成本回答一个问题——「这个 ID 到底可能不可能存在?」

朴素的做法是把一亿个真实 ID 全塞进内存 Set,但那要占好几个 GB。布隆过滤器的做法是:开一个 10 亿比特(约 120 MB)的位数组,初始全为 0;对每个真实 ID 用 k 个相互独立的哈希函数(工程上通常取约 7 个)各算出一个位置,把这 k 位都设为 1。请求进来时同样用这 k 个哈希函数算出 k 个位置,逐一查看:

  • 这 k 位中只要有任意一位是 0 → 这个 ID 一定不存在(它若真的存在,这 k 位理应全部被设为 1,可以斩钉截铁);
  • 这 k 位全部为 1 → 只能说可能存在(每一位都可能是别的 ID 哈希碰撞出来的 1,全部命中也只能说明每个位置上都至少有一个真实 ID 撞过)。

这是一个「单边可靠」的结构:说「不存在」绝对准确,说「存在」只是可能。

常见误区五:以为哈希碰撞误报放行的那一发「至少缓存也命中了」。

并非如此。那个伪造的 ID 从未被查询过、也不存在于数据库,Redis 中根本没有它的缓存;误报放行的请求照样穿透到数据库,由数据库替这一下买单。

那布隆过滤器岂不是白干了?再算一笔账:

  • 攻击者随机生成的伪造 ID,99% 以上会因这 k 位中至少有一位为 0 而被「绝对可靠」的那一侧当场拦死,连 Redis 都碰不到。一亿发攻击,九千九百多万发止步于这 120 MB 的内存判断。
  • 漏过去的不到 1%(在约 7 个哈希函数、每个元素约 10 比特这组参数下,误报率约 0.8%;再增大位数组或调整哈希函数个数还能进一步压低)才打到数据库,但量级已经从洪水变成滴水
  • 真实用户永远不会被误伤:真实 ID 对应的 k 位一定全是 1,一定放行。误报的方向是「把假的放进来一点点」,而不是「把真的拦在外面」。

这正是布隆过滤器的灵魂:

它出错的方向,和业务能够容忍的方向,是同一个方向。 防穿透要的正是「绝不冤枉好人,漏掉极少数坏人无所谓」——布隆过滤器恰好就是这个形状。反过来,如果某个场景要求「说存在就必须真存在」(比如判断优惠券是否已核销),布隆过滤器就完全不能用。选数据结构,看的从来不是它有多强,而是它出错的方向你受不受得起。

实践中,两招常常叠加使用:布隆过滤器拦下 99%,漏网的少数再进空值缓存防重放,两层过后数据库基本无感。

七、第一性原理:一致性是花钱买来的

把全文压缩成一句话:

只要缓存与数据库是两个独立系统、又不做分布式事务,就不可能有绝对的强一致。所有方案都是在缩小不一致的窗口。 因此正确的工程思维,是先反问业务:这项数据,能容忍多久的不一致?

  • 昵称、头像、文章内容:脏几秒无人在意,Cache Aside + TTL 足矣;
  • 库存、余额:不能拿缓存当权威,扣减必须走数据库(或 Redis 原子操作 + 异步落库),缓存只承担展示;
  • 订阅状态这类涉及金钱的数据:关键路径宁可穿透到库,或干脆不走缓存。

需要顺带纠正一个直觉:互联网系统里绝大多数读场景,其实都能容忍秒级的脏数据——这正是缓存能够大行其道的前提。真正不能容忍的是少数关键数据,而它们的答案是「不要依赖缓存」,而不是「换一个缓存方案」。如果确实需要强一致,正解是「别用缓存」,或者用读写锁把读写串行化——但那样一来,缓存的性能意义也就不复存在了。

八、知识地图速查

主题 病根 / 场景 解法 关键一句话
双写乱序 跨两个系统的操作无顺序保证 不用双写,改为删缓存 双写不原子,顺序无保证
Cache Aside 翻车 读的回填落在写的整套动作之后 接受极小概率 + TTL 兜底 快马跑输慢马,概率极低
删 vs 更新 写多读少时更新全是无用功 删缓存 成本推迟到消费那一刻(lazy loading)
删缓存失败 网络抖动导致旧值滞留 MQ 重试 / Canal 订阅 binlog binlog 天然有序且解耦
击穿 单个热点 key 失效 singleflight 锁 / 逻辑过期 / 预热 一个人劳动,其余人吃现成的
雪崩 海量 key 同时失效 / Redis 宕机 TTL 加随机抖动 / 高可用 / 降级限流 击穿看 key,雪崩看面
穿透 查询不存在的数据,缓存永远填不上 空值缓存 + 布隆过滤器 出错方向与业务容忍方向对齐

结语

回顾这套知识的脉络,会发现它并不是一堆需要死记硬背的方案清单,而是一条从同一个前提反复推导出来的决策链:承认缓存与数据库无法强一致,于是选择删除而非更新、接受极小概率并用 TTL 兜底、按数据的一致性容忍度分级对待。而文中标注的五个「常见误区」,恰恰是这条链上最容易被绕过的几个弯——把病根归给网络抖动、用错量词、误以为删缓存等于次次直击数据库、想用排队解决击穿、以为布隆过滤器的误报无伤大雅。能把这几个弯一一走通,缓存一致性这个话题也就真正吃透了。