在高并发系统中,Redis 几乎是标配的缓存中间件。但缓存用不好,不仅不能加速,反而会成为系统崩溃的导火索。

今天我们来拆解 Redis 缓存层最令人头疼的”三座大山”:缓存穿透、缓存击穿与缓存雪崩,以及它们对应的实战解决方案。

一、缓存穿透 (Cache Penetration)

场景还原: 黑客或者恶意用户疯狂请求一个在数据库中根本不存在的数据(例如 ID 为 -1 的商品)。每次请求先去 Redis 查,发现没有,然后打到 MySQL。数据库里也没有,无法将空结果回写到 Redis。最终结果:Redis 形同虚设,海量请求直接把 MySQL 打挂。

方案 1:缓存空对象

最简单粗暴的方法——哪怕数据库查出来是空的,也把这个”空值”存进 Redis,设置一个较短的过期时间(比如 5 分钟):

1
2
3
4
5
6
7
8
9
10
11
12
13
public Item getItem(Long id) {
String key = "item:" + id;
Item item = redis.get(key);
if (item != null) return item;

item = db.findById(id);
if (item == null) {
redis.setex(key, 300, EMPTY_OBJECT); // 缓存空值 5 分钟
return null;
}
redis.setex(key, 3600, item);
return item;
}

优点:实现简单。缺点:浪费 Redis 内存,可能存在短期数据不一致。

方案 2:布隆过滤器(推荐)

这是在请求到达 Redis 之前加一道”预筛网”。布隆过滤器能以极小的内存开销,快速判断一个 Key “绝对不存在”或”可能存在”。如果判定不存在,直接拒绝请求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 使用 Guava 的布隆过滤器
BloomFilter<String> filter = BloomFilter.create(
Funnels.stringFunnel(Charset.defaultCharset()),
1000000, // 预期插入量
0.01 // 误判率 1%
);

// 系统启动时将所有 ID 加入过滤器
List<Long> allIds = db.getAllIds();
allIds.forEach(id -> filter.put("item:" + id));

// 查询时先过布隆过滤器
public Item getItem(Long id) {
if (!filter.mightContain("item:" + id)) {
return null; // 绝对不存在,直接返回
}
// 可能存在,继续查 Redis → MySQL
// ...
}

二、缓存击穿 (Cache Breakdown)

场景还原: 某个超级爆款商品的缓存刚好在秒杀进行到一半时过期了。就在这失效的瞬间,成千上万的并发请求发现缓存没数据,全部涌向 MySQL 去重建缓存,瞬间造成数据库瘫痪。

方案 1:互斥锁 (Mutex Lock)

发现缓存失效后,不要所有线程都去查数据库。让大家先去 Redis 里”抢锁”。抢到锁的去查数据库重建缓存;没抢到的休眠后重试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Item getItemWithLock(Long id) {
String key = "item:" + id;
Item item = redis.get(key);
if (item != null) return item;

String lockKey = "lock:item:" + id;
if (redis.setnx(lockKey, "1", 10)) { // 抢锁成功
try {
item = db.findById(id);
redis.setex(key, 3600, item);
} finally {
redis.del(lockKey); // 释放锁
}
} else {
Thread.sleep(50);
return getItemWithLock(id); // 重试
}
return item;
}

方案 2:逻辑过期(永不过期)

物理上不给热点 Key 设过期时间,而是把过期时间写在业务 Value 里。取出数据时判断是否”逻辑过期”。如果过期了,返回旧数据同时异步更新:

1
2
3
4
5
6
7
8
9
10
11
public Item getItemLogicalExpire(Long id) {
String key = "item:" + id;
CacheData data = redis.get(key);
if (data == null) return db.findById(id);

if (data.logicalExpireTime < System.currentTimeMillis()) {
// 已过期,异步更新
executor.submit(() -> refreshCache(id));
}
return data.item; // 返回旧数据,用户无感知
}

三、缓存雪崩 (Cache Avalanche)

场景还原: 大量 Key 被设置了相同的过期时间,导致在同一时刻集体失效。或者 Redis 节点意外宕机。

防御策略:

  1. 过期时间加随机值expire = 3600 + random(0, 300),避免集体过期
  2. 多级缓存:本地 Caffeine + Redis 双层缓存
  3. Redis 哨兵/集群:主从 + 哨兵自动故障转移
  4. 熔断降级:Redis 不可用时,直接返回降级数据或限流
1
2
3
4
// 过期时间随机化
int baseExpire = 3600;
int randomOffset = ThreadLocalRandom.current().nextInt(300);
redis.setex(key, baseExpire + randomOffset, item);

总结

Redis 缓存是典型的”用好了锦上添花,用不好火上浇油”。穿透用布隆过滤器挡,击穿用互斥锁或逻辑过期防,雪崩用随机过期和集群高可用扛。三层防线各司其职,才能在高并发场景下真正做到游刃有余。