定义
同一时段大量的缓存key同时失效或者redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案
1.给不同key的TTL添加随机值。
2.利用redis集群提高服务的可用性。
3.给缓存业务添加降级限流策略。
4.给业务添加多级缓存。
缓存击穿
缓存击穿也叫做热点key问题,被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求会在瞬间给数据库带来巨大冲击。
解决方案
1.互斥锁
缺点:线程相互等待,性能差。可能有死锁风险。
优点:保证强一致性,没有额外的内存消耗,不用存储逻辑过期时间字段,实现简单。
2.逻辑过期
优点:线程无需等待,性能较好。不保证一致性,有额外内存消耗,实现复杂。
基于互斥锁方式解决缓存击穿
这里的互斥锁采用setnx命令,当这个key不存在的时候才能设置值,如果key已经存在了,则设置值失败。
127.0.0.1:6379> setnx lock 1
(integer) 1
127.0.0.1:6379> setnx lock 2
(integer) 0
127.0.0.1:6379> get lock
"1"
加锁是 setnx key 命令->赋值
释放锁 del key 命令->删除
如果设置锁,然后迟迟没有去删除,也就是没有释放锁。所以在设置setnx锁的时候,会加一个有效期。
/**
* 尝试加锁
* @return
*/
public boolean tryLock(String key) {
//setnx命令
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
//这里会自动拆箱,防止flag为null报空指针异常
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key
*/
public void unLock(String key) {
stringRedisTemplate.delete(key);
}
/**
* 防止缓存击穿-带互斥锁
* @return
*/
public Shop queryWithMutex(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
//从redis中查询商铺缓存
String shopJsonStr = stringRedisTemplate.opsForValue().get(key);
//redis中有数据直接返回
if(StrUtil.isNotBlank(shopJsonStr)) {
return JSONUtil.toBean(shopJsonStr, Shop.class);
}
//判断命中的是否为空值
if(shopJsonStr != null) {
//说明命中空字符串,不会去查数据库
return null;
}
Shop shop = null;
String lockKey = "lock:shop:"+id;
try {
//未命中,实现缓存重建
//1.获取互斥锁
boolean isLock = tryLock(lockKey);
//2.判断是否获取互斥锁成功
//3.失败则休眠重试
if(!isLock) {
Thread.sleep(50);
return queryWithMutex(id);
}
//4.成功则,redis中没有数据,继续查询数据库
shop = getById(id);
if(ObjectUtil.isNull(shop)) {
//将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", RedisConstants.CACHE_NULL_TTL, TimeUnit.MINUTES);
//数据库没有查询到数据,返回错误
return null;
}
//数据库中查询到数据,存入redis,再返回数据;设置超时时间
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), RedisConstants.CACHE_SHOP_TTL, TimeUnit.MINUTES);
}catch (Exception e) {
e.printStackTrace();
}finally {
//5.释放互斥锁
unLock(lockKey);
}
return shop;
}
逻辑过期解决缓存击穿
向redis中添加数据时,额外添加一个时间字段。
逻辑过期,其实旧的数据还存在于redis中,只是是旧数据而已,还是可以在缓存重建的过程中返回旧数据。这样就不至于给数据库带来巨大压力。
缓存数据预热
@Data
public class RedisData {
/**
* 逻辑过期时间
*/
private LocalDateTime expireTime;
private Object data;
}
/**
* 创造热点数据带逻辑过期,提前预热
* @param id
*/
public void saveShop2Redis(Long id, Long expiredSeconds) {
//1.查询店铺信息
Shop shop = getById(id);
//2.封装逻辑过期时间
String key = RedisConstants.CACHE_SHOP_KEY + id;
RedisData redisData = new RedisData();
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expiredSeconds));
redisData.setData(shop);
//3.写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
@Test
void testSaveShop() {
shopService.saveShop2Redis(1L, 10L);
}
redis中存储的店铺带逻辑过期时间
{
"data": {
"area": "北京",
"openHours": "10:00-22:00",
"sold": 4215,
"images": "https://qcloud.dpfile.com/pc/jiclIsCKmOI2arxKN1Uf0Hx3PucIJH8q0QSz-Z8llzcN56-_QiKuOvyio1OOxsRtFoXqu0G3iT2T27qat3WhLVEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vfCF2ubeXzk49OsGrXt_KYDCngOyCwZK-s3fqawWswzk.jpg,https://qcloud.dpfile.com/pc/IOf6VX3qaBgFXFVgp75w-KKJmWZjFc8GXDU8g9bQC6YGCpAmG00QbfT4vCCBj7njuzFvxlbkWx5uwqY2qcjixFEuLYk00OmSS1IdNpm8K8sG4JN9RIm2mTKcbLtc2o2vmIU_8ZGOT1OjpJmLxG6urQ.jpg",
"address": "北京王府井19号",
"comments": 3035,
"avgPrice": 80,
"updateTime": 1724455437000,
"score": 37,
"createTime": 1640167839000,
"name": "画画茶餐厅",
"x": 120.149192,
"y": 30.316078,
"typeId": 1,
"id": 1
},
"expireTime": 1725022878389
}
//创建线程池
private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);
/**
* 防止缓存击穿-逻辑过期+互斥锁
* @return
*/
public Shop queryWithLogicalExpired(Long id) {
String key = RedisConstants.CACHE_SHOP_KEY + id;
//从redis中查询商铺缓存
String shopJsonStr = stringRedisTemplate.opsForValue().get(key);
if(StrUtil.isBlank(shopJsonStr)) {
//不存在,直接返回
return null;
}
//redis中命中,需要将数据返序列化为java对象
RedisData redisData = JSONUtil.toBean(shopJsonStr, RedisData.class);
//判断是否逻辑过期
LocalDateTime expireTime = redisData.getExpireTime();
JSONObject data = (JSONObject)redisData.getData();
Shop shop = JSONUtil.toBean(data, Shop.class);
if(expireTime.isAfter(LocalDateTime.now())) {
//未过期,返回店铺信息
return shop;
}
//过期,需要重建缓存
//这里可能会有大量的线程在这里等着,只有一个线程能够获取锁去完成缓存重建,完成缓存重建后释放锁,其他线程还会获取锁
//所以在锁里面的逻辑需要再判断一次缓存是否过期,防止缓存反复的重建。
//1.获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean lock = tryLock(lockKey);
if(lock) {
//获取锁成功
//需要重新从redis中获取数据,再判断一次缓存是否过期,防止缓存反复的重建,DoubleCheck
shopJsonStr = stringRedisTemplate.opsForValue().get(key);
redisData = JSONUtil.toBean(shopJsonStr, RedisData.class);
expireTime = redisData.getExpireTime();
if(expireTime.isAfter(LocalDateTime.now())) {
//未过期,返回店铺信息
return shop;
}
//使用线程池去完成缓存重建(独立的线程)
CACHE_REBUILD_EXECUTOR.submit(() -> {
try{
saveShop2Redis(id, 10L);
}catch (Exception e) {
e.printStackTrace();
}finally {
//在线程里面释放锁
unLock(lockKey);
}
});
}
//立即返回旧的逻辑过期数据
return shop;
}
这里可能会有大量的线程在这里等着,只有一个线程能够获取锁去完成缓存重建,完成缓存重建后释放锁,其他线程还会获取锁.所以在锁里面的逻辑需要再判断一次缓存是否过期,防止缓存反复的重建。需要重新从redis中获取数据,再判断一次缓存是否过期,防止缓存反复的重建,DoubleCheck。