一、 什么是缓存击穿?
缓存击穿(Cache Breakdown) 是指一个热点 Key (比如某次秒杀活动的商品详情),在某个时间点过期了。恰好在这个时间点,有大量的并发请求访问这个 Key。这些请求发现缓存过期,瞬间全部打到数据库上,就像在防线上凿穿了一个洞,导致数据库压力激增甚至宕机。
核心特征:
- 高并发:访问量巨大。
- 热点 Key:大家都在查同一个数据。
- 瞬间失效:缓存 TTL 到期,数据物理消失。
二、 互斥锁&逻辑过期
面对缓存击穿,通常有两种解法:
1. 互斥锁(Mutex Lock)
- 思路:谁发现缓存过期了,谁就去抢一把锁。抢到锁的人去查数据库写缓存,其他人排队等待。
- 优点:数据强一致性(查到的绝对是新的)。
- 缺点 :性能较差。所有人都得等那一个线程干完活,如果不巧那个线程挂了或慢了,后面就是灾难性的阻塞。
2. 逻辑过期(Logical Expiration)
- 思路 :"永不过期" 。不在 Redis 层面设置 TTL,而是把过期时间写在 Value 里面。发现"逻辑"过期后,先返回旧数据 ,然后异步开个线程去后台更新。
- 优点 :高可用,性能极佳。用户永远不需要等待,拿了数据就走。
- 缺点:数据存在短暂的不一致(在重建完成前,用户看到的是旧数据)。
三、 逻辑过期的实现原理
我们不使用 Redis 的 setex 来控制生死,而是引入一个包装类 RedisData,人为地记录一个 expireTime。
1. 数据结构设计
我们需要一个容器来封装真实的业务数据和逻辑过期时间:
kotlin
@Data
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 真实的业务数据(如 Shop 对象)
}
2. 执行流程图解
- 查询缓存:从 Redis 取出数据(逻辑过期前提是数据必须预热,如果 Redis 没数据,直接返回空或降级)。
- 判断逻辑时间 :
- 如果
expireTime>now():数据新鲜,直接返回。 - 如果
expireTime<=now():逻辑已过期。
- 如果
- 重建缓存 :
- 抢锁:尝试获取互斥锁。
- 抢锁失败 :说明有人在更新了,不要等,直接返回旧数据。
- 抢锁成功:再次检查缓存是否已更新(Double Check)。如果没更新,则开启独立线程查库写缓存;如果已更新,直接释放锁并返回新数据。
- 返回数据:无论是否抢到锁,主线程都直接返回数据(旧的或新的)。
四、 代码实现 (Java)
以下是基于 SpringBoot + StringRedisTemplate 的完整实现,包含了二次检查(Double Check)逻辑。
1. 缓存预热
因为 Redis 里没有 TTL,数据不会自己消失。我们需要在活动开始前把数据"预热"进去。
scss
/**
* 预热数据到 Redis
* @param id 商品ID
* @param expireSeconds 逻辑过期时间(秒)
*/
public void saveShop2redis(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)
typescript
// 线程池:用于异步重建缓存
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.saveShop2redis(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 以为数据还过期,会再次开启线程去查库。虽然不影响正确性,但浪费了性能。加上二次检查可以避免不必要的重建。