Redis 做缓存常见的几个问题总结

image-20211231113023088

1. 数据一致性

我们知道,Redis 主要是用来做缓存使用,只要使用到缓存,无论是本地内存做缓存还是使用 Redis 做缓存,那么就会存在数据同步的问题。

一般情况下,我们都是先读缓存数据,缓存数据有,则立即返回结果;如果没有数据,则从数据库读数据,并且把读到的数据同步到缓存里,提供下次读请求返回数据

image-20220110113731468

这样能有效减轻数据库压力,但是如果修改删除数据库中的数据,而内存是无法感知到数据在数据库的修改。这样就会造成数据库中的数据与缓存中数据不一致的问题,那该如何解决呢?

通常的方案有以下几种:

  1. 先更新缓存,在更新数据库;
  2. 先更新数据库,在更新缓存;
  3. 先删除缓存,在更新数据库;
  4. 先更新数据库,在删除缓存。

1.1 先更新缓存,在更新数据库

这个方案我们一般不考虑。原因是更新缓存成功,更新数据库出现异常了,导致缓存数据与数据库数据完全不一致,而且很难察觉,因为缓存中的数据一直都存在。

1.2 先更新数据库,在更新缓存

这个方案也我们一般不考虑,原因跟第一个一样,数据库更新成功了,缓存更新失败,同样会出现数据不一致问题。

也就是说对于更新缓存来说,一般都不考虑,主要从下面 2 点考虑:

1、并发问题

如果同时有请求 A 和请求 B 进行更新操作,那么会出现:

  • 线程 A 更新数据库;
  • 线程 B 更新数据库;
  • 线程 B 更新缓存;
  • 线程 A 更新缓存。

这就出现请求 A 更新缓存应该比请求 B 更新缓存早才对,但是因为网络等原因,B 却比 A 更早更新了缓存。这就导致了脏数据,因此不考虑。

2、业务场景问题

如果你是一个写数据库场景比较多,而读数据场景比较少的业务需求,采用这种方案就会导致,数据压根还没读到,缓存就被频繁的更新,浪费性能。

其次很多时候,在复杂点的缓存场景,缓存不单单是数据库中直接取出来的值。比如可能更新了某个表的一个字段,然后其对应的缓存,是需要查询另外两个表的数据并进行运算,才能计算出缓存最新的值的。

而且是不是说,每次修改数据库的时候,都一定要将其对应的缓存更新一份,也许有的场景是这样,但是对于比较复杂的缓存数据计算的场景,就不是这样了。如果你频繁修改一个缓存涉及的多个表,缓存也频繁更新。但是问题在于,这个缓存到底会不会被频繁访问到?

举个例子:一个缓存涉及的表的字段,在 1 分钟内就修改了 20 次,或者是 100 次,那么缓存更新 20 次、100次,但是这个缓存在 1 分钟内只被读取了 1 次,有大量的冷数据。

实际上,如果你只是删除缓存的话,那么在 1 分钟内,这个缓存不过就重新计算一次而已,开销大幅度降低。用到缓存才去算缓存。

其实删除缓存,而不是更新缓存,就是一个 Lazy 计算的思想,不要每次都重新做复杂的计算,不管它会不会用到,而是让它到需要被使用的时候再重新计算。

说到底是选择更新缓存还是淘汰缓存呢,主要取决于更新缓存的复杂度,更新缓存的代价很小,此时我们应该更倾向于更新缓存,以保证更高的缓存命中率,更新缓存的代价很大,此时我们应该更倾向于淘汰缓存。但是淘汰缓存操作简单,并且带来的副作用只是增加了一次cache miss,所以一般作为通用的处理方式。

1.3 先删除缓存,在更新数据库

该方案也会出问题,具体出现的原因如下:

  1. 如果有两个请求,请求 A(更新操作) 和请求 B(查询操作);
  2. 请求 A 会先删除 Redis 中的数据,然后去数据库进行更新操作;
  3. 此时请求 B 看到 Redis 中的数据时空的,会去数据库中查询该值,补录到 Redis 中。

但是此时请求 A 并没有更新成功,或者事务还未提交,请求 B 去数据库查询得到旧值,那么这时候就会产生数据库和 Redis 数据不一致的问题。

那么如何解决呢?最简单的解决办法就是延时双删的策略,即:

  1. 先淘汰缓存;
  2. 再写数据库;
  3. 休眠 1 秒,再次淘汰缓存。

这种做法可以将 1s 内造成的造数据删除。

那么,这个 1 秒怎么确定的,具体该休眠多久呢?

针对上面的情形,自行评估自己的项目的读数据业务逻辑的耗时。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上,加几百 ms 即可。这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。

1.4 先更新数据库,在删除缓存

这种方式,被称为 Cache Aside Pattern

对于读请求

  1. 先读缓存,在读数据库;
  2. 若存在,则返回;
  3. 若不存在,读取数据库,然后取出数据后放入缓存,同时返回响应。

对于写请求

  1. 先更新数据库;
  2. 再删除缓存。

这种情况依然存在并发问题?

假设有两个请求,一个请求 A 做查询操作,一个请求 B 做更新操作,那么会有如下情形产生:

  1. 缓存刚好失效;
  2. 请求 A 查询数据库,得一个旧值;
  3. 请求 B 将新值写入数据库;
  4. 请求 B 删除缓存;
  5. 请求 A 将查到的旧值写入缓存。

但这种情况的概率其实是很低的,如果上述情况发生,则步骤 3 的写数据库操作比步骤 2 的读数据库操作耗时更短,才有可能使得步骤 4 先于步骤 5。

但实际上,数据库的读操作的速度远快于写操作的,因此步骤 3 耗时比步骤 2 更短,这一情形很难出现。

但是,理论上来说还是有存在的可能性,那么对于这种情况怎么处理?通常有两种:

  1. 给缓存设计一个过期时间;
  2. 异步延时删除。

1.5 删除策略存在的问题

因此,对于 Redis 缓存一致性来说,通常的做法是删除缓存,那么既然存在删除这一动作,如果在删除阶段出现问题,导致数据并没有被删除,那么此时每次查询都是错误数据。这又怎么解决呢?

通常来说存在以下两种方案:

利用消息队列进行删除的补偿重试

image-20220110171208001

  1. 先对数据库进行更新操作;
  2. 在对 Redis 进行删除操作的时候发现报错,删除失败;
  3. 此时将 Redis 的 key 作为消息体发送到消息队列中;
  4. 系统接收到消息队列发送的消息后;
  5. 再次对 Redis 进行删除操作。

但是这个方案会有一个缺点就是会对业务代码造成大量的侵入,深深的耦合在一起,所以这时会有一个优化的方案,我们知道对 MySQL 数据库更新操作后再 Binlog 日志中我们都能够找到相应的操作,那么我们可以订阅 Mysql 数据库的 Binlog 日志对缓存进行操作。

对于订阅 Binlog 日志可以通过阿里的开源框架 canal,具体可以看看这篇文章:

基于 canal 框架解决 mysql 与 redis 一致性的问题

2. 缓存穿透

是指查询一个根本不存在的数据,缓存层和存储层都不会命中,如果从存储层查不到数据则不写入缓存层。

缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。缓存穿透问题可能会使后端存储负载加大,由于很多后端存储不具备高并发性,甚至可能造成后端存储宕掉。

通常可以在程序中分别统计总调用数、缓存层命中数、存储层命中数,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。

2.1 原因分析

造成缓存穿透的基本原因有两个:

1、自身业务代码或者数据出现问题

比如,我们数据库的 id 都是 1 开始自增上去的,如发起为 id 值为 -1 的数据或 id 为特别大不存在的数据。如果不对参数做校验,数据库 id 都是大于 0 的,我一直用小于 0 的参数去请求,每次都能绕开 Redis 直接打到数据库,此时数据库也查不到,每次都这样,并发高点就容易崩掉了。

2、恶意攻击、爬虫等造成大量空命中

2.2 解决方案

对于缓存穿透的解决可以从以下几个方面入手:

1、增加校验

如用户鉴权校验,id 做基础校验,id<=0 的直接拦截;

2、缓存空对象

既然本来都是不存在的数据,那就把空对象保留到缓存层中,之后再访问这个数据将会从缓存中获取,这样就保护了后端数据源。

但是这样会存在 2 个问题:

  • 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。
  • 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。例如过期时间设置为 5 分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致,此时可以利用数据一致性方案处理。

3、布隆过滤器

在访问缓存层和存储层之前,将存在的 key 用布隆过滤器提前保存起来,做第一层拦截。

例如:一个推荐系统有 4 亿个用户 id,每个小时算法工程师会根据每个用户之前历史行为计算出推荐数据放到存储层中,但是最新的用户由于没有历史行为,就会发生缓存穿透的行为,为此可以将所有推荐数据的用户做成布隆过滤器。如果布隆过滤器认为该用户 id 不存在,那么就不会访问存储层,在一定程度保护了存储层。

这种方法适用于数据命中不高、数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂,但是缓存空间占用少。

3. 缓存击穿

缓存击穿是指一个热点 key,大并发集中对这一个点进行访问,当这个 key 在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库。

缓存击穿的话,设置热点数据永远不过期。或者加上互斥锁就能搞定了。

3.1 原因分析

设置了过期时间的 key,承载着高并发,是一种热点数据。从这个 key 过期到重新从 MySQL 加载数据放到缓存的一段时间,大量的请求有可能把数据库打死。

缓存雪崩是指大量缓存失效,缓存击穿是指热点数据的缓存失效。

3.2 解决方案

1、使用互斥锁

业界比较常用的做法,是使用互斥锁。

简单地来说,就是在缓存失效的时候(判断拿出来的值为空),不是立即去 load db,而是先使用缓存工具的某些带成功操作返回值的操作(比如 Redis 的 SETNX 或者 Memcache 的 ADD)去 set 一个 mutex key,当操作返回成功时,再进行 load db 的操作并回设缓存,否则,就重试整个 get 缓存的方法。

伪代码如下:

public String get(key) {
    
    
    String value = redis.get(key);
    if (value == null) {
    
    //代表缓存值过期
        // 设置 3min 的超时,防止 del 操作失败的时候,下次缓存过期一直不能 load db
        if (redis.setnx(key_mutex, 1, 3 * 60) == 1) {
    
    
            //代表设置成功
            value = db.get(key);
            redis.set(key, value, expire_secs);
            redis.del(key_mutex);
        } else {
    
    
            //这个时候代表同时候的其他线程已经 load db 并回设到缓存了,这时候重试获取缓存值即可
            sleep(50);
            get(key); //重试
        }
    } else {
    
    
        return value;
    }
}

2、永不过期

这里的永不过期包含两层意思:

  1. 从 Redis 上看,确实没有设置过期时间,这就保证了,不会出现热点 key 过期问题,也就是物理不过期
  2. 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在 key 对应的 value 里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是逻辑不过期

从实战看,这种方法对于性能非常友好,唯一不足的就是构建缓存时候,其余线程(非构建缓存的线程)可能访问的是老数据,但是对于一般的互联网功能来说这个还是可以忍受。

4. 缓存雪崩

缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至宕机。

4.1 原因分析

由于缓存层承载着大量请求,有效地保护了存储层,但是如果缓存层由于某些原因不能提供服务,比如同一时间缓存数据大面积失效,那一瞬间 Redis 跟没有一样,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层也会级联宕机的情况。

4.2 解决方案

预防和解决缓存雪崩问题,可以从以下三个方面进行着手:

  1. 保证缓存层服务高可用性

    和飞机都有多个引擎一样,如果缓存层设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务,例如 Redis Sentinel 和 Redis Cluster 都实现了高可用;

  2. 设置 key 永不失效(热点数据);

  3. 设置 key 缓存失效时候尽可能错开;

  4. 使用多级缓存机制,比如同时使用 Redis 和 Memcache 缓存,请求->redis->memcache->db。

5. Hot Key

在 Redis 中,访问频率高的 key 称为 hot key,即热点数据。

5.1 原因

热点问题产生的原因大致有以下两种:

  1. 用户消费的数据量远超预期

    在日常工作生活中一些突发的事件,例如:双十一期间某些热门商品的降价促销,当这其中的某一件商品被数万次点击浏览或者购买时,会形成一个较大的需求量,这种情况下就会造成热点问题。同理,被大量刊发、浏览的热点新闻、热点评论、明星直播等,这些典型的读多写少的场景也会产生热点问题。

  2. 请求分片集中,超过单 Server 的性能极限

    在服务端读数据进行访问时,往往会对数据进行分片切分,此过程中会在某一主机 Server 上对相应的 Key 进行访问,当访问超过 Server 极限时,就会导致热点 Key 问题的产生。

5.2 危害

流量集中,达到物理网卡上限。

请求过多,缓存分片服务被打垮。DB 击穿,引起业务雪崩。

如上面讲到的,当某一热点 Key 的请求在某一主机上超过该主机网卡上限时,由于流量的过度集中,会导致服务器中其它服务无法进行。如果热点过于集中,热点 Key 的缓存过多,超过目前的缓存容量时,就会导致缓存分片服务被打垮现象的产生。

当缓存服务崩溃后,此时再有请求产生,会缓存到后台 DB 上,由于 DB 本身性能较弱,在面临大请求时很容易发生请求穿透现象,会进一步导致雪崩现象,严重影响设备的性能。

5.3 解决方案

发现热点 key 之后,需要对热点 key 进行处理,通常有以下 2 中方案:

1、使用二级缓存

可以使用 guava-cache 或 hcache,将热点 key 加载到 JVM 中作为本地缓存。访问这些 key 时直接从本地缓存获取即可,不会直接访问到 Redis 层了,有效的保护了缓存服务器。

2、key 分散

将热点 key 分散为多个子 key,然后存储到缓存集群的不同机器上,这些子 key 对应的 value 都和热点 key 是一样的。当通过热点 key 去查询数据时,通过某种 hash 算法随机选择一个子 key,然后再去访问缓存机器,将热点分散到了多个子 key 上。

实际上,对于热 key 问题是在高并发下才会出现的问题,一般的单机系统并没有那么高的并发量,涉及到二级缓存架构有点复杂了,实际情况主要看业务场景是否确实需要。

比如每台 Redis 上限10w/s QPS,Redis5.0 的话我们一般是集群部署 3 主 6 从,热 key 一般分布到一个哈希槽上面,也就是一个主redis+两个从redis,理论上能满足30w/s的QPS,我们预留一点buffer,10wQPS肯定没问题了。如果再高,比如百万的访问量,那除了扩展 Redis 集群外,本地缓存也是必须的了。

6. Big Key

bigkey 是指 key 对应的 value 所占的内存空间比较大,例如一个字符串类型的 value 可以最大存到 512MB,一个列表类型的 value 最多可以存储 23-1 个元素。如果按照数据结构来细分的话,一般分为字符串类型 bigkey 和非字符串类型 bigkey。

字符串类型:体现在单个 value 值很大,一般认为超过 10KB 就是 bigkey,但这个值和具体的 QPS 相关。

非字符串类型:哈希、列表、集合、有序集合,体现在元素个数过多。

bigkey 无论是空间复杂度和时间复杂度都不太友好。

6.1 发现

redis-cli --bigkeys 可以命令统计 bigkey 的分布,但是在生产环境中,开发和运维人员更希望自己可以定义 bigkey 的大小,而且更希望找到真正的 bigkey 都有哪些,这样才可以去定位、解决、优化问题。判断一个 key 是否为 bigkey,只需要执行 debug object key 查看 serializedlength 属性即可,它表示 key 对应的 value 序列化之后的字节数。

如果键值个数比较多,scan + debug object 会比较慢,可以利用 Pipeline 机制完成。对于元素个数较多的数据结构,debug object 执行速度比较慢,存在阻塞 Redis 的可能,所以如果有从节点,可以考虑在从节点上执行。

6.2 危害

bigkey 的危害体现在三个方面:

  1. 内存空间不均匀

    例如在 Redis Cluster 中,bigkey 会造成节点的内存空间使用不均匀。

  2. 超时阻塞

    由于 Redis 单线程的特性,操作 bigkey 比较耗时,也就意味着阻塞 Redis 可能性增大。

  3. 网络拥塞

    每次获取 bigkey 产生的网络流量较大,假设一个 bigkey 为 1MB,每秒访问量为 1000,那么每秒产生 1000MB 的流量,对于普通的千兆网卡(按照字节算是 128MB/s)的服务器来说简直是灭顶之灾,而且一般服务器会采用单机多实例的方式来部署,也就是说一个 bigkey 可能会对其他实例造成影响,其后果不堪设想。

bigkey 的存在并不是完全致命的,如果这个 bigkey 存在但是几乎不被访问,那么只有内存空间不均匀的问题存在,相对于另外两个问题没有那么重要紧急,但是如果 bigkey 是一个热点 key,那么其带来的危害不可想象,所以在实际开发和运维时一定要密切关注 bigkey 的存在。

6.3 解决方案

主要思路为拆分,对 big key 存储的数据(big value)进行拆分,变成 value1,value2… valueN 等等。

例如 big value 是个大 json 通过 mset 的方式,将这个 key 的内容打散到各个实例中,或者一个 hash,每个 field 代表一个具体属性,通过 hget、hmget 获取部分 value,hset、hmset 来更新部分属性。

例如:big value 是个大 list,可以拆成将 list 拆成。list_1,list_2,list3,listN 其他数据类型同理。

7. Redis 脑裂

所谓的脑裂,就是指在保障可用性的主从集群中,同时产生了两个主节点,它们都能接收写请求。

造成的主要原因可能是由于网络问题导致 redis master 节点跟 redis slave 节点和 sentinel 集群处于不同的网络分区,此时因为sentinel集群无法感知到master的存在,所以将slave节点提升为master节点。。

而脑裂最直接的影响,就是客户端不知道应该往哪个主节点写入数据,结果就是不同的客户端会往不同的主节点上写入数据。而且,严重的话,脑裂会进一步导致数据丢失。

7.1 哨兵主从集群脑裂

假设现在有三台服务器,一台主服务器,两台从服务器,还有哨兵机制。

image-20220111195244601

基于上边的环境,这时候网络环境发生了波动导致了某个 master 所在机器突然脱离了正常的网络,但是实际上 master 还运行着,sentinel 会以通过选举的方式提升了一个 slave 为新 master。

如果恰好此时 App Server1 仍然连接的是旧的 master,而 App Server2 连接到了新的 master 上。数据就不一致了,哨兵恢复对老 master 节点的感知后,会将其降级为 slave 节点,然后从新 maste 同步数据(full resynchronization),导致脑裂期间老 master 写入的数据丢失。

image-20220111200737907

解决方案

通过配置如下参数来解决脑裂:

min-replicas-to-write 1 
min-replicas-max-lag 5

上面的参数表示要求至少有 1 个 slave,数据复制和同步的延迟不能超过 5 秒。

第一个参数表示最少的 slave 节点为 1 个,数据只有写入一个 master 结点并且也被同步到了至少 1 个从节点中,才说明这个数据添加成功。

第二个参数表示数据复制和同步的延迟不能超过 5 秒。

配置了这两个参数,如果发生脑裂,原 master 会在客户端写入操作的时候拒绝请求,这样可以避免大量数据丢失。

7.2 集群脑裂

默认情况下,Redis 集群的脑裂一般是不存在的,因为 Redis 集群中存在着过半选举机制,而且当集群 16384 个槽任何一个没有指派到节点时整个集群不可用。

所以我们在构建 Redis 集群时,应该让集群 Master 节点个数最少为 3 个,且集群可用节点个数为奇数。

非默认情况下,比如集群的个数为偶数个或者参数 cluster-require-full-coverage 设置为关闭(该参数的作用:开启时只要有节点宕机导致 16384 个分片没被全覆盖,整个集群就拒绝服务),这种情况下依然有可能导致脑裂。那么解决的办法同样可以使用参数 min-replicas-to-write 和 min-replicas-max-lag 。

猜你喜欢

转载自blog.csdn.net/weixin_43477531/article/details/122484843