☰
Redis缓存击穿全解析:互斥锁、逻辑过期与热点永不过期实践
2026/10/3 2:57:40 网站建设 项目流程

今天聊一个让不少同学头疼的Redis问题:缓存击穿。平时系统跑得好好的,一到某个热点数据失效的那几十秒,数据库连接瞬间被打满,接口延迟直接飙升,线上告警响个不停,严重一点整个服务直接雪崩。网上讲这个概念的文章其实不少,但多数停留在“什么是缓存击穿”的层面,真正能落地、能解决线上问题的细节反而没说清楚。这篇我想从实际研发和运维的视角把这些细节一次讲透,包含方案选型、代码示例、参数怎么定、踩过哪些坑,适合正在写业务代码的同学参考,也适合面试前需要把这块彻底弄明白的读者。

先说清楚一个容易被忽略的前提:缓存击穿不是Redis本身的问题,而是我们在“用缓存抗并发”这个策略下天然要面对的风险。Redis再快,它也只能挡在数据库前面,一旦某个热点key在某个瞬间失效了,大量请求就会绕过缓存直接打到数据库上。问题的难点不在于“会失效”,而在于“失效的时间点恰好是流量最高的时候”。

这篇博客不会讲太多理论,重点放在实操上。我会从最基础的概念区分讲起,然后逐步深入到三种主流解决方案的代码级细节,最后聊一聊线上排查和监控的经验。不管你是刚接触Redis的新手,还是已经在生产环境里维护过缓存系统的开发,应该都能从里面找到点有用东西。

1. 先从“三兄弟”说起:穿透、击穿、雪崩的区别

1.1 三个问题本质不同

缓存场景里有三个经典问题,名字很像,很多面试者容易搞混:缓存穿透、缓存击穿、缓存雪崩。我先把它们放在同一张表里对比一下,后面再展开说击穿的特殊性。

问题查的数据失效特征核心矛盾经典解法
缓存穿透数据库里根本不存在的数据key在缓存和DB中都不存在每次请求都穿透到底层存储布隆过滤器、缓存空值
缓存击穿数据库里存在、且极热的数据某个热点key的缓存刚好在那一瞬间过期单点失效引发并发重建互斥锁、逻辑过期、永不过期
缓存雪崩大量key同时失效大面积key在同一时段过期集体失效导致DB压力剧增过期时间加随机值、集群高可用

缓存穿透的典型场景是恶意请求反复查询一个不存在的商品ID,Redis里查不到,数据库里也查不到,每次请求都直达数据库。布隆过滤器可以在Redis这层挡住大部分无效key,或者干脆把空结果也缓存一小段时间,这些做法本质上是在“消灭穿透”。

缓存雪崩的典型场景是系统里几千个key设置了同一个过期时间,比如统一在凌晨三点更新一批数据,结果三点一到全部失效,流量高的时候数据库直接被打垮。解决思路是让过期时间分散开,比如在基础TTL上加一个随机值。

1.2 缓存击穿为什么最容易被忽视

缓存击穿和另外两个问题相比,有一个很不友好的特性:它平时没有任何迹象。只要热点key还没到期,系统运行毫无异常,监控曲线一片平稳。但它失效的那一刻,所有问题在几秒内集中爆发,等你反应过来去查日志的时候,可能缓存已经重建完毕,现场已经被“修复”了。这种“闪崩”特性让很多人第一次遇到时完全摸不着头脑。

更麻烦的是,击穿在监控上的表现和穿透很像——Redis命中率突然下降、数据库QPS突然上升。如果你没有提前把监控粒度做细,光看聚合曲线很难判断到底是哪个key出了问题。这也是为什么很多生产事故复盘时,最后才发现根因是“某个热门商品详情页的缓存过期了”。

而且缓存击穿往往带有业务含义。一个key之所以是热点key,说明当时有大量用户在访问同一份数据,这种场景通常就是秒杀、大促、抢购、热点新闻这类高价值流量。换句话说,击穿发生的时候,往往正是你承受不起故障的时候。

2. 击穿发生时的微观过程与影响范围

2.1 失效瞬间到底发生了什么

我画个时间线来描述击穿发生的过程。假设一个商品详情页的缓存key过期时间是30分钟,当时有3000个用户在同时刷新这个页面。

  • 第29分59秒:Redis里缓存还在,3000个请求全部命中,数据库几乎无感知。
  • 第30分00秒:第一个请求发现key不存在,开始查数据库准备重建缓存。
  • 第30分00秒到第30分01秒:剩下的2999个请求也发现key不存在,全部进入“查数据库”逻辑。
  • 数据库在1秒内收到3000个商品查询请求,正常情况下一张商品表的查询本身不慢,但突发流量让数据库连接池瞬间被打满。
  • 数据库连接打满后,后续的数据库查询开始排队,单次查询耗时从2毫秒变成500毫秒甚至几秒,接口整体超时。

整个过程的核心矛盾是:缓存重建本应该是“一次”的工作,却被并发请求重复执行了“几千次”。如果能保证只有一个线程去重建缓存,其他线程干等结果,问题就解决了一大半。

2.2 为什么重建缓存这么容易打爆数据库

很多人觉得“查一次库而已,能有多大事”,这恰恰是问题的迷惑性所在。单次数据库查询确实便宜,但要看背压强度。

举个例子,假设数据库连接池上限是50个连接,正常情况下每个连接平均处理100个并发查询。缓存失效的那一秒来了3000个请求,连接池只能同时处理50个,剩下2950个全部排队。如果单次查询加回写缓存需要20毫秒,那这2950个请求全部处理完需要1秒以上。如果数据库本身CPU已经偏高,慢查询增多,这个时间会被放大到几秒,最终表现为接口大面积超时。

还有一层隐含成本:重建缓存不只是查一次数据库,通常还涉及业务逻辑计算、序列化、网络传输。有些场景的缓存value不是简单字符串,而是聚合了大量数据的对象,重建一次可能要几百毫秒。这种key一旦失效,并发重建的杀伤力会成倍放大。

2.3 从一次击穿到全站故障的连锁反应

击穿最可怕的地方在于它能引发连锁反应。数据库连接池被打满之后,不只是这个热点key的请求出问题,所有需要访问数据库的接口都会开始排队。紧接着,应用服务器的线程池也被占满,健康检查可能失败,负载均衡开始摘除节点。如果架构里还有依赖同一个数据库的其他服务,故障面会进一步扩散,从“一个接口变慢”演变成“整个依赖链不稳定”。

所以遇到缓存击穿问题,第一反应不是想着怎么优化数据库,而是要在缓存层把“并发重建”这个动作彻底控制住。下面的几个方案,本质上都是围绕这一点来设计的。

3. 方案一:互斥锁重建缓存

3.1 核心思路:把并发重建变成串行重建

互斥锁方案的思路非常直接:当缓存失效时,不是让所有请求都去重建缓存,而是只让一个请求去重建,其他请求等待它完成,然后直接读取重建好的新缓存。

用生活类比解释就是:食堂只有一个窗口在卖饭,高峰期大家都涌过去肯定挤爆。与其让所有人一起挤,不如让排在最前面的那个人先去打饭,剩下的人排队等。等第一个打完了,后面的人直接拿现货,就不用再重复“打饭”这个动作了。

这个方案的好处是逻辑简单、容易理解、实现不复杂,坏处是“等待”会导致一定的请求延迟,而且锁本身要处理超时、释放等细节,处理不好容易出现死锁或重复重建。

3.2 锁的实现细节:SETNX的正确姿势

用Redis实现互斥锁,最常用的命令是SETNX,也就是SET如果key不存在则执行成功。但这里有一个非常容易踩的坑:SETNX和设置过期时间必须是原子操作。

网上很多老文章会让你先执行SETNX,再执行EXPIRE,分两步设置。这是错的,因为如果SETNX成功之后、EXPIRE执行之前应用挂了,锁永远不会过期,线上就会出现死锁。正确的做法是使用SET命令一条指令完成:

SET lock:product:10086 unique_value NX EX 10

这条命令的意思是:当lock:product:10086这个key不存在时,设置它,value为unique_value,过期时间10秒。NX参数保证了只有key不存在时才能设置成功,EX参数让锁自带过期时间,两个能力合在一条命令里,没有中间状态。

获取锁成功后才去查询数据库并重建缓存,重建完成后释放锁。释放锁不能简单使用DEL命令,因为你得先确认这把锁是自己的,不然有可能把别人刚获取到的锁误删掉。正确做法是使用Lua脚本,先比较value再删除,保证原子性:

if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end

这段脚本的意思是:只有当前锁的value等于我这个请求的unique_value才删除,否则直接返回0。这是分布式锁的标准释放方式,也是面试时很容易被追问的细节。

3.3 锁的粒度与超时时间怎么定

锁的粒度是很多人会忽略的问题。有些同学实现缓存互斥锁的时候喜欢用一个全局lock,比如key就叫lock_all。这会导致一个商品失效时,所有商品都不能重建缓存,虽然不会打爆数据库,但会无辜拖慢其他数据。正确的做法是把锁的粒度细化到业务key本身,用业务key加上锁后缀,比如cache:product:10086对应lock:product:10086。这样不同商品的缓存重建互不干扰。

锁的超时时间怎么定?我的经验是:需要评估重缓存一件事的平均耗时,然后在这个基础上留出2到3倍的余量。假设正常情况下重建一个缓存需要100毫秒,那锁的过期时间设置500毫秒到1秒就够了。如果业务逻辑非常复杂,需要查询多个服务,重建耗时可能到1秒,那锁的过期时间建议放到5秒。这里有一个权衡:锁时间太短,重建还没完成锁就过期了,其他请求还是会并发涌入;锁时间太长,如果持有锁的线程出现异常没有释放,其他请求会等待很久。

另外需要注意,在分布式环境下还要考虑锁过期了但重建还没结束的极端情况。如果某次重建特别慢,锁比先过期了,其他线程就会拿到锁再次重建。真出现这种情况,最差的结果也就是重复查库,不会造成死循环,所以不用过度设计。但如果你的系统对一致性要求很高,可以考虑在重建完成后、写回缓存时顺便延长锁的时间,或者用Redisson那种“看门狗”机制自动续锁。

3.4 分布式锁还是本地锁:这个判断很重要

用Redis实现互斥锁是标准方案,但我要提醒一点:大多数缓存击穿场景其实用本地锁就够了,不一定需要分布式锁。

缓存击穿的本质是“同一台机器上的多个线程同时发现一个key失效了”。如果你用的是Spring Boot单体部署,或者即使是一个服务集群,但请求在不同节点上的分布是均匀的,那本地锁就能把单机上的并发重建消掉大部分。举个例子,集群有10台机器,原本1000个请求分散到各台机器是每台100个,加上本地锁之后,每台机器只有1个线程去重建,总共也就10次数据库查询,而不是1000次。这对数据库来说已经完全可接受了。

用本地锁的好处是零额外依赖、没有网络延迟、不存在锁超时问题。只有当你的集群规模很大、而且热点请求在这几十台机器的流量分布极不均匀时,才需要考虑用Redis分布式锁做全局限流。

具体实现上,本地锁可以用ConcurrentHashMap加上每个key对应的Lock对象,也可以直接用一个常见的“DCL双检锁”写法:先查缓存,没有的话加锁,加锁成功后再查一次缓存,还是没有才重建。这个“二次查询”非常关键,它可以避免一个线程已经在重建了,你等锁拿到后再次重复查询数据库。

4. 方案二:逻辑过期与异步刷新

4.1 逻辑过期的原理

互斥锁方案有一个天然缺陷:缓存失效瞬间,第一个请求必然要承受一次较慢的“查库+重建”过程,其他请求也得排队等它完成。如果这个热点key正在被几万QPS冲击,等待的请求依然可能超时。逻辑过期方案就是为了解决这个问题而出现的。

逻辑过期的思路很巧妙:缓存key在Redis里永远不设置物理过期时间,也就是不设TTL,让它永远存在。真正判断过期与否的逻辑放在value里面。比如value的结构是这样:

{ "data": "实际的业务数据", "expireTime": 1735000000000 }

每次读取的时候,先检查当前时间是否已经大于expireTime。如果还没有过期,直接返回data,完全不需要查库。如果已经过期,先返回旧数据,同时触发一个异步线程去更新缓存。关键点在于:因为Redis里的key永远不会被删除,所以这个key在失效期间依然能被访问到,数据库的压力被彻底规避了。

这个方案完美吗?不是。它的代价是短暂的数据不一致:逻辑过期后的这段窗口期内,用户看到的是旧数据。如果业务对数据一致性要求没那么高,比如商品详情页、首页推荐列表、资讯内容这类场景,这点不一致完全可接受。但如果数据是库存余额、订单状态这类强一致场景,逻辑过期就不合适。

4.2 为什么逻辑过期能扛住超高峰值

用生活类比理解逻辑过期:互斥锁方案像是一个商店只在货架空了才去进货,进货期间顾客只能在门口等;逻辑过期方案像是货架上永远有货,哪怕仓库已经知道商品超过保质期了,也能先让顾客拿走,仓库同时安排补货。

这个方案的另一个隐藏优势是:它不依赖锁的等待机制。逻辑过期读取的时候,请求永远不会失败、永远不会等待,响应时间非常平稳。对于那种“宁可数据旧一点也绝不能宕机”的业务,逻辑过期的体验远优于互斥锁。

不过这里有一个细节必须注意:异步刷新任务本身也需要一个“互斥标志”,否则多个请求同时发现逻辑过期,每个都会触发一次异步刷新,数据库还是会被冲刷。通常的做法还是用SETNX设置一个刷新标志,比如refresh:product:10086,只有拿到这个标志的线程才执行更新,其他线程发现标志存在就直接返回,让已经触发的刷新线程去更新。

4.3 逻辑过期方案的完整代码思路

用一段伪代码说明逻辑过期的完整流程:

// 伪代码,演示逻辑过期方案的核心逻辑 public String getProductDetail(Long productId) { // 1. 从Redis读取缓存 String cacheJson = redisClient.get("cache:product:" + productId); if (cacheJson == null) { // 理论上不会走到这里,因为物理不过期,但防御一下 return loadFromDBAndBuildCache(productId); } // 2. 解析缓存内容 CacheWrapper wrapper = JsonUtil.parse(cacheJson, CacheWrapper.class); // 3. 判断逻辑过期 if (wrapper.getExpireTime() > System.currentTimeMillis()) { // 未过期,直接返回 return wrapper.getData(); } // 4. 已过期:异步触发重建,先返回旧数据 String flagKey = "refresh:product:" + productId; boolean flag = redisClient.setIfAbsent(flagKey, "1", 5, TimeUnit.SECONDS); if (flag) { asyncExecutor.submit(() -> { try { // 重建缓存,更新expireTime refreshCache(productId); } finally { redisClient.delete(flagKey); } }); } // 5. 无论是否触发重建,都返回旧数据 return wrapper.getData(); }

注意这里的refresh标志也设置了5秒过期时间。如果异步刷新本身超过5秒,标志过期后有可能被另一个请求再次触发刷新,但这通常无伤大雅。如果你希望更严格,可以在刷新完成后延长标志的持有时间,或者使用更长的标志过期时间。

5. 方案三:热点key永不过期与主动预热

5.1 对真正的热点,别等失效才处理

逻辑过期方案里,虽然key物理上永不过期,但还是会有“逻辑过期”的状态存在。有没有办法让热点key的逻辑过期状态都不存在?有,那就是主动缓存预热。

这个方法的核心管理思想是:别把失效当突发事件处理,而是要主动管理热点key的生命周期。具体做法是把真正的热点数据设置一个较长的过期时间,同时由后台定时任务在过期之前主动刷新它,让key永远不会进入失效状态。

比如有个热门商品,你可以把它的TTL设置为24小时。然后写一个定时任务,每2小时把这个商品的详情数据重新加载一遍并写回缓存,同时刷新TTL。这样这个key虽然在物理上存在过期时间,但永远在过期之前就被续上了,形同永不过期。

这个方案最典型的应用场景是大促、秒杀前:运营已经提前知道哪些商品会上活动,研发可以在活动开始前将这些商品的数据预先加载到Redis中,TTL设置成活动结束后才过期。活动期间不管流量多高,这些key都不会失效,数据库自然不会被击穿。

5.2 谁来识别“热点”:监控指标与业务预测相结合

主动预热的前提是你要知道哪些key是热点。这个“知道”有两个途径:一是业务预判,比如运营告诉你明天上午10点有秒杀,那这个商品必然是热点,直接加白名单预热;二是数据驱动,通过Redis的监控指标识别出哪些key访问量异常高,然后自动加入“重点保护名单”。

从数据驱动的角度,我建议至少采集这些指标:

  • 缓存key的每秒访问次数,可以用Redis的INFO命令配合采样实现,或者在客户端埋点上报。
  • 缓存key的命中率变化趋势,命中率骤降往往意味着大量key即将失效,或者某个热点key已经失效。
  • 重建耗时统计,如果某个key的重建耗时特别长,那它失效时造成的危害也更大。

把这些指标对接上监控系统后,你还可以在热点key快要过期前主动刷新。这个思路和缓存雪崩的“过期时间加随机值”有异曲同工之处,但面向对象不同,一个是单key保护,一个是整体错峰。

5.3 永不过期方案的优缺点

永不过期方案的优势非常明显:热点key失效这个偶然事件被彻底消灭了,因为热点key永远不会真的失效。数据库压力稳定,业务响应稳定。

缺点也很直接:一是内存占用高,永不过期意味着Redis里永远存着这批数据,不会自动释放,需要人工管理清理策略;二是数据一致性差,如果后台刷新任务挂了或者延迟了,用户会一直看到旧数据;三是对非热点数据不适用,不可能把所有数据都做成永不过期,只能针对TOP N的数据做保护。

所以永不过期方案通常不是单独使用的,而是作为互斥锁或逻辑过期方案之上的“增强包”,专门保护那批最核心的数据。

6. 三种方案对比与选型建议

6.1 方案放一起看更清楚

维度互斥锁逻辑过期永不过期+预热
实现复杂度中中高低
数据库压力低(只有一个线程重建)极低(异步刷新,且可加互斥标志)极低(定时任务主动刷新)
数据一致性高(重建完才对外服务)低(有短暂旧数据窗口)低(依赖定时任务及时刷新)
响应延迟失效瞬间有等待平稳无等待平稳无等待
适用场景强一致要求,如库存、订单高并发读多写少,如详情页、内容大促预知热点、TOP N数据保护
风险点锁超时、锁粒度失控需要接受数据短暂不一致内存占用、任务稳定性

6.2 不同业务场景的选型逻辑

如果业务对数据一致性要求比较高,比如金额、库存这类,互斥锁是首选。它能保证缓存重建完成之前不对外提供旧数据,损失的只是一次重建时间的等待。

如果业务是高并发读的展示型数据,比如商品详情、文章内容,我建议优先考虑逻辑过期。这种场景用户根本感知不到数据旧几十毫秒甚至几秒钟,但响应平稳带来的体验提升是实打实的。

如果业务已经提前知道哪些数据是热点,比如大促白名单商品,那就用永不过期加预热。这是最省心的方式,因为它从根源上消灭了击穿的可能。实际上我见过不少高流量团队的做法是:互斥锁做兜底、逻辑过期做核心方案、永不过期保护第一批核心热点,三者组合使用。

我个人的习惯是:先识别出真正的热点数据,对TOP 100的key做永不过期加定时刷新;对非Top热点但访问量也不低的key,用互斥锁保护;对一致性要求不高的大量普通业务,用逻辑过期。这三层叠加下来,缓存击穿的概率基本可以忽略不计。

7. 常见问题与排查实录

7.1 怎么确认线上真的是缓存击穿

出现缓存击穿后,现场很容易被“恢复”掩盖。很多同学值班时收到告警,打开监控看Redis命中率已经回到正常、数据库QPS也降了,就以为是偶发波动。这时候要判断是否击穿,要看时间点上的关联证据。

我的排查习惯是:告警发生时,立刻同时拉Redis的命中率曲线、数据库的QPS曲线、慢查询日志、以及应用的错误日志。击穿的特征是Redis命中率在某一秒出现断崖式下跌,同时数据库QPS在同一个时间窗口出现尖峰,并且这两个时间点和某个业务key的TTL到期时间吻合。如果数据库慢查询日志里恰好在那个时间窗口出现了大量同一条SQL,基本可以锁定是哪个key被击穿了。

另一个辅助手段是看Redis的expiredkeys指标。在INFO命令的stats段可以找到expired_keys的累积值,如果你在告警时间窗口内对比两次采样值,差值突然变大,说明有大量key集中过期,结合业务key的TTL设置就能定位。

7.2 实际踩过的坑记录

第一个坑:分两步设置锁导致死锁。早期项目里有人用setnx之后单独调用expire,结果有一次应用进程在两步之间被杀掉,锁永远没释放,缓存重建逻辑死锁,数据库被反复查。这个坑的本质是操作不具备原子性,所以现在统一要求用SET命令加NX和EX参数。

第二个坑:锁粒度太粗。我曾经见过有人把锁key设置成全局唯一的global:lock,结果任何缓存失效都要等这个锁,不同业务的缓存重建互相干扰,一个慢业务拖垮了所有业务。教训是锁粒度一定要跟着业务key走。

第三个坑:本地锁和分布式锁选错。项目从单机部署改为多节点部署后,本地锁导致每个节点都重建一次缓存,虽然没有打爆数据库,但重建次数明显上升。后来分析发现这个业务key的并发量不大,其实本地锁就够了,根本不是分布式锁的问题,白加了组件复杂度。

第四个坑:异步刷新任务没有接受控。逻辑过期方案里异步刷新线程触发得太频繁,导致应用线程池被打满。解决办法是给异步刷新加一个独立的队列和线程池,并设置最大并发数,避免刷新任务影响正常的请求处理。

7.3 常见问题速查表

问题现象可能原因解决办法
Redis命中率骤降+DB QPS尖峰热点key过期,并发重建互斥锁或逻辑过期
加了锁还是DB压力大锁粒度不对,或使用本地锁但分布多实例并发细化锁粒度,评估是否需要分布式锁
缓存重建死锁、请求全部等待SETNX和EXPIRE分步执行,锁无过期时间使用SET key value NX EX命令原子设置
逻辑过期但DB查询量还是高异步刷新没有互斥标志为异步刷新增加SETNX标志
锁等待导致接口超时锁超时时间过短,重建耗时超过锁有效期调大锁过期时间或引入续期机制
缓存key永不删除导致内存暴涨永不过期策略的代价定期清理冷数据,做好内存监控
活动开始瞬间缓存就崩预热没做,活动商品key没有提前加载大促前提前预热热点key

写在最后的一点体会

缓存击穿这个问题,本质上考验的是对“瞬间并发”的理解。很多系统不是死在平均流量上,而是死在流量尖峰的一瞬间,而缓存击穿就是制造这种尖峰的元凶之一。解决它不需要高深算法,核心就是控制住并发重建的节奏:要么加锁让重建变成串行,要么把过期状态延后处理让请求不感知失效,要么干脆不让key失效。我在实际项目中用的最多的组合是“逻辑过期+热点永不过期+互斥锁兜底”,这套组合应付过几次大促流量,整体下来数据库压力控制得很稳定,业务响应也一直平稳。遇到缓存击穿不用慌,思路理顺了、方案选对了,它就是一道很容易解的题。

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

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

立即咨询