先聊一个特别常见的现场:某电商平台运营后台改了一条商品价格,数据库里已经变成了 29.9,但用户端 App 上持续显示的却是 39.9。一开始大家都怀疑是缓存没更新,上去 Redis 一看,发现 key 还在,value 是旧的。为什么会这样?因为当时的逻辑是“先删缓存,再改数据库”,删除缓存后的一瞬间,有一个读请求刚好发现缓存 miss,回源数据库读到了旧值,又写回了缓存。等数据库更新完,缓存里早就躺着一份洗不掉的脏数据。这种问题在 Redis 缓存项目里极其常见,根子就在于缓存更新策略在设计时没有把并发时间窗考虑清楚。
缓存更新的本质,其实是数据库和缓存两个独立存储之间的一致性协调问题。一次写操作不可能原子地同时生效于两边,于是任何一个读请求在中间某一刻都可能读到旧值。所有缓存更新策略,本质上都在回答三个问题:先动数据库还是先动缓存;更新失败怎么办;并发访问时怎么兜底。这篇文章我打算把这三件事掰开揉碎,结合自己踩过的坑和线上事故复盘,把 Redis 缓存更新策略从原理到实战完整过一遍,适合正在做后端开发、正在设计缓存方案、或者已经被“缓存与数据库不一致”折磨过的同学参考。
1. 缓存不一致的三个时间窗口:先搞懂问题到底出在哪
1.1 一次典型事故的复盘
先复盘一下开头说的那个事故。当时我们排查链路时,第一反应是查应用日志里有没有删除 Redis 的操作,发现确实执行了,但执行顺序是:
- 删除缓存 key
- 更新数据库
- 结束
问题就出在第一步和第二步之间。删除缓存之后、更新数据库之前,有一个读请求进来了。它发现缓存没有数据,于是走了一次完整的“缓存重建”流程:从数据库里查出旧价格 39.9,写回 Redis。这个动作发生在数据库更新之前,所以写回 Redis 的必然是旧值。等数据库更新成 29.9 以后,Redis 里的旧值 39.9 已经“合法”地存在了,后续所有读请求都会直接命中缓存,一直读到旧数据,直到 key 过期。
这个事故说明了一个关键点:缓存的更新不是“删了就完事”,而是要保证删除缓存与数据库变更之间的时间窗里,没有任何读请求能把旧数据写回去。
1.2 不一致的三个时间窗口
把各种更新策略放在一起比较,你会发现所有的不一致问题都能归结到以下三个时间窗里:
窗口一:写请求与读请求交错。这是最典型的情况。写请求先改了数据库,还没删缓存;此时一个读请求来了,命中旧缓存,返回旧数据。这个窗口极小,但存在。
窗口二:删缓存与数据库更新之间的重建窗口。就像刚才复盘的那样。先删缓存,后改数据库,中间任何读请求都会回源数据库,查到什么就写回什么。如果查到的还是旧值,脏数据就落地了。
窗口三:删除操作本身失败。就算你选对了顺序,先更新数据库、再删缓存,但 Redis 删除操作可能因为网络超时、命令执行失败等原因没有生效。数据库已经是新值,缓存还是旧值,两者就会长期不一致。
理解这三个窗口,是选择更新策略的前提。因为任何策略都是在跟这三个窗口做斗争,只是有的选择规避窗口一,有的选择缩小窗口二,有的靠补偿机制兜住窗口三。
2. 主流更新策略横向拆解:Cache Aside、Read/Write Through、Write Behind
2.1 Cache Aside 旁路缓存:多数项目的默认答案
Cache Aside(旁路缓存)是目前应用最广的策略。它的读流程是:读请求先查缓存,命中就直接返回;不命中则查数据库,再把结果写回缓存。写流程是:先更新数据库,成功后再删除缓存。
为什么写的时候是“删除缓存”而不是“更新缓存”?这是很多人第一个想不通的点。我的理解是:删除比更新更安全。更新缓存意味着你要把新值主动写进 Redis,但如果这个 key 根本没人读,这次写就纯属浪费;而且更新缓存需要额外查一次数据库或者依赖调用方传入新值,调用链路会多一次 IO。删除缓存则简单得多,下次读请求来了,缓存 miss,自然会回源数据库并重建新值。
但 Cache Aside 不是完美的。它的第一个问题就是窗口一:更新数据库成功、删除缓存之前,会有一个极短的读旧缓存的过程。第二个问题是删除失败:Redis 不可用或者网络抖动时,删除命令没发出去,缓存就成了永久脏数据。这些后续都需要补偿手段兜底。
2.2 Read/Write Through 读写穿透:把缓存当成存储层
Read/Write Through(读写穿透)的思路完全不同。它引入了一个中间层(通常是缓存组件自身),对业务方来说,读和写都只跟缓存交互,缓存在背后负责与数据库同步。业务代码里你不会看到“删缓存”和“写数据库”这两个独立操作,它们都被封装进了缓存中间件里。
具体来说,读穿透流程是:业务读缓存,缓存 miss 时由缓存组件回源数据库并写回;写穿透流程是:业务写缓存,缓存组件同步把数据写入数据库,写成功才算完成。
这种策略最大的优点是业务代码极其简洁,你不需要关心先删缓存还是先更数据库,一致性逻辑全部收敛在中间件层,不容易在业务侧写出五花八门的错误顺序。缺点是:缓存组件变成强依赖,它的性能直接决定整个读写链路的上限;并且市面上很多缓存组件对穿透策略的支持并不完整,真要用往往需要自己基于客户端做二次封装,改造工程量不小。适合那些团队有专门的中间件维护能力、希望统一缓存管理规范的中大型项目。
2.3 Write Behind Caching:性能极致与一致性代价的交换
Write Behind(写后回写)的策略是:写请求只更新缓存,立即返回成功;缓存在后台异步批量地将数据写入数据库。
这是三种主流策略中性能最高的一种,因为写操作完全避开了数据库的实时 IO,数据库压力被削峰填谷,非常适合写入量大、对实时一致性要求低的场景,比如点击量统计、行为日志、点赞数等。
代价也极其明显:如果缓存进程在异步回写之前崩溃,这批数据就永久丢失;而且从写缓存到写数据库之间存在一个不可忽略的时间窗,期间数据库里查不到最新值,做读扩散类业务时会出现“写后立刻读不到”的问题。我在实际项目中只建议把它用在可以容忍最终一致、甚至可以容忍小概率丢数据的统计类场景,核心交易数据绝对不要用。
三种策略的对比我用一张表来收尾:
| 策略 | 一致性水平 | 读性能 | 写性能 | 实现复杂度 | 典型场景 |
|---|---|---|---|---|---|
| Cache Aside | 最终一致,需补偿 | 高 | 中(先写库再删缓存) | 低 | 绝大多数业务系统 |
| Read/Write Through | 较强(由中间件保证) | 高 | 中 | 高(需封装中间件) | 组件化团队、统一缓存平台 |
| Write Behind | 弱(最终一致窗口大) | 高 | 极高(异步批量) | 中 | 计数、日志、榜单 |
3. 实战中必须做对的三件事:顺序、补偿、并发防线
3.1 为什么是“先更新数据库,再删除缓存”
我在带团队时要求所有业务默认遵循“先更新数据库,成功后删除缓存”的顺序,理由其实在第一节的复盘里已经埋了伏笔。
如果反过来“先删缓存,再更新数据库”,你必须面对一个致命风险:读请求在缓存删除后、数据库更新前的时间窗里回源数据库,把旧值重新写回缓存。注意,这个时间窗不是一个极短瞬间——从删缓存到执行数据库 Update,中间隔着网络 RTT、连接池获取连接、SQL 解析执行,通常有几十毫秒甚至更长,足以让并发读请求完成一次完整的缓存重建。而且一旦脏数据被写回,后续所有读请求都会命中它,你很难知道它是什么时候写回去的。
而“先更新数据库,再删除缓存”的问题窗口就小得多:旧值只有在数据库更新成功到缓存删除成功之间才会被读到。这个时间窗实际就是两次 Redis 操作间隙,通常只有几毫秒,而且可以通过删除失败的重试机制进一步兜底。所以两害相权取其轻,先更数据库再删缓存是更稳妥的默认顺序。
3.2 删除失败的补偿:异步重试队列
先更数据库再删缓存,听起来稳了,但你没法保证 Redis 删除永远成功。网络抖动、Redis 主从切换瞬间、连接池耗尽,都可能让删除命令“静默失败”。删除丢了对业务是不可感知的,但缓存里的旧值会一直存在,直到过期时间到了。
所以我会给删除操作配一条补偿链路:把删除动作做成“可重试”的。具体做法是,业务写完数据库后,先尝试直接删除缓存;如果返回失败或超时,则把这次删除事件投递到本地的一个延迟消息队列或者定时任务表里,由后台任务稍后重试。重试时再读一次数据库当前值,删除缓存,确保最终收敛。
伪代码大致是这样的:
def update_data(db, cache, new_value): # 1. 先更新数据库 db.update(new_value) # 2. 尝试删除缓存 try: cache.delete(key) except Exception: # 3. 删除失败,投递到重试队列 retry_queue.send({"key": key, "attempt": 1}) def retry_worker(): while True: msg = retry_queue.receive() try: cache.delete(msg["key"]) except Exception: if msg["attempt"] < 5: retry_queue.send({...})注意,重试的时候最好不要只删一次,而是要结合后续的“延迟双删”一起做,否则重试窗口里仍有可能被并发读请求把旧值写回去。这个我放到下一节展开。
3.3 缓存重建风暴的防线:互斥锁与逻辑过期
“先删缓存”策略还有一个衍生风险:缓存被删掉之后,如果此时有大量读请求同时 miss,它们会一起回源数据库,瞬间把数据库压垮,这就是缓存重建风暴。
面对这种场景,我常用的手段有两个。第一个是互斥锁方案:当缓存 miss 时,读请求先尝试获取一把分布式锁(以 key 为粒度),只有拿到锁的请求才允许回源数据库并重建缓存,其他请求要么短暂自旋等待,要么直接返回旧值/降级值。这样数据库同一时间点最多只有一个线程在执行重建。
第二个是逻辑过期方案:给缓存 value 里额外存一个过期时间戳,不是等 Redis 物理过期,而是由代码判断逻辑上是否过期。如果发现逻辑过期,读请求不删除缓存,而是直接返回旧值,同时后台用一个异步线程去刷新缓存。这个方案的好处是读请求永远不会 miss,对数据库的压力几乎为零,代价是短暂时间内读请求会读到逻辑上已经过期的数据。对实时性要求没那么苛刻的业务(比如商品详情、资讯列表),这个方案效果非常好。
4. 把一致性窗口压到最小的进阶方案
4.1 延迟双删:一个简单有效的妥协方案
如果你的团队短期内不想引入大型改动,延迟双删是你用最简单的方式把一致性窗口压到最小的办法。
延迟双删的执行流程是:
- 先删除缓存;
- 再更新数据库;
- 等待一段时间(比如 500ms);
- 再次删除缓存。
这样做的目的是解决第一节复盘的那个事故场景:你在更新数据库之前删了一次缓存,中间如果有读请求把旧值写回缓存,那么等数据库更新完成后,第二次删除会把这份旧值清掉,从而保证最终的缓存值是从新数据库状态重建出来的。
那等待时间怎么定?核心原则是:要大于“读请求回源数据库+写回缓存”的最大耗时。你可以在压测环境模拟高并发读,统计出回源耗时 P99 值,再留足余量。比如 P99 是 300ms,那 sleep 就设置 500ms 以上。注意,延迟双删只是妥协方案,不能做到绝对一致,极端情况下(第二次删除前又有一个读请求回源)仍可能出现问题,但它能解决线上绝大多数不一致事故,而且成本极低。
4.2 版本号方案:让旧数据自己“失效”
延迟双删靠的是时间换空间,版本号方案则从数据层面直接让旧值失去意义。
核心思路是:给每条数据维护一个版本号(或者用更新时间戳),写入缓存时把版本号和 value 一并存储。读请求在拿到缓存值后,不只是无脑返回,而是拿版本号跟数据库当前版本号做一次比对(这个比对可以是内存里的位点信息,也可以由发送方在请求里带过来)。如果版本号落后,则主动丢弃缓存值,重新回源数据库并刷新缓存。
这个方案可以把一致性窗口从“毫秒级”消除到“只要有版本对比就能发现”。代价是:你需要保证版本号的单一来源,每次更新数据库时版本号都要递增;如果版本号本身是从数据库查询出来的,那一次额外的查询开销基本抵消了缓存的大部分收益。所以它更适合那些“写少读多、且写操作能天然携带递增序号”的业务,比如订单状态流转、文章编辑版本等。
4.3 订阅数据库变更日志,异步刷新缓存
更彻底、也更有工程感的方案是走变更数据捕获(CDC)路线。原理并不复杂:业务依然正常更新数据库,但不再由业务代码主动去删缓存,而是由一个独立的数据变更订阅组件监听数据库的变更日志(比如主从同步日志或事务日志),把每次数据变更解析出来,再异步地刷新对应缓存 key。
这个方案的好处是彻底解耦:业务代码只需要关心数据库写操作,缓存的一致性由旁路系统保证,即使业务侧漏删了缓存,旁路也能兜底。而且你还可以在刷新缓存前做一次“当前缓存值 vs 数据库值”的比对,只更新真正不一致的数据。缺点是需要维护一套额外的数据管道,以及一部分数据库变更日志的解析开发成本。对于已经有成熟消息中间件、且数据库量级较大的团队,这套方案的长期收益非常明显。
我在实际项目中的建议是:如果团队规模小、业务刚起步,延迟双删加异步重试已经足够;如果数据一致性是硬性要求,且团队有基础设施维护能力,直接上变更日志订阅方案,从根上解决被动删缓存的问题。
5. 不同业务场景怎么选:直接给结论
5.1 选型对照表与建议
看了这么多原理,最后还是要落到“我的业务到底该怎么选”。我按业务特征给出一个选型参考:
| 业务特征 | 推荐策略 | 理由 |
|---|---|---|
| 常规读多写少业务(商品、订单、用户信息) | Cache Aside + 延迟双删 + 异步重试 | 成本低,一致性可控 |
| 数据一致性要求极高(库存、余额) | 版本号方案 + 更新数据库后同步删缓存 | 版本比对让旧值无法被读取 |
| 写非常频繁、读可以接受最终一致的缓存(计数、广告曝光) | Write Behind Caching | 性能最好,一致性要求低 |
| 团队有中间件维护能力,希望规范统一 | Read/Write Through | 一致性逻辑收敛,防止业务各自写错 |
| 高并发读、数据库压力极大 | 逻辑过期 + 互斥锁 | 避免缓存重建风暴,保护数据库 |
5.2 三个常见错误认知
第一,“缓存设置了过期时间就不用管一致性了”。这是最常见的错觉。过期时间只是最后兜底,如果数据更新后缓存一直没人读,旧值确实会待到过期才消失,这段时间内用户读到的全是旧数据。过期时间应当视为一致性的“最后一道保险”,而不是唯一方案。
第二,“更新缓存比删除缓存好”。很多新手一上来就写“更新数据库后 SET 新值”,这在小并发下看起来没毛病,但一旦并发高起来,两个写请求同时到达数据库,后更新的数据库值可能先写进缓存,先更新的数据库值后写进缓存,缓存反而留下了更旧的值。删除缓存永远比更新缓存安全,因为它不引入值的比较问题。
第三,“延迟双删的 sleep 越久越安全”。sleep 太长意味着删除动作迟迟执行,期间缓存一直可能被旧值占据;而且 sleep 期间如果进程重启,第二次删除直接丢失。sleep 应当根据实际的回源耗时来设定,而不是拍脑袋越大越好。
5.3 我现在的默认做法
踩过不少坑之后,我现在接手一个新项目的缓存更新方案时,默认组合拳是这样的:核心数据用 Cache Aside,更新顺序固定为“先写数据库,成功后删缓存”;删除失败走本地延迟消息重试;缓存 miss 重建时加互斥锁;对一致性要求再高一点的场景,叠加延迟双删或版本号方案;同时给所有缓存 key 设置合理的物理过期时间(比如 30 分钟到 2 小时之间),保证即使方案里某个环节失效,系统最终还是能自愈。
最后再分享一个小技巧:不管你选了哪种策略,一定要把“缓存删除失败次数”“缓存重建回源数据库的次数”“缓存 miss 率”这几个指标监控起来。线上很多一致性事故不是突然爆发的,而是删除失败率慢慢升高、miss 率感觉还正常,等到用户投诉才被发现。把指标前置到监控面板上,很多坑都能在变成事故之前被提前发现。