在上一篇文章中,我们探讨了缓存的更新策略及其在分布式场景下的一致性挑战。今天,我们将视角深入到高并发场景下最常见的两个问题:缓存穿透缓存击穿

本文将结合黑马点评项目,拆解我们是如何通过封装通用的 CacheClient 工具类,利用空值缓存互斥锁以及逻辑过期这三种方法,优雅地应对高并发流量冲击的。

一、 拨开云雾:理解缓存问题的本质

在分布式系统中,高并发流量会瞬间冲垮后方脆弱的数据库。因此有缓存的保护是必要的,但缓存也不是万能的。我们需要清晰地认识到两种常见的缓存问题:

1. 缓存穿透 (Cache Penetration)

问题描述:请求直接穿透了缓存层,打到了数据库,但数据库里也没这个数据。
典型场景:恶意用户使用脚本,不断请求 id = -1 或不存在的 UUID。
后果:Redis 命中率为 0,数据库压力激增,可能瞬间宕机。
核心矛盾:数据库中不存在的数据,正常情况下不会被写入缓存,导致后续请求依然无法命中。

2. 缓存击穿 (Cache Breakdown)

问题描述:一个热点 Key(比如微博热搜)在缓存过期的瞬间,遭到海量并发访问。
典型场景:秒杀活动的商品详情页、突发热点新闻。
后果:在缓存重建完成前的几百毫秒内,成千上万的请求同时穿透 Redis 直击数据库。
核心矛盾:缓存重建需要时间(查库 + 写缓存),而在重建期间,并发请求没有被挡住。


二、 核心武器:封装 CacheClient 工具类

为了保持业务代码(Service 层)的整洁,避免在每个查询方法里都写一遍 try-catch 和锁逻辑,我们将缓存控制逻辑抽象封装到了 com.hmdp.utils.CacheClient 中。这不仅是代码复用的体现,更是“高内聚低耦合”设计思想的落地。

1. 应对缓存穿透:空值缓存策略

既然问题的根源是“数据库查不到,所以不写缓存”,那我们反其道而行之:查不到数据,我也给你存一个“空值”

当数据库返回 null 时,我们将一个特定的空值(如空字符串 "")写入 Redis,并赋予一个较短的 TTL(如 2 分钟)。后续请求再来,Redis 会返回这个“空值”,应用层识别后直接拦截,不再查询数据库。

代码实战 (queryWithCachePassThrough)

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
/**
* 解决缓存穿透的通用查询方法
* @param keyPrefix Key的前缀
* @param id 查询ID
* @param type 返回值的类型Class
* @param dbFallback 数据库查询逻辑(函数式接口)
* @param time 过期时间
* @param unit 时间单位
*/
public <R,ID> R queryWithCachePassThrough(
String keyPrefix,
ID id,
Class<R> type,
Function<ID,R> dbFallback,
Long time, TimeUnit unit){

String key = keyPrefix + id;
// 1. 从 Redis 查询
String json = stringRedisTemplate.opsForValue().get(key);

// 2. 命中有效数据,直接序列化返回
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}

// 3. 命中空值(解决穿透的关键判断)
// json != null 说明 key 存在,但 isNotBlank 为 false,说明只能是空字符串 ""
if (json != null) {
return null;
}

// 4. 缓存未命中,调用 fallback 查数据库
R r = dbFallback.apply(id);

// 5. 数据库也不存在,写入空值并设置短 TTL(防止长期占用内存)
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}

// 6. 存在,写入缓存
this.set(key, r, time, unit);
return r;
}

2. 应对缓存击穿:互斥锁策略 (Mutex Lock)

针对强一致性需求,我们采用互斥锁方案。简单来说,就是串行化重建

当发现缓存失效时,并不是所有线程都去查库。而是让大家先去抢一个“锁”(Redis 的 SETNX)。抢到锁的那个“天选之子”负责查库重建,其他线程则休眠重试。

代码实战 (queryWithMutex)

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
public <R,ID> R queryWithMutex(
String keyPrefix,
String lockKeyPrefix,
ID id,
Class<R> type,
Function<ID,R> dbFallback,
Long time, TimeUnit unit){

String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);

if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}
// 防止穿透的空值判断
if (json != null) {
return null;
}

// 4. 缓存未命中,准备重建
String lockKey = lockKeyPrefix + id;
R r = null;
try {
// 4.1 尝试获取互斥锁
boolean isLock = tryLock(lockKey);

// 4.2 获取失败,休眠并递归重试
if (!isLock) {
Thread.sleep(50);
return queryWithMutex(keyPrefix, lockKeyPrefix, id, type, dbFallback, time, unit);
}

// 4.3 获取成功,再次检查缓存(Double Check),防止已被其他线程重建
json = stringRedisTemplate.opsForValue().get(key);
if (StrUtil.isNotBlank(json)) {
return JSONUtil.toBean(json, type);
}

// 4.4 真正查库
r = dbFallback.apply(id);

// 4.5 写入缓存(含空值处理)
if (r == null) {
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
this.set(key, r, time, unit);

} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 5. 释放锁
unlock(lockKey);
}
return r;
}

// 锁的底层实现:利用 setIfAbsent (SETNX)
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);
}

3. 应对缓存击穿:逻辑过期策略 (Logical Expiration)

互斥锁保证了数据一致性,但牺牲了吞吐量。对于秒杀等对响应时间极度敏感、且能容忍短暂数据不一致的场景,我们采用逻辑过期。这是对待缓存击穿的第二种方案,也是我们项目最终的选择。

核心原理

  1. 永不过期:Redis 物理 key 不设置 TTL,保证随时能取到数据(哪怕是旧的)。
  2. 逻辑时间:在数据对象内部封装一个 expireTime 字段。
  3. 异步重建:查询时判断是否逻辑过期。如果过期,尝试获取锁。获取锁成功的线程开启独立线程去后台更新数据,自己直接返回旧数据;获取锁失败的线程,也直接返回旧数据。

代码实战 (queryWithLogicExpire)

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
// 线程池:用于异步重建缓存,避免高并发下创建过多线程耗尽资源
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

public <R,ID> R queryWithLogicExpire(
String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit){

String key = keyPrefix + id;
String json = stringRedisTemplate.opsForValue().get(key);

// 逻辑过期方案前提是数据需预热,若未命中直接返回 null
if (StrUtil.isBlank(json)) {
return null;
}

// 反序列化取出数据与逻辑过期时间
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();

// 判断是否逻辑过期
if (expireTime.isAfter(LocalDateTime.now())) {
return r; // 未过期,直接使用
}

// 已过期,尝试获取锁进行异步重建
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tryLock(lockKey);

if (isLock) {
// 获取锁成功,提交任务到线程池
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查询数据库
R newR = dbFallback.apply(id);
// 重置逻辑过期时间并写入redis
this.setWithLogicalExpire(key, newR, time, unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unlock(lockKey);
}
});
}

// 无论是否获取锁,都先“将就”着用旧数据,保证响应速度
return r;
}

结语

通过这篇笔记,不仅在项目里掌握了 Redis 缓存击穿与穿透的硬核解决方案,不过在分布式高并发的战场上,没有完美的架构,只有最适合业务场景的权衡。当然黑马点评作为学生项目,和企业级别的分布式缓存方案还有差距,所以这个主要是提供思路,也为面试积累一些实战经验。