redis6.0源码阅读 主从模式之数据同步
给新观众老爷的开场
大家好,我是弟弟!
最近读了一遍 黄健宏大佬的 <<Redis 设计与实现>>,对Redis 3.0版本有了一些认识
该书作者有一版添加了注释的 redis 3.0源码
👉官方redis的github传送门。
👉黄健宏大佬添加了注释的 redis 3.0源码传送门
👉antirez的博客
网上说Redis代码写得很好,为了加深印象和学习redis大佬的代码写作艺术,了解工作中使用的redis 命令背后的源码逻辑,便有了写博客记录学习redis源码过程的想法。
今天斗胆打开了redis 6.0的代码开始阅读理解。
redis6.0相对于redis3.0增加了不少的功能。
本篇博客的描述方式以主从模式的数据同步为主线
,其中涉及其他的东西为支线
。
主从模式-数据同步的原因
1. 数据热备份
从节点热备份主节点的数据,在哨兵 或 集群模式下。
主节点挂掉的时候,可以通过raft算法选择一个数据最新的从节点作为新的节点。
如果通过重启主节点,并利用主节点的rdb/aof文件来恢复数据,一个是慢,一个是可能会丢失较多的数据,在恢复数据期间,无法对外提供服务。
而实时备份的从节点,拥有与最新数据最接近的现成数据,将其提升为主机节点后可立即对外提供服务。
2. 读写分离
当单个主节点承受不了读/写压力时,
一种方式是,可以将读压力负载均衡分发到从节点,从而减轻主节点的读压力,但读写分离会存在一定的延迟。
热key问题,读写分离是一种解决方案。
另一种redis 6.0官方的客户端缓存方案,这个方案跟平时从redis中取出值之后再做本地内存缓存差不多是一个意思,不过官方的方案似乎有过期key通知,这个以后有时间再研究研究。
3. 从节点需要与主节点同步数据
新增一个从节点,或主从切换 从节点需与主节点数据同步
主从模式-数据同步的场景
从节点数据与主节点数据不一致
数据同步的大背景就是从节点数据与主节点数据不一致。😂 苍白无力的废话。
而该大背景可以分为一些具体的场景。
1. 给主节点新加入从节点 - 全量同步
新加入的从节点啥也没有
2. 从节点与主节点数据同步中断
中断的原因有很多,比如从节点挂了重启,主节点挂了重启,或者选举了另外一个从节点做主节点,网络不通等等…
根据从节点与主节点数据不一致的程度大小可以分为
- 不一致程度较小的,可以断点续传的部分重传
- 不一致程度较大的,不可以断点续传的全量同步
主从模式-数据同步的思路
全量同步
主节点向从节点写入完整的rdb文件流,随后写入这期间增量的命令
部分重传
利用复制积压缓冲区的增量同步,
也就是主节点会缓存最近一段时间执行的需要同步的增量命令,
如果从节点缺失的部分数据刚好在复制积压缓冲区中能找到,
那就可以仅同步缺失的部分数据。而不用全量同步
主从模式-数据同步的实现
向一个非redis_cluster模式下的redis实例B 发送 slave of master_ip master_port,
即可触发 将该redis实例 作为 目标redis实例的从节点的主从同步过程。
- 从节点会标记当前与主节点同步进行到哪一步了,也就是从节点的复制状态。
- 对于主节点来说,从节点会作为一个 client_slave加入到主节点的客户端链表上。
并且主节点里的从节点的客户端结构里也会有一个字段来标记 同步到哪一步了,也就是同步状态。
下面将主要以上述两种状态的变化,以及每个状态下需要做的事情为线索描述详细的同步过程
先来看一眼从节点内部的复制状态,后面会详细描述每个状态 👇
./server.h 头文件里的定义
/* Slave replication state. Used in server.repl_state for slaves to remember
* what to do next. */
#define REPL_STATE_NONE 0 /* No active replication */
#define REPL_STATE_CONNECT 1 /* Must connect to master */
#define REPL_STATE_CONNECTING 2 /* Connecting to master */
/* --- Handshake states, must be ordered --- */
#define REPL_STATE_RECEIVE_PONG 3 /* Wait for PING reply */
#define REPL_STATE_SEND_AUTH 4 /* Send AUTH to master */
#define REPL_STATE_RECEIVE_AUTH 5 /* Wait for AUTH reply */
#define REPL_STATE_SEND_PORT 6 /* Send REPLCONF listening-port */
#define REPL_STATE_RECEIVE_PORT 7 /* Wait for REPLCONF reply */
#define REPL_STATE_SEND_IP 8 /* Send REPLCONF ip-address */
#define REPL_STATE_RECEIVE_IP 9 /* Wait for REPLCONF reply */
#define REPL_STATE_SEND_CAPA 10 /* Send REPLCONF capa */
#define REPL_STATE_RECEIVE_CAPA 11 /* Wait for REPLCONF reply */
#define REPL_STATE_SEND_PSYNC 12 /* Send PSYNC */
#define REPL_STATE_RECEIVE_PSYNC 13 /* Wait for PSYNC reply */
/* --- End of handshake states --- */
#define REPL_STATE_TRANSFER 14 /* Receiving .rdb from master */
#define REPL_STATE_CONNECTED 15 /* Connected to master */
主节点中的从节点客户端的复制状态
/* State of slaves from the POV of the master. Used in client->replstate.
* In SEND_BULK and ONLINE state the slave receives new updates
* in its output queue. In the WAIT_BGSAVE states instead the server is waiting
* to start the next background saving in order to send updates to it. */
#define SLAVE_STATE_WAIT_BGSAVE_START 6 /* We need to produce a new RDB file. */
#define SLAVE_STATE_WAIT_BGSAVE_END 7 /* Waiting RDB file creation to finish. */
#define SLAVE_STATE_SEND_BULK 8 /* Sending RDB file to slave. */
#define SLAVE_STATE_ONLINE 9 /* RDB file transmitted, sending just updates. */
当从节点中的状态变为 REPL_STATE_CONNECTED
,
主节点中从节点客户端的状态变为 SLAVE_STATE_ONLINE
时,
这两个节点间的主从同步就完成了。
在看具体的同步过程前,先来看下会遇到的一些问题
-
这是一套网络编程啊 ,用tcp还是udp呢?😂
数据同步要求可靠性,故选择tcp
-
tcp传输的是字节流,那应用层的解析协议呢?
使用了RESP协议,redis6.0中可使用最新的RESP3
网上可以搜到一个叫tcp粘包的问题,查看过较多评论之后知道,
这个问题实际上不是问题,
而是tcp本身传输的就是字节流,应用层需要自己解析自己的"数据包"😂 -
redis6.0中的多线程网络I/O
redis6.0中出了一个多线程网络I/O。对于网络的读/写。
由原来的单线程,改成了攒一波然后多线程处理一波的做法。
这里的多线程网络I/O,并不涉及具体逻辑处理,仅仅是I/O,
所以核心逻辑处理线程还是单线程的。
并且多线程网络I/O似乎对于写操作有点效率提升,对于读没有啥帮助,详细的redis多线程网络I/O描述,可以在redis.conf里查看跟多线程网络I/O相关的注释 🙃️ -
tcp化身连接
从节点第一次连接服务器后,发送了一个命令。
由于网路超时,从节点关闭连接并重新与服务器建立连接后,
第二个连接拥有与第一个连接完全相同client_ip,client_port,master_ip,master_port,
第二个连接成为第一个连接的化身,
若此时在网络中游荡的第一次链接发送的命令数据发送到了主服务器,
若序列号什么的又能对得上,这是有问题的,
第二个连接不应该处理到第一个连接发送的数据。
😂 不过这是已知的问题
在网上一顿搜索后,了解到- TIME_WAIT状态的连接还需要等2MSL才能返回CLOSED状态,也就是要等此次连接发送的数据包都消失之后,才能再次使用
- 建立连接时端口分配不固定的,只要拿到一个和上次不一样的端口就没这个问题。
- 建立tcp连接时,初始化的序列号也不是固定的,只要序列号足够随机,应该也能避开化身连接问题
- 应用层处理该问题,比如redis 主从同步两边实例都记录了当前在什么状态,比较容易的知道可以做什么事情。不可以做什么事情。
同步流程
-
一个redis客户端连向一个redis实例,并发送slaveof master_ip master_port
-
redis实例检查是否可以与主节点同步
集群模式不能同步,该实例已经是子节点,不能同步
-
redis实例如果可以进行同步, 进行清理工作
断掉一些阻塞的客户端连接、断掉与自己的从节点的连接、redis module相关
redis module还没有深入了解过 🙃️初始化 该实例的 复制状态为 server.repl_state =
REPL_STATE_CONNECT;
-
若server.repl_state 为
REPL_STATE_CONNECT
, 需要与主节点建立tcp连接周期性的检查 server.repl_state,如果是 REPL_STATE_CONNECT 状态,
则 与 master_ip, master_port 建立tcp连接,
为该套接字关联一个可写事件处理器,在该事件处理中,
调用 syncWithMaster 函数来进行后续的同步工作
并设置 server.repl_state =REPL_STATE_CONNECTING
在周期进行的 serverCron() -> replicationCron();中进行, 默认每秒一次,REPL_STATE_CONNECT 状态下会建立连接,
在其他状态下会检查最后一次与主节点通信的时间 ,来判断主从连接是否超时,
默认设置超过60秒就认为连接中断
若超时,则会进行一些清理操作,
并将状态置为 REPL_STATE_CONNECT,重新同步 -
若server.repl_state 为
REPL_STATE_CONNECTING
,向主节点发送ping命令在syncWithMaster中,若 server.repl_state == REPL_STATE_CONNECTING。
将套接字的写事件处理器去掉,装上读事件处理器syncWithMaster。
😂,这两事件处理器一样,只不过触发的时机不同。
向主节点发送ping命令,
并设置 server.repl_state ==REPL_STATE_RECEIVE_PONG
以等待接受主节点的回复, 也就是 pong -
若server.repl_state 为
REPL_STATE_RECEIVE_PONG
,等待接收主节点的pong回复在syncWithMaster中,若server.repl_state == REPL_STATE_RECEIVE_PONG
等待接收主节点的回复,回复抛出ERR则会初始化状态重新再来,下同。
设置状态 server.repl_state =REPL_STATE_SEND_AUTH
-
若server.repl_state 为
REPL_STATE_SEND_AUTH
,根据配置判断是否向主节点发送auth命令进行身份认证在syncWithMaster中,发送相应auth命令,
设置 server.repl_state =REPL_STATE_RECEIVE_AUTH
若不需要auth验证则 设置 server.repl_state =REPL_STATE_SEND_PORT
-
若server.repl_state 为
REPL_STATE_RECEIVE_AUTH
,等待接收主节点auth命令认证结果在syncWithMaster中,认证成功则
设置 server.repl_state =REPL_STATE_SEND_PORT
-
若server.repl_state 为
REPL_STATE_SEND_PORT
发送REPLCONF listening-port port 命令,与主节点沟通从节点监听的端口信息在syncWithMaster中,若 server.repl_state == REPL_STATE_SEND_PORT。
将发送端口信息
这里发送的端口有三种可能
一种是 普通的port
一种是配置里的 slave_announce_port
一种是 开启了tls的 tls_port
之后将状态设置为 server.repl_state =REPL_STATE_RECEIVE_PORT;
-
若server.repl_state 为
REPL_STATE_RECEIVE_PORT
,接收主节点的回复消息在syncWithMaster中, 收到确认回复后
设置 server.repl_state =REPL_STATE_SEND_IP
-
若server.repl_state 为
REPL_STATE_SEND_IP
,根据是否配置了 slave_announce_ip 来决定是否向主节点发送ip信息在syncWithMaster中,
如果设置了 slave_announce_ip
状态将被设置为server.repl_state =REPL_STATE_RECEIVE_IP
状态将被设置为 server.repl_state =REPL_STATE_SEND_CAPA
-
若server.repl_state 为
REPL_STATE_RECEIVE_IP
, 接收主节点的回复消息收到正确回复消息,状态将被设置为 server.repl_state =
REPL_STATE_SEND_CAPA
-
若server.repl_state 为
REPL_STATE_SEND_CAPA
向主节点发送从节点支持数据同步的能力在syncWithMaster中,redis 6.0的从节点将发送 REPLCONF capa eof capa psync2。之后将状态设置为 server.repl_state =
REPL_STATE_RECEIVE_CAPA
/* Slave capabilities. */ #define SLAVE_CAPA_NONE 0 #define SLAVE_CAPA_EOF (1<<0) /* Can parse the RDB EOF streaming format. */ #define SLAVE_CAPA_PSYNC2 (1<<1) /* Supports PSYNC2 protocol. */
-
若server.repl_state 为
REPL_STATE_RECEIVE_CAPA
,接收主节点的回复消息收到正确回复消息,状态将被设置为 server.repl_state=
REPL_STATE_SEND_PSYNC
-
若server.repl_state 为
REPL_STATE_SEND_PSYNC
,
向主节点发送psync psync_replid psync_offset 命令假设这个从节点是一个刚启动的啥也没有的实例,
会发送 psync ? -1,来触发一次全量同步
设置 server.repl_state=REPL_STATE_RECEIVE_PSYNC
这个 psync_replid 复制id 相当于是某个实例某次运行的标识,
psync2中会保存当前的复制id 和上一次的复制id
用来辅助判断是进行部分重传,还是进行全量同步
与run_id一样使用相同的方法生成的长度为40的随机字符串psync_offset 这个是复制积压缓冲区的偏移量,如果该偏移还在主节点的复制积压缓冲区里,则可以触发部分重传
-
当主节点接收到了来自从节点的 psync命令,
正二八斤的给从节点客户端打上client_slave
的标记。如果这是主节点的第一个从节点,主节点会初始化一个复制积压缓冲区 和偏移量。
这个复制积压缓冲区是啥意思呢?
简单点说就类似与AOF,增量记录了最近一段时间的需要同步的命令,
给刚断开不久又重连的从节点 部分重传的机会。
毕竟全量同步需要耗费 主从节点大量的cpu,i/o资源。
这个复制积压缓冲区是固定大小的循环队列,可以通过配置文件修改配置。主节点记录了 开始和结束的偏移量,并且根据新写入的命令会调整 开始和结束偏移量
从节点会记录自己复制到哪里了的一个偏移量,
可以用来向主节点同步后续的未同步的复制积压缓冲区内的命令。
若从节点的偏移量 小于 主节点的开始偏移量 或 大于 主节点的结束偏移量,将无法进行部分重传 -
在主节点中,若psync_replid 匹配上了,且 psync_offset 在主节点的复制积压缓冲区之
可以进行部分重传在psync2下,若replid 是主节点记录的 replid、replid2的其中一个,
且从节点的offset在主节点的复制积压缓冲区范围内。
主节点向从节点回复 +CONTINUE replid,告诉从节点当前的replid是什么
将从节点的客户端设置为SLAVE_STATE_ONLINE
,
表示主从同步完成,从节点已在线,
主节点将复制积压缓冲区里从节点未同步的增量命令写入从节点的输入缓冲区在从节点收到 +continue replid 后,
会将自己记录的 server的repli、offset更新,
并将复制状态更改为REPL_STATE_CONNECTED
至此主从同步完成
从节点给主节点的连接安装上 读取/回复客户端的 读/写事件处理器。
后续与主节点的差异数据将通过读取主节点的命令传播来同步,也就是未同步的复制积压缓冲区里的命令。 -
不能进行部分重传的话,则只能全量同步
向从节点回复 +FULLRESYNC replid offset,
告诉从节点,主节点的复制id与复制积压缓冲区的偏移量,
从节点客户端的状态设置为SLAVE_STATE_WAIT_BGSAVE_END
表示该从节点客户端在等待 bgsave执行结束
从节点收到回复后会将从节点复制状态 设置为REPL_STATE_TRANSFER
,
并为从节点连接主节点的套接字安装 读事件处理器readSyncBulkPayload
用来等待接收主节点发送过来的rdb文件流主节点若设置无盘复制
主节点将等待一定事件,默认是5秒之后,再启动无盘复制,目的就是攒一波在 这期间需要全量同步的从节点,这样产生一份rdb文件流,可同时给多个从节点发送文件流,默认这个rdb文件流会以每份最大16kb的大小发送
以无盘复制复制的形式发送的文件流具有如下格式
$EOF:<40个字节的随机字符串>\r\n文件流内容
40个字节的随机字符串主节点没有设置无盘复制,使用rdb文件传输
主节点会直接开始bgsave,
当本次bgsave执行完毕前,
后来的从节点若需要全量复制可以直接拷贝触发本次bgsave的客户端的状态,并一起等待本次bgsave完成 -
不管是无盘复制还是 rdb文件传输。主从节点行为相同的如下 👇
-
在此期间 从节点与主节点会互向发送心跳包,阻止对方超时
-
从节点会定期向主节点报告已经同步到了什么地方,
也就是从节点数据的偏移量 -
主节点的复制积压缓冲区被创建后就会被用来记录相关的命令
比如会产生
dirty
数据的写命令, 被强制传播的命令等 -
所有状态不是
SLAVE_STATE_WAIT_BGSAVE_START
的从节点客户端,写到复制积压缓冲区的命令,也会写入对应客户端的输出缓冲区一旦流数据准备好后,可以通过设置客户端套接字写事件处理器,先写入rdb的数据,再切换写事件处理器,再写入输出缓冲区的命令,这样数据就完美的同步了。
-
当数据传输完毕后
主节点将从节点客户端的复制状态标记为SLAVE_STATE_ONLINE
,
并且为与从节点的套接字设置 写事件处理器sendReplyToClient
将输出缓冲区的增量命令写入客户端 -
不管是无盘复制还是rdb文件,实际上都是rdb的文件流,
且都是通过 fork+copy on write 开子进程来完成的,
子进程将数据流通过管道写入,父进程从管道中读取数据
-
-
无盘复制 与 rdb文件传输,有点区别的是
- 无盘复制 会在子进程写入部分数据之后,
会将数据读出来并写入等待本次无盘复制的从节点客户端 - rdb文件复制,会先生成一个rdb文件,
等rdb文件生成完毕之后再读文件
然后写入等待本次rdb文件流的从节点客户端 - 无盘复制和rdb文件复制,两种流区别头尾的方式不一样
rdb文件复制会先告诉对方一共有多少字节,
无盘复制目前使用如下形式
$EOF:<40个字节的随机字符串>\r\n文件流内容
40个字节的随机字符串
- 无盘复制 会在子进程写入部分数据之后,
-
当从节点发现已接受并处理了完整个rdb数据流
会将从节点的复制状态设置为REPL_STATE_CONNECTED
且给从节点里与主节点的套接字设置读事件处理器sendReplyToClient
,用来接收在该rdb文件流之后产生的增量命令无盘复制和rdb文件复制实质上都是rdb数据流,处理逻辑相差不大
-
至此从节点与主节点就同步完毕了
redis6.0 的 rdb文件内容
相比redis3.0,redis6.0中rdb文件增加记录的内容有
- redis辅助信息,比如redis版本,32/64位? 复制id,复制积压偏移 等
使用rdb恢复时,此复制id会以replid2的形式存在
psync2保留的上一次的复制id,似乎主要是用来应对主从切换之后的主从同步
这个在下一期写哨兵的时候可以关注一下 - k/v字典空间和过期k/v字典空间大小
- 根据内存淘汰策略,保存每个key的LRU/LFU信息
- OBJ_MODULE 与 OBJ_STREAM 类型的数据,以及redis module相关的数据
- lua脚本相关信息
小结
redis主从同步,
说简单一点实际上就是 全量同步 和 利用复制积压缓冲区的增量同步
思路实际上是比较简单的,
但实现这个简单的思路的代码并不像思路那么简单…
在网络较好,磁盘I/O较慢的场景下,
无盘复制会比rdb文件传输消耗的资源更少,同步速度更快
redis module 还没研究过,
光听概念就觉得很🐂 ,类似插件,可以自定义数据结构
下集预告
分布式一致性raft算法 在 redis 哨兵中 是如何落地实现的?
往期博客回顾
来源:oschina
链接:https://my.oschina.net/u/4413473/blog/4382430