☰
缓存更新策略实战:从Redis一致性到穿透、击穿、雪崩治理
2026/10/8 10:31:00 网站建设 项目流程

刚刚处理完一个线上事故,后台数据已经顶不住了。Redis命中率从99.2%直线掉到23%,数据库CPU瞬间飙满,告警群炸了锅。事后拉日志复盘,根因并不复杂——某个活动配置的缓存key在流量峰值被主动删掉,然后大批线程同时回源数据库重建缓存,直接把主库打挂了。这个场景太典型了,可以说每个做后端的人早晚都会碰上,而它的根源,就是“缓存更新策略”这个老生常谈又极容易踩坑的话题。

我写这篇文章,不是为了把教科书上的理论再抄一遍,而是想结合自己这几年在真实业务里踩过的坑、做过的治理,把缓存更新策略这件事从头到尾捋清楚:主流策略各自适合什么场景、为什么会有缓存与数据库不一致的问题、怎么在代码层面保证原子性、线上缓存治理该监控哪些指标,等等。无论你是在做高并发电商、优惠券系统,还是普通的CRUD应用,这篇文章应该都能帮你少走点弯路。

1. 一份真实的P0事故:缓存更新策略为什么值得被认真对待

先说说开头那次事故的完整链路。

当时我们做的是一个促销活动页,商品和优惠券信息都缓存在Redis里,缓存策略很简单:查询时先读缓存,缓存没有就查数据库,再把结果写回缓存,也就是典型的Cache Aside模式。这个模式本身没什么问题,问题出在活动运营需要在后台修改某个商品的状态,当时负责这块的代码是这样的:

public void updateProduct(Product product) { // 1. 先更新数据库 productDao.update(product); // 2. 再删除缓存 redisTemplate.delete("product:" + product.getId()); }

看起来好像没毛病?先更新DB,再删缓存,这是很多教程里推荐的做法。但如果删缓存这步失败了呢?如果Redis在这个瞬间正好发生了主从切换呢?如果恰好有大量流量在这几毫秒内读到了旧缓存呢?更关键的是,当你删掉缓存之后,瞬间有几千个请求发现缓存不存在,它们会同时扎进数据库去查数据、重建缓存。结果就是这里提到的——数据库被打爆、缓存被大量重建、命中率骤降。

很多人对缓存更新策略的理解停留在“先更新数据库,再删除缓存”或者“更新数据库的同时更新缓存”这个层面上,觉得能跑就行。但真实线上环境里,并发量一上来,数据一致性、缓存穿透、击穿、雪崩这些问题会一起冒出来,每一条都能让你加班到凌晨。所以在讨论任何策略之前,先把一个认知立住:缓存本质上是用“最终一致性”换“低延迟响应”,不存在既要绝对一致又要高性能的免费午餐。你的任务不是追求“绝对一致”,而是把不一致的概率降到可接受范围,同时保证系统不出故障。

2. 四种主流更新策略的选型逻辑与适用场景

缓存更新策略,严格来说有四套经典的玩法:Cache Aside(旁路缓存)、Read Through(读穿透)、Write Through(写穿透)、Write Behind(写回/异步写)。大部分团队其实从头到尾只用了Cache Aside,另外三种听过但没用过。我先逐一拆解它们的适用场景,再说为什么现实中大多数系统最终都落在Cache Aside上。

2.1 Cache Aside:最常用,但最容易被用错

Cache Aside的逻辑很朴素:

  • 读请求:先读缓存,命中就直接返回;没命中则查数据库,写回缓存,返回。
  • 写请求:先更新数据库,然后删除缓存(或者更新缓存)。

这个策略只要求应用层自己维护缓存和数据库的一致性,不要求缓存中间件提供任何额外特性,Redis、Memcached、本地缓存都能跑。它最大的优势就是“可控”,把一致性责任交给业务代码,出了事知道从哪里查。

但它也是最容易被用错的,常见的错误有这三种:

第一,删除缓存失败后没有补偿机制。你更新了DB,结果Redis删除超时或者异常了,旧数据继续留在缓存里。解决办法是加一个失败重试的队列,或者直接用后面会讲的延迟双删+binlog订阅兜底。第二,并发时序控制。线程A更新了数据库,准备删缓存;线程B在这之前刚好把旧数据读出来准备写回缓存,也就是典型的“脏写”。第三,大流量下热点key失效,导致同时穿透打到DB。

所以如果你选Cache Aside,就要接受一件事:它的正确性离不开“人工保证”,每一步都要有兜底。

2.2 Read Through / Write Through:让缓存自己管自己

Read Through和Write Through的意思,是缓存组件自己负责和数据库的同步,应用层不再直接操作数据库。

  • Read Through:应用读缓存,缓存没有时,由缓存组件自己去数据库捞数据并回填,之后返回给应用。应用完全感知不到数据库的存在。
  • Write Through:应用写缓存,缓存组件同步把数据写入数据库,写成功后才返回成功。

这两者通常由缓存中间件或者ORM框架提供支持,比如一些本地缓存框架、部分云数据库代理层。好处是应用代码极简,一致性逻辑集中在缓存层;坏处是缓存组件的复杂度飙升,如果它挂了,数据库读写通道也一起挂了,而且不同中间件对“写穿透”的实现细节差异很大,遇到问题很难排查。实际业务里,一般只有在团队对底层组件有很强掌控力的时候才用。

2.3 Write Behind:性能最大化的代价

Write Behind也叫Write Back,逻辑是:应用只写缓存,立即返回成功;后台异步批量地把缓存中的脏数据刷到数据库。

这个策略性能极高,特别适合写入量大、允许短时间数据丢失或延迟落库的场景,比如点赞数、播放量、库存预扣等。但它有一个根本性的问题——一致性窗口不可控。如果数据还没落库,缓存节点就崩溃了或者我们要下线清理数据,那这部分更新就永久丢了。而且数据库和缓存的数据会长期不一致,如果业务上对一致性要求很高(比如订单金额、账号余额),千万别用Write Behind。

我见过最典型的翻车场景:某团队把用户余额也做成了Write Behind,美其名曰“提升性能”,结果一次缓存集群扩容导致部分key丢失,用户余额凭空消失,最后花了两天从日志里逐条捞数据恢复。所以Write Behind这个策略,用之前一定要先问一句:数据丢了赔得起吗?

2.4 更新缓存 vs 删除缓存:为什么多数场景应该选删除

这里专门聊一个争论很多的问题:数据库更新之后,到底是“更新缓存”还是“删除缓存”?

我的结论很明确:优先选择删除缓存。理由有三点:

第一,更新缓存存在“写放大”问题。如果缓存的key被更新了,但是后续没人读它,这次更新操作就白做了。删除缓存是惰性的,只有下次读的时候才回填,天然节省资源。第二,更新缓存很难处理并发时序。多个线程并发更新同一数据,后写DB的可能先写缓存,也可能先写DB的后写缓存,你根本没法保证缓存最终等于DB。删除缓存没有这个问题,因为反正下次读会把最终值拉回来。第三,更新缓存往往还要重新计算序列化结果,比如商品详情需要拼接大量字段,成本比单纯delete高得多。

也许你会问:那如果删了缓存之后,正好有请求来读,发现缓存为空,又打到数据库上,怎么办?这就引出了后面要重点讲的穿透/击穿问题。但删除缓存本身在一致性上一定优于直接更新缓存,这个方向不用摇摆。

3. 缓存与数据库一致的三个魔鬼细节:时序、回滚与重试

很多人觉得“先更新DB再删缓存”已经足够了,但在并发场景下,这中间藏着的细节比我以为的多得多。下面逐一拆解。

3.1 并发时序问题:为什么A线程先更新DB却把旧值写进了缓存

假设我们有这样一个执行序列:

  1. 线程T1:查询商品,缓存未命中。
  2. 线程T2:更新商品,先更新DB,再删除缓存。
  3. 线程T1:查DB拿到的是更新前的旧值(T2还没提交事务),写回缓存。

从后往前看,T1执行顺序在T2之后,可写进缓存的却是旧值。此时缓存里就是一条永久性的脏数据,直到下一次更新才会被纠正。

这个问题的根源是删除缓存和读DB回填缓存这两个动作之间没有互斥关系。解决办法通常有两个方向:

一是延迟双删,也就是:

  1. 更新DB。
  2. 删除缓存。
  3. 等一段时间(比如300ms)。
  4. 再删除一次缓存。

第二次删除可以把第一步里并发读请求写回的旧缓存再清掉。这个方案写起来不难,但“等一段时间”的窗口不好选,不够优雅。

二是用版本号或者更新时间戳来控制缓存有效性。比如每条数据带一个version字段,写缓存时把version一起存进Redis;读缓存时如果发现缓存里的version小于数据库当前版本,就认为过期,重新回源。这本质上把“删除缓存”变成了“过期缓存”,准确性更高,但要侵入业务模型。

3.2 延迟双删的局限与补偿方案

延迟双删几乎是我见过最多人问的“网上抄来的方案”,它的完整形态一般长这样:

public void updateWithDelayDelete(Product product) { // 1. 更新数据库 productDao.update(product); // 2. 立即删除缓存 redisTemplate.delete("product:" + product.getId()); // 3. 延迟一段时间后再次删除 executor.schedule(() -> redisTemplate.delete("product:" + product.getId()), 500, TimeUnit.MILLISECONDS); }

但这里有几个坑要提醒你:

其一,延迟时间很难拍准。如果读请求回填缓存耗时很长(比如DB慢查询、序列化耗时大),500ms根本不够,旧数据还是会被写回。其二,第二次删除如果也失败了,还是会出现脏数据。其三,如果某个读请求在第二次删除之后才慢悠悠地读DB并回填,那旧值又会复活——这就是“缓存雪崩未清的僵尸数据”。

所以延迟双删只能降低概率,不能根除问题。更稳的做法是好几个大厂在用的binlog订阅方案:应用更新数据库,不主动去删缓存;有一个消费者监听数据库的binlog变更事件,解析出变更的主键,然后执行缓存删除。这样应用层不用关心缓存,缓存删除也一定发生在数据库事务提交成功之后,从根源上规避了“DB更新成功但缓存删除失败”和“回滚后却把旧值写回缓存”这两个问题。

3.3 可靠的最终一致性方案:binlog订阅与版本号

具体落地binlog订阅时,通常借助成熟框架,比如Canal这样的组件来伪装成MySQL从库,解析binlog并推送给MQ,业务侧再消费MQ删除对应缓存。整体链路大约是这样的:

MySQL - binlog -> Canal -> MQ -> 消费者 -> Redis DEL

这条链路有两点比较重要。一是要保证MQ消费成功后再确认(手动ack),否则消息丢了,缓存没删,脏数据就持久了。二是如果MQ下游处理能力跟不上,缓存删除会被延迟,这一小段时间内业务会读到旧值。一般来说,业务上会接受“秒级最终一致”,所以这个方案在绝大多数场景是能扛住的。

版本号方案则更轻量。你可以在缓存里同时存数据和版本号,比如:

// 写缓存时 stringRedisTemplate.opsForValue().set(key, jsonString, 30, TimeUnit.MINUTES); stringRedisTemplate.opsForValue().set(key + ":version", String.valueOf(version), 30, TimeUnit.MINUTES); // 读缓存时校验版本

读的时候如果发现缓存里的版本号小于DB当前版本,则直接穿透回源。这个方案适合能轻松拿到版本号或者更新时间的业务,比如文章表里有update_time。

唯一的前提是:事务和版本号的更新必须在同一个事务里完成,否则版本号先变,数据还没变,读请求又会拿到旧数据。

4. 压测遇上缓存穿透、击穿、雪崩:Redis治理的实战链路

光讲一致性策略还不够,缓存更新策略落地之后,紧接着要面对的就是三个经典故障:穿透、击穿、雪崩。它们经常一起出现在压测报告里,处理不好,事故就一个接一个。

4.1 缓存穿透:布隆过滤器与空值缓存的取舍

缓存穿透是指查询的数据在数据库里根本不存在,所以缓存永远没有值,每次请求都直接落到数据库。攻击者可以伪造一批不存在的ID,把DB活活打死。治理手段主要有两个思路。

思路一是布隆过滤器(Bloom Filter)。把所有合法ID在启动时预加载到一个大位图里,查询前先过过滤器,过滤器说不存在就不查后面的存储。但布隆过滤器有个特性是“可能有误判,但绝不会有漏判”,也就是说它能拦截掉绝大多数无效ID,但偶尔会把不存在的ID误判成存在,此时请求还是会落库,问题不大,因为概率低。另一个缺点是ID集合变动时,布隆过滤器更新成本比较高。

思路二是缓存空值。如果DB查不到,就在Redis里写一个特殊占位符,比如value = "",并且设置一个比较短的过期时间,比如30到60秒。这样后续相同请求会在短期内命中缓存空值,不会每次穿透到DB。这个方案实现简单,是目前我最常用的。但要注意两点:一是空值key的量可能很大,需要设置过期时间防堆积;二是如果有人用随机ID攻击,那空值缓存反而成了内存垃圾的放大镜,可能把Redis内存打满。因此,更稳妥的是两个方案结合:布隆过滤器拦大流量,空值缓存作为兜底。

4.2 缓存击穿:热点key重建的互斥锁

击穿和穿透的区别在于:击穿是缓存key确实存在,只是刚好在某个时刻过期了,结果瞬时请求全部打到DB。尤其是热门key,比如秒杀商品的详情、排行榜第一名的用户信息,一旦过期,QPS可能会从几百瞬间飙到几万。

解决办法里最简单有效的就是互斥锁(Mutex Key)。当查询发现缓存为空时,不是立刻回源DB,而是先尝试获取一把Redis锁,只有拿到锁的线程才去查库并回填缓存;其他线程等待短暂的时间后重新从缓存读取。大致代码如下:

public Product getProductWithLock(Long id) { String key = "product:" + id; Product product = redisTemplate.opsForValue().get(key); if (product != null) { return product; } // 尝试获取锁,防止大量线程同时回源 String lockKey = "lock:product:" + id; Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 二次检查:可能在等待锁的过程中缓存已被填充 product = redisTemplate.opsForValue().get(key); if (product != null) { return product; } // 回源DB并回填缓存 product = productDao.findById(id); if (product != null) { redisTemplate.opsForValue().set(key, product, 30, TimeUnit.MINUTES); } return product; } finally { redisTemplate.delete(lockKey); } } else { // 没抢到锁,短暂sleep后重试 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductWithLock(id); } }

这个小技巧能直接把热点key过期的“惊群效应”变成“单线程回源”,DB压力大幅下降。注意锁的过期时间要合理,如果回源DB和序列化时间超过了锁的超时时间,会导致锁提前释放,其他线程又抢着回源,反而放大压力。所以在高并发场景下,最好的做法是把锁的超时时间设置得比“最坏情况下回填耗时”更长一点,或者用Redisson这种支持看门狗自动续期的锁。

4.3 缓存雪崩:过期时间抖动的工程实践

雪崩比击穿更严重,它是“大量key在同一时间过期”,缓存集体失效,所有请求瞬间打到数据库。常见诱因有两个:一是代码里给key设置了相同的过期时间,比如所有商品都是30分钟过期,导致它们在同一时刻成批失效;二是缓存服务本身宕机。

针对第一类诱因,最简单的做法就是过期时间加随机抖动:

int baseExpire = 30 * 60; // 30分钟 int randomOffset = ThreadLocalRandom.current().nextInt(0, 300); // 随机0-300秒 redisTemplate.opsForValue().set(key, value, baseExpire + randomOffset, TimeUnit.SECONDS);

这样可以保证即使所有key同时写入,它们的过期时刻也会打散,不会形成同一秒的“集体死亡”。我认为这个技巧是一个性价比极高的上线规范,值得写进团队的编码checklist里。

针对第二类诱因,就涉及到Redis本身的高可用建设了:主从架构、哨兵、Cluster模式,以及本地缓存兜底。在极端情况下,如果Redis直接不可用,可以用一层短暂的本地缓存(如Caffeine)挡在前面,让请求尽量不直接打到DB。但引入本地缓存之后,多级缓存之间的一致性又会变成新的问题,这块放在后面展开。

5. 从代码到监控:一套可落地的缓存更新最佳实践

前面讲了很多选型和原理,这里沉淀一套我认为可以直接抄作业的工程实践,把操作层面的事说透。

5.1 Lua脚本与Redis原语:保证更新操作原子性

有时候,删除缓存和后续操作之间需要保证原子性,最典型的就是“先比较后删除”的场景。比如你想实现这样一个逻辑:只有当缓存里的值没有发生变化时才删除它,否则就放弃删除。如果分两步走,先GET再DEL,两步之间一定有间隙,并发场景下就会出问题。

Redis官方推荐的方案是使用Lua脚本,比如这面这个脚本实现“只有value等于期望值时才删除”:

-- KEYS[1]: 缓存key -- ARGV[1]: 期望的value if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) else return 0 end

这个脚本天然具备原子性,执行过程中不会被其他命令插入。你也可以用类似思路实现“获取锁”“检查锁持有者身份再释放”等操作。在Spring Data Redis里调用Lua脚本可以直接用DefaultRedisScript,我不多贴代码,但强烈建议凡是涉及“读+写”两步的Redis操作,都优先考虑用Lua脚本合并成一步。

另外,同缓存更新密切相关的还有Redis的事务命令MULTI/EXEC。但注意,Redis事务和MySQL事务不是一回事,Redis事务只是把多个命令打包按顺序执行,中间不会被其他客户端命令插入而已,它不会回滚已经执行的命令。如果你需要“要么全做,要么全不做的回滚语义”,Redis原生是给不了的,得自己设计补偿。

5.2 缓存治理的监控指标:命中率、延迟、key分布与失效曲线

只要用了缓存,就必须建立对应的监控体系。踩过几次坑之后,我的经验是至少盯住四个指标,每一项都在事故前兆里有明显变化:

指标作用报警建议
缓存命中率(hit ratio)低于90%说明回源频繁,可能穿透或key设计有问题连续5分钟低于80%触发
缓存读写时延(latency)突刺往往代表bigkey、热key或者网络抖动p99超过10ms触发
热点key分布(hot keys)单个key QPS过大,说明存在集中访问,需考虑本地缓存拆分单key QPS超过阈值触发
过期key批量失效曲线(expire graph)某时间窗口内大量失效,可能雪崩每分钟失效数量超过阈值触发

针对命中率,一个常见误区的纠正——缓存命中率不是越高越好。如果命中率高达99.9%,说明缓存里的数据几乎不更新,那还算是“更新策略”吗?真实业务里,很多key本来就应该经常失效,因为底层数据变化频繁。关键不是追求99.9%的命中率,而是确保DB流量在系统可承载的范围内、数据一致性在业务可接受范围内。平衡才是核心。

5.3 二级缓存与多级缓存的一致性思考

很多系统在高并发下,会考虑“本地缓存+Caffeine+Redis”的二级缓存架构,用本地缓存挡住最热门的读请求。但引入一级缓存之后,一致性问题的复杂度是直线上升的。比如Redis里的key更新了,但本机缓存还躺在JVM内存里,读请求依然读到旧值,怎么办?

此时通常要引入一个“版本号广播”机制:业务侧更新完DB和Redis后,通过MQ或者Redis Pub/Sub发布一个消息,让所有应用节点收到消息后主动失效本地缓存。这个机制在Java后端里很常见,尤其是配合Spring Cache、MyBatis缓存等框架时更要小心。

这里顺便提一下热搜词里经常看到的Spring三级缓存和MyBatis缓存。Spring的三级缓存是用来解决循环依赖的,本质上是“早期引用”的缓存,跟业务缓存不同,但它同样有“先引用后可用”的状态管理,理解Spring三级缓存有助于你理解“部分初始化对象为何能提前暴露”。MyBatis的二级缓存则是namespace级别的本地缓存,它的更新策略同样遵循Cache Aside的逻辑——在SQL执行更新时清空对应namespace的缓存,但由于它是应用内存缓存,多实例部署时会遇到各个节点缓存不一致的情况,此时最简单可靠的方案是直接关闭二级缓存、只使用一级缓存,或者引入Redis作为二级缓存的外部存储。

我的建议是:多级缓存是性能利器,也是运维噩梦。如果团队人数不多、监控不完善,宁可先只保留Redis一层,把击穿/穿透/雪崩都治理好,再考虑加本地缓存。

5.4 缓存更新的“封板”规范

最后,我把自己这几年沉淀下来的缓存更新规范浓缩成几条,可以直接用在团队评审里:

  1. 数据库更新之后,一律先删缓存,不直接更新缓存内容(除非极个别允许“写放大”的读多写少场景)。
  2. 删除缓存必须考虑失败重试,至少有一个内存重试队列或者MQ兜底。
  3. 所有缓存key必须有过期时间,并且加上随机抖动,禁止使用“永久不过期”的key,除非你已经为它的内存生命周期负责过。
  4. 热点key过期必然带来击穿风险,提前设计互斥锁;不存在的ID查询必须处理穿透,至少要缓存空值。
  5. 缓存删除尽量保证原子性,复杂的“读-改-写”操作交给Lua脚本。
  6. 上线前就要部署监控,至少覆盖命中率、时延、热点key、批量失效曲线。
  7. 数据能接受秒级不一致的业务,优先用binlog订阅方案,从根上绕开“人为删缓存可能失败”的坑。

我在实际项目里常跟团队说这样一句话:缓存不是一层KV存储,而是一条需要设计的异步数据链路。你写的每一条get/set背后,都牵涉到数据库事务边界、并发时序、失败补偿、容量规划和监控告警。把“缓存更新策略”当成一个完整的设计题来做,而不是一个注解或者一个工具方法,系统才能在真正的流量洪峰里站得住。

最后再分享一个经验:当你不确定这套策略该怎么做的时候,先把“如果缓存全部丢失,系统会怎样”这个问题回答清楚。如果答案是“数据库会挂”,那缓存策略的设计就还没有完成,别急着上线。回答清楚这个问题之后,你会发现自己已经绕开了绝大多数新手团队掉进去的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询