前言:设计一个Redis缓存系统,不得不要考虑的问题就是:缓存穿透、缓存击穿与失效时的雪崩效应。先来看一个常见的缓存使用方式:读请求来了,先查下缓存,缓存有值命中,就直接返回;缓存没命中,就去查数据库,然后把数据库的值更新到缓存,再返回。
缓存穿透是指缓存和数据库中都没有数据,用户请求的数据在缓存中没有命中,同时在数据库中也不存在,这样不会更新缓存,导致用户每次请求这个不存在数据都要到数据库中去查询。
通俗点说,读请求访问时,缓存和数据库都没有某个值,由于缓存是不命中时需要从数据库查询,查不到数据则不写入缓存,这样就会导致每次对这个值的查询请求都会穿透到数据库,这就是缓存穿透。在流量大时,可能DB就挂掉了,要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。比如发起id为“-1”的数据或id为特别大不存在的数据,这时的用户很可能是攻击者,攻击会导致数据库压力过大。
缓存穿透一般都是这几种情况产生的:
业务不合理的设计:比如大多数用户都没开守护,但是你的每个请求都去缓存,查询某个userid查询有没有守护。
业务/运维/开发失误的操作:比如缓存和数据库的数据都被误删除了。
黑客非法请求攻击:比如黑客故意捏造大量非法请求,读取不存在的业务数据。
如何避免缓存穿透呢? 一般有三种方法:
接口参数校验:如果是非法请求,我们在API入口,对参数进行校验,过滤非法值;
返回空对象:如果缓存未命中并且查询数据库也为空,我们可以给缓存设置个空值或者默认值,这样下次请求该key时直接从缓存中查询返回空对象,请求不会落到持久层数据库。但是如有有写请求进来的话,需要更新缓存以保证缓存一致性,同时,为了避免存储过多空对象,最后给缓存设置适当的过期时间;(业务上比较常用,简单有效)
布隆过滤器:使用布隆过滤器存储所有可能访问的 key,不存在的 key 直接被过滤,存在的 key 则再进一步查询缓存和数据库。布隆过滤器的巨大用处就是,能够迅速判断一个元素是否在一个集合中。布隆过滤器认为不在的,一定不会在集合中;布隆过滤器认为在的,可能在也可能不在集合中。
缓存击穿是指缓存中没有但数据库中有的数据,大量的请求同时查询一个热点 key 时,此时这个key正好失效了,就会导致大量的请求都打到数据库上面去。缓存击穿危害就是数据库瞬时压力骤增,造成大量请求阻塞。
缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。缓存击穿和缓存雪崩看着有点像,缓存雪崩是指数据库压力过大甚至down机,缓存击穿只是大量并发请求到了DB数据库层面,可以认为缓存击穿是缓存雪崩的一个子集吧。
解决方案有两种:
使用互斥锁方案:在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其他的线程拿不到锁就阻塞等着,等到第一个线程将数据写入缓存后,直接走缓存。这种思路比较简单,就是让一个线程回写缓存,其他线程等待回写缓存线程执行完,重新读缓存即可。无论是使用“分布式锁”,还是“JVM 锁”,加锁时要按 key 维度去加锁。
使用分布式锁,保证对于每个key同时只有一个线程去查询后端服务,其他线程没有获得分布式锁的权限,因此只需要等待即可。这里要注意,分布式环境中要使用分布式锁,单机的话用普通的锁(synchronized
、Lock
)就够了。
下面以一个获取商品库存的案例进行代码的演示,单机版的JVM锁实现具体实现的代码如下:
// 获取库存数量
public String getProduceNum(String key) {
try {
synchronized (this) { //加锁
// 缓存中取数据,并存入缓存中
int num= Integer.parseInt(redisTemplate.opsForValue().get(key));
if (num> 0) {
//没查一次库存-1
redisTemplate.opsForValue().set(key, (num- 1) + "");
System.out.println("剩余的库存为num:" + (num- 1));
} else {
System.out.println("库存为0");
}
}
} catch (NumberFormatException e) {
e.printStackTrace();
} finally {
}
return "OK";
}
分布式的锁实现具体实现的代码如下:
public String getProduceNum(String key) {
// 获取分布式锁
RLock lock = redissonClient.getLock(key);
try {
// 获取库存数
int num= Integer.parseInt(redisTemplate.opsForValue().get(key));
// 上锁
lock.lock();
if (num> 0) {
//减少库存,并存入缓存中
redisTemplate.opsForValue().set(key, (num - 1) + "");
System.out.println("剩余库存为num:" + (num- 1));
} else {
System.out.println("库存已经为0");
}
} catch (NumberFormatException e) {
e.printStackTrace();
} finally {
//解锁
lock.unlock();
}
return "OK";
}
热点数据永不过期(软过期):直接将缓存设置为不过期,把过期时间存在key对应的value里,然后由定时任务去异步线程加载数据更新缓存。这种方式适用于比较极端的场景,例如流量特别特别大的场景,使用时需要考虑业务能接受数据不一致的时间,还有就是异常情况的处理,不要到时候缓存刷新不上,一直是脏数据,那就凉了。
软过期指对缓存中的数据设置失效时间,就是不使用缓存服务提供的过期时间,而是业务层在数据中存储过期时间信息,由业务程序判断是否过期并更新,在发现了数据即将过期时,将缓存的时效延长,程序可以派遣一个线程去数据库中获取最新的数据,其他线程这时看到延长了的过期时间,就会继续使用旧数据,等派遣的线程获取最新数据后再更新缓存;也可以通过定时任务异步更新服务来更新设置软过期的缓存,这样应用层就不用关心缓存击穿的问题了。
缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,请求直接落到数据库上,引起数据库压力过大甚至宕机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。
缓存雪崩其实有点像“升级版的缓存击穿”,缓存击穿是一个热点 key,缓存雪崩是一组热点 key。
解决方案有二种:
设置不同的过期时间:缓存雪崩一般是由于大量数据同时过期造成的,可以给缓存的过期时间加上一个随机值时间,使得每个 key 的过期时间离散分布开来,防止同一时间内大量的key失效。比如采用一个较大固定值+一个较小的随机值,5小时—0到1800秒。
搭建高可用的Redis集群:Redis 故障宕机也可能引起缓存雪崩,此时就需要构造Redis高可用集群了。
缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统,这样就可以避免在用户请求的时候,先查询数据库,然后再将数据回写到缓存。
如果不进行预热, 那么 Redis 初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。
数据量不大的时候,工程启动的时候进行加载缓存动作;
数据量大的时候,设置一个定时任务脚本,进行缓存的刷新;
数据量太大的时候,优先保证热点数据进行提前加载到缓存。
布隆过滤器(Bloom Filter,简称BF)由Burton Howard Bloom在1970年提出,是一种空间效率高的概率型数据结构。布隆过滤器专门用来检测集合中是否存在特定的元素。
如果在平时我们要判断一个元素是否在一个集合中,通常会采用查找比较的方法,下面分析不同的数据结构查找效率:
采用线性表存储,查找时间复杂度为O(N)
采用平衡二叉排序树(AVL、红黑树)存储,查找时间复杂度为O(logN)
采用哈希表存储,考虑到哈希碰撞,整体时间复杂度也要O[log(n/m)]
当需要判断一个元素是否存在于海量数据集合中,不仅查找时间慢,还会占用大量存储空间。接下来看一下布隆过滤器如何解决这个问题。
布隆过滤器由一个长度为m比特的位数组(bit array)与k个哈希函数(hash function)组成的数据结构。位数组初始化均为0,所有的哈希函数都可以分别把输入数据尽量均匀地散列。
当要向布隆过滤器中插入一个元素时,该元素经过k个哈希函数计算产生k个哈希值,以哈希值作为位数组中的下标,将所有k个对应的比特值由0置为1;
当要查询一个元素时,同样将其经过哈希函数计算产生哈希值,然后检查对应的k个比特值:如果有任意一个比特为0,表明该元素一定不在集合中;如果所有比特均为1,表明该集合有可能性在集合中。为什么不是一定在集合中呢?因为不同的元素计算的哈希值有可能一样,会出现哈希碰撞,导致一个不存在的元素有可能对应的比特位为1,这就是所谓“假阳性”(false positive)。相对地,“假阴性”(false negative)在BF中是绝不会出现的。
总结一下:布隆过滤器认为不在的,一定不会在集合中;布隆过滤器认为在的,可能在也可能不在集合中。
举个例子:下图是一个布隆过滤器,共有18个比特位,3个哈希函数。集合中三个元素x,y,z通过三个哈希函数散列到不同的比特位,并将比特位置为1。当查询元素w时,通过三个哈希函数计算,发现有一个比特位的值为0,可以肯定认为该元素不在集合中。
优点:
节省空间:不需要存储数据本身,只需要存储数据对应hash比特位
时间复杂度低:插入和查找的时间复杂度都为O(k),k为哈希函数的个数
缺点:
存在假阳性:布隆过滤器判断存在,可能出现元素不在集合中;判断准确率取决于哈希函数的个数
不能删除元素:如果一个元素被删除,但是却不能从布隆过滤器中删除,这也是造成假阳性的原因了
爬虫系统url去重:网页爬虫对URL的去重,避免爬取相同的URL地址
垃圾邮件过滤:从数十亿个垃圾邮件列表中判断某邮箱是否垃圾邮箱
黑名单
参考链接:
一张图搞懂 Redis 缓存雪崩、缓存穿透、缓存击穿
布隆过滤器的方式解决缓存穿透问题
缓存穿透、缓存击穿、缓存雪崩的解决方案 - 简书