0%

『缓存穿透、缓存雪崩和缓存击穿』问题及解决方案

当项目在数据库层面出现瓶颈时,添加一个缓存系统就成了一件不得不考虑的事,而设计一个完善的缓存系统,通常需要解决以下几个问题:缓存穿透、缓存雪崩和缓存击穿。

缓存调用过程

缓存系统的调用过程是这样的:当我们请求一条数据时,先去缓存中查询,如果存在就直接返回,如果不存在就去数据库查询,然后进行缓存并返回,可以有效的减轻数据库的压力并提升响应速度。

50524-1.png

缓存穿透

什么是缓存穿透

正常情况下,查询的数据都是存在的,但是当请求查询一个一定不存在的数据时,缓存和数据库中都不存在这条记录,那么请求就会直接落到数据库上,这种直接查询数据库的现象称为缓存穿透。如果有恶意用户以不存在的key去查询数据,产生大量请求,从而导致所有的查询都落到数据库,会使数据库压力过大而影响正常服务甚至宕机。

解决方案

  1. 缓存空值或者默认值

    这种方式简单粗暴,即使查询返回空结果,仍然对该查询进行缓存,并对其设置一个较短的过期时间,避免产生了真实的数据而无法查出。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 伪代码
    String get(String key) {
    // 从缓存获取数据
    String data = Redis.get(key);
    if (null != data) {
    return data;
    }
    // 从数据库获取数据
    data = DB.get(key);
    if (null == data) {
    // 如果结果为空,那么将结果赋值为空值
    data = Data.Empty;
    // 将缓存过期时间设置为较短的值
    expired = sort;
    }
    // 缓存查询结果
    Redis.add(key, data, expired);
    return data;
    }

    50524-2.png

    缓存空值虽然简单,但是弊病还是很明显的,如果有恶意攻击短时间请求大量不重复的key,那么查询依旧会落到数据库上,同时缓存系统也会缓存下大量不存在的数据,占用缓存空间,这是需要考虑到的。

  2. 布隆过滤器
    布隆过滤器(BloomFilter)是一个数组形式且足够大的bitmap,通过多次哈希将数据映射到bitmap中。布隆过滤器可以快速检索一个数据是否包含在它所表示的集合中,它有一个特点,不存在的数据则一定不存在,表示存在的数据则大概率存在(这和布隆过滤器的误判率有关)。当布隆过滤器应用于防止缓存穿透时,我们可以事先将缓存的数据加载到布隆过滤器中,并将布隆过滤器置于缓存系统之前。得益于布隆过滤器的特点,当请求查询一个不存在的数据时,布隆过滤器可以将其直接过滤掉,而不会落到缓存系统和数据库上。

    50524-3.png

布隆过滤器的解决之道足够优秀,但是其依旧是存在不足之处的:一是因为哈希冲突,布隆过滤器存在一定的误判率,即表示存在的结果不一定真实存在,数据库查询结果也可能为空;二是布隆过滤器没有删除操作,对于数据库删除的数据,不能达到很好的过滤效果。

缓存雪崩

什么是缓存雪崩

对于设置了过期时间的数据,在同一时间大规模的失效,或者缓存服务器宕机不能提供服务,导致查询直接落到数据库的现象称为缓存雪崩。缓存雪崩导致大量的请求转到数据库,在面对大量的请求时会给数据库带来巨大的压力。

解决方案

  1. 随机失效时间

    通常在数据预热阶段,会对数据进行缓存设置,将缓存数据的过期时间在原来的基础上增加一个随时值,可以避免在某个时间出现大规模的缓存失效的情况,这样即使出现失效,也不会导致数据库瞬时负载过大而影响性能的情况。

  2. 永不过期

    (1)在设置缓存数据时不设置过期时间,物理意义上的不过期;

    (2)为缓存数据的过期时间进行动态设置,避免冷门数据一直占有空间。在设置缓存时,可以将过期时间存在key对应的value里,如果取出来时发现即将过期,则后台新开一个线程对该缓存数据进行更新,但是这种方案存在脏读的可能,在更新缓存期间,部分线程读取到的可能是旧数据。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    // 伪代码
    String get(String key) {
    Data data = Redis.get(key);
    String value = data.getValue();
    long timeout = data.getTimeout();
    if (timeout <= System.currentTimeMillis()) {
    // 后台异步更新
    ThreadPool.execute(new Runnable() {
    public void run() {
    String keyMutex = "mutex:" + key;
    // 使用SETNX抢占互斥锁,设置互斥锁过期时间,避免删除失败导致锁无法释放
    if (Redis.setnx(keyMutex, "1", 3 * 60)) {
    Data data = DB.get(key);
    Redis.set(key, data, expired);
    // 删除互斥锁
    Redis.delete(keyMutex);
    }
    }
    });
    }
    return value;
    }
  3. 配置缓存服务器集群

    为避免缓存服务器宕机影响服务,可以采用服务器集群,保证缓存服务的高可用。比如Redis,可以使用主从、哨兵或者Cluster模式来避免全盘崩溃的情况,发生宕机也可以快速恢复重启并提供服务。

缓存击穿

什么是缓存击穿

在高并发的环境中,热点数据会被大量请求,而当热点数据过期失效时,这些请求会从数据库查询数据并回设到缓存中,大量的请求落到数据库时会压力骤增,可能会压垮数据库。与缓存雪崩不同的是,缓存击穿针对的是某些访问频次极高的热点数据失效,是一个“点”,缓存雪崩是因为大面积的缓存失效,是一个“面”。

解决方案

  1. 设置数据永不过期

    此方案和缓存雪崩的永不过期方案基本类似,使缓存数据不失效即可。

  2. 限流/降级

    对高并发的接口进行限流,常用的策略有:滑动窗格、令牌桶、漏桶等,未通过限流的请求,走服务降级,只要请求的数量不会把数据库压垮,那就不会影响系统的总体稳定,这是牺牲部分用户的体验换取的服务安全。

  3. 互斥锁

    用互斥锁来限制查询数据库的线程数量,避免大量的请求落到数据库,单机环境可以使用ReentrantLock,分布式可以使用SETNX加锁,获取到锁的线程去查询数据并更新缓存,其他线程则等待重试。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // 伪代码
    String get(String key) {
    String value = Redis.get(key);
    if (value == null) {
    String keyMutex = "mutex:" + key;
    // 使用SETNX抢占互斥锁,单机环境可以使用其他独占锁,设置互斥锁过期时间,避免删除失败导致无法释放
    if (Redis.setnx(keyMutex, "1", 3 * 60)) {
    value = DB.get(key);
    Redis.set(key, value, expired);
    Redis.delete(keyMutex);
    } else {
    // 未获取到锁的线程50毫秒后重试
    Thread.sleep(50L);
    // 递归调用
    return get(key);
    }
    }
    return value;
    }

参考:

缓存穿透,缓存击穿,缓存雪崩解决方案分析

《我们一起进大厂》系列-缓存雪崩、击穿、穿透