文章目录
- 一、缓存穿透
- 1.1 产生原因
- 1.2 解决方法
- 接口校验
- 对空值进行缓存
- 使用布隆过滤器
- 实时监控
- 二、缓存雪崩
- 2.2 解决方法
- 将失效时间分散开
- 给业务添加多级缓存
- 构建缓存高可用集群
- 使用锁或者队列的方式
- 设置缓存标记
- 三、缓存击穿
- 3.2 解决方法
- 使用互斥锁
- ”提前“使用互斥锁 / 逻辑过期
- 提前对热点数据进行设置
- 监控数据,适时调整
- 3.3 实现
- 1 互斥锁
- 测试
- 2 逻辑过期
一、缓存穿透
1.1 产生原因
客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会访问数据库。导致DB的压力瞬间变大而卡死或者宕机。
- 大量的高并发的请求打在redis上
- 这些请求发现redis上并没有需要请求的资源,redis命中率降低
- 因此这些大量的高并发请求转向DB请求对应的资源
- DB压力瞬间增大,直接将DB打垮,进而引发一系列“灾害”
缓存穿透发生的场景一般有两类:
- 原来数据是存在的,但由于某些原因(误删除、主动清理等)在缓存和数据库层面被删除了,但前端或前置的应用程序依旧保有这些数据;
- 恶意攻击行为,利用不存在的Key或者恶意尝试导致产生大量不存在的业务数据请求。
1.2 解决方法
接口校验
类似于用户权限的拦截,对于id = -3872
这些无效访问就直接拦截,不允许这些请求到达Redis、DB上。
对空值进行缓存
比如,虽然数据库中没有id = 1022
的用户的数据,但是在redis中对他进行缓存(key=1022, value=null
),这样当请求到达redis的时候就会直接返回一个null的值给客户端,避免了大量无法访问的数据直接打在DB上。
但需要注意:
- key设置的过期时间不能太长,防止占用太多redis资源,设置一个合适的TTL,比如两三分钟。
- 当遇到黑客暴力请求很多不存在的数据时,就需要写入大量的null值到Redis中,可能导致Redis内存占用不足的情况。
使用布隆过滤器
简单的说就是:通过将一个key的hash值分布到一个大的bit数组上面,判断一个key是否存在时只需判断该的hash对应的bit位是否都是1,如果全是1则表示存在,否则不存在。性能很高但可能存在误判:
如果他告诉你不存在,则一定不存在;如果他告诉你存在,则可能不存在。
使用BitMap作为布隆过滤器,将目前所有可以访问到的资源通过简单的映射关系放入到布隆过滤器中(哈希计算),当一个请求来临的时候先进行布隆过滤器的判断,如果有那么才进行放行,否则就直接拦截。
实时监控
对redis进行实时监控,当发现redis中的命中率下降的时候进行原因的排查,配合运维人员对访问对象和访问数据进行分析查询,从而进行黑名单的设置限制服务(拒绝黑客攻击)。
二、缓存雪崩
当redis中的大量key集体过期,可以理解为Redis中的大部分数据都清空 / 失效了,这时候如果有大量并发的请求来到,Redis就无法进行有效的响应(命中率急剧下降),也会导致DB先生的绝望。
缓存雪崩的场景通常有两个:
- 大量热点key同时过期
- 缓存服务故障或宕机
2.2 解决方法
将失效时间分散开
常用且易于实现通过使用自动生成随机数使得key的过期时间TTL是随机的,防止集体过期。
给业务添加多级缓存
使用nginx缓存 + redis缓存 + 其他缓存,不同层使用不同的缓存,可靠性更强。
构建缓存高可用集群
主要针对缓存服务故障的情景,使用Redis集群来提高服务的可用性。
使用锁或者队列的方式
如果查不到就加上排它锁,其他请求只能进行等待,但这种方式可能影响并发量。
设置缓存标记
热点数据可以不考虑失效,后台异步更新缓存,适用于不严格要求缓存一致性的情景。
三、缓存击穿
Redis中的某个热点key过期,但是此时有大量的用户访问该过期key。
可以看成缓存雪崩的一个特殊子集。
比如xxx塌房哩、xxx商品活动,这时候大量用户都在访问该热点事件,但是可能优于某种原因,redis的这个热点key过期了,那么这时候大量高并发对于该key的请求就得不到redis的响应,那么就会将请求直接打在DB服务器上,导致整个DB瘫痪。
3.2 解决方法
使用互斥锁
只有一个请求可以获取到互斥锁,然后到DB中将数据查询并返回到Redis,之后所有请求就可以从Redis中得到响应。【缺点:所有线程的请求需要一同等待】
”提前“使用互斥锁 / 逻辑过期
在value内部设置一个比缓存(Redis)过期时间短的过期时间标识,当异步线程发现该值快过期时,马上延长内置的这个时间,并重新从数据库加载数据,设置到缓存中去。【缺点:不保证一致性,实现相较互斥锁更复杂】
提前对热点数据进行设置
类似于新闻、某博等软件都需要对热点数据进行预先设置在Redis中,或者适当延长Redis中的Key过期时间。
监控数据,适时调整
监控哪些数据是热门数据,实时的调整key的过期时长。
3.3 实现
1 互斥锁
使用setnx作为Redis中的锁。
- Redis中查询缓存
- 存在且不为空值,直接返回
- 为空值(比如“”、0等特殊值),返回失败结果
- 不存在,获取锁
- 获取锁失败,等待重试
- 获取成功,查找MySQL
- 不存在,Redis存入空值
- 存在,写入Redis
- 释放锁,返回结果
/**
* 根据id查找商户,先到redis中找,再到MySQL中找
* @param id
* @return
*/
@Override
public Result queryShopById(Long id) {
// 用String形式存储JSON
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 如果查询结果不为null,直接返回
if (StrUtil.isNotBlank(shopJson)) {
Shop shop = JSONUtil.toBean(shopJson, Shop.class);
return Result.ok(shop);
}
// 否则Redis中查询结果为空,判断是否为“”
if (shopJson != null) {
return Result.fail("店铺不存在,请确认id是否正确");
}
// 尝试获取锁,
// 如果没有得到锁,Sleep一段时间
if (!tryLock(LOCK_SHOP_KEY + id)) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 从开始重试
return queryShopById(id);
}
// 获得了锁,从MySQl中查找
Shop shop = this.getById(id);
// 模拟重建的延时
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 不在MySQL中
if (shop == null) {
// 将空值写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
// 释放锁
unLock(LOCK_SHOP_KEY + id);
return Result.fail("店铺不存在,请确认id是否正确");
}
else {
// 在MySQL中,存入redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
// 释放锁
unLock(LOCK_SHOP_KEY + id);
return Result.ok(shop);
}
}
public boolean tryLock(String key) {
// 尝试获取锁,set成功返回true,否则返回false
Boolean getLock = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 避免getLock为null,使用工具类
return BooleanUtil.isTrue(getLock);
}
public void unLock(String key) {
stringRedisTemplate.delete(key);
}
测试
F:\Jmeter\bin\ApacheJMeter.jar
对应地,MySQL只执行了1次SQL:
2 逻辑过期
/**
* 线程池
*/
private static final ThreadFactory NAMED_THREAD_FACTORY = new ThreadFactoryBuilder().build();
private static final ExecutorService POOL = new ThreadPoolExecutor(5, 200,
0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<Runnable>(1024), NAMED_THREAD_FACTORY,
new ThreadPoolExecutor.AbortPolicy());
public Result queryWithExpire(Long id) {
String shopJson = stringRedisTemplate.opsForValue().get(CACHE_SHOP_KEY + id);
// 如果查询结果为null,直接失败
if (StrUtil.isBlank(shopJson)) {
return Result.fail("您查询的数据不存在,请检查您的输入");
}
RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
Shop shop = JSONUtil.toBean((JSONObject)redisData.getData(), Shop.class);
// 判断缓存是否过期
LocalDateTime time = redisData.getExpireTime();
// 未过期,直接返回信息
if (time.isAfter(LocalDateTime.now())) {
return Result.ok(shop);
}
// 过期,获取互斥锁失败,返回过期信息
if (!tryLock(LOCK_SHOP_KEY + id)) {
unLock(LOCK_SHOP_KEY + id);
return Result.ok(shop);
}
// 过期,获取互斥锁成功,开启新线程,重建数据库
POOL.submit(() -> {
this.saveShop2Redis(id, 20L);
unLock(LOCK_SHOP_KEY + id);
});
// 返回过期信息
return Result.ok(shop);
}
/**
* 在MySQL中查找id的shop,写入Redis并更新虚拟过期时间
* @param id
* @param expireSeconds
*/
private void saveShop2Redis(Long id, Long expireSeconds) {
// 获取
Shop shop = this.getById(id);
// 封装
RedisData redisData = new RedisData();
redisData.setData(shop);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(expireSeconds));
// 写入Redis
stringRedisTemplate.opsForValue().set(CACHE_SHOP_KEY + id, JSONUtil.toJsonStr(redisData));
}