本文主要总结了 Redis 底层的数据结构和线程模型。

1 底层数据结构

1.1 数据结构总览

String类型的底层实现只有⼀种数据结构,也就是简单动态字符串。⽽List、Hash、Set和
Sorted Set这四种数据类型,都有两种底层实现结构。

1.2 压缩列表

压缩列表实际上类似于⼀个数组,数组中的每⼀个元素都对应保存⼀个数据。
和数组不同的是,压缩列表在表头有三个字段 zlbyteszltailzllen,分别表⽰列表⻓度、列表尾的偏移量和列表中的entry个数;压缩列表在表尾还有⼀个 zlend,表⽰列表结束。

在压缩列表中,如果我们要查找定位第⼀个元素和最后⼀个元素,可以通过表头三个字段的⻓度直接定位,
复杂度是O(1)。⽽查找其他元素时,就没有这么⾼效了,只能逐个查找,此时的复杂度为 O(N)。
使⽤压缩列表本质上是将所有元素紧挨着存储,所以分配的是⼀块连续的内存空间,虽然数据结构本⾝没有时间复杂度的优势,但是这样节省空间⽽且也能避免⼀些内存碎⽚。

1.3 跳表

跳表在链表的基础上,增加了多级索引,通过索引位置的⼏个跳转,实现数据的快速定位。

2 redis 的线程模型

2.1 单线程

在 redis 启动初始化的时候,redis 会将连接应答处理器跟 AE_READABLE 事件关联起来,接着如果一个客户端跟 redis 发起连接,此时会产生一个 AE_READBLE 事件,然后由连接应答处理器来处理与客户端建立连接,创建客户端对应的 socket,同时将这个 socket 的 AE_READABLE 事件跟命令请求处理器关联起来。

当客户端向 redis 发起请求的时候(不管是读请求还是写请求,都一样),首先都会在 socket 中产生一个 AE_READABLE 事件,然后由对应的命令请求处理器来处理。这个命令请求处理器就会从 socket 中读取请求相关数据,然后进行执行和处理。

接着 redis 这边准备好了给客户端的响应数据之后,就会将 socket 的 AE_WRITABLE 事件跟命令回复处理器 关联起来,当客户端这边准备好读取响应数据时,就会在 socket 上产生一个 AE_READABLE 事件,会由对应的命令回复处理器来处理,就是将准备好的响应数据写入 socket,供客户端来读取。

命令回复处理器写完之后,就会删除这个 socket 的 AE_WRITABLE 事件和命令回复处理器的关联关系。

redis 内部使用文件事件处理器(file event handler),这个文件事件处理器是单线程的,所以 redis 才叫做单线程的模型。它采用 IO 多路复用机制同时监听多个 socket,将产生事件的 socket 压入内存队列中,事件分派器根据 socket 上的事件类型来选择对应的事件处理器进行处理。

如果被监听的 socket 准备好执行 accept、read、write、close 等操作的时候,跟操作对应的文件事件就会产生,这个时候文件事件处理器就会调用之前关联好的事件处理器来处理这个事件。

文件事件处理器是单线程模式运行的,但是通过 IO 多路复用机制监听多个 socket,可以实现高性能的网络通信模型,又可以跟内部的其他单线程的模块进行对接,保证了redis 内部的线程模型的简单性。

文件事件处理器包含 4 个部分:

  • 多个 socket
  • IO 多路复用程序
  • 文件事件分派器
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

多个socket 可能并发地产生不同的操作,每个操作对应不同的文件事件,但是 IO 多路复用程序会监听多个 socket,将 socket 放入一个队列中排队,每次从队列中取出一个 socket 给事件分派器,事件分派器把 sokcet 给对应的时间处理器。

然后一个 socket 的事件处理完之后,IO 多路复用程序才会将队列中的下一个 socket 给事件分配器。文件事件分派器会根据每个 socket 当前产生的事件,来选择对应的事件处理器来处理。

2.1.1 Redis 单线程模型也能效率这么高?

  1. 纯内存操作。
  2. 核心是基于非阻塞的 IO 多路复用机制。
  3. C 语言实现,一般来说,C 语言实现的程序“距离”操作系统更近,执行速度相对会更快。
  4. 单线程反而避免了多线程的频繁上下文切换问题,预防了多线程可能产生的竞争问题。

2.2 多线程

Redis 6.0 之后的版本抛弃了单线程模型这一设计,原本使用单线程运行的 Redis 也开始选择性地使用多线程模型。

前面还在强调 Redis 单线程模型的高效性,现在为什么又要引入多线程?这其实说明 Redis 在有些方面,单线程已经不具有优势了。因为读写网络的 Read/Write 系统调用在 Redis 执行期间占用了大部分 CPU 时间,如果把网络读写做成多线程的方式对性能会有很大提升

Redis 的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程。 之所以这么设计是不想 Redis 因为多线程而变得复杂,需要去控制 key、lua、事务、LPUSH/LPOP 等等的并发问题。

2.3 总结

Redis 选择使用单线程模型处理客户端的请求主要还是因为** CPU 不是 Redis 服务器的瓶颈**,所以使用多线程模型带来的性能提升并不能抵消它带来的开发成本和维护成本,系统的性能瓶颈也主要在网络 I/O 操作上;而 Redis 引入多线程操作也是出于性能上的考虑,对于一些大键值对的删除操作,通过多线程非阻塞地释放内存空间也能减少对 Redis 主线程阻塞的时间,提高执行的效率。

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

3.1 缓存雪崩

3.1.1 什么是缓存雪崩

缓存雪崩的情况是说,当某一时刻发生大规模的缓存失效的情况,比如你的缓存服务宕机了,会有大量的请求进来直接打到DB上面。结果就是DB 撑不住,挂掉。

3.1.1 解决办法

  • 事前:redis 高可用,主从+哨兵,redis cluster,避免全盘崩溃。
  • 事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。
  • 事后:redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。

3.2 缓存穿透

3.2.1 什么是缓存穿透?

正常情况下,我们去查询数据都是存在。
那么请求去查询一条压根儿数据库中根本就不存在的数据,也就是缓存和数据库都查询不到这条数据,但是请求每次都会打到数据库上面去。
这种查询不存在数据的现象我们称为缓存穿透。

3.2.2 穿透带来的问题

黑客拿一个不存在的 key 不断请求你的接口,这样所有的请求最终都会打到数据库中,从而导致数据库宕机。

3.2.3 解决办法

  • 缓存空值
    之所以会发生穿透,就是因为缓存中没有存储这些空数据的key。从而导致每次查询都到数据库去了。
    那么我们就可以为这些key对应的值设置为null 丢到缓存里面去。后面再出现查询这个key 的请求的时候,直接返回null 。
    这样,就不用在到数据库中去走一圈了,但是别忘了设置过期时间。
  • BloomFilter
    这种方案可以加在第一种方案中,在缓存之前在加一层 BloomFilter ,在查询的时候先去 BloomFilter 去查询 key 是否存在,如果不存在就直接返回,存在再走查缓存 -> 查 DB。

3.3 缓存击穿

3.3.1 什么是缓存击穿

缓存击穿,就是说某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。

3.3.2 解决办法

解决方式也很简单,可以将热点数据设置为永远不过期;或者基于 redis or zookeeper 实现互斥锁,等待第一个请求构建完缓存之后,再释放锁,进而其它请求才能通过该 key 访问数据。
但是由于这样做会阻塞其他的线程,此时系统吞吐量会下降,需要结合实际的业务去考虑是否要这么做。

4 过期策略

Redis 的高性能有一个原因是 Redis 使用内存做缓存,但内存是宝贵且有限的,Redis 不可能使用内存做持久化。
使用 Redis 的过程中,常见的有两个问题:

  • Redis 数据丢失
  • 数据缓存过期了,但是还占用着内存

要想理解这些问题的产生原因,就不得不了解 Redis 的过期策略。

Redis 使用定期删除 + 懒惰删除的策略

  • 定期删除:指的是 redis 默认是每隔 100ms 就随机抽取一些设置了过期时间的 key,检查其是否过期,如果过期就删除。(因为是随机的,所以可能会有一些 key 一直没删除掉)
  • 懒惰删除:获取某个 key 的时候,redis 会检查一下 ,这个 key 如果设置了过期时间那么是否过期了,如果过期了此时就会删除,不会给你返回任何东西。

但是如果定期删除漏掉了很多过期 key,然后你也没及时去查,也就没走惰性删除,内存中还是会存在大量的无用数据。

所以 Redis 也就有了内存淘汰机制。
redis 内存淘汰机制有以下 6 种:

  • noeviction: 当内存不足以容纳新写入数据时,新写入操作会报错,这个一般没人用吧,实在是太恶心了。
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)。
  • allkeys-random:当内存不足以容纳新写入数据时,在键空间中,随机移除某个 key,这个一般没人用吧,为啥要随机,肯定是把最近最少使用的 key 给干掉啊。
  • volatile-lru:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,移除最近最少使用的 key(这个一般不太合适)。
  • volatile-random:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,随机移除某个 key。
  • volatile-ttl:当内存不足以容纳新写入数据时,在设置了过期时间的键空间中,有更早过期时间的 key 优先移除。

Redis lru 底层原理:Redis采用了一个近似的lru 算法,就是随机取出若干个key,然后按照访问时间排序后,淘汰掉最不经常使用的。

  • Redis会基于server.maxmemory_samples配置选取固定数目的key,然后比较它们的lru访问时间,然后淘汰最近最久没有访问的key,maxmemory_samples的值越大,Redis的近似LRU算法就越接近于严格LRU算法,但是相应消耗也变高,对性能有一定影响。

手写一个 LRU 算法:

class LRUCache<K, V> extends LinkedHashMap<K, V> {
    private final int CACHE_SIZE;

    /**
     * 传递进来最多能缓存多少数据
     *
     * @param cacheSize 缓存大小
     */
    public LRUCache(int cacheSize) {
        // true 表示让 linkedHashMap 按照访问顺序来进行排序,最近访问的放在头部,最老访问的放在尾部。
        super((int) Math.ceil(cacheSize / 0.75) + 1, 0.75f, true);
        CACHE_SIZE = cacheSize;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        // 当 map中的数据量大于指定的缓存个数的时候,就自动删除最老的数据。
        return size() > CACHE_SIZE;
    }
}

测试代码:

    static LRUCache<Integer, Integer> lruCache = new LRUCache<>(16);

    public static void main(String[] args) {
        for (int i = 0; i < 16; i++) {
            lruCache.put(i, i);
        }
        System.out.println(lruCache.entrySet());
        for(int i = 16; i < 20; i++) {
            lruCache.put(i, i);
            System.out.println(lruCache.entrySet());
        }

    }

结果:

[0=0, 1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9, 10=10, 11=11, 12=12, 13=13, 14=14, 15=15]
[1=1, 2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9, 10=10, 11=11, 12=12, 13=13, 14=14, 15=15, 16=16]
[2=2, 3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9, 10=10, 11=11, 12=12, 13=13, 14=14, 15=15, 16=16, 17=17]
[3=3, 4=4, 5=5, 6=6, 7=7, 8=8, 9=9, 10=10, 11=11, 12=12, 13=13, 14=14, 15=15, 16=16, 17=17, 18=18]
[4=4, 5=5, 6=6, 7=7, 8=8, 9=9, 10=10, 11=11, 12=12, 13=13, 14=14, 15=15, 16=16, 17=17, 18=18, 19=19]

可以看到在 size 超过 16 个时,进行了 LRU 缓存淘汰。

5 持久化之 AOF 与 RDB

5.1 AOF

5.1.1 AOF 的写后日志方式的优点与缺点

我们以Redis收到“set key1 value1”命令后记录的⽇志为例,看看AOF⽇志的内容。

*3
$3
set
$4
key1
$6
value1

其中,“*3”表⽰当前命令有三个部分,每部分都是由“$+数字”开头,后⾯紧跟着具体的命令、键或值。这⾥,“数字”表⽰这部分中的命令、键或值⼀共有多少字节。例如,“$3 set”表⽰这部分有3个字节。

AOF 采用写后⽇志这种⽅式,就是先让系统执⾏命令,只有命令能执⾏成功,才会被记录到⽇志中,否则,系统就会直接向客⼾端报错。所以,Redis使⽤写后⽇志这⼀⽅式的⼀⼤好处是,可以避免出现记录错误命令的情况

不过,AOF也有两个潜在的⻛险。

  1. 如果刚执⾏完⼀个命令,还没有来得及记⽇志就宕机了,那么这个命令和相应的数据就有丢失的⻛
    险。如果此时Redis是⽤作缓存,还可以从后端数据库重新读⼊数据进⾏恢复,但是,如果Redis是直接⽤作
    数据库的话,此时,因为命令没有记⼊⽇志,所以就⽆法⽤⽇志进⾏恢复了。
  2. AOF虽然避免了对当前命令的阻塞,但可能会给下⼀个操作带来阻塞⻛险。这是因为,AOF⽇志也是
    在主线程中执⾏的,如果在把⽇志⽂件写⼊磁盘时,磁盘写压⼒⼤,就会导致写盘很慢,进⽽导致后续的操
    作也⽆法执⾏了。

5.1.2 写回策略

  1. Always,同步写回:每个写命令执⾏完,⽴⻢同步地将⽇志写回磁盘;
  2. Everysec,每秒写回:每个写命令执⾏完,只是先把⽇志写到AOF⽂件的内存缓冲区,每隔⼀秒把缓冲区中的内容写⼊磁盘;
  3. No,操作系统控制的写回:每个写命令执⾏完,只是先把⽇志写到AOF⽂件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

5.1.3 AOF(Append Only File) 重写机制

AOF 的重写根据键值对的最新状态,将中间过程省略,从而缩小日志文件大小。
和AOF⽇志由主线程写回不同,重写过程是由后台线程bgrewriteaof来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。

重写的过程可以总结为“⼀个拷⻉,两处⽇志”。

“⼀个拷⻉”就是指,每次执⾏重写时,主线程fork出后台的bgrewriteaof⼦进程。此时,fork会把主线程的内存拷⻉⼀份给bgrewriteaof⼦进程,这⾥⾯就包含了数据库的最新数据。然后,bgrewriteaof⼦进程就可以在不影响主线程的情况下,逐⼀把拷⻉的数据写成操作,记⼊重写⽇志。

“两处⽇志”⼜是什么呢?因为主线程未阻塞,仍然可以处理新来的操作。此时,如果有写操作,第⼀处⽇志就是指正在使⽤的AOF⽇志,Redis会把这个操作写到它的缓冲区。这样⼀来,即使宕机了,这个AOF⽇志的操作仍然是⻬全的,可以⽤于恢复。⽽第⼆处⽇志,就是指新的AOF重写⽇志。这个操作也会被写到重写⽇志的缓冲区。这样,重写⽇志也不会丢失最新的操作。等到拷⻉数据的所有操作记录重写完成后,重写⽇志记录的这些最新操作也会写⼊新的AOF⽂件,以保证数据库最新状态的记录。此时,我们就可以⽤新的AOF⽂件替代旧⽂件了。

总结来说,每次AOF重写时,Redis会先执⾏⼀个内存拷⻉,⽤于重写;然后,使⽤两个⽇志保证在重写过
程中,新写⼊的数据不会丢失。⽽且,因为Redis采⽤额外的线程进⾏数据重写,所以,这个过程并不会阻
塞主线程。

5.2 RDB(Redis DataBase)

Redis的数据都在内存中,为了提供所有数据的可靠性保证,RDB 执⾏的是全量快照。
Redis提供了两个命令来⽣成RDB⽂件,分别是save和bgsave。

  • save:在主线程中执⾏,会导致阻塞;
  • bgsave:创建⼀个⼦进程,专⻔⽤于写⼊RDB⽂件,避免了主线程的阻塞,这也是Redis RDB⽂件⽣成的默认配置。

为了快照⽽暂停写操作,肯定是不能接受的。所以这个时候,Redis就会借助操作系统提供的写时复制技术
(Copy-On-Write, COW),在执⾏快照的同时,正常处理写操作。
简单来说,bgsave⼦进程是由主线程fork⽣成的,可以共享主线程的所有内存数据。bgsave⼦进程运⾏
后,开始读取主线程的内存数据,并把它们写⼊RDB⽂件。
Redis 4.0中提出了⼀个混合使⽤AOF⽇志和内存快照的⽅法。简单来说,内存快照以⼀定的频率执⾏,在两次快照之间,使⽤AOF⽇志记录这期间的所有命令操作。
此时,如果主线程对这些数据也都是读操作(例如图中的键值对A),那么,主线程和bgsave⼦进程相互不影响。但是,如果主线程要修改⼀块数据(例如图中的键值对C),那么,这块数据就会被复制⼀份,⽣成该数据的副本。然后,bgsave⼦进程会把这个副本数据写⼊RDB⽂件,⽽在这个过程中,主线程仍然可以直接修改原来的数据。

6 主从同步

6.1 全量复制同步流程

6.1.1 第一阶段

第⼀阶段是主从库间建⽴连接、协商同步的过程,主要是为全量复制做准备。在这⼀步,从库和主库建⽴起
连接,并告诉主库即将进⾏同步,主库确认回复后,主从库间就可以开始同步了。
具体来说,从库给主库发送psync命令,表⽰要进⾏数据同步,主库根据这个命令的参数来启动复制。
psync命令包含了主库的runID和复制进度offset两个参数。

  • runID,是每个Redis实例启动时都会⾃动⽣成的⼀个随机ID,⽤来唯⼀标记这个实例。当从库和主库第一次复制时,因为不知道主库的runID,所以将runID设为“?”。
  • offset,此时设为-1,表⽰第⼀次复制。
    主库收到psync命令后,会⽤FULLRESYNC响应命令带上两个参数:主库runID和主库⽬前的复制进度offset,返回给从库。从库收到响应后,会记录下这两个参数。
    这⾥有个地⽅需要注意,FULLRESYNC响应表⽰第⼀次复制采⽤的全量复制,也就是说,主库会把当前所
    有的数据都复制给从库。

6.1.2 第二阶段

这⾥有个地⽅需要注意,FULLRESYNC响应表⽰第⼀次复制采⽤的全量复制,也就是说,主库会把当前所有的数据都复制给从库。
具体来说,主库执⾏bgsave命令,⽣成RDB⽂件,接着将⽂件发给从库。从库接收到RDB⽂件后,会先清空当前数据库,然后加载RDB⽂件。这是因为从库在通过replicaof命令开始和主库同步前,可能保存了其他数据。为了避免之前数据的影响,从库需要先把当前数据库清空。
在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚⽣成的RDB⽂件中。为了保证主从库的数据⼀致性,主库会在内存中⽤专⻔的replication buffer,记录RDB⽂件⽣成后收到的所有写操作。

6.1.3 第三阶段

主库会把第⼆阶段执⾏过程中新收到的写命令,再发送给从库。
具体的操作是,当主库完成RDB⽂件发送后,就会把此时replication buffer中的修改操作发给从库,从库再重新执⾏这些操作。这样⼀来,主从库就实现同步了

6.2 主从级联模式

⼀次全量复制中,对于主库来说,需要完成两个耗时的操作:

  • ⽣成RDB⽂件
  • 传输RDB⽂件
    我们可以通过“主-从-从”模式将主库⽣成RDB和传输RDB的压⼒,以级联的⽅式分散到从库上.

6.3 增量复制

当主从库断连后,主库会把断连期间收到的写操作命令,写⼊replication buffer,同时也会把这些操作命令也写⼊repl_backlog_buffer这个缓冲区。
repl_backlog_buffer是⼀个环形缓冲区,主库会记录⾃⼰写到的位置,从库则会记录⾃⼰已经读到的位
置。
刚开始的时候,主库和从库的写读位置在⼀起,这算是它们的起始位置。随着主库不断接收新的写操作,它在缓冲区中的写位置会逐步偏离起始位置,我们通常⽤偏移量来衡量这个偏移距离的⼤⼩,对主库来说,对应的偏移量就是master_repl_offset。主库接收的新写操作越多,这个值就会越⼤。
同样,从库在复制完写操作命令后,它在缓冲区中的读位置也开始逐步偏移刚才的起始位置,此时,从库已
复制的偏移量slave_repl_offset也在不断增加。正常情况下,这两个偏移量基本相等。

增量复制流程如下所示:

注意:
因为repl_backlog_buffer是⼀个环形缓冲区,所以在缓冲区写满后,主库会继续写⼊,此时,就会覆盖掉之前写⼊的操作。如果从库的读取速度⽐较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不⼀致。
因此,我们要想办法避免这⼀情况,⼀般⽽⾔,我们可以调整repl_backlog_size这个参数。这个参数和所
需的缓冲空间⼤⼩有关。缓冲空间的计算公式是:缓冲空间⼤⼩ = 主库写⼊命令速度 * 操作⼤⼩ - 主从库间⽹络传输命令速度 * 操作⼤⼩。在实际应⽤中,考虑到可能存在⼀些突发的请求压⼒,我们通常需要把这个缓冲空间扩⼤⼀倍,即repl_backlog_size = 缓冲空间⼤⼩ * 2,这也就是repl_backlog_size的最终值。
举个例⼦,如果主库每秒写⼊2000个操作,每个操作的⼤⼩为2KB,⽹络每秒能传输1000个操作,那么,有1000个操作需要缓冲起来,这就⾄少需要2MB的缓冲空间。否则,新写的命令就会覆盖掉旧操作了。为了应对可能的突发压⼒,我们最终把repl_backlog_size设为4MB。

7 哨兵机制

7.1 原理

主从集群模式下,如果主库宕机,那就需要哨兵机制登场了。在Redis主从集群中,哨兵机制是实现主从库⾃动切换的关键机制,它有效地解决了主从复制模式下故障转移的三个问题:

  1. 主库真的挂了吗?
  2. 该选择哪个从库作为主库?
  3. 怎么把新主库的相关信息通知给从库和客⼾端呢?
    哨兵其实就是⼀个运⾏在特殊模式下的Redis进程,主从库实例运⾏的同时,它也在运⾏。哨兵主要负责的
    就是三个任务:监控、选主(选择主库)和通知。
  4. 监控:监控是指哨兵进程在运⾏时,周期性地给所有的主从库发送PING命令,检测它们是否仍然在线运⾏。如果从库没有在规定时间内响应哨兵的PING命令,哨兵就会把它标记为“下线状态”;同样,如果主库也没有在规定时间内响应哨兵的PING命令,哨兵就会判定主库下线,然后开始⾃动切换主库的流程。
  5. 选主:主库挂了以后,哨兵就需要从很多个从库⾥,按照⼀定的规则选择⼀个从库实例,把它作为新的主库。这⼀步完成后,现在的集群⾥就有了新主库。
  6. 通知:在执⾏通知任务时,哨兵会把新主库的连接信息发给其他从库,让它们执⾏replicaof命令,和新主库建⽴连接,并进⾏数据复制。同时,哨兵会把新主库的连接信息通知给客⼾端,让它们把请求操作发到新主库上。

为了降低哨兵监控的误判率,通常会采⽤多实例组成的集群模式进⾏部署,这也被称为哨兵集群。引⼊多个哨兵实例⼀起来判断,就可以避免单个哨兵因为⾃⾝⽹络状况不好,⽽误判主库下线的情况。同时,多个哨兵的⽹络同时不稳定的概率较⼩,由它们⼀起做决策,误判率也能降低。在判断主库是否下线时,不能由⼀个哨兵说了算,只有⼤多数的哨兵实例,都判断主库已经“主观下线”了,主库才会被标记为“客观下线”。

在选主时,除了要检查从库的当前在线状态,还要判断它之前的⽹络连接状态。使⽤配置项down-after-milliseconds * 10。其中,down-after-milliseconds是我们认定主从库断连的最⼤连接超时时间。如果在down-after-milliseconds毫秒内,主从节点都没有通过⽹络联系上,我们就可以认为主从节点断连了。如果发⽣断连的次数超过了10次,就说明这个从库的⽹络状况不好,不适合作为新主库。

接下来就要给剩余的从库打分了。我们可以分别按照三个规则依次进⾏三轮打分,这三个规则分别是

  1. 从库优先级
  2. 从库复制进度
  3. 从库ID号。

只要在某⼀轮中,有从库得分最⾼,那么它就是主库了,选主过程到此结束。如果没有出现得分最⾼的从库,那么就继续进⾏下⼀轮。

  1. 第⼀轮:优先级最⾼的从库得分⾼。
    ⽤⼾可以通过slave-priority配置项,给不同的从库设置不同优先级。⽐如,你有两个从库,它们的内存⼤⼩不⼀样,你可以⼿动给内存⼤的实例设置⼀个⾼优先级。在选主时,哨兵会给优先级⾼的从库打⾼分,如果有⼀个从库优先级最⾼,那么它就是新主库了。如果从库的优先级都⼀样,那么哨兵开始第⼆轮打分。

  2. 第⼆轮:和旧主库同步程度最接近的从库得分⾼。
    这个规则的依据是,如果选择和旧主库同步最接近的那个从库作为主库,那么,这个新主库上就有最新的数据。
    主从库同步时有个命令传播的过程。在这个过程中,主库会⽤master_repl_offset记录当前的最新写操作在repl_backlog_buffer中的位置,⽽从库会⽤slave_repl_offset这个值记录当前的复制进度。此时,我们想要找的从库,它的slave_repl_offset需要最接近master_repl_offset。如果在所有从库中,有从库的slave_repl_offset最接近master_repl_offset,那么它的得分就最⾼,可以作为新主库。

  3. 第三轮:ID号⼩的从库得分⾼。
    每个实例都会有⼀个ID,这个ID就类似于这⾥的从库的编号。⽬前,Redis在选主库时,有⼀个默认的规
    定:在优先级和复制进度都相同的情况下,ID号最⼩的从库得分最⾼,会被选为新主库。

总结:⾸先,哨兵会按照在线状态、⽹络状态,筛选过滤掉⼀部分不符合要求的从库,然后,依次按照优先级、复制进度、ID号⼤⼩再对剩余的从库进⾏打分,只要有得分最⾼的从库出现,就把它选为新主库。

备注:

  • 哨兵实例之间可以相互发现,要归功于Redis提供的pub/sub机制,也就是发布/订阅机制。主从集群中,主库上有⼀个名为“sentinel:hello”的频道,不同哨兵就是通过它来相互发现,实现互相通信的。
  • 哨兵除了彼此之间建⽴起连接形成集群外,还需要和从库建⽴连接。这是因为,在哨兵的监控任务中,它需要对主从库都进⾏⼼跳判断,⽽且在主从库切换完成后,它还需要通知从库,让它们和新主库进⾏同步。
  • 哨兵是如何知道从库的IP地址和端⼝?这是由哨兵向主库发送INFO命令来完成的。就像下图所⽰,哨兵2给主库发送INFO命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建⽴连接,并在这个连接上持续地对从库进⾏监控。哨兵1和3可以通过相同的⽅法和从库建⽴连接。

7.2 Redis 哨兵主备切换的数据丢失问题

导致数据丢失的两种情况:

  1. 异步复制导致的数据丢失
    因为 master->slave 的复制是异步的,所以可能有部分数据还没复制到 slave,master 就宕机了,此时这部分数据就丢失了。

  2. 脑裂导致的数据丢失
    脑裂,也就是说,某个 master 所在机器突然脱离了正常的网络,跟其他 slave 机器不能连接,但是实际上 master 还运行着。此时哨兵可能就会认为 master 宕机了,然后开启选举,将其他 slave 切换成了 master。这个时候,集群里就会有两个 master ,也就是所谓的脑裂。

    此时虽然某个 slave 被切换成了 master,但是可能 client 还没来得及切换到新的 master,还继续向旧 master 写数据。因此旧 master 再次恢复的时候,会被作为一个 slave 挂到新的 master 上去,自己的数据会清空,重新从新的 master 复制数据。而新的 master 并没有后来 client 写入的数据,因此,这部分数据也就丢失了。

7.3 quorum、majority、configuration epoch、configuration 传播

7.3.1 quorum 和 majority

每次一个哨兵要做主备切换,首先需要 quorum 数量的哨兵认为 odown,然后选举出一个哨兵来做切换,这个哨兵还需要得到 majority 哨兵的授权,才能正式执行切换。

如果 quorum < majority,比如 5 个哨兵,majority 就是 3,quorum 设置为 2,那么就 3 个哨兵授权就可以执行切换。

但是如果 quorum >= majority,那么必须 quorum 数量的哨兵都授权,比如 5 个哨兵,quorum 是 5,那么必须 5 个哨兵都同意授权,才能执行切换。

7.3.2 configuration epoch

哨兵会对一套 Redis master+slaves 进行监控,有相应的监控的配置。

执行切换的那个哨兵,会从要切换到的新 master(salve->master)那里得到一个 configuration epoch,这就是一个 version 号,每次切换的 version 号都必须是唯一的。

如果第一个选举出的哨兵切换失败了,那么其他哨兵,会等待 failover-timeout 时间,然后接替继续执行切换,此时会重新获取一个新的 configuration epoch,作为新的 version 号。

7.3.3 configuration 传播

哨兵完成切换之后,会在自己本地更新生成最新的 master 配置,然后同步给其他的哨兵,就是通过之前说的 pub/sub 消息机制。

这里之前的 version 号就很重要了,因为各种消息都是通过一个 channel 去发布和监听的,所以一个哨兵完成一次新的切换之后,新的 master 配置是跟着新的 version 号的。其他的哨兵都是根据版本号的大小来更新自己的 master 配置的。

参考: