你是不是觉得先更新数据库,再删除缓存,最后给缓存加一个过期时间兜底。这个是最正确的缓存更新套路? 不是的,这个方案也有漏洞的。
在某些并发场景下,会导致永久性的脏数据的,我们一起来看一下怎么回事。
通常更新缓存的套路有几步:
- 更新数据库:把新值写进数据库;
- 删缓存:把当前缓存里的那条旧数据清掉,下一次读请求会从数据库重新加载;
- 给缓存加个过期时间:这是给「删不干净」做兜底的。删的动作可能因为网络抖动没成功,或者刚删完就被并发的读请求把旧值又塞回了缓存,过期时间保证脏数据最多活一阵子,到期自动失效
但是就算这三步每一步都做对了,缓存和数据库还是可能不一致。有一种场景下脏数据会一直存在缓存里,用户怎么刷都是旧值,只能等过期时间到了才消失。
这是一个竞态,Cache-Aside这种方式的路径太短了,短到让人觉得不会出问题。下文我们来讨论一下这个问题。
关于缓存一致性
聊缓存之前得先约定好我们在聊什么。
缓存一致性的定义:如果某个键存在于缓存中,那么它的值最终要和底层数据存储里的值相同,这样的缓存就是一致的。
用这个定义的话,我们可以来看两个事情。
第一,缓存里没这条数据,就谈不上错,但缓存也就没用了,所以谈一致性不能脱离命中率,光保证不会错但是不保证能命中,那就没有意义。
第二,这个定义里有个关键词是「最终」,也就是允许短暂的不一致,但不允许永久的不一致。短暂的旧值业务上通常可以接受,用户刷新一下页面就正常了;永久的旧值是则是故障,用户怎么刷都是错的数据。
有人会问,为什么不直接做强一致,让缓存任何时刻都和数据库相同?因为缓存和数据库是两个独立的系统,中间没有跨系统的事务保证,要做到任何时刻都一致,就得引入分布式锁或者一致性协议,每次读写都要多付出协调的开销,用缓存换性能的初衷就没了。
因此,请记住下面这句话:
工程上谈缓存一致性,谈的基本都是最终一致,方案之间比的是「不一致窗口的长短,以及出现永久脏数据的概率」。
Cache-Aside模式的永久不一致问题
Cache-Aside是绝大多数业务在用的缓存模式:读请求先查缓存,命中就返回;未命中就去查数据库,拿到值之后回填进缓存。写请求由业务代码自己负责处理数据库和缓存。
在没有TTL兜底的情况下,这个模式存在一种可能导致长期脏数据的竞态,时序是这样的:
- 请求A读缓存,未命中
- 请求A去数据库查询,拿到旧值
- 此时请求B更新了数据库,并删除了缓存里的这个键(这时缓存里本来就没有它,删除是个空操作)
- 请求A把手里的旧值回填进缓存
从第四步开始,缓存里就是脏数据了。后续所有读请求都会命中缓存,拿到的都是旧值,而且没有任何机制会去纠正它,只能等过期时间兜底。

这个竞态的根源在于,回填这个动作写进去的值,在读出来到写进去之间的这段时间里可能已经过时了。读数据库和回填缓存不是原子的,中间隔着一段网络往返,谁也保证不了这段时间里数据库没有发生变更。
触发它需要写操作恰好挤进读库和回填之间的间隙。这个间隙通常不长,但在高并发系统里却基本会发生,读写都频繁的热点数据,迟早会撞上一次。
因此我们还是得去处理这个问题的,因为不同业务场景下,你不知道这种事情发生后,会导致多大的后果。正因为不清楚后果,因此得处理。
更新数据时,四种写法怎么选
写请求要同时修改数据库和缓存,这里有两个选择:对缓存是更新还是删除,先动数据库还是先动缓存。组合起来就是四种写法。
先说更新还是删除。我的建议是删除,理由有三个。
第一,更新缓存在并发写的情况喜爱下会乱序(在没有版本号或乐观锁的前提下)。请求A先更新了数据库,请求B后更新了数据库,但B先完成了缓存更新,A后完成,缓存里最终留下的是A的旧值。这个脏数据同样是长期性的,而且触发条件比前面那个竞态宽松得多,两个写请求并发就可能触发。删除没有这个问题,删两次和删一次的结果一样,谁先谁后无所谓。
第二,写进缓存的值不一定有人读。对写多读少的数据,每次写都同步刷新缓存是浪费。删除相当于把回填推迟到真正有人读的时候,按需计算。
第三,很多缓存里存的不是数据库字段的原样拷贝,而是几张表连接计算出来的结果。更新缓存意味着写路径要承担重新计算的成本,删除则不用。
删除这个思路也叫失效。微软Azure架构中心对Cache-Aside模式的描述就是这么写的:应用更新数据时,先写数据存储,然后让缓存中对应的条目失效。
这里要澄清一个容易产生的误解:删除缓存只删被更新的那一个键,不是清空整个缓存,所以其他键的读请求依然会命中缓存,不会整片流量都打到数据库。真正能把数据库压垮的情况只有一种:被删的恰好是一个读请求极多的热点键,删的瞬间大量并发读同时未命中,全部涌向数据库。
但大多数键不是热点,删完之后只有零星的读请求未命中,对Redis这类单实例几十万QPS的缓存来说根本不是问题。真正的热点键怎么办?后面「Facebook用租约解决回填竞态」那一节会讲,用租约可以让删键之后的第一次回填只有一个请求去做,其他并发读稍等重试,把打向数据库的流量压回一次查询。
确定了用删而不是用更新之后,还有一个顺序问题:先动数据库还是先动缓存?开头那三句话里说的是先更新数据库再删缓存,但读者自然会问,反过来先删缓存再更新数据库行不行?
答案是不太行,先删缓存这种顺序竞态窗口反而更大:缓存删掉之后、数据库事务提交之前,任何一个读请求过来都会未命中,然后从数据库读到旧值回填。这个窗口覆盖了整个写事务从执行到提交的耗时,业务里还经常是一个事务更新好几张表,它比前面说的读库到回填之间的间隙大得多,撞上的概率也高得多。
四种组合,先更新数据库再删除缓存,是在复杂度、性能和最终一致性之间综合表现最好的默认方案,这也是它被推荐得最多的原因。

先更新库再删缓存剩下的两个问题
这个写法把竞态窗口压到了工程上可接受的程度,但还有两个问题没解决。
第一个就是开头那个回填竞态。要触发它,读请求必须在写请求提交之前读了数据库,并且回填动作发生在写请求删除缓存之后。第二个条件不太容易满足,因为回填只是一次对缓存的写入,通常比数据库事务提交加删除缓存的整个过程快得多,所以概率比先删后更新低了不少,但不是零。
这也是过期时间必须加的原因:它是所有方案共同的最后一层,保证任何来路的脏数据存活时间都有上界。过期时间定多长,取决于业务能容忍旧值存在多久,这是个业务问题,不是技术问题。
第二个问题是删除本身会失败。数据库更新成功了,删缓存的那一下碰上网络抖动或者缓存服务不可用,删除丢了,缓存里就一直是旧值,同样只能等过期。
删除失败怎么兜底
思路是重试,但不能在业务线程里同步重试。缓存服务真出问题的时候,同步重试会把业务请求一起拖住,缓存故障升级成服务故障。常见做法是把删除失败的键投到消息队列,由消费端异步重试,重试若干次仍失败就告警转人工。
这个做法能用,但还是有一个问题:业务代码里除了写库、删缓存,还要多一段发消息的逻辑,缓存失效的处理散落在每一个更新数据的入口里,入口多了之后,漏写一处就是一个隐患。
把失效逻辑挪到binlog订阅里
更好的做法是让业务代码只管写数据库,缓存失效交给一个独立的组件:订阅MySQL的binlog,解析出哪些数据变了,去删除对应的缓存。可以用阿里开源的Canal做这个事情,它会把自己伪装成MySQL的从库去接收binlog,再把变更转成结构化的事件投出来,后面接一个消费程序,由消费程序维护数据库变更和缓存键之间的映射关系,执行对应的缓存删除。
这个架构解决了前面遗留的几个问题:
- 避免缓存失效逻辑散落在多个业务入口。不管有多少个更新入口,数据库层面的变化都会进入binlog,失效逻辑集中在一处,漏删的风险明显降低
- 只处理真实提交过的变更。以常见的ROW格式binlog为例,里面记录的是已提交事务产生的数据变更,不存在删了缓存而数据库最终回滚的情况
- 有序,可以重放。对单个MySQL实例,binlog提供了事务级别的变更顺序,按顺序消费就不会出现并发乱序覆盖;分库分表场景下需要按分区键组织消费顺序,不能跨库假设全局有序。消费失败还能从位点重放,删除失败的重试也顺便解决了
关键点:变更日志起到的是单点序列化的作用。我们现在来看我前面提到的几个竞态,根源都是多个客户端各自往缓存里写,谁先谁后没有一个全局的裁决。把所有变更收拢到一条有序日志里,再按这个顺序去处理缓存,乱序的源头就被掐掉了。
Facebook的TAO也采用类似思想:利用底层数据复制流驱动缓存更新和失效,让缓存一致性处理跟随数据复制链路传播,这个设计发表在2013年的USENIX ATC论文里。
当然代价肯定也是有的,就是链路变长了,从数据库提交到缓存删除,中间隔着binlog落盘、Canal解析、消息投递、消费执行,端到端延迟通常在几十毫秒到几秒之间,取决于binlog产生速率、Canal解析能力和消费端的处理速度。不一致窗口并没有消失,只是从不可控的竞态变成了一段可监控、可度量的固定延迟。另外Canal和消费程序本身也需要部署和运维,出了故障还得有人排查,更新入口收敛、体量不大的项目,未必划得来。
Facebook用租约解决回填竞态
binlog订阅解决的是失效那一侧的问题,开头那个回填竞态它管不着,因为回填是读请求做的,不经过binlog。Facebook在Memcache这一层用租约把它堵上了,方案发表在NSDI 2013的论文Scaling Memcache at Facebook里。
租约的机制是这样的:读请求未命中时,Memcache发一个租约给客户端,可以理解为一个跟这个键绑定的令牌;客户端回填时必须带上租约;如果这期间这个键被删除过,租约就作废,回填会被拒绝。
我们用这个方案,重新演练下,开头我提到的的时序:请求A未命中,拿到租约,去读数据库拿到旧值;请求B更新数据库并删除缓存,租约随之作废;请求A回填时校验失败,旧值写不进去。脏数据从源头上就进不了缓存。
租约还顺手解决了另一个问题:热点键失效瞬间,大量请求同时未命中,全部涌向数据库,也就是常说的缓存击穿。Memcache对同一个键每10秒只发放一个租约,没拿到租约的请求稍等重试,等第一个拿到租约的请求回填完成,后面的就直接命中了。论文给的数据是,租约上线后数据库峰值查询量从每秒1.7万降到了1300。
Redis没有租约这个原语,但可以用版本号或类似CAS的机制模拟出类似效果。例如数据库里带一个version字段,请求A回填前先读version,回填时把version一起带上,缓存层对比version,过期则拒绝写入。
具体到Redis,可以用Lua脚本原子地完成GET、比较、写入,避免回填过程中再插进新的更新。
但要注意version字段本身的来源问题:如果从数据库里读,回填时多了一次数据库访问;如果缓存在Redis里跟数据一起存,那version本身也会成为新的不一致源。这也是为什么版本号方案只适合用在极少数真正的热点键上,绝大多数业务不需要走到这一步。这个竞态概率本来就低,过期时间又保证了脏数据有存活上界,为它增加的复杂度往往超过它造成的业务损失。它真正值得投入的场景是读写都极高频的热点数据,而且业务对旧值敏感,比如库存、余额这类。
各种方案我整理成一张表,可以收藏
最后把几个方案放一起比较一下:
| 方案 | 不一致窗口 | 永久脏数据风险 | 复杂度 | 适合的场景 |
|---|---|---|---|---|
| 只加过期时间 | 等于过期时间 | 无,到期自愈 | 最低 | 数据很少变,业务容忍度高 |
| 先删缓存再更新库 | 大,数据库写耗时级别 | 高,延迟双删无法根治回填竞态 | 低 | 不建议作为首选 |
| 先更新库再删缓存 + 过期时间 | 毫秒级,低概率回填竞态 | 有,靠过期时间兜底 | 低 | 大多数业务的默认选择 |
| 上一行 + 消息队列重试删除 | 同上,删除失败可恢复 | 更低 | 中 | 对旧值较敏感,暂不想引入binlog组件 |
| binlog订阅失效(Canal) | 几十毫秒到几秒,可监控 | 失效侧无,回填竞态仍在 | 中高 | 更新入口多、缓存副本多、有异构存储要同步 |
| 租约或版本校验回填 | 回填竞态也被堵上 | 基本无 | 高 | 极高频读写的热点键,业务对旧值敏感 |
小结
缓存一致性这个话题的文章很多,各种方案看起来五花八门,但它们做的是同一件事:缩短不一致的时间窗口,并给脏数据的存活时间一个上界。方案之间比的从来不是谁能做到绝对一致,而是谁的不一致窗口更短、更可控。
知道这一点,选型就从技术问题变成了业务问题:先回答业务能容忍多久的旧值,再用最小的复杂度把窗口压进这个容忍度以内,而不是反过来先挑一个看起来最完备的方案。
感谢你的阅读,希望这篇文章对你有帮助。