缓存三大件

这是一篇关于 Java 缓存三大件的文章。主要介绍了缓存穿透、缓存击穿、缓存雪崩的概念和解决方案。

缓存三大件

缓存穿透

查询了一个既不在数据库也不在缓存中的key,导致请求直接被穿透到数据库。当大量请求都查询不到缓存时,数据库可能会成为系统的瓶颈,或出现宕机。

过滤

对非法请求进行过滤,比如请求参数为空,请求参数类型不匹配等。

缓存空值

当查询不到数据时,将空值缓存起来,并设置一个较短的过期时间。

但这种方式显然不太合理,因为它背离了缓存的初衷,缓存应该是存放热点数据,而不是冷数据。如果我们存储的空数据太多,又由于逐出策略的影响,导致本该待在缓存中的热点数据被逐出。

那么我们有几点解决方案:

  1. 设立两个同级的缓存,一个是热点数据,一个是冷数据。冷数据专门用来存储空值。当两个缓存都失效,我们查询数据库,如果数据库内存在这个数据,我们将其放入热点缓存;如果数据库内不存在这个数据,我们将其放入冷缓存。
  2. 设置两个不同层级的缓存,假设我们查询到数据库的方向是从上至下,上层是热点缓存,下层是冷缓存。如果上层存在这个热点数据,我们直接返回;如果上层不存在这个热点数据,我们查询下层的冷缓存,如果下层存在数据,则证明这个数据是冷数据,直接返回;如果二者都没有,我们查询数据库,如果数据库内存在这个数据,我们将其放入热点缓存;如果数据库内不存在这个数据,我们将其放入冷缓存。

布隆过滤器

Bloom-Filter是现实中最常用的一种缓存穿透解决方案,业界用的比较多的方案。

它由一个bitmap和一系列多个hash函数组成,其中bitmap的初值都为0。当我们进行写入时,分别对写入的key进行每个hash运算,求出来的值就将bitmap对应的位置置1。当我们进行查询时,我们对查询的key进行每个hash运算,然后去对应查找bitmap,如果每个位置都为1,则证明这个key可能存在,如果有一个位置为0,则证明这个key一定不存在。

也就是说,布隆过滤器其实对于“存在”可能会产生误差和误判,但对于“不存在”是完全准确的。

但是对于我们排除空值这样一个场景,布隆过滤器对于不存在的判定就已经够用了,因为我们只需要把不存在数据库中的key给排除在外。即使我们对不存在的key判定为了存在,那也只是将极小一部分的请求直接导向数据库,不会对系统的整体性能造成太大影响。

当然我们可以手动操作减小它误判的概率,比如设置多个hash函数,或者增大bitmap的大小等。

缓存雪崩

大量缓存key在短时间内过期或失效,或者缓存服务器组件故障或宕机,导致大量请求直接落到数据库上,造成数据库压力过大,甚至宕机。

缓存失效时间设置

对于大量key在同一时间失效,我们可以采用对过期时间进行随机化,使得缓存失效时间分散开来,避免集中在同一时间过期。

热点数据不过期

对于某些特别热点的数据,我们直接在之前就直接设置它为永久的,或者我们可以针对性地进行续期,在过期前进行续杯。

互斥锁

当大量请求同时查询同一个key时,我们采取加互斥锁的方法,即只有第一个请求到达时,才去查询数据库,其他请求则等待。等第一个请求查询完毕后,它将数据添加到缓存中,其他请求再次查询时,直接返回缓存数据,避免了缓存击穿。

服务熔断/限流/降级

对于服务器组件故障的情况,我们可以采取服务熔断/限流的方法,即当服务器组件故障时,我们暂时停止对外服务,等待一段时间,然后再恢复服务。只保证核心业务的可用性,其他业务暂时不可用。

缓存集群

构建高可用的缓存集群,可以避免缓存雪崩。缓存集群可以分为多个节点,当某个节点故障时,其他节点可以接管它的工作,避免单点故障。

缓存击穿

对于单个热点key的缓存失效,导致大量请求直接落到数据库上,造成数据库压力过大,甚至宕机。

这个其实是缓存雪崩的一个子集,因为雪崩是大量缓存同时失效,而击穿是单个缓存失效。所以我们的解决方案其实可以照搬缓存雪崩的解决方案。但是要主要随机化过期时间,这个解决方案是不适用的,因为击穿本身就只有一个key失效。

本地缓存

本地缓存对比分布式缓存的优势就是速度要快很多,主要由于它降低了网络IO,减少了网络延迟带来的影响。一般用于IO密集型,并且一致性要求不高的场景。

我们在SpringBoot中一般可以采用Caffeine来实现本地缓存,它是一个轻量级的缓存库,可以快速的处理缓存的读写。

Caffeine

  1. 引入依赖

    <dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>2.8.0</version>
    </dependency>
  2. 配置

    spring:
    cache:
    cache-names: my-cache
    caffeine:
    spec: maximumSize=1000,expireAfterWrite=10m
  3. 使用

    @Cacheable(cacheNames = "my-cache")
    public String getFromDb(String key) {
    // 从数据库中获取数据
    return "value";
    }

    这里的cacheNames参数指定了缓存的名称,spec参数指定了缓存的配置,这里配置了最大缓存数量为1000,缓存过期时间为10分钟。

缓存一致性

这个实在是有太多知识点了,也是区分中级开发者和高级开发者的重要考察点。

读取缓存步骤一般没有什么问题,但是一旦涉及到数据更新数据库缓存更新,就容易出现缓存和数据库间的数据一致性问题。不管是先写数据库,再删除缓存;还是先删除缓存,再写库,都有可能出现数据不一致的情况。

我们还需要厘清,我们到底在避免什么的不一致。是我们需要保证读写事务的串行性吗,也就是说,a操作在b操作之前,我们必须得保证b的得到结果是在a操作完成之后的,不是这样的。我们要维护的数据的一致性,也就是说数据库和缓存中数据需要是一样的。

先删缓存,再写数据库

先删缓存,再写数据库

假设有两个线程a和b,a出于数据的更新,第一步要先将缓存失效(也就是删除缓存),第二步再更新数据库。而b就在a数据更新时进行这个键的读取操作,由于这两步之间的延迟,导致b在这段时间内得到的是a还未更新的数据(因为缓存已经失效)。

而本来预期的结果是,因为a先b后,所以b应该得到的是a更新后的数据。

先写数据库,再删缓存

假设有两个线程a和b,a出于数据的更新,第一步选择先写数据库,第二步再删除缓存。而b就在a数据更新时进行这个键的读取操作,由于这两步之间的延迟,导致b在这段时间内得到的是a还未更新的数据(因为缓存还没有失效,读到的内容是缓存中的旧数据)。

而本来预期的结果是,因为a先b后,所以b应该得到的是a更新后的数据。 假设有两个线程a和b,a出于数据的更新,第一步要先将缓存失效(也就是删除缓存),第二步再更新数据库。而b就在a数据更新时进行这个键的读取操作,由于这两步之间的延迟,导致b在这段时间内得到的是a还未更新的数据(因为缓存已经失效)。

而本来预期的结果是,因为a先b后,所以b应该得到的是a更新后的数据。

先写数据库,再删缓存

假设有两个线程a和b,a出于数据的更新,第一步选择先写数据库,第二步再删除缓存。而b就在a数据更新时进行这个键的读取操作,由于这两步之间的延迟,导致b在这段时间内得到的是a还未更新的数据(因为缓存还没有失效,读到的内容是缓存中的旧数据)。

而本来预期的结果是,因为a先b后,所以b应该得到的是a更新后的数据。

延时双删策略

所谓双删就是在写入数据库前后都进行一次缓存的删除,以保证缓存和数据库的数据一致性。

进入方法后,先将缓存失效(即删除缓存),然后再更新数据库,延迟一段时间后(根据具体的业务和场景而定),再次将缓存失效。

延迟时间的计算一般根据设备读取和写入的性能来确定,比如100ms到1000ms之间。

RedisUtils.del(key);
updateDB(key, value);
Thread.sleep(1000); // 延迟时间一般100ms到1000ms的都挺常见的
RedisUtils.del(key);

这种情况下,我们再进行一次脑测。还是线程a和b,a出于数据的更新,第一步先将缓存失效,第二步再更新数据库,第三步再删除一次缓存。如果b在第一步和第二步之间进行读取操作,则会读到缓存中的旧数据;如果b在第二步和第三步之间进行读取操作,它会读到数据库中的新数据,但仍然会使用旧的缓存数据,因为缓存还没有删除。最后的结果极大概率都是数据库和缓存一致的。