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

本文将结合 hm-dianping 项目实战,拆解我们是如何通过封装通用的 CacheClient 工具类,利用空值缓存互斥锁以及逻辑过期这三把利剑,优雅地应对高并发流量冲击的。同时,文末的知识小窗还将带你深入理解 Java 范型与 JUC 并发编程的精髓。

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

在分布式系统中,缓存就像是数据库的一道防洪堤坝。一旦这道堤坝出现裂缝,滔天洪水(高并发流量)就会瞬间冲垮后方脆弱的数据库。

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;
}

:::tip 知识小窗 | JavaSE 高阶特性深度解析

在封装 CacheClient 过程中,我们运用了多个 Java 高阶特性,这里详细拆解一下。

1. 泛型方法与函数式编程

源码回顾
public <R, ID> R queryWithCachePassThrough(..., Function<ID, R> dbFallback, ...)

  • 泛型声明 <R, ID>:如果不声明,编译器不知道 RID 是什么。这里的声明意味着:“在这个方法作用域内,R 代表返回值类型,ID 代表入参类型”。
  • 函数式接口 Function<ID, R>
    • 是什么:Java 8 引入的接口,代表“接收一个参数 ID,产生一个结果 R 的函数”。
    • 怎么用:调用者通过 Lambda 表达式或方法引用传递具体逻辑。
    • 为什么用:为了解耦CacheClient 只管缓存逻辑,不知道怎么查库(查用户?查商铺?)。通过传入这个函数,我们将“查库”的行为参数化了,交还给调用者定义。
    • 示例id -> getById(id)this::getById

2. ExecutorService 与线程池工厂

源码回顾
ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

  • Executors:Java JUC 提供的工厂类,用于快速创建线程池。
  • ExecutorService:线程池的顶层接口,管理线程生命周期。
  • newFixedThreadPool(10)
    • 作用:创建一个定长线程池,固定持有 10 个线程。
    • 适用场景:执行长期的、资源消耗较重的任务(如这里的数据库查询 + 写缓存)。
    • 为什么:在逻辑过期策略中,如果每次缓存失效都 new Thread(),面对 1000 QPS 的并发,服务器瞬间就会因为创建过多线程而 OOM(内存溢出)。池化技术限制了最大并发数,起到了削峰和资源保护的作用。

3. CountDownLatch 并发测试神器

在测试缓存击穿时,为了模拟“1000个线程同时请求”的瞬间压力,我们使用了 CountDownLatch

  • 定义:一个同步辅助类,允许一个或多个线程等待其他线程完成操作。
  • 用法
    1
    2
    3
    4
    5
    CountDownLatch latch = new CountDownLatch(1000); // 设定门闩计数 1000
    // ... 在每个线程执行完任务后
    latch.countDown(); // 计数 -1
    // ... 主线程
    latch.await(); // 阻塞,直到计数变为 0 才继续往下走
  • 常见用途
    • 并发起跑线:让所有线程度准备好,同时开始执行。
    • 任务汇聚:等待多个子任务全部完成后,主线程再汇总结果(类似 MapReduce)。在测试中,我们用它来确保所有并发请求都跑完了,再去统计耗时和成功率。

:::

结语

通过这篇实战笔记,我们不仅掌握了 Redis 缓存击穿与穿透的硬核解决方案,还深入复习了 Java 泛型与并发编程的知识。在分布式高并发的战场上,没有完美的架构,只有最适合业务场景的权衡。

下一篇,我们将继续学习常见项目的一个重点问题——秒杀系统,以及一些常见的Redis 全局唯一 ID 生成器的设计与实现。