6-10节 数据同步、哨兵机制、哨兵集群、切片集群
06-数据同步:主从库如何实现数据一致?
Redis高可用有两层含义:
- 一是数据尽量少丢失【AOF和RDB】
- 二是服务尽量少中断【增加副本冗余量,将一份数据同时保存在多个实例上】
主从库之间采用的是读写分离的方式。
- 读操作:主库、从库都可以接收;
- 写操作:首先到主库执行,然后,主库将写操作同步给从库。
那么,主从库同步是如何完成的呢?主库数据是一次性传给从库,还是分批同步?要是主从库间的网络断连了,数据还能保持一致吗?这节课,我就和你聊聊主从库同步的原理,以及应对网络断连风险的方案。
主从库间如何进行第一次同步?
当我们启动多个Redis实例的时候,它们相互之间就可以通过replicaof(Redis 5.0之前使用slaveof) 命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。
- 第一阶段是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。在这一步,从库和主库建立起连接,并告诉主库即将进行同步,主库确认回复后,主从库间就可以开始同步了。
- psync命令包含了主库的runID和复制进度offset两个参数。
- runID,是每个Redis实例启动时都会自动生成的一个随机ID,用来唯一标记这个实例。当从库和主库第一次复制时,因为不知道主库的runID,所以将runID设为“?”。
- offset,此时设为-1,表示第一次复制。
- 主库收到psync命令后,会用FULLRESYNC响应命令带上两个参数:主库runID和主库目前的复制进度offset,返回给从库。从库收到响应后,会记录下这两个参数。
- FULLRESYNC响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。
- psync命令包含了主库的runID和复制进度offset两个参数。
- 在第二阶段,主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的RDB文件。
- 具体来说,主库执行bgsave命令,生成RDB文件,接着将文件发给从库。
- 从库接收到RDB文件后,会先清空当前数据库,然后加载RDB文件。【为了避免之前数据的影响,从库需要先把当前数据库清空。】
- 主库会在内存中用专门的replication buffer,记录RDB文件生成后收到的所有写操作。
- 第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库。
- 具体的操作是,当主库完成RDB文件发送后,就会把此时replication buffer中的修改操作发给从库,从库再重新执行这些操作。
主从级联模式分担全量复制时的主库压力
一次全量复制中,对于主库来说,需要完成两个耗时的操作:生成RDB文件和传输RDB文件。
fork这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。
此外,传输RDB文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。
可以通过“主-从-从”模式将主库生成RDB和传输RDB的压力,以级联的方式分散到从库上。
一旦主从库完成了全量复制,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。
这个过程中存在着风险点,最常见的就是网络断连或阻塞。
主从库间网络断了怎么办?
从Redis 2.8开始,网络断了之后,主从库会采用增量复制的方式继续同步。
当主从库断连后,主库会把断连期间收到的写操作命令,写入replication buffer,同时也会把这些操作命令也写入repl_backlog_buffer这个缓冲区。
repl_backlog_buffer是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。
主从库的连接恢复之后,从库首先会给主库发送psync命令,并把自己当前的slave_repl_offset发给主库,主库会判断自己的master_repl_offset和slave_repl_offset之间的差距。
在网络断连阶段,主库可能会收到新的写操作命令,所以,一般来说,master_repl_offset会大于slave_repl_offset。此时,主库只用把master_repl_offset和slave_repl_offset之间的命令操作同步给从库就行。
因为repl_backlog_buffer是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。
为避免这一情况,一般而言,我们可以调整repl_backlog_size这个参数。这个参数和所需的缓冲空间大小有关。缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,考虑到可能存在一些突发的请求压力,我们通常需要把这个缓冲空间扩大一倍,即repl_backlog_size = 缓冲空间大小 * 2,这也就是repl_backlog_size的最终值。
这样一来,增量复制时主从库的数据不一致风险就降低了。不过,如果并发请求量非常大,连两倍的缓冲空间都存不下新操作请求的话,此时,主从库数据仍然可能不一致。
针对这种情况,一方面,你可以根据Redis所在服务器的内存资源再适当增加repl_backlog_size值,比如说设置成缓冲空间大小的4倍,另一方面,你可以考虑使用切片集群来分担单个主库的请求压力。
一个Redis实例的数据库不要太大,一个实例大小在几GB级别比较合适,这样可以减少RDB文件生成、传输和重新加载的开销。
补充一个概念辨析:在主从复制中,主节点和从节点都是 Redis 实例的一种。
提问:主从复制为什么不用AOF呢?
1、因为AOF文件比RDB文件大,网络传输比较耗时,
2、从库在初始化数据时,RDB文件比AOF文件执行更快
07-哨兵机制:主库挂了,如何不间断服务?
如果主库挂了,我们就需要运行一个新主库,比如说把一个从库切换为主库,把它当成主库。这就涉及到三个问题:
- 主库真的挂了吗?
- 该选择哪个从库作为主库?
- 怎么把新主库的相关信息通知给从库和客户端呢?
哨兵机制的基本流程
哨兵其实就是一个运行在特殊模式下的Redis进程,主从库实例运行的同时,它也在运行。哨兵主要负责的就是三个任务:监控、选主(选择主库)和通知。
监控是指哨兵进程在运行时,周期性地给所有的主从库发送PING命令,检测它们是否仍然在线运行。
这三个任务以及它们各自的目标可见下图
在监控和选主这两个任务中,哨兵需要做出两个决策:
- 在监控任务中,哨兵需要判断主库是否处于下线状态;
- 在选主任务中,哨兵也要决定选择哪个从库实例作为主库。
主观下线和客观下线
哨兵进程会使用PING命令检测它自己和主、从库的网络连接情况,用来判断实例的状态。如果哨兵发现主库或从库对PING命令的响应超时了,那么,哨兵就会先把它标记为“主观下线”。
如果检测的是从库,那么,哨兵简单地把它标记为“主观下线”就行了。
如果检测的是主库,那么,哨兵还不能简单地把它标记为“主观下线”,开启主从切换。需要防止误判
如何减少误判:在日常生活中,当我们要对一些重要的事情做判断的时候,经常会和家人或朋友一起商量一下,然后再做决定。
哨兵机制也是类似的,它通常会采用多实例组成的集群模式进行部署,这也被称为哨兵集群。
“客观下线”的标准就是,当有N个哨兵实例时,最好要有N/2 + 1个实例判断主库为“主观下线”,才能最终判定主库为“客观下线”。(可由管理员设定)
如何选定新主库?
一般来说,我把哨兵选择新主库的过程称为“筛选+打分”。简单来说,我们在多个从库中,先按照一定的筛选条件,把不符合条件的从库去掉。然后,我们再按照一定的规则,给剩下的从库逐个打分,将得分最高的从库选为新主库,如下图所示:
注意“一定”这个用词:在选主时,除了要检查从库的当前在线状态,还要判断它之前的网络连接状态。 如果从库总是和主库断连,而且断连次数超出了一定的阈值,我们就有理由相信,这个从库的网络状况并不好。
使用配置项down-after-milliseconds * 10。其中,down-after-milliseconds是我们认定主从库断连的最大连接超时时间。如果在down-after-milliseconds毫秒内,主从节点都没有通过网络联系上,我们就可以认为主从节点断连了。
接下来就要给剩余的从库打分了。我们可以分别按照三个规则依次进行三轮打分,这三个规则分别是从库优先级、从库复制进度以及从库ID号。 只要在某一轮中,有从库得分最高,那么它就是主库了,选主过程到此结束。如果没有出现得分最高的从库,那么就继续进行下一轮。
- 第一轮:优先级最高的从库得分高。
- 用户可以通过slave-priority配置项,给不同的从库设置不同优先级。【可以手动设置,例如根据内存大小】
- 第二轮:和旧主库同步程度最接近的从库得分高。
- 如果在所有从库中,有从库的slave_repl_offset最接近master_repl_offset,那么它的得分就最高,可以作为新主库。
- 第三轮:ID号小的从库得分高。
- 目前,Redis在选主库时,有一个默认的规定:在优先级和复制进度都相同的情况下,ID号最小的从库得分最高,会被选为新主库。
相关提问
08-哨兵集群:哨兵挂了,主从库还能切换吗?
一旦多个实例组成了哨兵集群,即使有哨兵实例出现故障挂掉了,其他哨兵还能继续协作完成主从库切换的工作,包括判定主库是不是处于下线状态,选择新主库,以及通知从库和客户端。
如果你部署过哨兵集群的话就会知道,在配置哨兵的信息时,我们只需要用到下面的这个配置项,设置主库的IP和端口,并没有配置其他哨兵的连接信息。
问题:这些哨兵实例既然都不知道彼此的地址,又是怎么组成集群的呢?
基于pub/sub机制的哨兵集群组成
哨兵实例之间可以相互发现,要归功于Redis提供的pub/sub机制,也就是发布/订阅机制。
当多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的IP地址和端口。
我们自己编写的应用程序也可以通过Redis进行消息的发布和订阅。只有订阅了同一个频道的应用,才能通过发布的消息进行信息交换。
在主从集群中,主库上有一个名为“sentinel:hello”的频道,不同哨兵就是通过它来相互发现,实现互相通信的。
哨兵除了彼此之间建立起连接形成集群外,还需要和从库建立连接。这是因为,在哨兵的监控任务中,它需要对主从库都进行心跳判断,而且在主从库切换完成后,它还需要通知从库,让它们和新主库进行同步。【注意是哨兵与从库】
那么,哨兵是如何知道从库的IP地址和端口的呢?
这是由哨兵向主库发送INFO命令来完成的。就像下图所示,哨兵2给主库发送INFO命令,主库接受到这个命令后,就会把从库列表返回给哨兵。接着,哨兵就可以根据从库列表中的连接信息,和每个从库建立连接,并在这个连接上持续地对从库进行监控。哨兵1和3可以通过相同的方法和从库建立连接。
哨兵不能只和主、从库连接。因为,主从库切换后,客户端也需要知道新主库的连接信息,才能向新主库发送请求操作。所以,哨兵还需要完成把新主库的信息告诉客户端这个任务。
其他问题:如何在客户端通过监控了解哨兵进行主从切换的过程呢?比如说,主从切换进行到哪一步了?——>【这其实就是要求,客户端能够获取到哨兵集群在监控、选主、切换这个过程中发生的各种事件。】
基于pub/sub机制的客户端事件通知
从本质上说,哨兵就是一个运行在特定模式下的Redis实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,每个哨兵实例也提供pub/sub机制,客户端可以从哨兵订阅消息。哨兵提供的消息订阅频道有很多,不同频道包含了主从库切换过程中的不同关键事件。
知道了这些频道之后,你就可以让客户端从哨兵这里订阅消息了。具体的操作步骤是,客户端读取哨兵的配置文件后,可以获得哨兵的地址和端口,和哨兵建立网络连接。然后,我们可以在客户端执行订阅命令,来获取不同的事件消息。
有了这些事件通知,客户端不仅可以在主从切换后得到新主库的连接信息,还可以监控到主从库切换过程中发生的各个重要事件。这样,客户端就可以知道主从切换进行到哪一步了,有助于了解切换进度。
新的问题——>主库故障以后,哨兵集群有多个实例,那怎么确定由哪个哨兵来进行实际的主从切换呢?
由哪个哨兵执行主从切换?
确定由哪个哨兵执行主从切换的过程,和主库“客观下线”的判断过程类似,也是一个“投票仲裁”的过程。
具体的判断流程:
任何一个实例只要自身判断主库“主观下线”后,就会给其他实例发送is-master-down-by-addr命令。接着,其他实例会根据自己和主库的连接情况,做出Y或N的响应,Y相当于赞成票,N相当于反对票。
一个哨兵获得了仲裁所需的赞成票数后,就可以标记主库为“客观下线”。这个所需的赞成票数是通过哨兵配置文件中的quorum配置项设定的。例如,现在有5个哨兵,quorum配置的是3,那么,一个哨兵需要3张赞成票,就可以标记主库为“客观下线”了。这3张赞成票包括哨兵自己的一张赞成票和另外两个哨兵的赞成票。
此时,这个哨兵就可以再给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。这个投票过程称为“Leader选举”。因为最终执行主从切换的哨兵称为Leader,投票过程就是确定Leader。
在投票过程中,任何一个想成为Leader的哨兵,要满足两个条件:第一,拿到半数以上的赞成票;第二,拿到的票数同时还需要大于等于哨兵配置文件中的quorum值。
如果S3没有拿到2票Y,那么这轮投票就不会产生Leader。哨兵集群会等待一段时间(也就是哨兵故障转移超时时间的2倍),再重新选举。
- 这是因为,哨兵集群能够进行成功投票,很大程度上依赖于选举命令的正常网络传播。如果网络压力较大或有短时堵塞,就可能导致没有一个哨兵能拿到半数以上的赞成票。所以,等到网络拥塞好转之后,再进行投票选举,成功的概率就会增加。
需要注意,一般哨兵集群至少有3个实例,如果只有2个实例,有个哨兵挂掉了,那么,此时的集群是无法进行主从库切换的。这一点很重要,你在实际应用时可不能忽略了。
最后,我想再给你分享一个经验:要保证所有哨兵实例的配置是一致的,尤其是主观下线的判断值down-after-milliseconds。 我们曾经就踩过一个“坑”。当时,在我们的项目中,因为这个值在不同的哨兵实例上配置不一致,导致哨兵集群一直没有对有故障的主库形成共识,也就没有及时切换主库,最终的结果就是集群服务不稳定。
这个是chatgpt对于主从切换的流程概述、如何确定由哪个哨兵执行主从切换的过程概述
09-切片集群:数据增多了,是该加内存还是加实例?
在使用RDB进行持久化时,Redis会fork子进程来完成,fork操作的用时和Redis的数据量是正相关的,而fork在执行时会阻塞主线程。数据量越大,fork操作造成的主线程阻塞的时间越长。
在实际应用Redis时,随着用户或业务规模的扩展,保存大量数据的情况通常是无法避免的。而切片集群,就是一个非常好的解决方案。
如何保存更多数据?
- 纵向扩展(scale up):升级单个Redis实例的资源配置
- 横向扩展(scale out):横向增加当前Redis实例的个数
使用切片集群,需要解决两大问题:
- 数据切片后,在多个实例之间如何分布?
- 客户端怎么确定想要访问的数据在哪个实例上?
数据切片和实例的对应分布关系
Redis Cluster方案采用哈希槽(Hash Slot,接下来我会直接称之为Slot),来处理数据和实例之间的映射关系。在Redis Cluster方案中,一个切片集群共有16384个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的key,被映射到一个哈希槽中。
我们在部署Redis Cluster方案时,可以使用cluster create命令创建集群,此时,Redis会自动把这些槽平均分布在集群实例上。
当然, 我们也可以使用cluster meet命令手动建立实例间的连接,形成集群,再使用cluster addslots命令,指定每个实例上的哈希槽个数。【例如假设集群中不同Redis实例的内存大小配置不一,但想均分在各个实例的场景】
另外,我再给你一个小提醒,在手动分配哈希槽时,需要把16384个槽都分配完,否则Redis集群无法正常工作。
客户端如何定位数据?
Redis实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。当实例之间相互连接后,每个实例就有所有哈希槽的映射关系了。
客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。
在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:
- 在集群中,实例有新增或删除,Redis需要重新分配哈希槽;
- 为了负载均衡,Redis需要把哈希槽在所有实例上重新分布一遍。
Redis Cluster方案提供了一种重定向机制,所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据,客户端要再给一个新实例发送操作命令。
(如何知道重定向的新实例的访问地址呢?)当客户端把一个键值对的操作请求发给一个实例时,如果这个实例上并没有这个键值对映射的哈希槽,那么,这个实例就会给客户端返回下面的MOVED命令响应结果,这个结果中就包含了新实例的访问地址。
【特殊情况】如果slot2的数据比较多,只有部分数据迁移完成的情况 ——> 客户端就会收到一条ASK报错信息
1 | GET hello:key |
含义如下图所示:
和MOVED命令不同,ASK命令并不会更新客户端缓存的哈希槽分配信息。所以,在上图中,如果客户端再次请求Slot 2中的数据,它还是会给实例2发送请求。这也就是说,ASK命令的作用只是让客户端能给新实例发送一次请求,而不像MOVED命令那样,会更改本地缓存,让后续所有命令都发往新实例。
课后问题
1-9节的答疑
第1讲:和跟Redis相比,SimpleKV还缺少什么?
第2讲:问题:整数数组和压缩列表作为底层数据结构的优势是什么?
整数数组和压缩列表的设计,充分体现了Redis“又快又省”特点中的“省”,也就是节省内存空间。整数数组和压缩列表都是在内存中分配一块地址连续的空间,然后把集合中的元素一个接一个地放在这块空间内,非常紧凑。因为元素是挨个连续放置的,我们不用再通过额外的指针把元素串接起来 ——> 避免了额外指针带来的空间开销。
第7讲:如果想要应用程序不感知服务的中断,还需要哨兵或客户端再做些什么吗?
- 一方面,客户端需要能缓存应用发送的写请求。只要不是同步写操作(Redis应用场景一般也没有同步写),写请求通常不会在应用程序的关键路径上,所以,客户端缓存写请求后,给应用程序返回一个确认就行。
- 另一方面,主从切换完成后,客户端要能和新主库重新建立连接,哨兵需要提供订阅频道,让客户端能够订阅到新主库的信息。同时,客户端也需要能主动和哨兵通信,询问新主库的信息。
典型问题讲解
问题1:rehash的触发时机和渐进式执行机制
1.Redis什么时候做rehash?
Redis会使用装载因子(load factor)来判断是否需要做rehash。装载因子的计算方式是,哈希表中所有entry的个数除以哈希表的哈希桶个数。Redis会根据装载因子的两种情况,来触发rehash操作:
- 装载因子≥1,同时,哈希表被允许进行rehash;
- 在进行RDB生成和AOF重写时,哈希表的rehash是被禁止的,这是为了避免对RDB和AOF重写造成影响。如果此时,Redis没有在生成RDB和重写AOF,那么,就可以进行rehash。否则的话,再有数据写入时,哈希表就要开始使用查询较慢的链式哈希了。
- 装载因子≥5。
- 表明当前保存的数据量已经远远大于哈希桶的个数,哈希桶里会有大量的链式哈希存在,性能会受到严重影响,此时,就立马开始做rehash。
其他不允许rehash的情况:
- 表明当前保存的数据量已经远远大于哈希桶的个数,哈希桶里会有大量的链式哈希存在,性能会受到严重影响,此时,就立马开始做rehash。
- 装载因子小于1
- 装载因子大于1但是小于5,同时哈希表暂时不被允许进行rehash(例如,实例正在生成RDB或者重写AOF)
2.采用渐进式hash时,如果实例暂时没有收到新请求,是不是就不做rehash了?
其实不是的。Redis会执行定时任务,定时任务中就包含了rehash操作。
在rehash被触发后,即使没有收到新请求,Redis也会定时执行一次rehash操作,而且,每次执行时长不会超过1ms,以免对其他任务造成影响。
问题2:主线程、子进程和后台线程的联系与区别
如果一个进程启动后,没有再创建额外的线程,那么,这样的进程一般称为主进程或主线程。
在主线程中,我们还可以使用fork创建子进程,或是使用pthread_create创建线程。下面我先介绍下Redis中用fork创建的子进程有哪些。
- 创建RDB的后台子进程,同时由它负责在主从同步时传输RDB给从库;
- 通过无盘复制方式传输RDB的子进程;
- bgrewriteaof子进程。
从4.0版本开始,Redis也开始使用pthread_create创建线程,这些线程在创建后,一般会自行执行一些任务,例如执行异步删除任务。相对于完成主要工作的主线程来说,我们一般可以称这些线程为后台线程。【注意区分子进程和后台线程不是一回事】
问题3:写时复制的底层实现机制
对Redis来说,主线程fork出bgsave子进程后,bgsave子进程实际是复制了主线程的页表。这些页表中,就保存了在执行bgsave命令时,主线程的所有数据块在内存中的物理地址。这样一来,bgsave子进程生成RDB时,就可以根据页表读取这些数据,再写入磁盘中。如果此时,主线程接收到了新写或修改操作,那么,主线程会使用写时复制机制。
具体来说,写时复制就是指,主线程在有写操作时,才会把这个新写或修改后的数据写入到一个新的物理地址中,并修改自己的页表映射。
问题4:replication buffer和repl_backlog_buffer的区别
- replication buffer是主从库在进行全量复制时,主库上用于和从库连接的客户端的buffer
- Redis主从库在进行复制时,当主库要把全量复制期间的写操作命令发给从库时,主库会先创建一个客户端,用来连接从库,然后通过这个客户端,把写操作命令发给从库。在内存中,主库上的客户端就会对应一个buffer,这个buffer就被称为replication buffer。Redis通过client_buffer配置项来控制这个buffer的大小。主库会给每个从库建立一个客户端,所以replication buffer不是共享的,而是每个从库都有一个对应的客户端。
- repl_backlog_buffer是为了支持从库增量复制,主库上用于持续保存写操作的一块专用buffer。
- repl_backlog_buffer是一块专用buffer,在Redis服务器启动后,开始一直接收写操作命令,这是所有从库共享的。主库和从库会各自记录自己的复制进度,所以,不同的从库在进行恢复时,会把自己的复制进度(slave_repl_offset)发给主库,主库就可以和它独立同步。