Redis 系列(5)— 常见缓存问题

数据库缓存双写一致性

我们在使用 Redis 缓存的时候,必然会面对的一个问题就是缓存和数据库的一致性问题。这个一致性问题产生的原因主要在于更新数据库和更新Redis是两个步骤,那就有可能一个更新成功,一个更新失败,这时数据不一致性就产生了。

缓存类型

Redis 缓存都是部署在数据库的前端,业务应用在访问数据时,会先查询 Redis 中是否保存了相应的数据。

  • 如果缓存中有相应的数据,称为缓存命中,则直接读取Redis中的数据返回,性能非常快。

  • 如果缓存中没有相应的数据,称为缓存缺失,则需要回源到数据库读取数据,性能则会变慢。读取出来的数据还需要写回Redis中,便于之后的请求能从缓存读取数据。

按照 Redis 缓存是否接受写请求,可以把它分成只读缓存读写缓存

  • 只读缓存:数据库更新后,删掉缓存中的数据,下一次读取缓存时发生缓存缺失,再从数据库读取数据写回缓存。

  • 读写缓存:数据库更新后,同步更新缓存中的数据,下一次读取缓存时就会直接命中缓存。

这两种模式的区别在于:

  • 只读缓存是删除缓存中的数据,下次访问这个数据时,会重新读取数据库中的值,这样可以保证数据库和缓存完全一致,并且缓存中保留的是经常访问的热点数据。缺点是删除缓存后,之后的访问会先触发一次缓存缺失,然后从数据库读取数据,这个过程访问延迟会变大。

  • 读写缓存是同步更新缓存中的值,这样被修改的数据永远都在缓存中,下次访问能够直接命中缓存,不再查询数据库,这个过程性能比较好,比较适合先修改又立即访问的场景。缺点是在高并发场景下,并发更新同一个值时,可能会导致缓存和数据库的不一致;并且对于某些缓存值的计算可能会比较复杂,但是又不常访问,那么缓存的利用率就会降低,更新缓存的代价就比较大。

只读缓存牺牲了一定的性能,优先保证数据库和缓存的一致性,它更适合对于一致性要求比较要高的场景。而如果对于数据库和缓存一致性要求不高,或者不存在并发修改同一个值的情况,那么使用读写缓存就比较合适,它可以保证更好的访问性能,但要考虑到缓存更新的代价。

读写缓存

新增数据

如果是新增数据,先把数据写到数据库中,缓存则有两种处理方式:

    1. 新增时不用对缓存做任何处理,下次查询缓存时从数据库查询写回缓存;
    1. 新增时同步写入缓存

这两种方式,最终来说缓存和数据库的数据都是一致的。

删改数据

如果是更新数据,那么就有先更新缓存还是先更新数据库的区分:

    1. 先更新缓存,再更新数据库,更新缓存成功,更新数据库失败:此时缓存中是新值,数据库是旧值,后续读请求会直接命中缓存,但得到的是最新值,出现数据不一致。
    1. 先更新数据库,再更新缓存,更新数据库成功,更新缓存失败:如果更新缓存失败时,能回滚数据库的更新,那么数据库和缓存都是旧值,数据是一致的;如果更新缓存失败,没有回滚数据库的更新,那么数据库是新值,缓存是旧值,数据就不一致了。

可以看到无论是先更新缓存还是先更新数据库,只要第二步失败,就可能出现数据不一致的问题。

这时可以增加重试机制,把第二步操作放入到消息队列(MQ)中,如果应用没有更新成功,可以从消息队列取出消息,再更新缓存或数据库,成功后把消息从消息队列删除,否则进行重试,以此达到数据库和缓存的最终一致。如果多次重试失败,可以发送告警信息。

还有一种情况是,更新缓存和数据库都成功,但存在并发读写时,也可能出现不一致:

    1. 并发 写+读,A线程先更新数据库,B线程读缓存,A线程再更新缓存,此时B线程读到旧值,出现短暂的不一致性,对业务影响比较小。
    1. 并发 写+读,A线程先更新缓存,B线程读缓存,A线程再更新数据库,此时B线程读到新值,数据是一致的,对业务没有影响。
    1. 并发 写+写,A、B 线程并发更新同一条数据,先更新缓存,再更新数据库,顺序为 A更新缓存 -> B更新缓存 -> B更新数据库 -> A更新数据库,这时数据库和缓存的数据不一致。
    1. 并发 写+写,A、B 线程并发更新同一条数据,先更新数据库,再更新缓存,顺序为 A更新数据库 -> B更新数据库 -> B更新缓存 -> A更新缓存,这时数据库和缓存的数据不一致。

可以看到,主要在 写+写 并发时,会出现数据不一致,对业务影响也比较大。针对这种情况,可以使用分布式锁来保证多个线程操作同一资源的顺序性,同一时间只允许一个线程去更新数据库和缓存,以此保证一致性。但对并发更新的性能会有较大的影响,需要权衡。

只读缓存

新增数时与读写缓存是一样的模式,没有数据不一致的问题,主要看下更新数据时的情况。

如果是更新数据,就有先删缓存还是先更新数据库的区分:

  • 1、先删缓存,再更新数据库,删缓存成功,更新数据库失败:此时缓存没有值,数据库是旧值,下次查询触发缓存缺失,读取数据库的旧值,缓存与数据库是一致的。

  • 2、先更新数据库,再删缓存,更新数据库成功,删缓存失败:删缓存失败时,如果能回滚数据库更新,那么缓存和数据库的值是一致的;如果不能回滚数据库更新,那么缓存是旧值,数据库是新值,出现数据不一致。

可以看到,先更新数据库,再删缓存,可能会出现数据不一致的问题;先删缓存,再更新数据库则没有不一致的问题。所以一般采用先删缓存,再更新数据库的模式即可。

同样的,存在并发读写时,也可能出现不一致:

  • 1、并发 写+读,A线程先删缓存,B线程读缓存,缓存缺失,读数据库旧值并写入缓存,接着A线程更新数据库,数据库是新值,此时缓存是旧值,数据库是新值,数据不一致。

  • 2、并发 写+读,A线程先更新数据库,B线程读缓存,读到旧值,接着A线程删除缓存,缓存之后会由于缓存缺失更新为新值,只是出现短暂的不一致性,对业务影响较小。

  • 3、并发 写+写,写+写并发的场景下,不会出现数据不一致的问题,因为都会删缓存,然后触发缓存缺失重新读取数据库的数据,最终数据是一致的。

对于第一种情况可能会出现数据不一致的情况,这种情况可以使用延迟双删机制:就是在 先删缓存,后更新数据库后,sleep 一小段时间,再进行一次缓存删除操作。sleep 的时间就约等于B线程 读取数据+写入缓存的时间,这样就可以在B线程写入旧缓存,A线程更新完数据库后,再次删掉旧缓存。

最后,对比下读写缓存和只读缓存模式:

  • 读写缓存模式下,无论先更新缓存、再更新数据库,还是先更新数据库、再更新缓存,第二步失败都可能导致数据不一致,解决方案是第二步增加重试机制。存在并发写的情况,可以增加分布式锁保证更新顺序的一致性。

  • 只读缓存模式下,采用先删缓存、再更新数据库的方式,同时在并发读写的情况下,增加延迟双删机制,就能保证数据的一致性。

可以看到,将Redis做为读写缓存,采用更新缓存的方式,会有数据不一致的风险,否则就要增加重试机制、分布式锁机制来保证一致性,这在实现上有一定的复杂度;除此之外,如果缓存计算比较复杂,又不常用到这些缓存,那缓存更新的代价就比较大。这种模式一般用在先修改又立即访问,对性能有较高要求的场景。

一般情况下,将Redis做为只读缓存,采用先删除缓存,再更新数据库,再删缓存的方式更好,实现方式更简单。采用删除缓存而不是更新缓存,其实就是一种懒加载的思想,只有在使用这个缓存的时候再去重新计算。

这其实就是经典的 Cache Aside Pattern

  • 读的时候先读缓存,缓存没有则读数据库,然后计算放入缓存,再返回响应;
  • 更新的时候,先删除缓存,再更新数据库(为保证一致性,可再删一次缓存)。

缓存异常

我们常常还会面临缓存异常的三个问题,缓存雪崩、缓存击穿和缓存穿透。这三个问题一旦发生,会导致大量的请求积压到数据库层,如果并发量很大,就会导致数据库宕机或是故障,这就是很严重的生产事故了。

缓存雪崩

缓存雪崩 是指大量的请求无法从 Redis 缓存读到数据,接着大量请求就会回源到数据库层读取数据,导致数据库层的压力激增,甚至导致数据库卡死或者宕机。然后就会导致源服务对数据库的请求也卡住,源服务也无法对外提供服务,最终导致整个系统无法使用。

缓存雪崩一般由两个原因导致:

  • 1、缓存中有大量 Key 同时过期,如果并发请求量很大,大量请求触发缓存缺失然后回源数据库,就会导致数据库压力激增,从而发生缓存雪崩。
  • 2、Redis 缓存发生故障宕机,无法处理请求,继而导致大量请求一下子积压到数据库层,从而发生缓存雪崩。

1、大量数据同时过期

针对这种情况,可以差异化缓存过期时间,不要让大量的 Key 在同一时间过期。我们可以在给每个 Key 设置过期时间时,给这些 Key 的过期时间增加一个较小的随机数,这样就可以避免大量的数据同时过期。

除此之外,还可以增加服务降级机制。可以做个全局开关,比如在最近一分钟内,有10个请求从Redis中获取数据失败,则全局开关打开,然后暂停从Redis获取数据,直接返回预定义好的降级数据或错误信息。当然对于一些核心业务,还是可以继续从Redis获取数据,Redis没有则从数据库获取。

2、Redis 实例故障宕机

针对这种情况,主要涉及缓存系统本身高可用的配置,分别有事前、事中、事后的方案:

  • 事前方案

要避免发生缓存雪崩,首先就要保证 Redis 不会宕机,保证 Redis 的高可用性。我们可以根据实际的需求,采用 主从架构+哨兵,或者集群架构来保证 Redis 的高可用,主节点宕机后,从节点可以切换为主库继续提供服务。对于集群架构,还可以双机房部署,一个机房的Redis实例故障了,另一个机房的Redis实例还可以提供服务。

  • 事中方案

我们可以在业务服务中增加 服务降级、熔断限流 的机制,在 Redis 实例已经故障时,避免大量的请求打到数据库层。

一是对 Redis 的访问做资源隔离,在 Redis 故障时,进行熔断,不再访问Redis实例,直接返回预定义信息或错误信息,避免长时间阻塞占用应用资源,进而导致系统雪崩。待Redis恢复服务后,再将请求发送到Redis缓存。

二是对请求进行限流,在业务系统的请求入口控制每秒进入系统的请求数,避免过多的请求被发送到数据库,防止引发连锁的数据库雪崩,甚至是整个系统的崩溃。

还可以在服务中做多级缓存架构,增加一层本地缓存,先访问本地缓存,本地缓存缺失则访问Redis缓存,Redis缓存缺失则访问数据库。在 Redis 实例无法访问时,则直接返回本地缓存的值。这也是一种降级的策略。

  • 事后方案

在 Redis 重启后,需要快速恢复数据,这就需要事前开启了Redis的 AOF 和 RDB 持久化机制。并且做好定期备份,防止 AOF 和 RDB 文件损坏或丢失。这块可参考:Redis系列(1) — 单机版安装及数据持久化

如果 Redis 数据彻底丢失或者数据过旧,系统还需要有预热机制,快速将数据加载到缓存中,然后对外提供服务。

缓存击穿

缓存击穿 是指某些极端热点数据,在并发量很大的情况下,如果这个 Key 过期,可能会在某个瞬间出现大量的并发请求同时触发缓存缺失,相当于大量的并发请求回源到了数据库层。这就是常说的缓存击穿或缓存并发问题。

针对这种情况,我们可以通过锁机制来控制回源的并发数。例如在 Java 中,我们可以定义一个带有超时机制的 ConcurrentHashMap, 例如叫 TTLConcurrentHashMap,访问 Map 中的 key 时,如果 key 已经过期,则删除这个 key。然后可以使用 computeIfAbsent(K key, Function function) 方法来控制并发,这个方法会在 key 不存在时,保证同一时刻只有一个线程能执行 Function 函数去获取值,这样就可以避免大量的并发回源到数据库层,而是直接返回 map 中的值。再借助超时机制,例如设置2秒超时,2秒后 map 中的key就过期了,就会再次调用 Function 函数查询数据库。

还有一种方案,针对热点数据,就不再设置过期时间,然后启动一个后台线程定时把数据更新到缓存。

缓存穿透

缓存穿透 是指要访问的数据既不在 Redis 缓存中,也不在数据库中,导致请求在访问缓存时,发生缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据。如果有大量这类并发请求,就会给缓存和数据库带来巨大压力。

缓存穿透 一般来说可能是恶意攻击,用户专门访问数据库中不存在的 Key。应对缓存穿透一般有两大方案:

1、缓存空值或缺省值

在发生缓存缺失查询数据库时,如果数据库没有,则针对查询的数据,在Redis中缓存一个空值或是一个预定义值。后续再查询时,就可以直接从Redis中读取空值返回,就不用再查询数据库了。为了避免存储过多空值,通常会给Key设置一个比较短的过期时间,比如30秒,只要能防住瞬时大量的并发就可以了。

另外,如果缓存了空值,而此时数据库恰好有数据了,这时就会出现数据不一致性。一般可以在数据库更新时,同步删除缓存中的空值即可。

2、使用布隆过滤器

缓存空值 存在一个问题是:如果有大量的Key穿透,就会占用Redis内存,使用布隆过滤器可以避免这个问题。

我们可以通过布隆过滤器来快速检测Key是否存在,避免从数据库中查询数据是否存在,减轻数据库压力。布隆过滤器的原理就不在此多说了,它的底层就是一个 bit 数据,所以对内存的占用非常少,它的检测速度也更快。

布隆过滤器在初始化时,需要先查询数据库中所有数据的Key,然后在布隆过滤器中做标记,后续在把数据写入数据库时,也要使用布隆过滤器做个标记。但注意布隆过滤器不支持删除,不过这一般对业务没有影响,因为布隆过滤器是判断是否存在。

我们一般可以把布隆过滤器放在缓存前面,请求进来后,先通过布隆过滤器判断Key是否存在,如果存在,再查询Redis,缓存缺失再查询数据库;如果布隆过滤器判断Key不存在,则直接返回空,就不需要查询缓存和数据库了,可以同时降低缓存和数据库的压力。

布隆过滤器实现方案:

  • 可以使用 Redis 实现,基于Redis的 Bitmap 数据结构,但需要自己定义多个hash函数,分别计算出Key对应的值,然后将对应位置上的bit设置为1。
  • 可以使用 Redisson 提供的 RBloomFilter,它的底层本质上是通过 Bitmap 这种数据结构来实现的。
  • 可以使用 Google Guava 中的 BloomFilter 组件。

例如 Redisson 中的 RBloomFilter:

public static void main(String[] args) {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://127.0.0.1:6379");
    //构造 Redisson
    RedissonClient redisson = Redisson.create(config);

    RBloomFilter<String> bloomFilter = redisson.getBloomFilter("user");
    // 预计插入元素
    long expectedInsertions = 100000;
    // 误差率 1%
    double fpp = 0.01;
    // 初始化布隆过滤器
    bloomFilter.tryInit(expectedInsertions, fpp);

    // 添加元素
    bloomFilter.add("tom");

    // 利用布隆过滤器做判断
    System.out.println(bloomFilter.contains("tom"));//true
    System.out.println(bloomFilter.contains("tom2"));//false
}
复制代码

例如 Google Guava 中的 BloomFilter 组件:

public static void main(String[] args) {
    // 输入对象转换器,转换为布隆过滤器存储的对象
    Funnel<User> funnel = new Funnel<User>() {
        @Override
        public void funnel(User from, PrimitiveSink into) {
            into.putString(from.getUsername(), Charset.defaultCharset());
        }
    };
    // 期望存储的数据总量
    int expectedInsertions = 100000;
    // 误判率 1%,误判率越低,布隆过滤器占用内存越高,计算hash的函数个数越多,CPU消耗越高
    double fpp = 0.01;
    // 构造布隆过滤器
    BloomFilter<User> bloomFilter = BloomFilter.create(funnel, expectedInsertions, fpp);

    User user1 = new User("tom", "", Collections.emptyList());
    User user2 = new User("tom2", "", Collections.emptyList());
    // 放入对象
    bloomFilter.put(user1);

    // 用布隆过滤器判断是否存在
    System.out.println(bloomFilter.mightContain(user1)); // true
    System.out.println(bloomFilter.mightContain(user2)); // false
}
复制代码

3、熔断限流

除了上面两种方案,如果缓存穿透的原因是恶意攻击,攻击者故意访问数据库中不存在的数据。这种情况可以先使用服务熔断、服务降级、请求限流的方式,对缓存和数据库层增加保护,防止大量恶意请求把缓存和数据库压垮。在这期间可以对攻击者进行防护,例如封禁IP等操作。

缓存淘汰策略

我们可以通过下面这个参数设置Redis缓存容量,一般建议把缓存容量设置为总数据量的 15% ~ 30%,兼顾访问性能和内存空间开销。

maxmemory <bytes>
复制代码

内存大小毕竟有限,随着要缓存的数据量越来越大,有限的缓存空间不可避免地会被写满。这就涉及到缓存数据的淘汰机制,找出不常用的数据,然后从缓存中删除。

Redis 缓存淘汰策略可以通过 maxmemory-policy 来配置,淘汰策略有8中,默认值是 noeviction

# volatile-lru -> Evict using approximated LRU, only keys with an expire set.
# allkeys-lru -> Evict any key using approximated LRU.
# volatile-lfu -> Evict using approximated LFU, only keys with an expire set.
# allkeys-lfu -> Evict any key using approximated LFU.
# volatile-random -> Remove a random key having an expire set.
# allkeys-random -> Remove a random key, any key.
# volatile-ttl -> Remove the key with the nearest expire time (minor TTL)
# noeviction -> Don't evict anything, just return an error on write operations.
#
# The default is:
#
# maxmemory-policy noeviction
复制代码

这8种淘汰策略可以分为三大类:

  • 不淘汰数据的策略:noeviction
  • 在设置了过期时间的数据中进行淘汰:volatile-lru、volatile-lfu、volatile-random、volatile-ttl
  • 在所有数据范围内进行淘汰:allkeys-lru、allkeys-lfu、allkeys-random

下面看下每种策略的工作原理:

  • noeviction:不进行数据淘汰的策略,默认的淘汰策略,一旦缓存被写满了,再有写请求来时,Redis就会直接返回错误。

  • volatile-lru:在设置了过期时间的键值对中,使用 LRU 算法筛选。

  • volatile-lfu:在设置了过期时间的键值对中,使用 LFU 算法筛选。

  • volatile-ttl:在设置了过期时间的键值对中,根据过期时间的先后进行删除,越早过期的越先被删除。

  • volatile-random:在设置了过期时间的键值对,进行随机删除。

  • allkeys-lru:在所有键值对中,使用 LRU 算法筛选。

  • allkeys-lfu:在所有键值对中,使用 LFU 算法筛选。

  • allkeys-random:在所有键值对中,进行随机删除。

LRU 算法的全称是 Least Recently Used,意思是按照最近最少使用的原则来筛选数据。使用 LRU 策略时,Redis 会记录每个数据最近一次访问的时间戳,然后按照访问的时效性来淘汰数据,而最近频繁访问的数据就会留在缓存中。

LFU 算法的全称是 Least Frequently Used,意思是按照最近最不常用的原则来筛选数据。LFU 缓存策略是在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。使用 LFU 策略时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,LFU 策略再比较这两个数据的访问时效性,把距离上一次访问时间更久的数据淘汰出缓存。

作者:bojiangzhou
链接:https://juejin.cn/post/7089273876811055140
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

猜你喜欢

转载自blog.csdn.net/chuixue24/article/details/130295294
今日推荐