一、什么是缓存击穿?Redis热点Key失效引发的问题详解
先来理解一个典型场景:某个秒杀活动的商品详情页,平日里访问量一般,但一到活动开始,流量瞬间暴增。这个商品就是所谓的“热点 Key”。Redis缓存击穿,指的就是这个热点 Key 在某个时间点恰好过期了,而就在过期的那个瞬间,海量并发请求一起涌来。缓存里没有数据,这些请求就直接穿透到数据库——就像防线上被凿了个洞,数据库瞬间承受不住压力,轻则响应变慢,重则直接宕机。理解缓存击穿机制是掌握高并发缓存优化的第一步。

缓存击穿核心特征:高并发、热点Key、瞬间失效
- 高并发:同一时间访问量巨大,系统承载压力极高。
- 热点 Key:大量请求都在查询同一个数据对象,资源竞争集中。
- 瞬间失效:缓存 TTL 到期,数据物理消失,导致请求直接穿透至后端数据库。
二、互斥锁与逻辑过期方案对比:如何选择缓存击穿解决方案?
面对缓存击穿这个“漏洞”,业界有两种主流解法:一种是互斥锁方案,另一种是逻辑过期方案。它们各有各的特点,也各有各的适用场景。理解两者的区别,有助于在实际项目中做出合理的技术选型。
1. 互斥锁(Mutex Lock)方案
- 思路:谁发现缓存过期了,谁就去抢一把分布式锁。抢到锁的线程负责查询数据库并写入缓存,其他线程则排队等待。
- 优点:数据强一致性——用户查询到的绝对是最新数据,适合对数据一致性要求较高的场景。
- 缺点:性能较差。所有请求都得等那一个线程完成缓存重建,如果不巧那个线程执行缓慢或发生故障,后面就会引发灾难性的阻塞,影响整体系统吞吐量。
2. 逻辑过期(Logical Expiration)方案
- 思路:“永不过期”。不在 Redis 层面设置 TTL,而是把过期时间写在 Value 里面。发现“逻辑”过期后,先返回旧数据,然后异步开启一个后台线程去更新缓存。
- 优点:高可用,性能极佳。用户永远不需要等待,拿了数据就直接返回,响应速度极快。
- 缺点:数据存在短暂的不一致(在缓存重建完成前,用户看到的是旧数据),属于用一致性换可用性的典型 trade-off。
三、逻辑过期实现原理与RedisData数据结构设计
逻辑过期方案的关键,是不再依赖 Redis 自带的 setex 来控制数据生命周期,而是自己设计一个包装类,人为记录一个过期时间。这个包装类通常命名为 RedisData,它是整个逻辑过期机制的核心数据结构。
1. 数据结构设计
一个容器,用来封装真实的业务数据和逻辑过期时间,方便在查询时进行过期判断:
@Data
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 真实的业务数据(如 Shop 对象)
}
2. 逻辑过期执行流程图解
- 查询缓存:从 Redis 取出数据(注意:逻辑过期方案的前提是数据必须预热,如果 Redis 没数据,直接返回空或执行降级处理)。
- 判断逻辑过期时间:
- 如果
expireTime>now():数据仍然新鲜,直接返回给调用方。 - 如果
expireTime<=now():逻辑已过期,需要触发缓存重建流程。
- 如果
- 缓存重建流程:
- 抢锁:尝试获取互斥锁,确保只有一个线程执行重建操作。
- 抢锁失败:说明已经有其他线程在更新缓存,不要等待,直接返回旧数据给用户。
- 抢锁成功:再次检查缓存是否已被其他线程更新(Double Check 机制)。如果没更新,则开启独立线程查询数据库并写入缓存;如果已更新,直接释放锁并返回新数据。
- 返回数据:无论是否抢到锁,主线程都直接返回数据(旧的或新的),确保用户请求不被阻塞。
四、逻辑过期代码实现:SpringBoot+StringRedisTemplate实战
下面是一段基于 SpringBoot + StringRedisTemplate 的完整实现代码,包含了二次检查(Double Check)逻辑。代码本身不算复杂,但细节值得留意,尤其是在高并发场景下的锁处理和异步重建机制。
1. 缓存预热
因为 Redis 里没有设置 TTL,数据不会自动消失。我们需要在活动开始前把数据“预热”进去,确保缓存中已经有初始数据可供查询。
/**
* 预热数据到 Redis
* @param id 商品ID
* @param expireSeconds 逻辑过期时间(秒)
*/
public void sa veShop2redis(Long id, Long expireSeconds) {
// 1. 查询数据库
Shop shop = getById(id);
// 2. 封装成 RedisData
RedisData redisData = new RedisData();
redisData.setData(shop);
// 重点:设置逻辑过期时间 = 当前时间 + 指定秒数 (注意单位是 PlusSeconds)
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 3. 写入 Redis (不设置 TTL)
stringRedisTemplate.opsForValue().set(RedisConstants.CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}
2. 业务逻辑实现 (queryWithLogicalExpire)
// 线程池:用于异步重建缓存
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
public Shop queryWithLogicalExpire(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 1. 从 Redis 查询
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 2. 如果未命中(未预热),直接返回 null
if (StrUtil.isBlank(shopJson)) {
return null;
}
// 3. 反序列化
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
JSONObject data = (JSONObject) redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
LocalDateTime expireTime = redisData.getExpireTime();
// 4. 判断是否过期
if (expireTime.isAfter(LocalDateTime.now())) {
// 未过期,直接返回
return shop;
}
// ==========================================================
// 5. 已过期,需要缓存重建
// ==========================================================
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
// 6. 尝试获取互斥锁
boolean isLock = tryLock(lockKey);
if (isLock) {
// 6.1 获取锁成功
// 【二次检查 (Double Check)】
// 再次查询 Redis,防止在上一个线程释放锁的瞬间,缓存已经被更新了
shopJson = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(shopJson)) {
RedisData newRedisData = JSONUtil.toBean(shopJson, RedisData.class);
LocalDateTime newExpireTime = newRedisData.getExpireTime();
// 如果发现已经被更新(不过期了)
if (newExpireTime.isAfter(LocalDateTime.now())) {
// 释放锁,直接返回新数据,不再开启线程重建
unlock(lockKey);
return JSONUtil.toBean((JSONObject) newRedisData.getData(), Shop.class);
}
}
// 6.2 确认依然过期,开启独立线程重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 重建缓存(假设逻辑过期时间 20秒)
this.sa veShop2redis(id, 20L);
} catch (Exception e) {
e.printStackTrace(); // 建议使用 log.error
} finally {
// 释放锁
unlock(lockKey);
}
});
}
// 7. 【核心】无论是否抢到锁,都直接返回旧数据,绝不等待!
return shop;
}
// 辅助方法:获取锁
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
// 辅助方法:释放锁
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
五、总结:逻辑过期方案在高并发场景下的应用价值
1. 为什么选择逻辑过期方案?
逻辑过期本质上是一种“妥协的艺术”。它牺牲了短暂的数据一致性(用户可能在几百毫秒内看到旧数据),换取了系统在极高并发下的稳定性——Redis 永不阻塞,数据库压力极小。在秒杀、大促等高并发场景下,这种取舍往往是值得的,也是业界常用的缓存击穿解决方案之一。
2. 为什么要做二次检查 (Double Check)?
如果不加二次检查,在高并发下,线程 B 可能会在线程 A 重建完刚刚释放锁的时候抢到锁。此时线程 B 以为数据还过期,会再次开启线程去查询数据库。虽然不影响数据正确性,但浪费了系统性能。加上二次检查可以避免不必要的缓存重建,让锁的利用率更高,整体系统的并发处理能力也更强。
