做后端开发的几乎没有人能绕开缓存。Redis作为缓存层的事实标准配合MySQL这类关系型数据库已经是各种业务系统最常用的搭配。但提到Redis缓存更新策略很多人第一反应就是“删除缓存更新数据库再删除缓存”这一套。这套流程网上教程满天飞实际写起来好像也就几行代码的事可真到线上环境跑起来你会发现坑远比你想象的多。缓存什么时候更新、数据库什么时候写、顺序怎么排、失败了怎么办、并发下会不会读到旧值……每一步都藏着时序问题。这篇文章我把缓存更新策略这件事从头到尾掰开揉碎讲一遍包含三大主流策略对比、Cache Aside为什么是默认选择、延迟双删到底怎么用才靠谱、以及我在生产环境实际踩过和解决过的几个典型问题。适合刚接触缓存的后端同学也适合做了两三年还是只停留在“删缓存、更新库、删缓存”这个层面、想系统梳理一下的人。1. 为什么“先删缓存再更新数据库”会坑了你1.1 缓存更新策略的本质先想清楚一件事缓存到底是什么它是数据库结果集的一份临时副本存活在内存里速度极快但可靠性差、容量有限。而数据库是持久化的真相源头数据以它为准。既然有了两份数据问题就来了数据库里的数据变了缓存里那份怎么处理站在工程角度处理方式就三大类一是让缓存里的副本失效下次读的时候重新从数据库拉一份这是删除型策略二是直接把新的值写进缓存这是更新型策略三是交给别的东西异步去同步这是异步型策略。绝大多数业务系统最终选择的都是第一种删缓存。原因后面详说这里只记住一个结论删缓存这件事本身不难难的是“什么时候删”以及“删失败怎么办”。1.2 缓存与数据库一致性的根本矛盾缓存和数据库的不一致本质上是时序问题。想象一下一个读操作和一个写操作同时在跑。读操作先查缓存如果缓存有就直接返回没有就去查数据库再把结果写回缓存。写操作则是改数据库同时想办法把缓存里对应的记录作废或更新。问题在于这两个操作不是原子的它们之间存在一个时间窗口。在这个窗口里读操作有可能拿到旧数据也可能出现更隐蔽的交叉覆盖一个线程把新值写回了缓存另一个线程又把旧值覆盖回去。很多同学对一致性的理解停留在“最终一致”这个层面认为只要时间足够长什么都对得上。理论上没错但在高并发场景下这个“最终”可能出现在几毫秒内也可能出现在几秒甚至更久之后。系统不同的部分对“多久能容忍不一致”的答案完全不同——订单状态这种用户可能一秒都忍不了用户昵称这种晚几秒刷新根本无所谓。所以选策略从来不是选一个“完全一致”的策略因为完全同步根本做不到而是选一个“在可接受的时间窗口内尽量一致、极端情况可兜底”的策略。这才是缓存更新策略的全部意义。2. 三大主流缓存更新策略2.1 Cache Aside大家默认的“标准答案”Cache Aside也叫旁路缓存策略是目前应用最广泛、也最符合直觉的一种方式。读操作没什么特殊先查缓存命中就直接返回没命中就去查数据库把结果写进缓存设置过期时间然后返回。写操作核心只有两步更新数据库里的数据删除缓存里的对应记录这个操作模式看起来很简单但注意一个细节是“更新数据库 删缓存”不是“更新数据库 更新缓存”。似乎更新缓存会更合理因为缓存里有新值下次读就直接命中了性能不是更好吗但真正生产环境里更新缓存往往是个坑缓存更新的成本比较高尤其当数据结构复杂、有嵌套关系、需要序列化很多字段时一次写操作可能要把一大坨数据重算一遍写进Redis。更关键的是更新缓存这个动作很容易和读操作的写缓存互相覆盖产生老数据被覆盖回去的极端情况。而删除缓存一了百了下次读自然会把新数据从数据库拉回来。这个策略最大的优点就是简单、易实现、极端情况下还能靠重新加载自救。它的短板也很明显删缓存和更新数据库之间的一致性保障完全靠操作顺序和重试机制来凑。2.2 Read Through / Write Through把缓存当主角Read Through和Write Through的思路是把缓存从“旁路”变成“主路”。Read Through指的是应用层不直接操作数据库而是让缓存系统自己负责如果缓存miss了缓存组件自动去数据库加载数据填充到缓存并返回给应用。整个过程对应用透明。Write Through则是写操作也走缓存应用只写缓存缓存组件同步把数据写到数据库写成功才算操作完成。这两个策略配合起来应用层感知不到数据库的存在所有读写都面向缓存一致性由缓存组件统一保证。看起来非常优雅对吧但问题是绝大多数团队用的是RedisRedis本身并没有这种机制。要实现Read Through / Write Through得自己封装或者在应用层做一些代理逻辑相当于把缓存加载逻辑下沉到底层。对于大部分业务系统来说这套东西复杂度偏高且涉及到缓存组件维护成本性价比不高。2.3 Write Behind异步写回Write Behind的思路更狂野写操作直接写缓存然后立刻给调用方返回成功数据库的更新由后台异步完成。这种策略的性能最好因为主链路完全不碰数据库极端场景下可以解决数据库写入瓶颈。但它的一致性也是最低的因为如果写入缓存后还没来得及异步落库进程崩了或者缓存数据被清了这笔写操作就永远丢失了。所以Write Behind常见于对一致性要求不高的场景比如计数统计、点赞数、排行榜分数这类允许数据短暂不一致、也允许极端情况下丢失一点精确度。订单、支付、库存这类的数据没人敢用Write Behind。2.4 对比关键差异一张表看透策略写入路径读取路径一致性性能复杂度适用场景Cache Aside更新数据库 删缓存读缓存miss后回源最终一致需处理时序高低绝大多数业务系统Read Through缓存组件自动回源读缓存miss后由缓存层加载强由缓存层保证中高高有统一缓存中间件的场景Write Behind写缓存异步落库读缓存为主弱极高高计数类、非关键数据从上到下一致性在下降性能在提升复杂度也在提升。大部分业务系统选Cache Aside是有道理的一致性可接受性能损耗小出问题也容易排查。3. Cache Aside是怎么一步步被玩坏的3.1 核心时序竞态先删还是先更Cache Aside只有一个问题需要想清楚更新数据库和删除缓存哪个先做先删缓存再更新数据库这个顺序最大的隐患是在删缓存和更新数据库的间隔里如果有读请求进来发现缓存miss于是去数据库查到旧值把旧值重新写回缓存。等写操作把数据库更新完缓存里已经是旧值了后面的读请求全都会命中旧缓存直到缓存过期或者下一次删除。这个情况在并发量稍高的系统里极其容易出现。你删缓存删得再快也挡不住一个“恰好在那个时间窗口里”的读请求。先更新数据库再删除缓存这个顺序同样有问题但问题出现的条件更苛刻。更新完数据库后、删除缓存前如果有读请求进来命中的还是旧缓存但它会把旧值返回给用户。这个窗口期的时长就是“数据库更新完成”到“缓存删除完成”的间隔通常非常短。两害相权取其轻业内共识是先更新数据库再删除缓存。3.2 先更新DB再删缓存的并发风险先更新数据库、再删缓存常见的问题场景是这样一个读请求读缓存正好缓存刚过期于是它去数据库查到了旧值。在它把旧值写回缓存之前一个写请求到了更新了数据库并删除了缓存。这时候读请求把旧值写回缓存老数据覆盖了本应不存在的缓存项。这种情况理论上确实存在但发生的概率取决于“读请求查到旧值”和“写请求更新数据库”之间的时间差以及“读请求写回缓存”这个步骤是否发生在“删除缓存”之后。数据库中一条数据查询往往只要几毫秒多条件是索引命中时甚至不到一毫秒所以窗口非常窄是典型的小概率问题。这就是为什么业内普遍接受“先更新DB再删缓存”它把所有并发风险压缩到了一个极小的时间窗口里即便偶发出错下一轮删除或缓存过期也能兜底。3.3 为什么业内共识是“删”而不是“更新”有人会问既然更新数据库后要删缓存为什么不直接把新值写进缓存这样读请求立刻就能拿到新值不是更好吗我之前也是这么想的直到在项目里被数据一致性教做人。更新缓存的问题在于它把“更新数据库”和“更新缓存”两个操作绑定在了一起。只要缓存写入失败数据库已经变了缓存还是旧的系统进入不一致状态。这时候你没法通过“删除一个不存在的key”来止损必须想办法把新值再写进缓存而重试更新缓存比重试删除缓存麻烦得多。删缓存就不同了。Redis里DEL一个不存在的key也是成功天然幂等。就算你删除失败重试删一遍成本极低。更重要的是删除缓存让缓存保持了“被动加载”的机制无论缓存里的是新是旧只要它不存在了下次读就一定会从数据库拿最新数据。这是更新缓存永远做不到的。所以在Cache Aside里删缓存是一个比更新缓存更可靠、更优雅的兜底方案。3.4 缓存过期时间隐藏的最后防线Cache Aside里缓存通常会设置过期时间。这个过期时间不是用来保证一致性的但它是一道非常重要的兜底防线。设想一下如果没有过期时间缓存里的数据一旦因为某种原因没被删掉——比如删除失败、重试也失败、或者前面提到的极端并发覆盖——那缓存里的旧数据就会永驻用户永远看到旧信息。有了过期时间即使删除失败到点缓存项自动被淘汰下次读取重新回源数据最终还是会新起来。过期时间设得短一致性更好但回源压力大设得长回源压力小但出错后数据恢复慢。很多团队喜欢把过期时间设置为24小时图省事。但做交易类业务时我建议对敏感数据把过期时间缩短到分钟级别以牺牲少量性能换取一致性冗余。4. 延迟双删能破但别高估4.1 延迟双删的完整时序说明延迟双删Delay Double Delete是解决“先更新DB再删缓存”并发问题的一种经典方案网上讨论很多共识度和争议并存。核心流程是第一次删除缓存更新数据库短暂休眠一段时间称为延迟时间第二次删除缓存听起来很直白第一次删是赶紧把旧的清掉更新完数据库后要做的就是等一个“窗口期”让可能读到旧数据的请求完成“查库写缓存”的全过程然后再删一次把被旧数据写回去的缓存项彻底清掉。这个方案的精妙之处在于它把“读请求拿到旧值写回缓存”这个可能发生在不知名地方的恶性事件变成了一个可预期、可在第二次删除时被干掉的有限窗口事件。延迟双删真正的问题是你没法保证延迟时间足够覆盖所有场景。下面的分析会说明。4.2 延迟时间怎么算出一个能用的值延迟双删最关键的不是“双删”是“延迟”这个时间取多少。要覆盖的是一个读请求从开始查数据库到把结果写回缓存整个过程需要的时间。这个时间取决于数据库查询性能、网络往返耗时、缓存写入耗时以及并发线程调度的情况。你可以直接用经验值一般数据库主从同步延迟在几十到几百毫秒之间读请求写回缓存的时间通常也在几十毫秒量级所以很多人直接把延迟时间定为500毫秒或1秒。但高并发系统里线程调度不可控数据库查询可能因为锁、IO、慢SQL而耗时翻倍1秒延迟不一定可靠。更强的做法是在业务侧埋点统计“从查库到写回缓存”的实际耗时P99把这个值作为延迟时间的下限再放一点余量。需要明确的是延迟双删不是严格意义上的数据一致性方案它只是在概率上把不一致窗口压缩得更小。如果你的业务对一致性要求很高光靠延迟双删是不够的你需要下面这套兜底机制。4.3 删除失败的兜底机制延迟双删最大的头号问题不是时序而是第二次删除失败了怎么办。很多人会把删除操作放在业务代码里同步调用Redis的DEL然后认为“返回成功就行”。但Redis的DEL也可能因为网络抖动、连接池耗尽、Redis实例故障而失败。一旦第二次删除失败缓存里就是旧数据这个旧数据会一直待到过期。可靠的工程做法是把“删除缓存”做成一个可异步重试的任务第一次删除走正常业务路径失败的话记录日志第二次删除不管第一次删没删成功都进入一个“删除任务队列”删除任务由后台线程或者专门的重试组件消费失败则按照指数退避策略重试重试次数有上限最后可以作为异常告警推送出来把这个逻辑落到Redis层面还可以用Lua脚本保障“判断删除”的原子性防止并发拿到的值和自己预期的不一致。任何删除缓存的重试机制都需要配套一个监控如果某个key的删除重试次数超过阈值说明要么Redis不稳定要么业务逻辑出了问题。这时候要告警而不是默默吞掉。4.4 双删的局限与变通方案延迟双删有几个绕不开的局限。首先它是异步的。两次删除之间存在明显的时间差在这个时间差内系统对外提供的仍然是旧数据。如果你的业务要求“数据一变立刻读到新的”延迟双删做不到。其次它不解决多实例下的复杂时序。假设一个服务有多个实例写操作从A实例更新数据库并删缓存而读请求正好在B实例上B本地缓存里可能有旧副本在读取路径中被回填——不过这里要指出的前提是大多数团队用的是中心化Redis而不是本地缓存但如果做了多层缓存这种问题就会被放大。再次数据库主从架构下如果写操作先写了主库读操作读从库主从存在复制延迟那么旧值被读到的窗口期会更长延迟双删的延迟时间需要相应放大甚至变得不可控。实战里如果确实需要更强的实时一致性可以考虑把“删缓存”改成“订阅数据库变更日志”由变更消息异步触发删除在更近的入口层增加版本号校验读到旧值也可以直接丢弃在缓存值里附带业务时间戳比对后决定是否覆盖这些方案比单纯延迟双删可靠但会增加不少复杂度需要按业务逐条权衡。5. 缓存雪崩、穿透、击穿是同一个问题吗说到缓存更新策略三个人人皆知的“崩”就绕不开缓存雪崩、缓存穿透、缓存击穿。不少初学者分不清我用最通俗的方式讲清楚。5.1 三个“崩”的区别缓存穿透查询一个根本不存在的数据比如一个被删掉的商品。缓存和数据库里都没有于是每次请求都直接打进数据库。如果攻击者故意构造大量不存在的数据ID数据库可能被打爆。缓存击穿一个热点key过期了一瞬间大量请求同时去打数据库导致数据库压力突增。缓存雪崩大量key在同一时间过期或者Redis实例整体不可用导致所有请求瞬间打到数据库。三者的共同点是数据库突然承受了远超预期的请求量。区别在于原因不同、治理方式也完全不同。5.2 互斥锁重建缓存对付缓存击穿最经典的手段是互斥锁Mutex。以Redis的方式来说当缓存miss的时候不直接去查数据库而是先尝试获取一个分布式锁比如设置一个带有过期时间的key作为锁。获取成功的请求才去数据库回源并写缓存其他请求短暂等待后重试读取缓存。这个方案很容易实现用一个SET NX EX命令就能搞定但要注意锁的粒度。锁的粒度是key级别的还是全量级别的对并发表现影响很大。粒度太粗所有缓存miss都会被串行化性能大跌粒度太细又可能出现同一个热点key的大量并发miss。一般来说热点key的互斥锁按key粒度加锁普通key不加锁这样既保护了热点又不拖累整体吞吐。互斥锁还有个问题如果持有锁的线程在写缓存前挂掉了锁会一直存在到过期时间期间所有请求全部打库。所以锁必须设置合理的过期时间且业务操作必须在过期时间内完成。5.3 过期时间打散与多级缓存缓存雪崩的核心治理思路是让大量key不要在同一时刻过期。最简单的做法叫打散过期时间给所有key的基础过期时间设置一个随机偏移量。比如基础过期时间是1小时每个key就再加一个0到5分钟之间的随机数。这样即使这些key是同一个批次写进去的过期时刻也会错开。极端情况下缓存雪崩还有一层兜底方案本地缓存如进程内的缓存再加一层。Redis挂了本地还有一份可用数据不至于全部请求打到数据库。本地缓存的问题是数据一致性更难保证所以一般只放一部分读多写少的配置类数据。把多层缓存加在一起请求链路变成了本地缓存 → Redis → 数据库逐级穿透逐级兜底。5.4 Bloom Filter兜底穿透防护缓存穿透的治理重点不是“怎么缓存”而是“怎么避免无意义的数据库查询”。最常用的方案是预先加载一份合法key的集合。可以用Bloom Filter来实现把所有合法ID预先hash存入一个位数组每次请求过来先查Bloom Filter如果判断不存在直接返回空不去打数据库。要注意的是Bloom Filter存在误判率它判断“不存在”一定不存在判断“存在”可能有一点点概率实际不存在。所以用Bloom Filter只能拦截“确定不存在”的请求对“实际不存在但被误判存在”的请求还是会打穿到数据库。更可靠的方式是“忽略式缓存”对数据库查询结果为空的key也往缓存里写一个空值并设置一个较短的过期时间比如1分钟。后续同样的请求直接命中缓存里的空值就不用去打数据库了。这种方式简单直接是最好的穿透兜底缺点是废弃数据会占用缓存空间需要配合定期清理或缩短过期时间来控制。结合到缓存更新策略上穿透防护其实是在Cache Aside的读路径上加了一个前置过滤层并不改变写路径的删缓存逻辑但能极大降低回源压力。6. 我在生产环境踩过的坑6.1 内部缓存陈旧最大的坑有一次在做订单状态同步改造时我们把订单状态更新到数据库然后删除Redis缓存逻辑看着毫无问题但线上出现了大量旧状态数据。排查下来发现问题根本不在Redis而在服务进程内部做了一层本地缓存。读请求优先查本地缓存本地缓存miss了才去查Redis。数据库更新了、Redis里也删了但本地缓存里的旧数据一直没清用户通过请求命中的永远是本地缓存里那份。这个坑提醒了我一件事当系统里引入了多层缓存删除逻辑必须是多层级的。你在Redis上做删缓存本地缓存也需要同步失效。如果本地缓存是进程内的还得通过发布订阅或者版本号机制让所有实例在数据变更时统一失效。6.2 回源风暴一次性删掉太多key还有一次是运营后台做了一个批量操作把某活动下的几千个商品一次性禁售。代码很“标准”遍历商品ID更新数据库删除Redis缓存。结果这段代码一执行数据库瞬间被打满。原因很简单几千个key同时被删除这些key的过期时间又不是打散的于是所有读请求几乎在同一时刻全部miss全部回源打数据库形成了缓存雪崩。从那以后我的做法是批量操作时要分批删key控制删除速率或者先用一个开关把读流量切换到只读数据库等缓存全部重建以后再放量。批量场景下延迟双删里的“休眠”会对整个批次放大执行时间需要格外注意休眠之外的并发控制。6.3 缓存更新策略没有银弹但有个通用公式说句实在话缓存更新策略没有全局银弹每个业务场景的容忍度、数据敏感度都不一样。但经过这些年实践我总结出一个比较通用的成熟套路绝大多数业务Cache Aside先更新数据库再删除缓存配合过期时间。对一致性要求略高在Cache Aside基础上加延迟双删与重试删除任务。对热点数据引入互斥锁 批量重建。对不存在数据空值缓存 Bloom Filter。对极要求的强一致放弃Redis缓存或者走“数据库变更订阅 独立缓存重建”的链路。这个套路不复杂但足够覆盖日常开发里绝大多数场景。每次都要先想清楚这个数据丢了缓存、短暂读到旧值用户能不能容忍能容忍多久想明白了这两点策略的答案其实已经浮出水面。7. 一些实操心得与建议最后分享几点这些年实际敲代码下来的体会。第一缓存更新策略里最不值得做的就是“过度设计”。我在早期做技术选型时总是想把所有高级方案都用上延迟双删、异步重试、本地缓存、分布式锁……结果系统复杂度翻了几倍线上稳定性反而没有明显提升。后来想明白了大部分业务的缓存数据变更频率很低Cache Aside加上可靠的重删机制已经足够不需要把每个点都做得那么复杂。第二删除缓存逻辑必须做监控埋点。很多人上线了删缓存运行正常就再也不看一眼。直到某天缓存没删掉、数据长时间不一致才发现连日志都没有排查时无从下手。一套最简单的做法被删除的key如果是热点key记录一下删除耗时和成功率删除失败的进入补偿队列并给出可观测面板。这个投入非常小但能让你在出问题时第一时间定位。第三Redis的可用性比缓存策略本身更值得关注。很多时候缓存更新的问题不是策略不对而是Redis网络超时、连接池打满、主从切换期间写入丢失。想让缓存体系稳定先把Redis的慢查询、大key、连接数这些底子打牢策略才有发挥的空间。缓存更新策略永远是权衡题。没有哪种方案能把性能和一致性同时做到完美你只能在明确业务容忍度之后选一个风险最低、实现最简、可观测性最强的方案。接受这一点你的系统会稳健得多。