ShaneD711's Blog.

Redis实战: 利用逻辑过期解决缓存击穿

2025/12/14
loading

一、 什么是缓存击穿?

缓存击穿(Cache Breakdown) 是指一个热点 Key(比如某次秒杀活动的商品详情),在某个时间点过期了。恰好在这个时间点,有大量的并发请求访问这个 Key。这些请求发现缓存过期,瞬间全部打到数据库上,就像在防线上凿穿了一个洞,导致数据库压力激增甚至宕机。

核心特征:

  1. 高并发:访问量巨大。
  2. 热点 Key:大家都在查同一个数据。
  3. 瞬间失效:缓存 TTL 到期,数据物理消失。

二、 互斥锁&逻辑过期

面对缓存击穿,通常有两种解法:

1. 互斥锁(Mutex Lock)

  • 思路:谁发现缓存过期了,谁就去抢一把锁。抢到锁的人去查数据库写缓存,其他人排队等待。
  • 优点:数据强一致性(查到的绝对是新的)。
  • 缺点性能较差。所有人都得等那一个线程干完活,如果不巧那个线程挂了或慢了,后面就是灾难性的阻塞。

2. 逻辑过期(Logical Expiration)

  • 思路“永不过期”。不在 Redis 层面设置 TTL,而是把过期时间写在 Value 里面。发现“逻辑”过期后,先返回旧数据,然后异步开个线程去后台更新。
  • 优点高可用,性能极佳。用户永远不需要等待,拿了数据就走。
  • 缺点:数据存在短暂的不一致(在重建完成前,用户看到的是旧数据)。

三、 逻辑过期的实现原理

我们不使用 Redis 的 setex 来控制生死,而是引入一个包装类 RedisData,人为地记录一个 expireTime

1. 数据结构设计

我们需要一个容器来封装真实的业务数据和逻辑过期时间:

1
2
3
4
5
@Data
public class RedisData {
private LocalDateTime expireTime; // 逻辑过期时间
private Object data; // 真实的业务数据(如 Shop 对象)
}

2. 执行流程图解

  1. 查询缓存:从 Redis 取出数据(逻辑过期前提是数据必须预热,如果 Redis 没数据,直接返回空或降级)。
  2. 判断逻辑时间
    • 如果 expireTime > now():数据新鲜,直接返回。
    • 如果 expireTime <= now()逻辑已过期
  3. 重建缓存
    • 抢锁:尝试获取互斥锁。
    • 抢锁失败:说明有人在更新了,不要等,直接返回旧数据。
    • 抢锁成功:再次检查缓存是否已更新(Double Check)。如果没更新,则开启独立线程查库写缓存;如果已更新,直接释放锁并返回新数据。
  4. 返回数据:无论是否抢到锁,主线程都直接返回数据(旧的或新的)。

四、 代码实现 (Java)

以下是基于 SpringBoot + StringRedisTemplate 的完整实现,包含了二次检查(Double Check)逻辑

1. 缓存预热

因为 Redis 里没有 TTL,数据不会自己消失。我们需要在活动开始前把数据“预热”进去。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 预热数据到 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)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
// 线程池:用于异步重建缓存
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 以为数据还过期,会再次开启线程去查库。虽然不影响正确性,但浪费了性能。加上二次检查可以避免不必要的重建。

CATALOG
  1. 1. 一、 什么是缓存击穿?
    1. 1.1. 核心特征:
  2. 2. 二、 互斥锁&逻辑过期
    1. 2.1. 1. 互斥锁(Mutex Lock)
    2. 2.2. 2. 逻辑过期(Logical Expiration)
  3. 3. 三、 逻辑过期的实现原理
    1. 3.1. 1. 数据结构设计
    2. 3.2. 2. 执行流程图解
  4. 4. 四、 代码实现 (Java)
    1. 4.1. 1. 缓存预热
    2. 4.2. 2. 业务逻辑 (queryWithLogicalExpire)
  5. 5. 五、 总结
    1. 5.1. 1. 为什么选择逻辑过期?
    2. 5.2. 2. 为什么要做二次检查 (Double Check)?