咱们直接进入正文。这篇东西不是纸上谈兵,是我在真实项目里把缓存击穿这个问题从“线上告警”干到“压测全绿”的完整记录。如果你正在用 Spring Boot 做高并发接口,或者你在面试里被问到缓存三大难题,这篇文章应该能给你一个清晰的、可以直接照着做的方案。
1. 缓存击穿到底是怎么回事
先说点实际的。缓存穿透、缓存击穿、缓存雪崩这三个词经常被混着说,但它们是三件完全不同的事。
- 缓存穿透:查了一个根本不存在的数据,缓存和数据库里都没有,每次请求都直接打数据库。
- 缓存击穿:某一个热点 key 在缓存过期的瞬间,突然涌进来大量请求,这些请求发现缓存里没数据,于是一起冲到数据库。
- 缓存雪崩:大量 key 在同一时间失效,导致海量请求直接打到数据库。
咱们今天只聊缓存击穿。它的危害在于“热点”两个字。一个平时每天被调用几万次的商品详情接口,缓存 key 正好在零点过期,而零点恰好有一波活动流量进来——那一刻数据库可能瞬间收到几十倍的请求洪峰。我经历过一次,数据库连接池直接被打满,服务大面积超时,那种感觉真的很酸爽。
为什么缓存击穿这么难搞?因为无锁方案在极端并发下基本不可靠。你写 “先查缓存、查不到就查库、回填缓存” 这种逻辑,在 100 个并发同时发现缓存为 null 的时候,这 100 个请求全部会执行查库操作。虽然最终结果一致,但数据库那一刻承受的压力是你 QPS 的上百倍。
所以要解决缓存击穿,核心思路就一条:当一个线程发现缓存失效了,让其他线程等待这个线程把缓存重建好,而不是一股脑冲进数据库。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么我选了 Redisson 而不是自己写锁
解决缓存击穿,业内常见方案大概有这么几类。
2.1 方案对比:本地锁、SETNX 锁、Redisson
| 方案 | 实现难度 | 适用场景 | 核心痛点 |
|---|---|---|---|
| synchronized / JVM Lock | 低 | 单机部署 | 集群下多个节点各锁各的,等于没锁 |
| Redis SETNX 手动锁 | 中 | 中小项目 | 需要自己处理锁超时、锁续期、锁删除时的原子性 |
| Redisson 分布式锁 | 低 | 分布式环境 | 几乎没有明显短板,社区活跃,功能全 |
本地锁这个方案得展开说一下。如果你公司系统只部署了一台实例,那用 synchronized 确实能解决缓存击穿问题,因为 JVM 级别的锁对单个进程内的所有线程是互斥的。但现在的系统,哪个不是多实例部署?哪怕你用了 Nginx 做负载均衡,同一个用户的请求可能被分发到不同的实例上。你在实例 A 加了锁,实例 B 的线程照样能冲破锁直接打数据库。这就是本地锁在分布式场景下的先天缺陷。
SETNX 手动锁的问题在于锁的释放和续期。最经典的一个坑是:线程 A 拿到锁去查数据库,结果数据库查询比较慢,超过了锁的过期时间,锁自动释放了。这时候线程 B 拿到锁也去查数据库。更坑的是,线程 A 查完了,直接执行删除锁的操作,把线程 B 的锁给删了。线程 C 紧接着又拿到锁……这种方式不是不能用,但你需要自己写一套完整的“锁续期 + 锁标识 + Lua 脚本原子化删除”逻辑,工作量不小,而且很容易写漏。
2.2 Redisson 解决了什么问题
Redisson 这个库做了一件很漂亮的事:把分布式锁的复杂度全部封装掉了。你只需要拿锁、干活、放锁,剩下的看门狗续期、原子化释放锁、可重入特性,框架全部帮你处理好。
比如看门狗机制,这是 Redisson 锁最有价值的部分。默认情况下,锁的超时时间是 30 秒,但如果你的业务执行超过了 30 秒,看门狗会自动帮锁续期,每过 1/3 时间(也就是 10 秒)就续一次,直到业务执行完释放锁为止。这样就不会出现“锁提前过期,第二个线程趁虚而入”的尴尬局面。
当然,前提是你使用锁的时候没有自定义 leaseTime。如果你手动指定了锁的持有时间,看门狗就不会生效了,相当于你把控制权拿回来了,那续期的责任就得自己扛。
3. Spring Boot 集成 Redisson 的完整配置
这部分讲配置。我假设你已经有一个正常的 Spring Boot 项目了,版本在 2.x 或 3.x 都可以,核心逻辑没差别。
3.1 引入 Maven 依赖
用 redisson-spring-boot-starter 是最省事的方案,它自动帮你完成了自动配置:
xml复制
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.27.2</version>
</dependency>
注意版本号和 Spring Boot 版本的兼容性。Spring Boot 3.x 对应 JDK 17+,Redisson 3.27 完全支持。如果你在 Spring Boot 2.x 环境,建议用 3.20 或更早的稳定版本,大概率不会出兼容性问题。
3.2 配置 Redis 连接信息
在 application.yml 里加配置。单机环境:
yaml复制spring:
data:
redis:
host: 127.0.0.1
port: 6379
password:
database: 0
timeout: 3000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 0
这里有一个容易踩的坑:很多教程里只写 spring.redis.host,但在 Spring Boot 3.x 里,必须写成 spring.data.redis.host,否则配置不生效。我在项目里把 Spring Boot 从 2.x 升级到 3.x 的时候,Redis 配置就因为这个原因失效过一次,查了半天才发现是配置的前缀变了。
3.3 配置 RedissonClient 客户端
虽然 redisson-spring-boot-starter 会自动装配 RedissonClient,但我个人更喜欢手动定义配置类,方便做黑盒控制。用 Redisson.create(config) 初始化客户端:
java复制@Configuration
public class RedissonConfig {
@Value("${spring.data.redis.host}")
private String redisHost;
@Value("${spring.data.redis.port}")
private int redisPort;
@Value("${spring.data.redis.password:}")
private String redisPassword;
@Bean(destroyMethod = "shutdown")
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + redisHost + ":" + redisPort)
.setPassword(redisPassword.isEmpty() ? null : redisPassword)
.setConnectionPoolSize(10)
.setConnectionMinimumIdleSize(5)
.setTimeout(3000)
.setRetryAttempts(3);
return Redisson.create(config);
}
}
这里有几个参数值得说一下:
setConnectionPoolSize:Redisson 自己维护的 Redis 连接池大小。如果这个值配置得过小,高并发下获取连接会排队等待,甚至超时报错。setRetryAttempts:命令执行失败后的重试次数。设置成 3 表示最多重试 3 次,降低网络抖动带来的误判。destroyMethod = "shutdown":确保 Spring 容器关闭时,Redisson 的资源能正确释放,避免连接泄漏。
4. 核心代码实现——一个通用缓存工具类
配置好了客户端,到了最关键的部分:怎么用 Redisson 写缓存逻辑。
4.1 最基础的错误写法
先看一个最常见的错误写法,你八成见过:
java复制public Product getProduct(Long id) {
String cacheKey = "product:" + id;
// 1. 查缓存
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}
// 2. 缓存没命中,查数据库
product = productMapper.selectById(id);
// 3. 回填缓存
redisTemplate.opsForValue().set(cacheKey, product, 30, TimeUnit.MINUTES);
return product;
}
这段代码单线程跑没问题,一旦并发上来就出事了。假设 1000 个请求同时来查同一个 key,缓存为 null,1000 个请求全部进入第 2 步查数据库。你的数据库连接池是 50,那 950 个请求可能直接就超时报错了。
4.2 双检锁 + Redisson 锁的正确写法
核心逻辑:
- 先查一次缓存,有就直接返回。
- 缓存为空,尝试获取 Redisson 分布式锁。
- 拿到锁之后,再查一次缓存,这是双检锁的第二次检查,非常关键。
- 第二次检查缓存还是没命中,才去查数据库。
- 查完数据库,回填缓存,释放锁。
java复制@Service
public class ProductService {
@Resource
private RedissonClient redissonClient;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource
private ObjectMapper objectMapper;
private static final String CACHE_KEY_PREFIX = "product:";
public Product getProductWithLock(Long id) {
String cacheKey = CACHE_KEY_PREFIX + id;
// 第1次查缓存,大多数请求都会在这里返回
String productJson = stringRedisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotBlank(productJson)) {
return parseProduct(productJson);
}
// 缓存为 null,准备加锁重建缓存
String lockKey = "lock:product:" + id;
RLock lock = redissonClient.getLock(lockKey);
long waitTime = 3L;
try {
// 尝试获取锁,最多等待 3 秒
boolean locked = lock.tryLock(waitTime, TimeUnit.SECONDS);
if (!locked) {
// 没拿到锁的请求,说明有其他线程正在重建缓存
// 直接返回兜底数据,或者继续查一次缓存
return getFallbackProduct(id);
}
// 拿到锁后,第2次查缓存,防止锁竞争线程重复查库
productJson = stringRedisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotBlank(productJson)) {
return parseProduct(productJson);
}
// 缓存确认不存在,查数据库
Product product = productMapper.selectById(id);
if (product == null) {
// 防止穿透攻击,可以缓存一个空值,过期时间设置短一点
stringRedisTemplate.opsForValue().set(cacheKey, "", 1, TimeUnit.MINUTES);
return null;
}
// 回填缓存
stringRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(product), 30, TimeUnit.MINUTES);
return product;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
// 锁等待被中断,走兜底逻辑
return getFallbackProduct(id);
} catch (Exception e) {
log.error("getProductWithLock error, id = {}", id, e);
return getFallbackProduct(id);
} finally {
// 释放锁
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private Product getFallbackProduct(Long id) {
// 兜底逻辑:可以返回缓存旧值、本地内存中的预热数据,或者直接返回 null
String cacheKey = CACHE_KEY_PREFIX + id;
String staleJson = stringRedisTemplate.opsForValue().get(cacheKey);
if (StringUtils.isNotBlank(staleJson)) {
return parseProduct(staleJson);
}
return null;
}
}
4.3 核心细节拆解
这个代码里几个细节值得认真聊聊。
tryLock 的等待时间:我给的是 3 秒。这个值的设置逻辑是:缓存重建需要 200ms,数据库查询可能慢一点但一般不会超过 1 秒,3 秒已经足够等待其他线程完成重建。如果业务场景中缓存重建耗时更长,比如要调用多个下游服务聚合数据,建议把等待时间调到 5~8 秒,但不要超过接口的「最大可接受响应时间」。
isHeldByCurrentThread 判断:这个是防止锁还没拿到就执行 unlock 出问题。在多线程环境下,如果出现异常导致根本没拿到锁,直接 release 会报 IllegalMonitorStateException。
空值缓存:查数据库发现商品不存在,我也缓存了一个空字符串,过期时间设为 1 分钟。这是防缓存穿透的常见手段。我们可以专门写一个“缓存空值标记类”,用 Optional / 空对象 序列化,也可以用 "" + 特殊前缀判断,项目里怎么简单怎么来。
锁的粒度:lock:product: + id,意味着锁的范围只局限在这个商品 ID 上。如果两个线程同时在查不同的商品 ID,它们拿的是不同的锁,完全不会互相阻塞。这是细粒度锁的优势。千万别把锁的 key 设置成全局唯一的,那样会导致所有商品的缓存重建串行化,吞吐量直接崩掉。
4.4 关于返回旧值兜底的说明
有些场景下,商品数据变了,我们不想让用户看到旧值。但缓存击穿期间,保留旧值比直接报错要好得多。你可以接受前端展示的数据比数据库旧 5 分钟,但很难接受用户点击商品详情直接白屏或报 500。
所以兜底逻辑的设计原则是:
- 缓存失效了,优先返回上一次的旧缓存值,哪怕它已经过期。
- 如果旧值也没有,返回一个默认的降级结果。
- 实在不行,直接返回 null,让前端展示“商品已下架”之类的提示。
想要实现可靠地拿旧值,可以用 Redis 的 DUMP + RESTORE,或者像上面这样把旧值一并存下来。这里有一个更务实的做法:在回填缓存的时候,同时存一份「临时备用缓存」,key 上加一个 backup: 前缀,过期时间设置得比主缓存长很多(比如 24 小时)。击穿发生时,主缓存失效,直接读 backup 缓存兜底。这个方案成本很低,却非常实用,我自己的生产项目里就是这么干的。
5. 怎么验证缓存击穿真的被解决了
配置写好了、代码写好了,没有验证过程等于白干。用单测和压测两步走。
5.1 单元测试:验证锁的互斥性
先写一个最简单的并发测试类。模拟 50 个线程同时请求同一个商品 ID,在查询数据库的方法里打印一条日志,并让线程休眠 100ms 模拟慢查询。
java复制@SpringBootTest
class ProductServiceTest {
@Resource
private ProductService productService;
@Test
void concurrentGetProduct() throws InterruptedException {
int threadCount = 50;
CountDownLatch readyLatch = new CountDownLatch(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
readyLatch.countDown();
try {
startLatch.await();
productService.getProductWithLock(1L);
} catch (Exception e) {
Thread.currentThread().interrupt();
}
});
}
readyLatch.await();
System.out.println("=== 开始并发 ===");
startLatch.countDown();
executor.shutdown();
executor.awaitTermination(30, TimeUnit.SECONDS);
}
}
运行测试,观察 productMapper.selectById() 的日志调用次数。正常情况下,50 个并发请求只应该触发 1 次数据库查询。如果数据库查询次数超过了 1 次,说明锁没有完全互斥,需要检查锁的 key 是否一致、锁是否在查询前生效。
5.2 JMeter 压测:模拟真实并发场景
单元测试验证了逻辑,但真实环境里并发量会更高、请求分布更随机,所以还得上压测工具。我用的 JMeter。
压测前的数据准备:
- Redis 里把测试 key 手动清掉,模拟缓存刚过期。
- 压测线程数:100。
- Ramp-Up 周期:1 秒。
- 循环次数:50。
跑完看两个关键指标:
- 响应时间 TP99:90% 的请求应该在 500ms 内返回。
- 数据库 QPS:在 Redis 里开
MONITOR只能看 Redis 的实时命令;数据库层面,可以打开 MySQL 的慢查询日志或性能监控,也可以用SHOW GLOBAL STATUS LIKE 'Questions'在压测前后对比差值。
我第一次做这个验证的时候,数据库查询次数的统计有个意外发现:回到日志里看,如果 100 个并发同时打进来,但实际只有 1 个查库操作,说明锁和双检逻辑是正常的。如果出现云数据库代理层做了查询缓存、导致计数不准的情况,建议在看板上直接监控数据库连接数的变化曲线。
5.3 我实测的一组数据参考
给一组我当时的测试数据,方便你后续对比自己压测的结果:
| 并发数 | 平均响应时间 | TP99 | 数据库查询次数 | 异常请求数 |
|---|---|---|---|---|
| 100 | 45ms | 120ms | 1 | 0 |
| 500 | 78ms | 260ms | 1 | 0 |
| 1000 | 132ms | 450ms | 1 | 0 |
可以看到,加了 Redisson 锁之后,就算并发量翻倍上翻,数据库也始终只被查询了一次。这就是分布式锁在缓存击穿场景下的价值所在。
6. 实战中踩过的坑和排查记录
讲完方案和验证,最后分享几个我在真实场景里踩过的坑。这些内容不写出来,你可能要用一次线上事故才能体会得到。
6.1 锁过期导致重复查库
Redisson 的看门狗默认只在没有指定 leaseTime 的时候才生效。如果你调用 lock.lock(10, TimeUnit.SECONDS) 手动指定了持有时间,那么这个时间到了之后锁就会自动释放,不管业务有没有执行完。
我遇到过的情况是:缓存重建逻辑里有两个下游 RPC 调用,耗时波动很大,平时 300ms,高峰期可能到 15 秒。当时锁的过期时间设置了 5 秒,结果就是高峰期大量请求冲破锁直接打到数据库。
解决方案两个:
- 不指定
leaseTime,让看门狗自动续期。 - 必须指定
leaseTime的话,根据最坏情况设置足够长的超时时间,比如 30 秒甚至 60 秒,但这种情况更容易加剧锁等待。
推荐第一种方案,省心。
6.2 tryLock 等待时间设置过长
有次上线的接口响应突然变慢,排查发现是因为我把 tryLock 的 waitTime 设置成了 10 秒。当服务接口出现慢查询、锁一直持有不释放的时候,大量请求会阻塞在锁等待这里,超过 10 秒直接超时。
如果你的锁持有时间本来就长,那 waitTime 一定要克制。一个相对合理的参数组合是:锁持有时间 30 秒(看门狗自动续期),锁等待时间 1~3 秒,拿不到锁直接走兜底。
6.3 删锁时把别人的锁删了
这个问题发生在手动实现锁的代码里。使用 Redisson 的话,unlock() 方法会校验持有者线程,不会误删其他线程的锁,所以这个问题在 Redisson 方案中被绕开了。但如果你之前自己用 SETNX 写过锁,升级到 Redisson 之后,记得不要再保留老代码,两套逻辑并存容易产生新的问题。
6.4 Redis 序列化方式不一致导致反序列化失败
这是配置层面最容易忽略的问题。如果你用 StringRedisTemplate 存字符串,序列化方式默认是 String,那没问题。但如果你用了 RedisTemplate<Object, Object> 且没有指定序列化器,Redis 里存的可能是 JDK 序列化后的二进制数据,前端或者其他服务直接读出来就是乱码。
我之前在项目里就踩过这个坑:缓存服务用 RedisTemplate + JDK 序列化写入,另一个数据分析服务用 StringRedisTemplate 去读,结果全部反序列化失败。最后统一规范:所有团队共用 Redis 时,统一采用 String 序列化 + JSON 格式存储,避免因为序列化器的不同产生各种怪问题。
6.5 锁粒度太大导致吞吐量骤降
这个前面提到过,再展开说一下。假设你有一个商品列表接口,它有一个对应的缓存 key。如果这个 key 的锁是全局锁(比如 lock:productList:all),那么在任何商品数据变更、列表缓存重建的时候,所有请求都会被阻塞。热点商品查询会受影响,冷门商品查询也会因为等同一个锁而延迟。
正确的做法是:按业务维度拆分锁的粒度。查询单个商品,锁用商品 ID;查询某个分类下的列表,锁用分类 ID;查询用户订单,锁用用户 ID。锁的粒度越细,不同请求之间的互斥关系越少,系统的整体吞吐量越好。
7. 最后的个人实战体会
如果你问我 Redisson 在 Spring Boot 项目里到底值不值得引进,我的答案是:太值得了。缓存击穿只是它解决的问题之一,像秒杀场景下的库存扣减、分布式任务调度防抖、集群环境下的幂等控制,Redisson 都提供了成熟的能力。一个 RedissonClient 放在容器里,就能复用出无数种可靠方案,这个技术投资非常划算。
最后再分享一个小技巧:分布式锁别只用在缓存击穿上。你可以在微服务里封装一个通用的 @Lockable 注解,基于 AOP + SpEL 表达式动态生成锁 key,然后任何需要互斥的方法只需要加一行注解就行。这个扩展方向很实用,也不难实现,我后续在项目里就一直在用这套。
希望这篇文章能帮你在系统里稳稳地跑起来。有问题的话,评论区见。
