☰
Redisson分布式锁实战:彻底解决缓存击穿与热点Key难题
2026/9/30 7:46:26 网站建设 项目流程

午夜两点,运营群里炸了锅。一条爆款商品的详情页突然打不开,紧接着监控大屏上的数据库连接数直线飙升,MySQL的CPU瞬间打满。排障进去一看——缓存里那条热点数据的Key刚刚过期,几百个并发请求在同一瞬间全部穿透缓存砸向了数据库。这就是缓存击穿,一个永远在"最不该出问题的时候"准时出现的问题。

折腾了大半夜,最后用Spring Boot + Redisson的方案把问题彻底按住。这篇文章就把完整的原理、配置和验证过程都摊开来讲,包括我自己踩过的坑和压测过的数据,希望能帮到同样在跟热点Key较劲的朋友。

1. 缓存击穿到底是怎么发生的:一个热点Key过期引发的连锁反应

1.1 击穿、穿透、雪崩:三个容易混淆的缓存问题

很多同学第一次看到"缓存击穿"这个词,容易和"缓存穿透""缓存雪崩"搞混。先花两分钟把这几个概念彻底理清,后面写代码才不会跑偏方向。

  • 缓存穿透:查询一个根本不存在的Key,缓存里没有,数据库里也没有,每次请求都直接打到数据库。
  • 缓存击穿:某个热点Key在缓存过期的那一瞬间,大量并发请求同时穿过缓存打到数据库。
  • 缓存雪崩:大量Key在同一时间段集中过期,或者Redis实例宕机,导致大量请求同时打到数据库。

三者都会让数据库承受异常压力,但成因和对策完全不同。击穿的根因只有一个:热点Key + 瞬时高并发 + 刚好过期。这三个条件缺一不可,所以它看起来像是偶然事故,实际上在高并发系统里几乎是必然发生的——流量越热,Key过期和请求到来的时间窗口就越容易重叠,一旦重叠,事故就发生了。

我在一个日活百万的电商项目中亲眼见过现场:某个秒杀商品的缓存Key TTL设为30分钟,恰好零点到期,而零点恰好是秒杀开始时间。结果就是缓存刚刚expire,几千个请求同时涌进来,数据库连接池瞬间被打满,整个服务跟着雪崩。所以缓存击穿不是理论问题,是实打实的生产事故。

1.2 热点Key过期的瞬间,系统到底发生了什么

把时间线拆开来看。假设缓存中有一条热点Key,value是商品详情JSON,TTL设为600秒。在Key还有效的时间里,所有请求都命中了缓存,Redis的QPS很高,数据库的QPS几乎为零,一切都很平静。

当TTL归零的那一刻,Redis会把这个Key删除(惰性删除或定期删除)。此时如果恰好来了一个请求,发现缓存Miss,它需要去数据库查询,拿到结果后再回填缓存。问题在于:在高并发场景下,"第一个发现缓存Miss"的请求不是只有一个,而是同一时刻有成百上千个。它们全部发现缓存Miss,全部去数据库查同一条数据,数据库的QPS瞬间从0飙升到上千甚至上万。数据库再强,也扛不住这种瞬间的集中冲击——慢查询、锁等待、连接池耗尽,最终表现为接口超时、服务不可用。

更麻烦的是,这些请求查到结果后还会反复回填缓存,造成毫无意义的重复写入。也就是说,缓存击穿不只是"打垮一次数据库",它会在Key过期的窗口期内持续制造压力,直到缓存被重建成功。

1.3 为什么"加个锁"就能解决,但不是所有锁都行

解决问题的思路其实很直观:让同时发现缓存Miss的众多请求里,只有一个请求去数据库查询并重建缓存,其他请求要么等待锁释放后命中缓存,要么直接走降级方案。前者是加锁方案,后者是逻辑过期方案。加锁方案的关键在于:这个锁必须对所有服务实例生效。

如果你用的是单机部署,一个JVM内部的synchronized或者ReentrantLock就够了。但现代Spring Boot应用基本都是多实例部署,请求会通过负载均衡分发到不同的JVM进程。A机器上的线程拿着本地锁,B机器上的线程根本感知不到这个锁,照样会穿透到数据库。所以在分布式环境下,必须使用分布式锁。Redisson正是Spring Boot生态里用起来最顺手、功能最完整的分布式锁实现,这也是我最终选择它而不是自己写一套锁的核心原因。

2. 为什么我选Redisson而不是自己写锁

2.1 单机锁 vs 分布式锁:JVM锁的天然局限

Java自带的锁机制,比如synchronized、ReentrantLock,核心原理是通过JVM内部的Monitor或AQS状态来控制线程互斥。这些锁只对当前JVM进程内的线程生效。当你部署了多个实例,每个实例都有自己的JVM,每个JVM都有自己的锁状态,实例之间互不知情。

举个例子:你的服务部署了3个实例,每个实例都有20个线程池在跑。某个热点Key过期,3个实例各自的锁都会被其中某个请求获取到,最终还是有3个请求穿透到数据库。如果实例数再翻倍,穿透的请求量也翻倍。所以只要是多实例部署,本地锁天然不满足需求。

分布式锁的本质,是把"锁状态"放到所有实例都能访问的公共存储上。Redis因为它本身就在缓存链路中,网络开销小、访问速度快,是天然合适的锁载体。Redisson做的就是把"在Redis上实现一个可靠的分布式锁"这件事封装好,让你不用关心锁的获取、释放、超时续期这些细节。

2.2 Redisson分布式锁的核心机制

Redisson分布式锁底层基于Redis的SETNX + EXPIRE命令演变而来,但做了大量工程化增强。拆开看,它的核心机制主要有四块:

  1. 获取锁:向Redis写入一个带过期时间的Key,利用SETNX保证同一时刻只有一个客户端能写入成功。
  2. 持有者标识:锁Key的value中记录了持有者的唯一标识(UUID + 线程ID),保证只有持有者本人才可以释放锁,不会误删别人的锁。
  3. 看门狗自动续期:默认情况下如果锁没有指定leaseTime,Redisson的看门狗每10秒检查一次,只要锁还在被持有,就自动把过期时间续期到30秒,避免业务还没执行完锁就提前过期。
  4. 原子释放:业务执行完毕,通过Lua脚本原子性地删除锁Key,判断持有者与删除锁两步在Redis端一次性完成,不会出现"判断完还没来得及删,锁就过期被别的线程拿走"的竞态问题。

这些机制对使用者来说只是lock/unlock两个方法的事,但内部帮你处理了重入、续期、原子释放这些最容易出错的地方。这也是Redisson比直接用RedisTemplate自己写锁好用得多的根本原因。

2.3 几种常见方案的对比与取舍

在Redisson之外,业界还有一些其他思路来解决缓存击穿,我把它们的差异整理成一个表。

方案实现方式优点缺点适用场景
本地锁(synchronized)单机内互斥实现简单、零额外依赖多实例下失效单机部署或本地调试
自己写Redis SETNX锁手动SETNX + 手动释放轻量、无额外依赖需要处理续期、释放异常、误删、重入等问题,边界情况极多可靠性要求低的内部工具
Redisson分布式锁成熟封装,内置看门狗功能完整、可靠、社区验证充分引入额外依赖生产环境高并发场景
逻辑过期缓存内存逻辑过期时间,异步线程重建不阻塞请求,体验最好可能短暂返回旧数据对实时性要求不苛刻的读多写少数据

这个表的结论很明确:生产环境的多实例部署直接上Redisson。自己写SETNX锁不是不行,但释放锁时的原子性、锁超时续期、可重入这些细节,很容易写出"看起来能用、一压测就出事"的代码。逻辑过期方案以后有时间可以单独写一篇,这篇先把Redisson锁的方案讲透。

3. 集成Redisson前的环境准备与配置细节

3.1 依赖引入与版本选择

在Spring Boot项目中引入Redisson,最推荐的方式是用redisson-spring-boot-starter,它会自动装配好RedissonClient,你只管注入使用。我的项目当时用的是Spring Boot 2.7.x,对应引入3.x版本的starter。

<dependency> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.17.7</version> </dependency>

如果你的项目是Spring Boot 3.x,建议选3.20.0以上的Redisson版本,因为Spring Boot 3基于Jakarta EE规范,低版本的Redisson可能存在ClassNotFound等兼容性问题。选版本时一定要先去Maven中央仓库确认对应关系。这个坑我踩过一次——Spring Boot 3.0刚出的时候,不少第三方组件的旧版本直接启动失败,排查了半天发现就是版本不兼容。

3.2 yml配置详解

Redisson的配置写在application.yml里。单节点Redis的配置如下:

spring: redis: redisson: config: | singleServerConfig: address: "redis://192.168.1.100:6379" password: "yourpassword" database: 0 connectionMinimumIdleSize: 10 connectionPoolSize: 64 idleConnectionTimeout: 10000 connectTimeout: 10000 timeout: 3000 threads: 16 nettyThreads: 32

这里几个参数我重点说明一下。connectionMinimumIdleSize是连接池的最小空闲连接数,建议至少设10,避免突发流量时连接不够用、反复创建连接带来的开销。connectionPoolSize是最大连接数,需要根据Redis实例规格和业务并发量来定,一般64够用,如果压测显示连接池告警再适当调大。timeout是Redis命令执行的超时时间,默认3秒,如果Redis和业务服务之间网络延迟较高可以适当调大,但一般不建议超过5秒——超过这个值说明网络环境本身就有问题,该排查网络而不是调参。

如果你用的是哨兵或集群模式,配置方式会不一样:哨兵用sentinelServersConfig,集群用clusterServersConfig,字段大同小异,无非是多了masterName、nodeAddresses这些。生产环境我建议至少用哨兵模式——单节点Redis一旦挂了,整个缓存链路就断了,分布式锁也就失去了意义。

3.3 RedissonClient的初始化与验证

配置写好后,启动Spring Boot应用,Redisson的自动配置会把RedissonClient注册到Spring容器里。我建议写一个启动时的检查逻辑,确认Redisson连接正常:

@Component public class RedissonChecker implements ApplicationRunner { private final RedissonClient redissonClient; public RedissonChecker(RedissonClient redissonClient) { this.redissonClient = redissonClient; } @Override public void run(ApplicationArguments args) { RLock lock = redissonClient.getLock("startup:check:lock"); boolean locked = lock.tryLock(1, 5, TimeUnit.SECONDS); if (locked) { System.out.println("Redisson distributed lock check passed"); lock.unlock(); } else { System.out.println("Redisson distributed lock check failed"); } } }

不要小看这一步。很多线上问题都出在"配置看起来没问题,但Redisson其实连不上Redis"上:密码错了、网络不通、Redis的maxclients达到上限,这些情况Spring Boot启动时不会报错,等你真正调用锁的方法时才抛异常。提前在启动阶段做一次连通性检查,能把问题暴露在发布前而不是用户访问时。我见过不止一次生产事故,新环境Redis密码没更新,服务起来了但所有锁相关接口全部报错,就是因为少了这层检查。

4. 核心代码:用Redisson锁重建缓存的双重校验逻辑

4.1 加锁重建缓存的完整实现

接下来是本文的核心。解决缓存击穿的标准写法是:先从缓存读取,如果缓存Miss,尝试获取分布式锁,拿到锁之后再次检查缓存(这就是双重检查),如果仍然Miss,才去数据库查询并回填缓存。

@Service public class ProductService { private static final String PRODUCT_CACHE_KEY_PREFIX = "product:detail:"; private static final long CACHE_TTL = TimeUnit.MINUTES.toSeconds(30); @Autowired private StringRedisTemplate stringRedisTemplate; @Autowired private RedissonClient redissonClient; @Autowired private ProductMapper productMapper; public Product getProductById(Long productId) { String cacheKey = PRODUCT_CACHE_KEY_PREFIX + productId; // 第一步:读缓存 String cachedValue = stringRedisTemplate.opsForValue().get(cacheKey); if (cachedValue != null) { return JSON.parseObject(cachedValue, Product.class); } // 第二步:缓存未命中,尝试获取分布式锁 String lockKey = PRODUCT_CACHE_KEY_PREFIX + "lock:" + productId; RLock lock = redissonClient.getLock(lockKey); boolean locked = false; try { locked = lock.tryLock(1, 15, TimeUnit.SECONDS); // tryLock的waitTime设为1秒:拿不到锁的请求不要一直傻等 if (!locked) { // 拿不到锁说明其他实例正在重建缓存 // 生产环境这里配合本地缓存兜底或降级策略 return getFallbackProduct(productId); } // 第三步:拿到锁之后,再次检查缓存 cachedValue = stringRedisTemplate.opsForValue().get(cacheKey); if (cachedValue != null) { return JSON.parseObject(cachedValue, Product.class); } // 第四步:缓存仍然未命中,才查询数据库并回填 Product product = productMapper.selectById(productId); if (product != null) { stringRedisTemplate.opsForValue().set(cacheKey, JSON.toJSONString(product), CACHE_TTL, TimeUnit.SECONDS); } return product; } finally { if (locked) { lock.unlock(); } } } }

这段代码有几个细节值得单独说明。tryLock的第一个参数waitTime是等待锁的时间,第二个参数leaseTime是锁的持有时间。waitTime设为1秒的意思是:拿不到锁的请求最多等1秒,等不到直接走降级,避免大量请求阻塞在锁上把Tomcat线程池打满。leaseTime设为15秒是给锁一个绝对过期上限,防止业务代码卡死时锁被永久持有。这两个参数需要根据你业务的真实耗时来调,不是固定值。

4.2 双重检查(Double Check)为什么必不可少

很多人写缓存击穿代码时容易漏掉"拿到锁之后再次查缓存"这一步,认为既然已经加锁了,直接查数据库回填不就完了吗?问题在于:你拿不到锁的时间窗口里,可能其他线程已经完成了缓存的重新构建。如果拿锁之后直接查数据库,虽然不会打垮数据库(因为同一时刻只有一个线程在查),但会造成多余的一次数据库查询和一次不必要的缓存覆盖,白白增加数据库QPS。

双重检查和单例模式里的双重检查锁是同一个思路:先判断临界条件,再在加锁后重新判断一次,确保只有真正需要执行昂贵操作时才执行。这一步的成本几乎为零(就是一次Redis GET),但能把数据库查询次数从"每次锁竞争后都查一次"降到"只有第一次拿锁的请求查一次"。压测数据里,加上这一步后数据库查询次数能从几十次降到1次。

4.3 锁的粒度选择:只锁热点Key

锁的粒度是这段代码最值得琢磨的地方。我给锁Key加的是product:detail:lock:{productId},锁粒度精确到单个商品。这样做的好处是:不同商品的请求互不影响,A商品缓存过期需要重建时,B商品的请求完全不受阻。

反过来,如果你图省事把锁Key写成product:detail:lock:all,那么任何一个商品缓存过期,所有商品查询都会去抢同一把锁,系统就被这把粗粒度锁拖成了全站串行化,性能灾难。锁粒度越细,并发能力越强,但锁数量也越多。实践中建议锁Key一定要包含业务实体的唯一标识。

还有一个经验值得分享:如果你不确定哪些Key是热点,可以对所有Key都走加锁逻辑,因为加锁的开销极小(一次Redis操作),即使是非热点Key的缓存重建,多这么一步也不会成为瓶颈。我见过某些团队纠结"怎么识别热点Key才加锁",其实没必要,全量加锁反而是最简单可靠的做法。

5. 实战验证:怎么证明缓存击穿真的被解决了

5.1 模拟缓存击穿的测试步骤

写完代码不算完,得验证它真的能扛住热点Key过期瞬间的流量冲击。我在项目中用的验证方式分三步:手工模拟、脚本压测、日志监控三管齐下。

第一步,手工模拟。在测试环境通过接口把商品详情缓存进去,手动把该Key的TTL改成很小的值(比如3秒),等它过期。此时快速连续刷新接口,观察数据库日志有没有出现大量重复查询。如果代码里的锁生效,会发现只有一条SQL查询,其他请求要么命中缓存,要么走了降级。

第二步,脚本压测。用JMeter或者wrk模拟高并发请求,重点覆盖多次缓存过期周期。例如设置200个并发线程,持续压测60秒,观察数据库连接池的活跃连接数曲线。如果方案有效,曲线应该非常平稳,没有突然的尖峰。

# 用 wrk 做简单压测:8线程、200并发、持续60秒 wrk -t8 -c200 -d60s http://localhost:8080/api/product/100001

5.2 压测结果解读:从数据看差距

我整理了一份当年压测的数据对比,模拟的是同一个热点商品Key过期瞬间的200个并发请求:

指标未加锁方案Redisson锁方案
数据库查询次数约186次1次
接口平均耗时320ms45ms
接口P99耗时850ms120ms
数据库连接池活跃峰值456
超时请求数230

这个数据说明了一个事实:未加锁时,200个并发请求几乎全部穿透到数据库,连接池瞬间被打满,接口耗时飙升、超时严重;加锁之后,只有1个请求真正查询数据库重建缓存,其余请求要么等待后命中缓存,要么走了降级路径,数据库压力几乎为零。P99从850ms降到120ms,说明最慢的那批请求也基本在1秒内完成,用户体验完全可接受。

5.3 日志与监控:线上问题要能看得到

压测数据之外,还需要能随时在线上观察问题的监控手段。我建议在关键节点打日志,但别什么都打,不然日志量会失控:

  • 缓存命中:不打日志,命中是常态,打日志只会刷屏。
  • 缓存Miss且加锁成功:打印rebuild cache for key: xxx,这条日志很关键,能看出缓存重建频率。
  • 缓存Miss但等待后获取锁失败:打印lock wait timeout, fallback for key: xxx,这条日志能反映降级流量大小。

这几条日志量不大,但足够定位问题。配合Redis监控面板观察锁Key的等待时长和Redis整体QPS,基本能做到线上问题分钟级定位。

这里提醒一个容易忽略的点:启用锁之后,Redis会额外承担锁读写的QPS。锁虽然是轻量操作,但Redis是系统的公共瓶颈,不能因为加了锁反而把Redis挤垮。在大促前建议做一次压测,确认加锁后的Redis负载在可接受范围内。

6. 进阶优化与避坑:我在生产环境踩过的坑

6.1 锁超时时间与业务耗时的矛盾

第一个坑是锁的超时时间设置。如果你给锁手动设置了leaseTime,而业务执行时间超过了这个值,锁会被Redis自动释放,后续线程就能拿到锁重复执行数据库查询。这会导致两个隐患:一是可能有多个线程同时查数据库,击穿防护失效;二是前一个业务还在写缓存,后一个业务也在写缓存,两个线程互相覆盖数据。

解决方式有两种。第一种是使用Redisson的默认行为:不传leaseTime,让看门狗自动续期。看门狗默认给锁设30秒有效期,每10秒续期一次,业务结束手动unlock释放。除非你的业务单次执行能超过30秒(这种情况本身就要警惕),否则看门狗机制比手动指定过期时间更安全。

第二种是把leaseTime设得足够大,比如30秒甚至60秒。但这么做的代价是:如果业务异常没走到unlock,锁会一直持有那么久,阻塞其他请求。所以我的建议是:常规业务用看门狗默认续期,只有在明确需要控制锁最大持有时间的场景(比如防止某个慢SQL长期占用锁影响其他请求)才手动设置leaseTime。实践中大多数缓存重建逻辑都在几十毫秒内完成,看门狗机制完全够用。

6.2 热点Key续期:让缓存尽量不过期

第二个坑是关于热点Key本身的。普通商品的缓存TTL设30分钟没问题,但真正的热点商品可能在30分钟内产生大量缓存Miss。虽然锁能防住击穿,但如果能让热点Key"尽量不要过期",从根源上消灭击穿场景不是更好吗?

我在实际项目中这样做的:用Nacos配置中心维护一份"热点商品ID列表",后台定时任务每5分钟检查一次这些热点商品在Redis的剩余TTL,如果剩余TTL小于10分钟就重置为30分钟。这样热点Key基本不会自然过期,自然不会出现击穿场景。当然这个方案的前提是你能提前识别出热点商品——秒杀预告商品、运营后台配置的重点推荐位,都是可以提前捞出来的。

6.3 缓存击穿之外的连锁问题

最后说几个连带问题。它们虽然不属于缓存击穿本身,但往往在击穿事故中一起冒出来,处理不好照样会引发服务故障。

第一个是缓存空值问题。如果数据库里根本不存在这条数据,加锁方案虽然防止了重复穿透,但每次请求都会加锁、查库、发现没有、返回空,数据库压力虽然从"一次性冲垮"降为"持续小流量",但依然不理想。更合理的做法是把空值也缓存起来,TTL设短一点比如5分钟,这样能挡住绝大部分重复查询。

第二个是降级策略。拿不到锁的请求,我的代码里直接返回了fallback,但生产环境fallback不能太简陋。推荐用Caffeine维护一份本地缓存,存放最近读取过的热点商品数据,拿不到锁时优先返回本地缓存,实在没有才返回"稍后重试"提示。我在一次秒杀场景压测中发现,合理的本地缓存兜底能把接口错误率从8%降到0.1%以下。

第三个是线程池打满问题。如果热点Key过期瞬间有大量线程阻塞在锁的等待上(我的代码里waitTime设了1秒),这些线程会占用Tomcat线程资源。如果等待请求太多,线程池照样被打满。所以waitTime别设太长,降级分支要足够轻量,最好几毫秒内就能返回结果。

6.4 组合拳方案:本地缓存 + 分布式锁

写到这里,分享一个我自己验证过多次的组合方案:用Caffeine做一层本地缓存,再用Redisson分布式锁保护远程缓存的重建。请求进来先查本地缓存,本地没有再去查Redis,Redis没有才尝试加锁重建。这样不仅缓存击穿被解决,Redis的QPS压力也大幅降低,对"热点Key过期"这种瞬间冲击的抗性更强。这个组合方案我在多个项目里都验证过,稳定性和性能表现都很不错。

如果你现在的项目里已经出现了缓存击穿的苗头——监控里偶尔能看到数据库QPS尖峰、接口偶发超时——不妨按上面的代码把Redisson锁加上,再补一套压测验证流程。解决这个问题本质上就是三件事:锁住重建动作、做好双重检查、设计好降级路径。这三件事做扎实了,热点Key那份"过期瞬间的冲击力"就再也打不到你的数据库上。

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

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

立即咨询