Redis基础

1. 设计一个键值数据库SimpleKV

1.1. 需要存哪些数据

不同键值数据库支持的 key 类型一般差异不大,而 value 类型则有较大差别。我们在对键值数据库进行选型时,一个重要的考虑因素是它支持的 value 类型。例如,Memcached 支持的 value 类型仅为 String 类型,而 Redis 支持的 value 类型包括了 String、哈希表、列表、集合等。

value类型不同的两个思考维度:

  • 业务的数据需求
  • 不同数据结构在性能、空间效率等方面的差异

1.2. 需要对数据做什么操作

一般包含以下四种基本操作集合:

  • PUT:新写入或更新一个 key-value 对
  • GET:根据一个 key 读取相应的 value 值
  • DELETE:根据一个 key 删除整个 key-value 对
  • SCAN:根据一段 key 的范围返回相应的 value 值

1.3. 键值数据存储在外存还是内存

  • 保存在内存的好处:读写很快,毕竟内存的访问速度一般都在百 ns 级别。但是,潜在的风险是一旦掉电,所有的数据都会丢失。
  • 保存在外存的好处:虽然可以避免数据丢失,但是受限于磁盘的慢速读写(通常在几 ms 级别),键值数据库的整体性能会被拉低。

一般得考虑键值数据库的主要应用场景,Memcached 和 Redis 都是属于内存键值数据库

1.4. 一个键值数据库的基本结构

一个键值数据库包括了访问框架索引模块操作模块存储模块四部分

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled.png

1.5. 采用什么访问模式

访问模式通常有两种:

  • 通过函数库调用的方式供外部应用使用,比如,上图中的 libsimplekv.so,就是以动态链接库的形式链接到我们自己的程序中,提供键值存储功能;
  • 通过网络框架以 Socket 通信的形式对外提供键值对操作,这种形式可以提供广泛的键值存储服务。

Memcached 和 Redis 是通过网络框架访问。

键值数据库网络框架接收到网络包,并按照相应的协议进行解析之后,就可以知道,客户端想写入一个键值对,并开始实际的写入流程。此时,我们会遇到一个系统设计上的问题,简单来说,就是网络连接的处理、网络请求的解析,以及数据存取的处理,是用一个线程、多个线程,还是多个进程来交互处理呢?该如何进行设计和取舍呢?我们一般把这个问题称为 I/O 模型设计

1.6. 如何定位键值对的位置

这依赖于键值数据库的索引模块。索引的作用是让键值数据库根据 key 找到相应 value 的存储位置,进而执行操作。

索引的类型有很多,常见的有哈希表、B+ 树、字典树等。不同的索引结构在性能、空间消耗、并发控制等方面具有不同的特征。Memcached 和 Redis 采用哈希表作为 key-value 索引

1.7. 如何实现重启后快速服务

  • 内存分配器,比如C的标准库glibc中的malloc,free函数,其实会产生内存碎片的问题。Redis 的内存分配器提供了多种选择,分配效率也不一样。
  • 对于持久化的数据存储,有两种方式:
    1. 对于每一个键值对,都对其进行落盘保存,虽然让数据更加可靠,但是,因为每次都要写盘,性能会受到很大影响。
    2. 周期性地把内存中的键值数据保存到文件中,这样可以避免频繁写盘操作的性能影响。但是,一个潜在的代价是数据仍然有丢失的风险。

2. Redis整体模型

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%201.png

  • Redis 主要通过网络框架进行访问,而不再是动态库了,这也使得 Redis 可以作为一个基础性的网络服务进行访问,扩大了 Redis 的应用范围。
  • Redis 数据模型中的 value 类型很丰富,因此也带来了更多的操作接口,例如面向列表的 LPUSH/LPOP,面向集合的 SADD/SREM 等。在下节课,我将和你聊聊这些 value 模型背后的数据结构和操作效率,以及它们对 Redis 性能的影响。
  • Redis 的持久化模块能支持两种方式:日志(AOF)和快照(RDB),这两种持久化方式具有不同的优劣势,影响到 Redis 的访问性能和可靠性。
  • SimpleKV 是个简单的单机键值数据库,但是,Redis 支持高可靠集群和高可扩展集群,因此,Redis 中包含了相应的集群功能支撑模块。

3. Redis为什么那么快

  • 内存型数据库,所有操作都在内存上完成,内存的访问速度本身就很快。
  • **高效的数据结构,**包括key-value的数据结构和value本身的数据结构。
  • 基于多路复用的高性能 I/O 模型

3.1. 键和值的数据结构

对于key和value的快速访问,Redis使用了一个哈希表来保存所有键值对,也被称为全局哈希表。

哈希桶中的元素保存的并不是值本身,而是指向具体值的指针,因此也可以用来指向集合类型的value。

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%202.png

查找过程主要依赖于哈希计算,和数据量的多少并没有直接关系,时间复杂度为O(1)

和大多数哈希表一样,这里有个哈希冲突问题,Redis采用两种方法解决哈希冲突:

  • 冲突链表法。即落到同一个哈希桶中的元素(冲突的元素)用一个链表保存,用指针依次连接。
  • rehash,即链表的遍历比较慢,当链表过长时会进行rehash,也就是增加哈希桶数量,让entry在桶之间分散保存,减少单个桶的元素数量。

就像下面这个图描述的一样:

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%203.png

为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:

  1. 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;
  2. 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
  3. 释放哈希表 1 的空间。

之后就可以从哈希表 1 切换到哈希表 2,用增大的哈希表 2 保存更多数据,而原来的哈希表 1 留作下一次 rehash 扩容备用。这个过程中,第2步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。为了避免这个问题,Redis 采用了渐进式 rehash,把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。

渐进式rehash简单来说就是在第2步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%204.png

3.2. 值的数据结构

首先需要明白一点,Redis的Value的类型有以下五种,一般记为1个String,4种集合类型。

  • String(字符串)
  • List(列表)
  • Hash(哈希)
  • Set(集合)
  • Sorted Set(有序集合)

这里说的数据结构并不等于这五种value类型,而是数据的保存形式,Redis底层共有6种数据结构:

  • 简单动态字符串
  • 双向链表
  • 压缩列表
  • 哈希表
  • 跳表
  • 整数数组

这个图很重要

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%205.png

以集合类型的数据结构为例,哈希表同key的数据结构,整数数组和双向链表都是顺序读写。

3.2.1. 压缩列表

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

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%206.png

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。

3.2.2. 跳表

有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%207.png

这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是 O(logN)。

3.2.3. 数据结构的时间复杂度

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%208.png

这里有个话题,整数数组和压缩列表在查找时间复杂度方面并没有很大的优势,那为什么 Redis 还会把它们作为底层数据结构呢:

  • 内存利用率,数组和压缩列表都是非常紧凑的数据结构,它比链表占用的内存要更少。Redis是内存数据库,大量数据存到内存中,此时需要做尽可能的优化,提高内存的利用率。
  • 数组对CPU高速缓存支持更友好(高速缓存读取N个缓存行,数据连续,可以读取更多的缓存行做处理,因此缓存命中率高),所以Redis在设计时,集合数据元素较少情况下,默认采用内存紧凑排列的方式存储,同时利用CPU高速缓存不会降低访问速度。当数据元素超过设定阈值后,避免查询时间复杂度太高,转为哈希和跳表数据结构存储,保证查询效率。

3.3. 不同操作的复杂度

蒋德钧老师将其总结为四句口诀:

  • 单元素操作是基础(通常O(1))
  • 范围操作非常耗时(尽量用SCAN相关命令渐进式遍历代替KEYS,SMEMBERS这种命令导致Redis服务block较长时间)
  • 统计操作通常高效(指集合类型对集合中所有元素个数的记录,通常有专门记录个数统计,高效)
  • 例外情况只有几个(例如压缩列表和双向链表都会记录表头和表尾的偏移量,对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操作来说,它们是在列表的头尾增删元素,这就可以通过偏移量直接定位,所以它们的复杂度也只有 O(1),可以实现快速操作。)

3.4. 单线程的Redis

Redis 是单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。

3.4.1. 多线程 or 单线程

一般认为,使用多线程,可以增加系统吞吐率。

但是,多线程会存在以下问题:

  • 多线程的并发访问控制问题。处理共享资源时,需要额外机制保证准确性,通常需要同步原语(互斥锁等)让其串行化,系统的调试性和可维护性会变差,吞吐率未必变高或变高收益没有那么高。
  • 多线程之间切换有上下文切换的损耗。

Redis直接采用了单线程模式

3.4.2. 基本的IO模型

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%209.png

为了处理一个 Get 请求,需要:

  • 监听客户端请求(bind/listen)
  • 和客户端建立连接(accept)
  • 从 socket 中读取请求(recv)
  • 解析客户端发送请求(parse)
  • 根据请求类型读取键值数据(get)
  • 最后给客户端返回结果,即向 socket 中写回数据(send)。

在这里的网络 IO 操作中,有潜在的阻塞点,分别是 accept() 和 recv()。

  • 当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。
  • 当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。

3.4.3. 非堵塞模式的IO模型

socket 网络模型本身支持非阻塞模式。

在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字。

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2010.png

针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用 accept() 时,已经存在监听套接字了。

虽然 Redis 线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知 Redis。

类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知 Redis。

3.4.4. 基于多路复用的高性能 I/O 模型

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2011.png

总体类似医院分诊台机制。

  • 一个线程处理多个IO流,基于Linux select/epoll机制
  • 内核可同时监听多个套接字
  • 提供基于事件的回调机制,一旦有请求到达,就会交给Redis线程处理
  • 这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理(避免轮询浪费CPU资源)

4. Redis的持久化

目前,Redis 的持久化主要有两大机制,即 AOF(Append Only File)日志和 RDB 快照

4.1. AOF日志

4.1.1. AOF日志原理

Redis的AOF日志是一个写后日志,即先执行命令,再把数据写入内存,最后记录日志。这一点与Mysql的innoDB引擎的redo log不一样,redo log是一个写前日志(Write Ahead Log, WAL)。

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2012.png

AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的。(InnoDB引擎的redo log是存的物理日志,即“在某个数据页上做了什么修改”)

Redis 收到“set testkey testvalue”命令后记录的日志:

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2013.png

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

AOF优点:

  1. 因为AOF是一个写后日志,所以Redis在记录AOF日志的时候是不会对命令进行语法检查的,只有执行成功才会记录日志。
  2. 不会阻塞当前的写操作

AOF缺点:

  • 如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。
  • 写日志对过程会对下一个操作造成堵塞,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。

Redis提供了3种写回策略:

  • Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
  • Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
  • No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2014.png

4.1.2. AOF日志重写机制

AOF 文件过大会带来的性能问题:

  • 文件系统本身对文件大小有限制,无法保存过大的文件
  • 如果文件太大,之后再往里面追加命令记录的话,效率也会变低
  • 如果发生宕机,AOF 中记录的命令要一个个被重新执行,用于故障恢复,如果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到 Redis 的正常使用

AOF 重写机制就是在重写时,Redis 根据数据库的现状创建一个新的 AOF 文件,也就是读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。重写完成之后,直接替换旧文件即可。

这会有一个多合一的效果(缩小AOF日志文件大小):

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2015.png

和 AOF 日志由主线程写回不同,重写过程是由后台子进程 bgrewriteaof 来完成的,这也是为了避免阻塞主线程,导致数据库性能下降。

4.2. RDB快照

4.2.1. RDB快照原理

AOF日志记录的是操作命令,而不是实际数据,因此在恢复数据的时候,需要逐一执行一遍AOF日志,这个过程会很缓慢。因此就有了RDB(Redis DataBase),RDB快照可以更快的进行数据恢复。

RDB存储的是全量快照

Redis 提供了两个命令来生成 RDB 文件:

  • save:在主线程中执行,会导致阻塞
  • bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞,这也是 Redis RDB 文件生成的默认配置。

对于bgsave命令,为了保证快照完整性,RDB文件应该记录的是“执行那一刻的快照数据“,这里为量不堵塞主线程正常处理写操作,Redis 就会借助操作系统提供的写时复制技术(Copy-On-Write, COW),在执行快照的同时,正常处理写操作。也就是说,bgsave 子进程是由主线程 fork 生成的,可以共享主线程的所有内存数据。bgsave 子进程运行后,开始读取主线程的内存数据,并把它们写入 RDB 文件。

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2016.png

RDB缺点

  • 频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
  • “写时复制”需要内存资源存储写的资源副本,带来额外内存开销
  • fork 这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长

4.3. 混合使用 AOF 日志和内存快照

Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。即内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。

通过一次RDB日志 + 下一次RDB日志之前的所有操作的AOF日志就可以拼出一次新的RDB日志,分析这个过程不难发现以下优化改变:

  • RDB日志复制主线程全量内存快照数据 → 上一次RDB的数据 + 到当前为止的修改数据(AOF日志呈现)

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2017.png

通过这个方法,就可以快速进行Redis数据恢复。

5. Redis的主从库模式

5.1. Redis主从库模式原理

Redis 提供了主从库模式,为保证数据副本的一致,主从库之间采用的是读写分离的方式:

  • 读操作:主库、从库都可以接收
  • 写操作:首先到主库执行,然后,主库将写操作同步给从库

通过 replicaof命令可以构建Redis主从库:(图中是用docker启动了两个redis实例,左边映射宿主机6379端口,右边映射宿主机6380端口)

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2018.png

主从库间数据同步图

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2019.png

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

    1. runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为“?”。
    2. offset,此时设为 -1,表示第一次复制。

    主库收到 psync 命令后,会用 FULLRESYNC 响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。FULLRESYNC 响应表示第一次复制采用的全量复制,主库会把当前所有的数据都复制给从库。

  • 第二阶段,进行全量同步。主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作

  • 第三阶段,进行增量同步。主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。在增量同步的时候会有一个repl_backlog_buffer,也就是环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置,流程如图:

    Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2020.png

replication bufferrepl_backlog_buffer 的讨论:

  • 关于replication buffer主库会给每个从库建立一个客户端,在内存中,每个客户端会对应一个buffer,也就是replication buffer,所以 replication buffer 不是共享的。

  • 关于repl_backlog_buffer,这是一块专用 buffer,在 Redis 服务器启动后,开始一直接收写操作命令,这是所有从库共享的。主库和从库会各自记录自己的复制进度,所以,不同的从库在进行恢复时,会把自己的复制进度(slave_repl_offset)发给主库,主库就可以和它独立同步。这个缓冲区可以在主从库间网络波动,或者从库写入数据过慢时记录主从的写数据的进度差异,最终实现数据一致。通过调整repl_backlog_size这个参数可以控制repl_backlog_buffer的大小,一般来说这样设置:

    repl_backlog_size = 缓冲空间大小 * 2
    									= (主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小) * 2
    

    repl_backlog_size环形缓冲区满了后(主库写的位置覆盖掉了从库的写的位置记录),那么就会发生全量复制。

    除此以外,为了减轻主库的压力,其实也可以使用“主-从-从”的模式,举个例子:

    有主库A,从库B,从库C
    设置B replicaof A
    设置C replicaof B
    

    这就实现了类型分裂营销一样的传播关系,一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,从库再同步给从库。这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

    Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2021.png

最后,做个小总结,关于Redis主从库,主要包含以下模式:

  • 全量复制
  • 增量复制
  • 基于长连接的命令传播

5.2. Redis哨兵机制

在 Redis 主从集群中,哨兵机制是实现主从库自动切换的关键机制它解决的是主库挂了后,从库提升为主库进而保证Redis主从集群继续可用的问题。它其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行。注意,**哨兵实例通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。**哨兵集群可以减少一些误判,下文再讨论。

哨兵主要负责的就是三个任务:

  • 监控:判断主库是否下线
  • 选主:在从库中选择主库
  • 通知:通知客户端(包括其他从库)新主库信息

整体流程如图:

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2022.png

下面介绍一下基本使用:

首先需要准备一份哨兵配置文件sentinel.conf,这个文件的example在Redis的源码里可以找到,里面有详细的注释说明,重点是这段:

# sentinel monitor <master-name> <ip> <redis-port> <quorum>
......

这几个参数分别对应主库名,主库ip,主库端口,quorum值(需要多少张赞成票可以判断“客观下线”,后文详细说明)

笔者这里做以下配置:

sentinel monitor redis-master 192.168.1.172 6379 1

之后就可以启动redis哨兵服务:

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2023.png

5.2.1. 如何判断是否下线(主观下线与客观下线)

哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态,如果响应超时,哨兵会将其记为主观下线(注意主库,从库都会有这个判断,从库判断出下线后没有额外处理)。

当一个哨兵实例判断主库“主观下线”后,会给其他实例发送is-master-down-by-addr命令,其他实例会根据自己和主库的连接情况做出Y(赞成票)或N(反对票)响应。

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2024.png

当一个哨兵获得所需的赞成票后,他会标记主库为客观下线,这个“所需的赞成票”对应配置文件中的quorum参数。

5.2.2. 哪个哨兵执行主从切换

上文中提到判断客观下线的过程,在这个过程中,当一个哨兵实例判断主库为“客观下线”后,会给其他哨兵发送命令,表明希望由自己来执行主从切换。

这里很容易想到,在一个哨兵集群中会有很多的实例都会进入到这个阶段,去尝试由自己来进行主从切换,那这里就需要所有哨兵形成一个“共识”。

这个共识一般会通过选举(共识)算法(比如Paxos、Raft等,Redis哨兵使用的是类似Raft算法的方式),选出一个Leader哨兵,由这个Leader完成整个主从切换的过程

在这个选举算法中,要想成为Leader,需要拿到半数以上的赞成票。

可以看一个例子:

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2025.png

最终S3哨兵拿到2票,成为了Leader。

这个过程有一些东西需要特别注意:

  1. 所有从库都同时判断到“客观下线”是以一定理论可能性的,几率极低。
  2. 投票选Leader的时候,每个实例只能投一张赞成票。(如果一个哨兵已经投了其他实例的赞成票,后续当他发起Leader选举的时候,他是不能再给自己投赞成票的)

5.2.3. 如何选择主库(筛选+三轮打分)

成为了Leader哨兵后,会带来一个问题,那么多从库,选择谁当主库呢?

哨兵在选择主库的时候首先会筛选

  • 从库当前在线状态(是否上线)
  • 从库的网络连接状态(网络质量)

具体来说,哨兵会有一个最大连接超时时间,也就是配置项中的down-after-milliseconds参数,当超时10次以后,这个从库会被认为网络状况不好,不适合当主库

其次会进行三轮打分每一轮如果没有出现最高分,就会进行下一轮,否则直接选出主库:

  1. 第一轮,优先级最高的从库得分高。用户可以通过slave-priority配置项,给不同的从库设置不同优先级。
  2. 第二轮,和旧主库同步程度最接近的从库得分高。也就是比较各自的slave_repl_offset(从库复制的进度),选出一个最高的从库。
  3. 第三轮,ID 号小的从库得分高。

5.2.4. pub/sub 机制

Redis提供了pub/sub机制,也就是发布/订阅机制,最终实现:

  • 哨兵与主库,从库的信息交换
  • 哨兵与客户段的信息交换
  • 提供给应用程序使用的发布订阅模型

哨兵和主库建立连接后,就可以在主库发布和接受消息了。Redis以“频道”的形式区分消息,只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换

我们在配置哨兵的时候,其实只配置了主库的信息,最终哨兵通过主库上__**sentinel__:hello**频道感知到其他哨兵的存在,进而建立出哨兵集群。

哨兵通过发送给主库INFO命令,获取到从库信息,并与从库连接:

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2026.png

除此以外,哨兵也是一个特别模式的下的Redis实例,本身也有pub/sub机制,客户端通过订阅哨兵的一些凭悼,可以获取一些消息,比如主库是否发生切换,主库是否下线等:

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2027.png

6. Redis切片集群

6.1. Redis切片集群基础

一般认为,一个Redis实例不要太大,否则开启RDB持久化+主从模式的时候会带来更多的数据生成,传输,加载的开销。但如果业务需求数据量确实很大,那就要考虑Redis 应对数据量增多的两种方案:纵向扩展(scale up)和横向扩展(scale out)。

  • 纵向扩展:升级单个 Redis 实例的资源配置,包括增加内存容量、增加磁盘容量、使用更高配置的 CPU。
  • 横向扩展:横向增加当前 Redis 实例的个数

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2028.png

纵向扩展会受到硬件和成本的限制,而横向扩展一般采用Redis切片集群。

在切片集群中,数据需要分布在不同实例上,下文以Redis官方的Redis Cluster(需要Redis3.0+)为例进行陈述。

Redis Cluster 方案采用哈希槽(Hash Slot),来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽每个键值对都会根据它的 key,被映射到一个哈希槽中

具体来说:

  1. 根据CRC16算法(循环冗余校验算法)获得一个16bit的值
  2. 对哈希槽总数取模(%16384)
  3. 模数对应相应编号的哈希槽
  4. 分配哈希槽与Redis实例的映射关系,每个哈希槽有对应的Redis实例

Redis%E5%9F%BA%E7%A1%80%2014228084dd94451f866e44377bc9cb0d/Untitled%2029.png

哈希槽与Redis实例质检的对应关系默认是均分,也就是16384/N个实例,也可以手动设置,手动设置必须分配完所有的哈希槽

Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。

因此,客户端在连接任何一个Redis实例时,实例就会把全部的哈希槽映射信息发给客户端。

这样,客户端定位数据有以下流程:

  1. 根据key计算得到哈希槽的值
  2. 查询本地存的哈希槽映射关系,找到对应的Redis实例
  3. 向对应Redis实例发送命令

最终,数据可以通过键的哈希值映射到哈希槽,再通过哈希槽分散保存到不同的实例上。

6.2. Redis Cluster处理hash slot映射关系变化

一般有两种情况会改变哈希槽与实例的映射关系:

  1. 在集群中,实例有新增或删除,Redis 需要重新分配哈希槽
  2. 为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍

如果数据已经全部迁移完毕(变化完),Redis会提供MOVED命令,本质上是一个重定向机制:

  1. 假设客户端根据计算,结合本地映射关系得到数据在Redis实例A,于是去查询实例A

  2. Redis实例A找不到对应的数据,因为数据应该在实例B上,于是回调MOVED命令:

    GET hello:key
    (error) MOVED 13320 172.16.19.5:6379
    
  3. MOVED命令表示,客户端请求的哈希槽13320这个位置的数据已经在另一个实例(实例B上)

  4. 服务端请求实例B,并且更新本地缓存,哈希槽13320这个位置对应实例B

如果数据还在迁移过程中,客户端会返回ASK命令

GET hello:key
(error) ASK 13320 172.16.19.5:6379

客户端要向新的实例发送ASKING命令去执行命令。(这个命令的意思是,让这个实例允许执行客户端接下来发送的命令),ASK命令不会更新客户端缓存的哈希槽映射信息,只是允许客户端向新实例发送请求。

参考文档