一、Redis的高可用

实现Redis的高可用,主要有哨兵和集群两种方式。
1. 哨兵:
Redis Sentinel(哨兵)是一个分布式架构,它包含若干个哨兵节点和数据节点。每个哨兵节点会对数据节点和其余的哨兵节点进行监控,当发现节点不可达时,会对节点做下线标识。如果被标识的是主节点,它就会与其他的哨兵节点进行协商,当多数哨兵节点都认为主节点不可达时,它们便会选举出一个哨兵节点来完成自动故障转移的工作,同时还会将这个变化实时地通知给应用方。整个过程是自动的,不需要人工介入,有效地解决了Redis的高可用问题!

一组哨兵可以监控一个主节点,也可以同时监控多个主节点,两种情况的拓扑结构如下图:
图片说明
哨兵节点包含如下的特征:

  • 哨兵节点会定期监控数据节点,其他哨兵节点是否可达;
  • 哨兵节点会将故障转移的结果通知给应用方;
  • 哨兵节点可以将从节点晋升为主节点,并维护后续正确的主从关系;
  • 哨兵模式下,客户端连接的是哨兵节点集合,从中获取主节点信息;
  • 节点的故障判断是由多个哨兵节点共同完成的,可有效地防止误判;
  • 哨兵节点集合是由多个哨兵节点组成的,即使个别哨兵节点不可用,整个集合依然是健壮的;
  • 哨兵节点也是独立的Redis节点,是特殊的Redis节点,它们不存储数据,只支持部分命令。

2. 集群:
Redis集群采用虚拟槽分区来实现数据分片,它把所有的键根据哈希函数映射到0-16383整数槽内,计算公式为slot=CRC16(key)&16383,每一个节点负责维护一部分槽以及槽所映射的键值数据。虚拟槽分区具有如下特点:

  • 解耦数据和节点之间的关系,简化了节点扩容和收缩的难度;
  • 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据;
  • 支持节点、槽、键之间的映射查询,用于数据路由,在线伸缩等场景。

Redis集群中数据的分片逻辑如下图:
图片说明

二、Redis主从同步

从2.8版本开始,Redis使用psync命令完成主从数据同步,同步过程分为全量复制和部分复制。全量复制一般用于初次复制的场景,部分复制则用于处理因网络中断等原因造成数据丢失的场景。psync命令需要以下参数的支持:

  1. 复制偏移量:主节点处理写命令后,会把命令长度做累加记录,从节点在接收到写命令后,也会做累加记录;从节点会每秒钟上报一次自身的复制偏移量给主节点,而主节点则会保存从节点的复制偏移量。
  2. 积压缓冲区:保存在主节点上的一个固定长度的队列,默认大小为1M,当主节点有连接的从节点时被创建;主节点处理写命令时,不但会把命令发送给从节点,还会写入积压缓冲区;缓冲区是先进先出的队列,可以保存最近已复制的数据,用于部分复制和命令丢失的数据补救。
  3. 主节点运行ID:每个Redis节点启动后,都会动态分配一个40位的十六进制字符串作为运行ID;如果使用IP和端口的方式标识主节点,那么主节点重启变更了数据集(RDB/AOF),从节点再基于复制偏移量复制数据将是不安全的,因此当主节点的运行ID变化后,从节点将做全量复制。

psync命令的执行过程以及返回结果,如下图:
图片说明
全量复制的过程,如下图:
图片说明
部分复制的过程,如下图:
图片说明

三、Redis的存储块,内存断电数据怎么恢复

Redis存的快是因为它的数据都存放在内存里,并且为了保证数据的安全性,Redis还提供了三种数据的持久化机制,即RDB持久化、AOF持久化、RDB-AOF混合持久化。若服务器断电,那么我们可以利用持久化文件,对数据进行恢复。理论上来说,AOF/RDB-AOF持久化可以将丢失数据的窗口控制在1S之内。

四、Redis的缓存淘汰策略

Redis的缓存淘汰策略分为数据过期策略和内存淘汰策略
1. 数据过期策略
图片说明

  • 惰性删除是指Redis并不会每时每刻去检查这个key有没有过期,而是在用到的时候,需要去访问的时候看看它有没有过期,如果过期了就立刻删除,这是一种比较高效率的方法,但是他有个问题就是不能及时清除过期数据,这样很浪费内存。
  • 正是因为上面这个问题,Redis还需要提供另一种数据过期策略,就是定期删除。它会将设置过期时间的key放在一个字典里,对字典进行每秒10次的扫描,并删除扫描到的已过期的key,采用贪心策略扫描,每次随机20个key,若过期比例超过25%则再次随机;那比例不超过25%就不再扫描了。

过期扫描不会遍历字典中所有的key,而是采用了一种简单的贪心策略。该策略的删除逻辑如下:

  • 从过期字典中随机选择20个key;
  • 删除这20个key中已过期的key;
  • 如果已过期key的比例超过25%,则重复步骤1。

2. 内存淘汰策略
图片说明

内存淘汰策略就是说当写入的数据超出了maxmemory这个值时,就要采取指定的策略进行删除,Redis一共提供8种策略,第一种noeviction:直接返回错误,一般不会采取,直接忽。volatile-ttl/random/lru/lfu:从设置了过期时间的的键中选择时间最小的键(最早过期的那个key)/随机选择/利用LRU算法/利用LFU算法,进行淘汰。allkeys就是从所有键中进行选择去淘汰。

这里我们重点讲一下LRU和LFU算法:
图片说明
LUR(Least Recently Used)算法:
它是通过一个链表来实现的,将最近被使用的放在表头,那最近最常使用的集中在表头位置,越不经常被使用的就越放在表尾。

  • 标准LRU:把所有的数据组成一个链表,表头和表尾分别表示MRU和LRU端,即最常使用端和最少使用端。刚被访问的数据会被移动到MRU端,而新增的数据也是刚被访问的数据,也会被移动到MRU端。当链表的空间被占满时,它会删除LRU端的数据。
  • 近似LRU:Redis会记录每个数据的最近一次访问的时间戳(LRU)。Redis执行写入操作时,若发现内存超出maxmemory,就会执行一次近似LRU淘汰算法。近似LRU会随机采样N个key,然后淘汰掉最旧的key,若淘汰后内存依然超出限制,则继续采样淘汰。可以通过maxmemory_samples配置项,设置近似LRU每次采样的数据个数,该配置项的默认值为5。

LRU算法的不足之处在于,若一个key很少被访问,只是刚刚偶尔被访问了一次,则它就被认为是热点数据,短时间内不会被淘汰。

LFU(Least Frequently Used)算法:
用于解决LRU的问题,它根据key的最近访问频率进行淘汰。LFU在LRU的基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。当使用LFU策略淘汰数据时,首先会根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出内存。如果两个数据的访问次数相同,LFU再比较这两个数据的访问时间,把访问时间更早的数据淘汰出内存。

五、缓存穿透、缓存击穿、缓存雪崩

图片说明

1. 缓存穿透

图片说明

简单来说就是客户端访问一个根本不存在数据,那么这个数据在缓存里找不到,那它就会去数据库里找,这样就导致数据库负载太大,甚至宕机。还有可能是有人恶意攻击,编造了一大串不存在的数据,专门为了搞垮数据库。

处理办法有两种(如图所示):

  • 缓存空对象:如果访问的这个数据不存在,那就存一个空值留在缓存内,这样下次再访问就返回空值。但这不是一个好办法,因为造成了极大的内存浪费。
  • 布隆过滤器:布隆过滤器中包含了很多哈希表,是用来做判断的。当你每次要访问缓存时,布隆过滤器首先判断你这个数据是不是合法的,会不会存在,他会根据哈希表去估计,但是这个估计是极为可靠的。

2. 缓存击穿

图片说明

简单来说就是大家同时访问一个已经失效的数据,那么在缓存找不到,就得去数据库找,会导致数据库一瞬间访问量过大,甚至崩溃。

处理办法也有两个(如图所示):

  • 永不过期:热点数据不设置过期时间,这里的永不过期不是说永远,是指在活动范围内,比如你这个活动搞一天,那就设一天不过期。
  • 加互斥锁:当你在访问这个数据的时候,那其他人就等你访问玩再访问,这是一个简单粗暴的方法。

3. 缓存雪崩

图片说明

简单来说就是缓存中的大量数据同时过期,然后所有请求就会直达数据库,导致数据库宕机,当然也可能是Redis节点发生故障,导致大量请求无法得到处理。

解决办法有三个(如图所示):

  • 避免数据同时过期:在设置过期时间时再加一个随机数,比如过期时间是一分钟,那在一分钟后面再加一个随机数;
  • 启用降级和熔断措施:就是在发生雪崩的时候直接返回客户端一个错误信息,或者直接返回,这时候数据库虽然不能响应这个请求,但还是可以响应别的请求,是一种半死不活的状态(比死了强)。
  • 构建高可用的缓存服务:采用哨兵和集群模式,部署多个Redis实例,这样一个发生故障,其他的还能干活,依然可以保持服务的整体可用。

六、缓存与数据库的双写一致性

四种同步策略:

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

从这4种同步策略中,我们需要作出比较的是:

  1. 更新缓存与删除缓存哪种方式更合适?
  2. 应该先操作数据库还是先操作缓存?

更新缓存还是删除缓存:

  • 更新缓存
    优点:每次数据变化都及时更新缓存,所以查询时不容易出现未命中的情况。
    缺点:更新缓存的消耗比较大。如果数据需要经过复杂的计算再写入缓存,那么频繁的更新缓存,就会影响服务器的性能。如果是写入数据频繁的业务场景,那么可能频繁的更新缓存时,却没有业务读取该数据。
  • 删除缓存
    优点:操作简单,无论更新操作是否复杂,都是将缓存中的数据直接删除。
    缺点:删除缓存后,下一次查询缓存会出现未命中,这时需要重新读取一次数据库。

从上面的比较来看,一般情况下,删除缓存是更优的方案。

先操作数据库还是缓存:
为了更好的解决这个问题我们从两个方向进行,我们先操作数据库,或者先操作缓存,如图所示:A、B分别为两个线程,来演示并发操作

这里演示第二步都失败的情况

图片说明

先看上图,A先删除缓存,再去更新数据库,但是更新操作失败了,B就在这个夹缝中间去访问缓存,可是缓存已经被删除了,B得不到数据就只能取数据库里找,然后在写入缓存,这里就有个问题了,B得到的是没更新前的旧数据(前提设定第二步更新失败),最后A要异步重试,更新成功,可是这时候缓存和数据库的数据不同步了。

再看下图,A先更新数据库,然后去删缓存,但是删缓存这一步失败了(前提设定第二步都失败),B又在A失败的夹缝中检去访问缓存,得到了旧数据,因为A删缓存没成功,最后A再异步重试,缓存删除成功。这里也有个问题,就是会有一些线程得到一些旧数据,但是缓存就是这样,有时候并不能及时更新,相比上面这个缓存与数据库数据不同步来说,还是更好去接受的。

下面我们演示第二步成功的情况

图片说明

先看上图,A先去删缓存,这一瞬间B马上去访问缓存,得不到数据就去数据库找,然后得到旧数据写入缓存,B这波操作都结束了,A才更新数据库,那那这是先删缓存,在更新数据库,还是会导致数据不同步的问题。

再看下图,A先更新数据库,B在A还没删缓存的夹缝间去访问缓存然后得到了旧数据,最后A删除缓存,同样先更新数据库,再删缓存,还是会有一些用户得到旧数据,但这和上面的后果相比还是能够接受的。

最终结论
经过对比你会发现,先更新数据库、再删除缓存是影响更小的方案。如果第二步出现失败的情况,则可以采用重试机制解决问题。
图片说明

延时双删
上面我们提到,如果是先删缓存、再更新数据库,在没有出现失败时可能会导致数据的不一致。如果在实际的应用中,出于某些考虑我们需要选择这种方式,那有办法解决这个问题吗?答案是有的,那就是采用延时双删的策略,延时双删的基本思路如下:

  1. 删除缓存;
  2. 更新数据库;
  3. sleep N毫秒;
  4. 再次删除缓存。

阻塞一段时间之后,再次删除缓存,就可以把这个过程中缓存中不一致的数据删除掉。而具体的时间,要评估你这项业务的大致时间,按照这个时间来设定即可。

采用读写分离的架构怎么办?
如果数据库采用的是读写分离的架构,那么又会出现新的问题,如下图:

图片说明

进程A先删除缓存,再更新主数据库,然后主库将数据同步到从库。而在主从数据库同步之前,可能会有进程B访问了缓存,发现数据不存在,进而它去访问从库获取到旧的数据,然后同步到缓存。这样,最终也会导致缓存与数据库的数据不一致。这个问题的解决方案,依然是采用延时双删的策略,但是在评估延长时间的时候,要考虑到主从数据库同步的时间。

第二次删除失败了怎么办?
如果第二次删除依然失败,则可以增加重试的次数,但是这个次数要有限制,当超出一定的次数时,要采取报错、记日志、发邮件提醒等措施。