Redis的缓存穿透、缓存雪崩、缓存击穿问题的概念与解决办法

760 阅读12分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第21天,点击查看活动详情

详细介绍了Redis的缓存穿透、缓存雪崩、缓存击穿等问题的概念与解决办法。

1 缓存穿透

1.1 什么是缓存穿透?

缓存穿透是指查询一个在缓存和数据库中一定不存在的数据,按照传统使用缓存流程:由于缓存不命中,接着查询数据库,但是数据库也无法查询出结果,因此也不会将空值写入到缓存中,这将会导致每个这样的查询都会去请求数据库,造成缓存穿透。

如果有恶意用户,就可以利用这个漏洞,模拟请求很多缓存和数据库中不存在的数据,比如传递负数id,由于缓存中都没有,并且不会缓存空值,导致这些请求短时间内直接落在了数据库上,对数据库造成压力,甚至导致数据库异常宕机。

1.2 怎么解决

一般来说有以下三种方式!

最基础的方式就是在业务层的代码中做好数据校验,比如自增ID肯定是不能为负数的,对于一些很直观的异常请求执行进行拦截。这一点说起来简单,但是却需要开发者足够的细心,考虑的情况要足够全面,很多小公司的参数是没有进行类似的校验的。

另外一个方法就是对于从缓存取不到的数据,如果在数据库中也没有取到,则将不存在的结果也存入缓存,可以是空字符串或者空对象。并且设定较短的的缓存过期时间,比如设置为30秒,之后30秒内再访问这个数据将会从缓存中获取(如果该数据没有被写入数据库和缓存),防止攻击者使用同一个id进行恶意攻击,但这种方法会存在两个问题:

  1. 如果缓存空值起来,将会需要更多的内存空间;虽然可以防止攻击者使用同一个id进行恶意攻击,但如果某个攻击者在短时间内恶意制造大量不存在的不同的id,那么缓存中会有很多无效数据,内存很可能会被打爆,所以这个“较短的”过期时间,但具体多短不好确定。

    1. 一种解决办法是,对请求的IP进行限制,我们能够知道真正用户不会在短时间内发起这么多的请求。那么可以限制同一个IP在一定时间内对某个接口最多发起多少次请求,超出的请求直接拦截,或者直接将IP放入黑名单,限制所有请求,这一点Nginx、API网关、Redis或者其他中间件都能做到。
  2. 如果对空值设置了过期时间,那么可能会存在缓存层和存储层的数据会有一段时间的不一致的情况,这对于需要保持强一致性的业务会有影响。

还有一种更加高级的方法就是使用Redis的布隆过滤器(Bloom Filter),它也能很好的防止缓存穿透的发生,并且比较优雅。布隆过滤器利用一种概率性数据结构,快速的判某个key是否存在。我们首先将可能存在的请求的值都存放在布隆过滤器中(通常是数据库中的值),当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中,不存在就直接return,存在时才会走真正的查缓存和数据库的逻辑。

Bloom Filter的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。

1.3 Bloom Filter布隆过滤器

布隆过滤器(Bloom Filter)是1970年由布隆((Burton Howard Bloom))提出的,它是一种比较巧妙的概率型数据结构(probabilistic data structure),可以用来判断“某个元素一定不存在或者可能存在集合中”。它的空间效率和查询速度都远高于一般的数据结构和算法,但缺点是有一定的误识别率和删除困难。

Bloom Filter常被用来解决缓存穿透的问题,或者网页黑名单系统、垃圾邮件过滤系统、爬虫网址判重系统等大量数据并且允许一定误差的系统。

1.3.1 Bloom Filter的原理

Bloom Filter的基础数据结构很简单,就是一张位图,即Bitmap,或者叫bitarray。Bitmap可以看作是一个位数组结构,在这个数组中每一个位置只占有1个bit的大小,而每个bit只有0和1两种状态。

Bloom Filter内部还定义了K个不同的哈希函数,当一个元素尝试被add加入Bloom Filter时,会进行K个哈希函数的计算,得到k个不同的bit索引位置,并将这k个bit索引位都置为1,表示插入成功。

而如果要检索某个元素,同样经过K个不同的哈希函数得到k个哈希点位,然后再看看这些点位在对应的bitmap索引位上是否都为1:如果这些点位有任何一个0,则被检元素一定不存在;如果都是1,则被检元素很可能存在。这就是布隆过滤器的基本思想。

Bloom Filter与单哈希函数Bit-Map不同之处在于它使用了k个哈希函数,每个字符串跟k个bit位对应,这样做的目的很明显,就是为了降低哈希冲突的概率。但我们知道,哈希函数永远都有可能是发生哈希冲突的,因此即使使用了k个哈希函数,哈希冲突的概率被降低得很低,然而仍然有发生哈希冲突的可能性。

某个Bloom Filter示意图:

在这里插入图片描述

上图中的Bloom Filter假设具有三个哈希函数,预先存入a、b、c三个元素并根据哈希函数计算出不同的bit位,将这些bit位置都为1。

判断c,根据三个哈希函数计算出不同的bit位置,然后判断发现这些位置的bit值都是1,于是Bloom Filter返回true表示该元素存在,实际上该元素确实存在。

判断d,根据三个哈希函数计算出不同的bit位置,然后判断发现有一个函数hash3(d)结果对应的的bit索引位的值是0,于是Bloom Filter返回false表示该元素存在,实际上该元素确实不存在。

判断e,根据三个哈希函数计算出不同的bit位置,然后判断发现这些位置的bit值都是1,于是Bloom Filter返回true表示该元素存在,实际上该元素是不存在,也就是说此时发生了误判。

1.3.2 Bloom Filter的优缺点

相比于传统的Map 等来判断数据是否存在的数据结构,它不会存储实际的数据,占用空间更少,申请一个100w个元素的位数组只占用1000000Bit/8=125000Byte=125000/1024kb≈122kb的空间。

缺点是其返回的结果是概率性的,从上面的演示结果能够知道,可能要查到的元素并没有在容器中,但是hash之后得到的k个位置上值都是1,于是就会发生误判,这几乎是不能完全避免的,因此这对于哈希算法的要求很高。另外足够的Bitmap长度也能让误判的概率变得相当小,但同样会占用更多的空间。也可以建立起一个单独的列表来放置可能会误判的元素。

还有一个缺点是删除操作非常困难,或者一般的直接不允许remove移除元素,因为那样的话会把相应的k个bits位置为0,而其中很有可能有其他元素执行哈希计算之后也会对应该bit位,从而造成更多的误判!如果要删除元素,则使用Counting Bloom Filter。

1.3.3 Guava Bloom Filter

知道了Bloom Filter的原理,我们能够比较“轻易”的实现一个自己的Bloom Filter,Java中也提供了BitSet这个现成的位数组,主要难点是多个哈希函数的设计以及bitmap的大小。

Guava也提供了比较良好的Bloom Filter的实现,使用需要引入Guava的依赖:

<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>30.1.1-jre</version>
</dependency>

如下是一个基本的测试案例:

/**
 * @author lx
 */
public class BloomFilterTest {

    //一百万
    static int total = 1000000;

    private static BloomFilter<Integer> bf = BloomFilter.create(Funnels.integerFunnel(), total);

    public static void main(String[] args) {
        //预先存入数据
        for (int i = 0; i < total; i++) {
            bf.put(i);
        }
        //判断真实存入的数据
        int x1 = 0, x2 = 0;
        for (int i = 0; i < total; i++) {
            //如果不存在
            if (!bf.mightContain(i)) {
                //System.out.println("已存在数据的误判: " + i);
                x1++;
            }
        }
        //判断不存在的数据
        for (int i = total; i < total * 2; i++) {
            //如果存在
            if (bf.mightContain(i)) {
                //System.out.println("不存在数据的误判: " + i);
                x2++;
            }
        }
        System.out.println("已存在数据的误判数量: " + x1);
        System.out.println("不存在数据的误判数量: " + x2);

        //计算误判率
        NumberFormat numberFormat = NumberFormat.getInstance();
        numberFormat.setMaximumFractionDigits(10);
        String result = numberFormat.format((float) (x1 + x2) / (float) (total + total));
        System.out.println("误判率: " + result);
    }
}

结果如下:

在上面的测试案例中,我们创建布隆过滤器时,第一个参数表示插入元素类型是int类型,第二个参数表示布隆过滤器预期插入数量是一百万次,第三个参数表示允许错误率,没有指定最大可以容忍误判的概率,则默认为0.03。容忍的错误率越大,则底层bitarray越小,所需哈希函数越少,容忍的错误率越大,则底层bitarray越大,所需哈希函数越多。

随后我们预先插入0-1000000共计一百万数据,随后测试已存入数据的误判数量,结果为0,然后测试不存在数据的误判数量,此时出现了误判。最终误判概率小于0.03。

已存在数据的误判数量: 0
不存在数据的误判数量: 30155
误判率: 0.0150774997

我们debug,发现所有的create方法都指向4个参数的create方法,并且可以看到插入一百万的int数据所需的bit位数约700万。如果采用hashmap,那么一个int就占据四个byte,那么就需要一般哈希表的存储效率为50%(需要扩容两倍),那么hashmap至少需要,1000000432*2=6400万位,可以看到,BloomFilter的存储空间很小,只有HashMap的1/10左右。

在这里插入图片描述

Guava Bloom Filter仅仅适用于单机部署的服务器,如果是集群部署,则需要一个外部的中间件来实现Bloom Filter。

1.3.4 Redis Bloom Filter

Redis支持Bitmap的位操作,因此我们可以使用Redis的Bitmap来编写自己的Bloom Filter,而在4.0版本之后,Redis提供了Module(模块/插件,redis.io/modules)功能,… Filter才算登场,并作为一个插件加载到Redis服务器中,提供Bloom Filter的过滤功能。

目前最流行的Bloom Filter插件是RedisBloom :github.com/RedisBloom/…

目前docker中已经提供了整合的redis与RedisBloom插件的镜像,即redislabs/rebloom,我们直接下载运行即可,无需自己安装。

docker拉取镜像:

docker pull redislabs/rebloom

运行容器:

docker run -p 6379:6379 --name redis-redisbloom redislabs/rebloom

进入redis容器启动执行命令:

docker exec -it redis-redisbloom bash

# redis-cli
# 127.0.0.1:6379>

通过添加新元素创建一个新的布隆过滤器(BF.MADD命令用于添加多个元素):

# 127.0.0.1:6379> BF.ADD newFilter foo
(integer) 1

判断过滤器中是否存在某个元素(BF.MEXISTS命令用于判断多个元素):

# 127.0.0.1:6379> BF.EXISTS newFilter foo
(integer) 1

返回1 表示 foo 最有可能在 newFilter 表示的集合中。

# 127.0.0.1:6379> BF.EXISTS newFilter bar
(integer) 0

返回0 表示 bar 绝对不在集合中。

处理默认过滤器之外,在使用BF.ADD命令添加元素之前,可以使用BF.RESERVE命令创建一个自定义的布隆过滤器。格式为:

BF.RESERVE {key} {error_rate} {capacity} [EXPANSION expansion]

有如下说参数:

  1. key:布隆过滤器的名称;
  2. error_rate:期望误判率,介于0到1之间的十进制值,期望错误率越低,需要的空间就越大,CPU开销也越大。
  3. capacity:初始容量,当实际元素的数量超过这个初始化容量时,性能下降,误判率上升。
  4. expansion:可选。如果创建了一个新的子过滤器,则其大小将是当前过滤器的大小乘以expansion。默认扩展值为2。这意味着每个后续子过滤器将是前一个子过滤器的两倍。

2 缓存雪崩

缓存雪崩指的是大量缓存集中在一段时间内失效,发生大量的缓存穿透,所有的查询都落在数据库上,造成了缓存雪崩。

产生雪崩的原因之一,就是在设置或者刷新缓存的时候,大量的缓存被同时设置或者刷新,并且缓存的失效时间相同。

处理缓雪崩的方法很简单,一般来说设置缓存超时时间的时候加上一个随机的时间长度,比如这个缓存key的超时时间是固定的5分钟加上随机的2分钟,这样可从一定程度上避免雪崩问题。

如果是热点数据,则可以直接设置热点数据永远不过期,有更新操作就更新缓存就好了。

3 缓存击穿

缓存击穿和缓存雪崩有点像,但是又有一点不一样,缓存雪崩是因为大面积的缓存失效,打崩了DB,而缓存击穿不同的是缓存击穿是指一个Key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个Key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,造成数据库宕机。

解决方法也很简单,那就是对于热点数据直接设置永远不过期,有更新操作就更新缓存就好了。

另一个方法就是设置互斥锁,先从缓存中尝试获取,如果没有那么再尝试获取锁,获取到锁之后,再次尝试从缓存中获取,如果获取到了就直接返回,如果没有获取到就查库,然后设置到缓存中再返回,对于分布式集群系统,这里的锁是分布式锁,也可以直接使用Redis来实现。

4 缓存预热

缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统中。这样就可以避免了系统刚上线的时候,在用户请求的时候,先查询数据库,然后再将数据缓存导致数据库压力大的问题,用户可以直接查询事先被预热的缓存数据!

缓存预热解决方案:

  1. 直接在后台管理系统中写个缓存刷新页面,上线时手工操作下;
  2. 数据量不大,可以在项目启动的时候自动进行加载;
  3. 定时刷新缓存;

5 防止Redis宕机

对于 "Redis 挂掉了,请求全部走数据库" 这样的情况,我们还可以有如下的思路:

  1. 事发前:实现 Redis 的高可用(主从架构 + Sentinel 或者 Redis Cluster),尽量避免 Redis 挂掉这种情况发生。
  2. 事发中:万一Redis真的挂了,我们可以设置本地缓存(ehcache) + 限流,尽量避免我们的数据库被干掉(起码能保证我们的服务还是能正常工作的)
  3. 事发后:Redis 持久化,重启后自动从磁盘上加载数据,快速恢复缓存数据。

相关文章:

  1. redis.io

如有需要交流,或者文章有误,请直接留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!