参考笔记:
一、 前言

缓存的使用方式如上图所示:
①发起一个查询请求时,首先判断缓存中是否有该数据;
② 如果缓存中存在,则直接返回数据
③如果缓存中不存在,则查询数据库,然后把数据写入到缓存中,最后返回数据
了解了上述过程后,下面说说缓存击穿
二、什么是缓存击穿
缓存击穿(Cache Breakdown)是指某个热点数据过期失效的瞬间,大量并发请求直接穿透缓存层,同时涌入数据库进行查询的现象。这种突发的高负载可能导致数据库瞬间压力激增,引发系统崩溃
缓存击穿案例:

线程1 在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了。但是假设在线程1 没有走完的时候,后续的线程2、线程3、线程4并发访问当前这个方法, 此时这些线程都去缓存中查询数据,都没查到,那么他们就会同一时刻去访问数据库,数据库压力激增。此外,当这些请求查询完成后,都会重复地重建缓存
三、为什么会发生缓存击穿?
根本原因在于热点数据的高并发访问特性:
- 电商首页爆款商品
- 新闻网站头条内容
- 秒杀活动核心数据
当这些数据缓存过期时,如果未能及时重建缓存,海量请求会像洪水般冲破缓存防线
四、五大解决方案及代码实现
1. 互斥锁(Mutex Lock) ----- 推荐方案
核心思想 :只允许一个线程重建缓存,其他线程阻塞等待

原理: 锁能实现互斥性。多个并发线程过来,使用互斥锁可以实现不同线程间互斥地访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行
伪代码实现:
java
public String getData(String key) {
// 1. 尝试从缓存获取数据
String data = redis.get(key);
if (data != null) return data; // 缓存命中
// 2. 尝试获取分布式锁
String lockKey = "lock:" + key;
if (redis.setnx(lockKey, "1", 30)) { // SETNX实现锁,30为锁的有效期,避免因为服务异常一直不释放锁,产生死锁
try {
// 3. 再次检查缓存(防止重复查询)
data = redis.get(key);
if (data != null) return data;
// 4. 查询数据库
data = db.query(key);
// 5. 写入缓存(设置合理过期时间)
redis.setex(key, 3600, data);
} finally {
// 6. 释放锁
redis.del(lockKey);
}
return data;
} else {
// 7. 未获取锁的线程休眠后重试
Thread.sleep(100);
return getData(key); // 递归重试
}
}
2. 逻辑过期
核心思想:缓存永不过期,但存储包含时间戳的封装对象
方案分析: 之所以会出现缓存击穿 问题,主要原因是对 Key 设置了过期时间 ,假设不设置过期时间,就不会有缓存击穿问题。但是不设置过期时间就会一直占用内存,所以可以采用逻辑过期方案
逻辑过期方案,即不设置 Key 的过期时间,而是把过期时间设置在 Redis 的 Value 中,后续再通过逻辑去处理。如下所示:

结合具体案例,理解逻辑过期方案:

假设线程1 去查询缓存,然后从 Value 中判断出来当前数据已经过期,此时线程1去 获得互斥锁 ,那么其他线程会进行阻塞,获得锁的 线程1 开启一个新的异步线程 ,即 线程2 去进行 重建缓存数据 的逻辑,直到 线程2 完成这个逻辑后,才 释放互斥锁 , 而 线程1 作为主线程不会被线程2阻塞,直接返回过期的缓存数据。假设 线程2 还未结束,而此时 线程3 过来访问,由于 线程2 持有锁,所以 线程3 无法获得锁, 线程3 也直接返回过期的缓存数据,只有等到 线程2 重建缓存结束后,其他线程才能走返回正确的数据
逻辑过期方案的妙处在于能够异步地重建缓存数据,不会影响查询性能。缺点在于在重建完缓存之前,返回的都是脏数据,存在数据不一致的问题
伪代码:
java
// 创建10条线程的线程池,最多10个线程同时跑"重建缓存"
private static final ExecutorService threadPool = Executors.newFixedThreadPool(10);
// 商铺信息
class Shop{
Long id;
String name;
String image;
.....;
}
// 封装逻辑过期时间
class CacheData {
Object data;
long expireTime; // 逻辑过期时间
}
public String getData(String key) {
// 1、获取缓存数据,检查逻辑时间是否已过期
CacheData cacheData = redis.get(key);
if (cacheData.expireTime >= System.currentTimeMillis()) { // 未过期,直接返回数据
return cacheData.data;
}
// 2、已过期,获取互斥锁,完成缓存重建
String lockKey = "lock:" + key;
Boolean getLock = redis.setnx(lockKey, "1", 30);
if(!getLock){ // 获取锁失败,直接返回过期数据
return ccacheData.data;
}
// 3、成功获取锁,这里可以做个Double Check(可选),再次检查缓存,避免多线程并发下,重复重建缓存
cacheData = redis.get(key);
if (cacheData.expireTime >= System.currentTimeMillis()) { // 未过期,则直接返回数据
return cacheData.data;
}
// 4、开异步线程,重建缓存,主线程不会被阻塞
threadPool.submit(() ->{
// 数据库查询数据
Shop newShop = db.query(key);
// 封装逻辑过期时间
CacheData cacheData = new CacheData(newShop,newExpireTime);
// 写入缓存
redis.set(key,cacheData);
// 释放锁
redis.del(lockKey);
});
// 5、主线程返回旧数据
return cacheData.data;
}
3. 热点数据永不过期
适用场景:极少变更的静态热点数据(如城市列表)
bash
# Redis操作
redis.set(Ket,Value); // 不设置过期时间
4. 其他方案....
补充...
5. 方案对比与建议
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 互斥锁 | * 保证一致性 * 没有额外的内存消耗 * 实现简单 | * 存在短暂阻塞风险,查询性能受到影响 * 可能有死锁风险 | 金融/交易类业务 |
| 逻辑过期 | * 查询性能强 | * 数据一致性弱, * 有额外内存消耗 * 实现复杂 | 资讯/社交类业务 |
| 永不过期 | * 实现简单 * 查询性能强 | * 内存压力大 | 极少变更的静态数据 |
| .... | ..... | ...... | ...... |