缓存三大件

这是一篇关于 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 在第二步和第三步之间进行读取操作,它会读到数据库中的新数据,但仍然会使用旧的缓存数据,因为缓存还没有删除。最后的结果极大概率都是数据库和缓存一致的。

Leases in Meta

Meta 发过一篇论文“Scaling Memcache at Facebook”,其中就提到了大公司对于这种实际的业务上的一致性需求是怎么做的。延时双删的方案在实际应用中,出于缓存命中率不高和数据量过大,请求全部打到数据库的缺陷,我们可以考虑使用 Leases 的方案来解决一致性和并发性的权衡。

假设同时有 10 个请求同时问 a 的值是多少,那么最先进来的线程先查询缓存,发现缓存当中没有这个 a(因为如果有的话,整个流程都不需要了),代码逻辑需要生成一个 64 位的 token(作为 lease 并保存查询的 key 键信息)返回给请求方。同时的 9 个请求和之后短时间内进来的任何请求再尝试查询,都会因为 lease 存在而被拒绝。直到线程 1 完成去数据库的查询后,客户端会发送携带 token 的查询结果,服务端验证 token 有效性,然后将值写入缓存(lease 与 key 是绑定的,当更新缓存后,lease 自然失效)。