缓存击穿
1.1 定义
缓存击穿是指某一热点Key突然过期,在失效到重写中间这段时间内,大量请求直接落在了MySQL上,注重“单点击破“。
1.2 解决方法
互斥锁:在读写Redis之前要先拿到锁,否则等待,保证了强一致性,但是要牺牲一点可用性,因为其他线程要等待。并且要注意在实现上,线程拿到锁进入临界区后,要先查一遍Redis(==双重检查锁定==),Redis没有数据再去查数据库,并回写Redis,防止后来的线程拿到锁后又去查数据库。在单体项目和分布式项目的实现上有区别,单体项目使用Java的Synchronized或者setnx即可,分布式项目需要使用Redission(分布式锁)。
热点key永不过期
有两种实现:
物理上永不过期:不设TTL,实现简单,但是会占用内存,更新不及时,适用于数据更新不频繁的场景,并且实现的时候,需要将该key的淘汰优先级调低,防止Redis内存淘汰机制将其剔除。
逻辑上永不过期:设置一个过期时间字段
expiredtime,每次请求数据时查看是否过期,如果已经过期,则获取锁,同时需要将旧数据返回,如果获得锁,则起一个异步线程去更新字段,更新完数据之后,释放锁。在此段时间内,如果有其他线程过来,获取锁失败,则返回旧数据。适用于极大并发,可以短暂牺牲数据一致性的场景,如秒杀。

实现细节
在实际业务情况中,可能会有非预期的key流量激增,可以使用类似Sentinel 或 Redis-Cell 的工具,实时统计请求频次,一旦发现某个非预期的 Key 流量激增,系统自动通过程序将其设置为“逻辑永不过期”或延长其 TTL,实现被动变主动。
1.3 实际代码开发
1.3.1 互斥锁
java
/**
* 设置锁,返回是否成功持有锁
* @return {@link Boolean }
*/
private Boolean lock(String key) {
Boolean haveLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return Boolean.TRUE.equals(haveLock);
}
/**
* 释放锁
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
/**
*
* 查询Redis中数据,考虑到了缓存穿透情况
* @param id
* @return {@link Shop }
*/
private Result queryCacheById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 有记录
if (StrUtil.isNotBlank(shopJson)) {
// 判断是否是缓存的空对象,如果是,则不再查询数据库,解决缓存穿透问题
if ("NULL".equals(shopJson)) {
return Result.fail("店铺不存在!");
}
// 查到记录续期
stringRedisTemplate.expire(key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
}
// 无记录
return null;
}
/**
* 引入缓存层的查询商户
* @param id
* @return {@link Result }
*/
@Override
public Result queryById(Long id) {
// 1. 先查Redis
Result result = queryCacheById(id);
// 有记录或者缓存穿透情况
if (result != null) {
return result;
}
// Redis无记录,查数据库
// 2. 抢锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
// 互斥锁解决缓存击穿,Redis没数据之后,查数据库之前,获取锁
// 使用setnx,注意,要有持锁时间,查数据库,最多10秒,并且在回写Redis之后,要释放掉锁
Boolean haveLock = lock(lockKey);
// (1)不持有锁,等待一段时间重试
if (!haveLock) {
Thread.sleep(50);
return queryById(id);
}
// (2)持有锁
// 双重检查锁定,线程进来之后要先查Redis,主要应对第一个线程写完之后的进来的线程
result = queryCacheById(id);
// 有记录或者缓存穿透情况
if (result != null) {
return result;
}
// 处理第一次拿到锁进来的线程
// 3. 无记录去查MySQL
shop = getById(id);
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 4. 数据库也无记录,缓存低TTL的空对象,防止缓存穿透
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "NULL", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// 数据库无记录,也需要释放掉缓存击穿的锁,否则第一次之后的线程会白白被锁住直到锁过期
return Result.fail("店铺不存在!");
}
// 5. 有记录回写Redis并返回
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 6. 当前线程处理完之后,必须释放锁
unlock(lockKey);
}
}
1.3.2 逻辑不过期
java
// 声明一个全局静态线程池(避免重复创建线程)
private static final ExecutorService CACHE_REBUILD_EXECUTOR =
Executors.newFixedThreadPool(10);
/**
* 将数据及其逻辑过期时间封装后写入 Redis
*
* @param key Redis Key
* @param obj 要缓存的对象
* @param expireTime 过期时间长度
* @param unit 时间单位
*/
public void setWithLogicalExpire(Object obj, String key, Long expireTime, TimeUnit unit) {
if (obj == null) {
log.warn("Attempting to set null object for logical expire, key: {}", key);
return;
}
// 1. 封装逻辑过期包装类
RedisData redisData = new RedisData();
redisData.setData(obj); // RedisData 的 data 属性本身就是 Object,直接存即可
// 2. 设置逻辑过期时间
// 将当前时间加上指定步长,转换为 LocalDateTime
LocalDateTime logicalExpireTime = LocalDateTime.now().plusSeconds(unit.toSeconds(expireTime));
redisData.setExpiredTime(logicalExpireTime);
// 3. 写入 Redis
// 注意:这里使用的是 JSONUtil 序列化,确保你的序列化器能处理 LocalDateTime
stringRedisTemplate.opsForValue().set(key, JsonUtils.toJsonStr(redisData));
}
/**
* 逻辑不过期
*
* @param id
* @return {@link Result }
*/
@Override
public Result queryById(Long id) {
// 1. 查Redis,这里一定能查到,因为没有设TTL
String key = RedisConstants.CACHE_SHOP_KEY + id;
String jsonBean = stringRedisTemplate.opsForValue().get(key);
// 有记录
if (StrUtil.isNotBlank(jsonBean)) {
// 处理缓存穿透
if ("NULL".equals(jsonBean)) {
return Result.fail("店铺不存在!");
}
// 2. 判断是否到了expiredTime过期时间
LocalDateTime expiredTime = Objects.requireNonNull(JsonUtils.toBean(jsonBean, RedisData.class)).getExpiredTime();
if (expiredTime.isBefore(LocalDateTime.now())) {
// 3. 已过期就拿锁去起一个线程去数据库查询新数据写回
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Boolean haveLock = lock(lockKey);
// 4. 拿到锁拉起一个线程去查数据库,写回
if (haveLock) {
// 获取锁成功,开启独立线程,查数据库回写
// 注意:这里最好再进行一次 Double-Check,重新查一遍 Redis 确认是否真的过期
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 【重要】再次查询 Redis,确认是否真的需要重建
// 如果别的线程刚刚更新完,我们就没必要再查库了
String latestJson = stringRedisTemplate.opsForValue().get(key);
RedisData latestData = JsonUtils.toBean(latestJson, RedisData.class);
if (latestData.getExpiredTime().isAfter(LocalDateTime.now())) {
return; // 已经被别人更新过了
}
Shop shop = getById(id);
setWithLogicalExpire(shop, key, 30L, TimeUnit.MINUTES);
}finally {
// 释放锁
unlock(lockKey);
}
});
}
}
// 4. 不管有没有拿到锁,都返回旧数据
return Result.ok(Objects.requireNonNull(JsonUtils.toBean(jsonBean, RedisData.class)).getData());
}
// 无记录去查数据库
Shop shop = getById(id);
// 数据库也无记录,缓存低TTL的空对象,防止缓存穿透
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "NULL", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
// 写回
setWithLogicalExpire(shop, key, 30L, TimeUnit.MINUTES);
return Result.ok(shop);
}
1.4 自己实现的代码的AIreview
1.4.1 互斥锁
原代码:
java
/**
*
* 设置锁,返回是否成功持有锁
* @return {@link Boolean }
*/
private Boolean lock(String key){
Boolean haveLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return Boolean.TRUE.equals(haveLock);
}
/**
*
* 释放锁
*/
private void unlock(String key){
stringRedisTemplate.delete(key);
}
/**
*
* 引入缓存层的查询商户
*
* @param id
* @return {@link Result }
*/
@Override
public Result queryById(Long id) {
// 1. 先查Redis
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 1.1 有记录直接返回
if (StrUtil.isNotBlank(shopJson)) {
// 判断是否是缓存的空对象,如果是,则不再查询数据库,解决缓存穿透问题
if ("NULL".equals(shopJson)) {
return Result.fail("店铺不存在!");
}
// 续期
stringRedisTemplate.expire(key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
}
// 判断是否是缓存的空对象,如果是,则不再查询数据库
// if (shopJson != null) {
// return null;
// }
// 互斥锁解决缓存击穿,Redis没数据之后,查数据库之前,获取锁
// 使用setnx,注意,要有持锁时间,查数据库,最多10秒,并且在回写Redis之后,要释放掉锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Boolean haveLock = lock(lockKey);
// (1)不持有锁,等待一段时间重试
if (!haveLock) {
try {
Thread.sleep(50);
queryById(id);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
// (2)持有锁
// 双重检查锁定,线程进来之后要先查Redis,
shopJson = stringRedisTemplate.opsForValue().get(key);
// 处理第二次及以后进来的线程
if (StrUtil.isNotBlank(shopJson)) {
// 判断是否是缓存的空对象,如果是,则不再查询数据库,解决缓存穿透问题
if ("NULL".equals(shopJson)) {
unlock(lockKey);
return Result.fail("店铺不存在!");
}
// 续期
stringRedisTemplate.expire(key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
}
// 处理第一次拿到锁进来的线程
// 1.2 无记录去查MySQL
Shop shop = getById(id);
// 1.3 数据库也无记录,缓存低TTL的空对象,防止缓存穿透
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "NULL", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// 数据库无记录,也需要释放掉缓存击穿的锁,否则第一次之后的线程会白白被锁住直到锁过期
unlock(lockKey);
return Result.fail("店铺不存在!");
}
// 1.4 有记录回写Redis并返回
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 缓存穿透,释放锁
unlock(lockKey);
return Result.ok(shop);
}优化方向:
- 将查询Redis并续期的操作提取出来,并且建议保留当下情况,缓存穿透情况直接返回Result.fail(),不要再往下参与竞争
- 使用try-finally结构,保证当前线程一定会释放锁
- 关于queryById(id),其执行完之后会继续执行下方代码,依然会进行差Redis等操作,需要使用 return 的写法,就不会再调用下方逻辑
优化代码:
java
/**
* 设置锁,返回是否成功持有锁
* @return {@link Boolean }
*/
private Boolean lock(String key) {
Boolean haveLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "", RedisConstants.LOCK_SHOP_TTL, TimeUnit.SECONDS);
return Boolean.TRUE.equals(haveLock);
}
/**
* 释放锁
*/
private void unlock(String key) {
stringRedisTemplate.delete(key);
}
/**
*
* 查询Redis中数据,考虑到了缓存穿透情况
* @param id
* @return {@link Shop }
*/
private Result queryCacheById(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
String shopJson = stringRedisTemplate.opsForValue().get(key);
// 有记录
if (StrUtil.isNotBlank(shopJson)) {
// 判断是否是缓存的空对象,如果是,则不再查询数据库,解决缓存穿透问题
if ("NULL".equals(shopJson)) {
return Result.fail("店铺不存在!");
}
// 查到记录续期
stringRedisTemplate.expire(key, RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(JSONUtil.toBean(shopJson, Shop.class));
}
// 无记录
return null;
}
/**
* 引入缓存层的查询商户
* @param id
* @return {@link Result }
*/
@Override
public Result queryById(Long id) {
// 1. 先查Redis
Result result = queryCacheById(id);
// 有记录或者缓存穿透情况
if (result != null) {
return result;
}
// Redis无记录,查数据库
// 2. 抢锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Shop shop = null;
try {
// 互斥锁解决缓存击穿,Redis没数据之后,查数据库之前,获取锁
// 使用setnx,注意,要有持锁时间,查数据库,最多10秒,并且在回写Redis之后,要释放掉锁
Boolean haveLock = lock(lockKey);
// (1)不持有锁,等待一段时间重试
if (!haveLock) {
Thread.sleep(50);
return queryById(id);
}
// (2)持有锁
// 双重检查锁定,线程进来之后要先查Redis,主要应对第一个线程写完之后的进来的线程
result = queryCacheById(id);
// 有记录或者缓存穿透情况
if (result != null) {
return result;
}
// 处理第一次拿到锁进来的线程
// 3. 无记录去查MySQL
shop = getById(id);
String key = RedisConstants.CACHE_SHOP_KEY + id;
// 4. 数据库也无记录,缓存低TTL的空对象,防止缓存穿透
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "NULL", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
// 数据库无记录,也需要释放掉缓存击穿的锁,否则第一次之后的线程会白白被锁住直到锁过期
return Result.fail("店铺不存在!");
}
// 5. 有记录回写Redis并返回
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
return Result.ok(shop);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
// 6. 当前线程处理完之后,必须释放锁
unlock(lockKey);
}
}- 进阶优化:目前递归调用queryCacheById在高并发情况下仍有栈溢出风险,后续可以进一步使用while(True)来完成这部分逻辑。
1.4.2 逻辑不过期
java
/**
* 将数据及其逻辑过期时间封装后写入 Redis
*
* @param key Redis Key
* @param obj 要缓存的对象
* @param expireTime 过期时间长度
* @param unit 时间单位
*/
public void setWithLogicalExpire(Object obj, String key, Long expireTime, TimeUnit unit) {
if (obj == null) {
log.warn("Attempting to set null object for logical expire, key: {}", key);
return;
}
// 1. 封装逻辑过期包装类
RedisData redisData = new RedisData();
redisData.setData(obj); // RedisData 的 data 属性本身就是 Object,直接存即可
// 2. 设置逻辑过期时间
// 将当前时间加上指定步长,转换为 LocalDateTime
LocalDateTime logicalExpireTime = LocalDateTime.now().plusSeconds(unit.toSeconds(expireTime));
redisData.setExpiredTime(logicalExpireTime);
// 3. 写入 Redis
// 注意:这里使用的是 JSONUtil 序列化,确保你的序列化器能处理 LocalDateTime
stringRedisTemplate.opsForValue().set(key, JsonUtils.toJsonStr(redisData));
}
/**
* 逻辑不过期
*
* @param id
* @return {@link Result }
*/
@Override
public Result queryById(Long id) {
// 1. 查Redis,这里一定能查到,因为没有设TTL
String key = RedisConstants.CACHE_SHOP_KEY + id;
String jsonBean = stringRedisTemplate.opsForValue().get(key);
// 有记录
if (StrUtil.isNotBlank(jsonBean)) {
// 处理缓存穿透
if ("NULL".equals(jsonBean)) {
return Result.fail("店铺不存在!");
}
// 2. 判断是否到了expiredTime过期时间
LocalDateTime expiredTime = Objects.requireNonNull(JsonUtils.toBean(jsonBean, RedisData.class)).getExpiredTime();
if (expiredTime.isBefore(LocalDateTime.now())) {
// 3. 已过期就拿锁去起一个线程去数据库查询新数据写回
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
Boolean haveLock = lock(lockKey);
// 4. 拿到锁拉起一个线程去查数据库,写回
if (haveLock) {
new Thread(() -> {
// 查数据库
Shop shop = getById(id);
// 写入Redis
setWithLogicalExpire(shop,key, 30L, TimeUnit.MINUTES);
unlock(lockKey);
}).start();
}
}
// 4. 不管有没有拿到锁,都返回旧数据
return Result.ok(Objects.requireNonNull(JsonUtils.toBean(jsonBean, RedisData.class)).getData());
}
// 无记录去查数据库
Shop shop = getById(id);
// 数据库也无记录,缓存低TTL的空对象,防止缓存穿透
if (shop == null) {
stringRedisTemplate.opsForValue().set(key, "NULL", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
return Result.fail("店铺不存在!");
}
// 写回
setWithLogicalExpire(shop, key, 30L, TimeUnit.MINUTES);
return Result.ok(shop);
}踩到的坑:
- new 完 Thread 没有 ==.start()==,一定注意
- 在封装过期时间的时候,创建的RedisData类型,两个成员变量:LocalDateTime 过期时间 ,Object data,使用Object类型有助于之后扩展
优化方向:
- 使用线程池
优化代码如上