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. 一个键值数据库的基本结构
一个键值数据库包括了访问框架、索引模块、操作模块和存储模块四部分
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 的内存分配器提供了多种选择,分配效率也不一样。
- 对于持久化的数据存储,有两种方式:
- 对于每一个键值对,都对其进行落盘保存,虽然让数据更加可靠,但是,因为每次都要写盘,性能会受到很大影响。
- 周期性地把内存中的键值数据保存到文件中,这样可以避免频繁写盘操作的性能影响。但是,一个潜在的代价是数据仍然有丢失的风险。
2. Redis整体模型
- 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。
查找过程主要依赖于哈希计算,和数据量的多少并没有直接关系,时间复杂度为O(1),
和大多数哈希表一样,这里有个哈希冲突问题,Redis采用两种方法解决哈希冲突:
- 冲突链表法。即落到同一个哈希桶中的元素(冲突的元素)用一个链表保存,用指针依次连接。
- rehash,即链表的遍历比较慢,当链表过长时会进行rehash,也就是增加哈希桶数量,让entry在桶之间分散保存,减少单个桶的元素数量。
就像下面这个图描述的一样:
为了使 rehash 操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。一开始,当你刚插入数据时,默认使用哈希表 1,此时的哈希表 2 并没有被分配空间。随着数据逐步增多,Redis 开始执行 rehash,这个过程分为三步:
- 给哈希表 2 分配更大的空间,例如是当前哈希表 1 大小的两倍;
- 把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
- 释放哈希表 1 的空间。
之后就可以从哈希表 1 切换到哈希表 2,用增大的哈希表 2 保存更多数据,而原来的哈希表 1 留作下一次 rehash 扩容备用。这个过程中,第2步涉及大量的数据拷贝,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。为了避免这个问题,Redis 采用了渐进式 rehash,把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。
渐进式rehash简单来说就是在第2步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:
3.2. 值的数据结构
首先需要明白一点,Redis的Value的类型有以下五种,一般记为1个String,4种集合类型。
- String(字符串)
- List(列表)
- Hash(哈希)
- Set(集合)
- Sorted Set(有序集合)
这里说的数据结构并不等于这五种value类型,而是数据的保存形式,Redis底层共有6种数据结构:
- 简单动态字符串
- 双向链表
- 压缩列表
- 哈希表
- 跳表
- 整数数组
这个图很重要:
以集合类型的数据结构为例,哈希表同key的数据结构,整数数组和双向链表都是顺序读写。
3.2.1. 压缩列表
压缩列表实际上类似于一个数组,数组中的每一个元素都对应保存一个数据。和数组不同的是,压缩列表在表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;压缩列表在表尾还有一个 zlend,表示列表结束。
在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了。
3.2.2. 跳表
有序链表只能逐一查找元素,导致操作起来非常缓慢,于是就出现了跳表。具体来说,跳表在链表的基础上,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:
这个查找过程就是在多级索引上跳来跳去,最后定位到元素。这也正好符合“跳”表的叫法。当数据量很大时,跳表的查找复杂度就是 O(logN)。
3.2.3. 数据结构的时间复杂度
这里有个话题,整数数组和压缩列表在查找时间复杂度方面并没有很大的优势,那为什么 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模型
为了处理一个 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 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用 accept() 时,已经存在监听套接字了。
虽然 Redis 线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知 Redis。
类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知 Redis。
3.4.4. 基于多路复用的高性能 I/O 模型
总体类似医院分诊台机制。
- 一个线程处理多个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)。
AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的。(InnoDB引擎的redo log是存的物理日志,即“在某个数据页上做了什么修改”)
Redis 收到“set testkey testvalue”命令后记录的日志:
“*3”表示当前命令有三个部分,每部分都是由“$+数字”开头,后面紧跟着具体的命令、键或值。这里,“数字”表示这部分中的命令、键或值一共有多少字节。例如,“$3 set”表示这部分有 3 个字节,也就是“set”命令。
AOF优点:
- 因为AOF是一个写后日志,所以Redis在记录AOF日志的时候是不会对命令进行语法检查的,只有执行成功才会记录日志。
- 不会阻塞当前的写操作。
AOF缺点:
- 如果刚执行完一个命令,还没有来得及记日志就宕机了,那么这个命令和相应的数据就有丢失的风险。
- 写日志对过程会对下一个操作造成堵塞,AOF 日志也是在主线程中执行的,如果在把日志文件写入磁盘时,磁盘写压力大,就会导致写盘很慢,进而导致后续的操作也无法执行了。
Redis提供了3种写回策略:
- Always,同步写回:每个写命令执行完,立马同步地将日志写回磁盘;
- Everysec,每秒写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘;
- No,操作系统控制的写回:每个写命令执行完,只是先把日志写到 AOF 文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘。
4.1.2. AOF日志重写机制
AOF 文件过大会带来的性能问题:
- 文件系统本身对文件大小有限制,无法保存过大的文件
- 如果文件太大,之后再往里面追加命令记录的话,效率也会变低
- 如果发生宕机,AOF 中记录的命令要一个个被重新执行,用于故障恢复,如果日志文件太大,整个恢复过程就会非常缓慢,这就会影响到 Redis 的正常使用
AOF 重写机制就是在重写时,Redis 根据数据库的现状创建一个新的 AOF 文件,也就是读取数据库中的所有键值对,然后对每一个键值对用一条命令记录它的写入。重写完成之后,直接替换旧文件即可。
这会有一个多合一的效果(缩小AOF日志文件大小):
和 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 文件。
RDB缺点:
- 频繁将全量数据写入磁盘,会给磁盘带来很大压力,多个快照竞争有限的磁盘带宽,前一个快照还没有做完,后一个又开始做了,容易造成恶性循环。
- “写时复制”需要内存资源存储写的资源副本,带来额外内存开销。
- fork 这个创建过程本身会阻塞主线程,而且主线程的内存越大,阻塞时间越长
4.3. 混合使用 AOF 日志和内存快照
Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法。即内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
通过一次RDB日志 + 下一次RDB日志之前的所有操作的AOF日志就可以拼出一次新的RDB日志,分析这个过程不难发现以下优化改变:
- RDB日志复制主线程全量内存快照数据 → 上一次RDB的数据 + 到当前为止的修改数据(AOF日志呈现)
通过这个方法,就可以快速进行Redis数据恢复。
5. Redis的主从库模式
5.1. Redis主从库模式原理
Redis 提供了主从库模式,为保证数据副本的一致,主从库之间采用的是读写分离的方式:
- 读操作:主库、从库都可以接收
- 写操作:首先到主库执行,然后,主库将写操作同步给从库
通过 replicaof
命令可以构建Redis主从库:(图中是用docker启动了两个redis实例,左边映射宿主机6379端口,右边映射宿主机6380端口)
主从库间数据同步图:
-
第一阶段,主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。具体来说,从库给主库发送
psync
命令,表示要进行数据同步,主库根据这个命令的参数来启动复制。psync
命令包含了主库的runID
和复制进度offset
两个参数。runID
,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为“?”。offset
,此时设为 -1,表示第一次复制。
主库收到
psync
命令后,会用FULLRESYNC
响应命令带上两个参数:主库runID
和主库目前的复制进度offset
,返回给从库。从库收到响应后,会记录下这两个参数。FULLRESYNC
响应表示第一次复制采用的全量复制,主库会把当前所有的数据都复制给从库。 -
第二阶段,进行全量同步。主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。在主库将数据同步给从库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证主从库的数据一致性,主库会在内存中用专门的
replication buffer
,记录 RDB 文件生成后收到的所有写操作。 -
第三阶段,进行增量同步。主库会把第二阶段执行过程中新收到的写命令,再发送给从库。具体的操作是,当主库完成 RDB 文件发送后,就会把此时
replication buffer
中的修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。在增量同步的时候会有一个repl_backlog_buffer
,也就是环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置,流程如图:
replication buffer
与repl_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主从库,主要包含以下模式:
- 全量复制
- 增量复制
- 基于长连接的命令传播
5.2. Redis哨兵机制
在 Redis 主从集群中,哨兵机制是实现主从库自动切换的关键机制,它解决的是主库挂了后,从库提升为主库进而保证Redis主从集群继续可用的问题。它其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行。注意,**哨兵实例通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。**哨兵集群可以减少一些误判,下文再讨论。
哨兵主要负责的就是三个任务:
- 监控:判断主库是否下线
- 选主:在从库中选择主库
- 通知:通知客户端(包括其他从库)新主库信息
整体流程如图:
下面介绍一下基本使用:
首先需要准备一份哨兵配置文件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哨兵服务:
5.2.1. 如何判断是否下线(主观下线与客观下线)
哨兵进程会使用 PING 命令检测它自己和主、从库的网络连接情况,用来判断实例的状态,如果响应超时,哨兵会将其记为主观下线(注意主库,从库都会有这个判断,从库判断出下线后没有额外处理)。
当一个哨兵实例判断主库“主观下线”后,会给其他实例发送is-master-down-by-addr
命令,其他实例会根据自己和主库的连接情况做出Y(赞成票)或N(反对票)响应。
当一个哨兵获得所需的赞成票后,他会标记主库为客观下线,这个“所需的赞成票”对应配置文件中的quorum
参数。
5.2.2. 哪个哨兵执行主从切换
上文中提到判断客观下线的过程,在这个过程中,当一个哨兵实例判断主库为“客观下线”后,会给其他哨兵发送命令,表明希望由自己来执行主从切换。
这里很容易想到,在一个哨兵集群中会有很多的实例都会进入到这个阶段,去尝试由自己来进行主从切换,那这里就需要所有哨兵形成一个“共识”。
这个共识一般会通过选举(共识)算法(比如Paxos、Raft等,Redis哨兵使用的是类似Raft算法的方式),选出一个Leader哨兵,由这个Leader完成整个主从切换的过程。
在这个选举算法中,要想成为Leader,需要拿到半数以上的赞成票。
可以看一个例子:
最终S3哨兵拿到2票,成为了Leader。
这个过程有一些东西需要特别注意:
- 所有从库都同时判断到“客观下线”是以一定理论可能性的,几率极低。
- 投票选Leader的时候,每个实例只能投一张赞成票。(如果一个哨兵已经投了其他实例的赞成票,后续当他发起Leader选举的时候,他是不能再给自己投赞成票的)
5.2.3. 如何选择主库(筛选+三轮打分)
成为了Leader哨兵后,会带来一个问题,那么多从库,选择谁当主库呢?
哨兵在选择主库的时候首先会筛选:
- 从库当前在线状态(是否上线)
- 从库的网络连接状态(网络质量)
具体来说,哨兵会有一个最大连接超时时间,也就是配置项中的down-after-milliseconds
参数,当超时10次以后,这个从库会被认为网络状况不好,不适合当主库
其次会进行三轮打分,每一轮如果没有出现最高分,就会进行下一轮,否则直接选出主库:
- 第一轮,优先级最高的从库得分高。用户可以通过
slave-priority
配置项,给不同的从库设置不同优先级。 - 第二轮,和旧主库同步程度最接近的从库得分高。也就是比较各自的
slave_repl_offset
(从库复制的进度),选出一个最高的从库。 - 第三轮,ID 号小的从库得分高。
5.2.4. pub/sub 机制
Redis提供了pub/sub机制,也就是发布/订阅机制,最终实现:
- 哨兵与主库,从库的信息交换
- 哨兵与客户段的信息交换
- 提供给应用程序使用的发布订阅模型
哨兵和主库建立连接后,就可以在主库发布和接受消息了。Redis以“频道”的形式区分消息,只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换。
我们在配置哨兵的时候,其实只配置了主库的信息,最终哨兵通过主库上__**sentinel__:hello**
频道感知到其他哨兵的存在,进而建立出哨兵集群。
哨兵通过发送给主库INFO命令,获取到从库信息,并与从库连接:
除此以外,哨兵也是一个特别模式的下的Redis实例,本身也有pub/sub机制,客户端通过订阅哨兵的一些凭悼,可以获取一些消息,比如主库是否发生切换,主库是否下线等:
6. Redis切片集群
6.1. Redis切片集群基础
一般认为,一个Redis实例不要太大,否则开启RDB持久化+主从模式的时候会带来更多的数据生成,传输,加载的开销。但如果业务需求数据量确实很大,那就要考虑Redis 应对数据量增多的两种方案:纵向扩展(scale up)和横向扩展(scale out)。
- 纵向扩展:升级单个 Redis 实例的资源配置,包括增加内存容量、增加磁盘容量、使用更高配置的 CPU。
- 横向扩展:横向增加当前 Redis 实例的个数。
纵向扩展会受到硬件和成本的限制,而横向扩展一般采用Redis切片集群。
在切片集群中,数据需要分布在不同实例上,下文以Redis官方的Redis Cluster(需要Redis3.0+)为例进行陈述。
Redis Cluster 方案采用哈希槽(Hash Slot),来处理数据和实例之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,每个键值对都会根据它的 key,被映射到一个哈希槽中。
具体来说:
- 根据CRC16算法(循环冗余校验算法)获得一个16bit的值
- 对哈希槽总数取模(%16384)
- 模数对应相应编号的哈希槽
- 分配哈希槽与Redis实例的映射关系,每个哈希槽有对应的Redis实例
哈希槽与Redis实例质检的对应关系默认是均分,也就是16384/N个实例,也可以手动设置,手动设置必须分配完所有的哈希槽。
Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。
因此,客户端在连接任何一个Redis实例时,实例就会把全部的哈希槽映射信息发给客户端。
这样,客户端定位数据有以下流程:
- 根据key计算得到哈希槽的值
- 查询本地存的哈希槽映射关系,找到对应的Redis实例
- 向对应Redis实例发送命令
最终,数据可以通过键的哈希值映射到哈希槽,再通过哈希槽分散保存到不同的实例上。
6.2. Redis Cluster处理hash slot映射关系变化
一般有两种情况会改变哈希槽与实例的映射关系:
- 在集群中,实例有新增或删除,Redis 需要重新分配哈希槽
- 为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍
如果数据已经全部迁移完毕(变化完),Redis会提供MOVED
命令,本质上是一个重定向机制:
-
假设客户端根据计算,结合本地映射关系得到数据在Redis实例A,于是去查询实例A
-
Redis实例A找不到对应的数据,因为数据应该在实例B上,于是回调
MOVED
命令:GET hello:key (error) MOVED 13320 172.16.19.5:6379
-
MOVED
命令表示,客户端请求的哈希槽13320这个位置的数据已经在另一个实例(实例B上) -
服务端请求实例B,并且更新本地缓存,哈希槽13320这个位置对应实例B
如果数据还在迁移过程中,客户端会返回ASK
命令:
GET hello:key
(error) ASK 13320 172.16.19.5:6379
客户端要向新的实例发送ASKING命令去执行命令。(这个命令的意思是,让这个实例允许执行客户端接下来发送的命令),ASK命令不会更新客户端缓存的哈希槽映射信息,只是允许客户端向新实例发送请求。