前言
本文参考源码版本为
redis6.2
前面的系列文章我们聊了 redis 持久化机制,尽可能的保障少丢数据。那么,如何保障服务的高可用呢?相信你也想到了,副本机制
。
副本,也就是我们常说的主从模式,从节点通过全量或增量的方式从主节点同步数据。一般情况下,从节点可以作为只读节点对外提供服务,也可以仅作为主节点的备份节点;
当主节点故障挂掉之后,提升从节点为主节点,并对外提供无间断服务。
本文主要探讨,redis 副本数据同步的过程以及相关原理介绍,至于如何做到主节点故障后,从节点自动切换为主节点?我们将通过后面的文章在进行分析。
redis 的复制功能可以分为数据同步
和命令传播
两部分。
- 数据同步:将从节点保持与主节点一致,一般有完全重同步和部分重同步两种模式。
- 命令传播:当主从节点状态一致后,后续新的命令通过 TCP 长连接发送至从节点。
当然,我们也可以通过以下三种具体的数据传输形式来区分:
- 完全重同步,一般是启动时或者主从断开连接过长时间
- 部分重同步,一般是主从短时间断连
- 命令传播
一、使用
主从同步:
+------------------+ +---------------+
| Master | ---> | Replica |
| (receive writes) | | (exact copy) |
+------------------+ +---------------+
redis 主从同步中,从节点(副本)一般能做到一下几点:
- 副本数据同步过程是异步的,当然你也可以做到,当指定数量的副本节点断掉连接后,主节点停止对外服务。
- 副本可以做到部分重同步。尤其是主从连接短时间断掉后,可以通过参数 backlog 来控制增量数据集的大小。
- 副本可以做到自动重连。当主从连接断掉后,副本会自动常数与主节点进行连接。
1.配置
我们可以在从节点的配置中指定:
replicaof <masterip> <masterport>
或者,你也可以进入客户端(从)并执行:
127.0.0.1:6378>replicaof 127.0.0.1 6379
这里我们使用第一种方式来看看效果,先启动主节点,然后启动从节点,从节点启动过程大概是这样:
可以看到,从服务器在启动过程中大概做了这几件事:
- 首先加载自身 RDB 文件
- 尝试连接主节点
- 与主节点沟通交互,并确定数据同步方式(这里采用完整重同步)
- 接收 RDB 文件
- Flush(删除)自身旧数据
- 加载 RDB 文件
- 同步完成。等待主节点新数据写过来
我们反过来再看看主节点做了什么:
- 接收从节点同步请求
- 选择同步方式
- BGSAVE 生成 RDB 文件(这里持久到磁盘)
- 数据同步
2.INFO
接着,我们查看主节点:
➜ ~ redis-cli
127.0.0.1:6379> info replication
# Replication
role:master
connected_slaves:1
slave0:ip=127.0.0.1,port=6378,state=online,offset=342812208,lag=1
master_replid:c2fb9fccaa2935131ae2bc57615f60828899d88e
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:342812208
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:341763633
repl_backlog_histlen:1048576
然后查看从节点:
➜ ~ redis-cli -p 6378
127.0.0.1:6378> info replication
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
master_last_io_seconds_ago:4
master_sync_in_progress:0
slave_repl_offset:342812306
slave_priority:100
slave_read_only:1
connected_slaves:0
master_replid:c2fb9fccaa2935131ae2bc57615f60828899d88e
master_replid2:0000000000000000000000000000000000000000
master_repl_offset:342812306
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:341763731
repl_backlog_histlen:1048576
可以看到,主从节点相互记录了对方的信息,同时还记录了 repl_backlog_size、offset 等信息。
二、原理
副本同步采用的是 RDB 文件格式,而这种方式有分为两种,一种是先将 RDB 文件持久化到磁盘,然后从磁盘加载并传输到从节点;
另一种是无磁盘化,即直接将 RDB 文件写入 socket 网络通道传输给从节点。如果你的网络带宽高或者磁盘读取速度慢,这也许是一种不错的选择。
从库连接到主库经过的几个步骤:
- socket 连接
- 发送 PING 心跳检查连接是否正常
- 发起密码验证(如果需要)
- IP / PORT 等信息同步
- 发送 PSYNC 命令
- 接收 RDB 文件并加载
- 连接完成,等待主服务器同步命令请求
1.实现与优化(同步)
这里的实现与优化指的是数据同步阶段。首先,通过发送 SYNC 命令执行完全重同步操作,缺点在于每次执行 SYNC 指令都需要完全重同步
,操作太重。
对于主从短时间断连接这种情况,其实我们只需要发送增量部分数据即可,因此,引入 PSYNC 命令,在达到一定条件就可以使用部分重同步
。
接着,我们需要考虑一些极端情况,比如,从库崩溃了 — 记录的复制信息就不见了;主库异常下线 — 然后其从库提升为主库;对于这两种情况,我们分别通过 RDB 持久化、记录 replid2 来放宽限制条件,这便是 PSYNC2
。
关于主从复制命令的实现流程,我们通过一个实际例子来看看,先在 bash 客户端执行:
127.0.0.1:6378> replicaof 127.0.0.1 6379
这种场景下,主从节点执行的主流程如下:
- 1)从服务器 127.0.0.1:6378 向主服务器 127.0.0.1:6379 发送 sync 命令,请求同步数据。
- 2)主服务器 127.0.0.1:6379 接收到 sync 命令请求,开始执行 bgsave 命令持久化数据到 RDB 文件,并且在持久化数据期间会将所有新执行的写入命令都保存到一个缓冲区。
- 3)当持久化数据执行完毕后,主服务器 127.0.0.1:6379 将该 RDB 文件发送给从服务器127.0.0.1:6378,从服务器接收该 RDB 文件,并将文件中的数据加载到内存。
- 4)主服务器 127.0.0.1:6379 将缓冲区中的命令请求发送给从服务器 127.0.0.1:6378。
- 5)每当主服务器 127.0.0.1:6379 接收到写命令请求时,都会将该命令请求按照 Redis 协议格式发送给从服务器 127.0.0.1:6378,从服务器接收并处理主服务器发送过来的命令请求。
上述流程已经可以完成主从复制基本功能了,Redis 2.8
以前就是这样实现的。最大的问题在于,每次都进行全量数据同步,非常影响性能;比如主从短暂断开连接,此时可能主从差异并不大,当从库重新连接上主库时,发送 sync 命令同步数据,我们只需要发送增量差异数据即可。
Redis 2.8 提出了新的主从复制解决方案。从服务器会记录已经从主服务器接收到的数据量(复制偏移量);而主服务器会维护一个复制缓冲区
,记录自己已执行且待发送给从服务器的命令请求,同时还需要记录复制缓冲区第一个字节的复制偏移量。
从服务器请求同步主服务器的命令也改为了 psync
。当从服务器连接到主服务器时,会向主服务器发送 psync 命令请求同步数据,同时告诉主服务器自己已经接收到的复制偏移量,主服务器判断该复制偏移量是否还包含在复制缓冲区;
如果包含,则不需要执行持久化操作,直接向从服务器发送复制缓冲区中命令请求即可,这称为部分重同步
,同步过程如下:
如果不包含,则需要执行持久化操作,同时将所有新执行的写命令缓存在复制缓冲区中,并重置复制缓冲区第一个字节的复制偏移量,这称为完整重同步
。
主从复制总体流程图:
当然,优化还没结束。以上针对部分重同步的操作改进有两个限制条件:
- 从节点需要记录复制信息
- 主节点不能变更
但现实情况下,从节点可能会宕机,此时,复制信息将丢失;主节点也可能因宕机而变更,此时与从节点记录的 runid 不一致,无法进行部分重同步。
在 redis4.0 做出了两点优化,提出了 psync2 协议:
1)优化一:持久化从节点复制信息。
通过将主从复制信息持久化到 RDB 文件,当从节点宕机后,通过 RDB 文件可以拿到主从节点复制进度。所以你会看到,当重启从节点时,会先加载 RDB 文件。
2)优化二:存储上一个主服务器复制信息。
初始化 replid2 为空字符串,second_replid_offset 为-1;当主服务器发生故障,自己成为新的主服务器时,便使用 replid2 和 second_replid_offset 存储之前主服务器的运行 ID 与复制偏移量。
然后再判断条件中修改,满足 replid2 和 second_replid_offset 限制也能进行部分重同步。
2.状态机
由于整个副本同步期间有多个步骤需要处理,比如连接、握手、认证、数据传输等,并且这些步骤具有顺序性,因此,redis 采用状态流转的方式进行处理。
使用状态机处理的好处在于,按状态选择不同的处理逻辑,代码结构清晰;更重要的是,遇到异常、断连等情况可以跳转到指定状态阶段进行处理,非常灵活。
副本数据同步的入口方法:replication.c#replicaofCommand
,你可以从此处一览总体逻辑。
每一个状态,都采用与主节点一问一答的方式进行交互,当收到主节点响应之后,有两种选择,正常结果则进入下一个状态,异常结果则进行相关异常处理。
值得注意的是,整个流程并不是阻塞串行处理(状态间),而是依托于 redis 主事件循环来处理。本质来说,在从库与主库成功建立连接之后便有了相关套接字描述符 fd,从库会将 fd 注册至内核并监听读事件,当主库做出了响应,从库便能监听到并进行相关处理。
换句话说,redis 主从的交互过程,这部分能力仍然是交给 aeMain(主事件循环)来完成,而副本的主要工作就是做好状态处理及流转过程即可。
我画了张图,你可以简单看下:
3.RDB?
上文聊到,在完全重同步模式下,我们需要借助于 RDB文件来传输数据。因此,对于 RDB 数据的传输(Master 端
)也提供了两张选择:
1)Disk-backed:走一般的 RDB 持久化流程,先 fork 子进程生成 RDB 文件,然后同步至磁盘;最后,在主从节点数据传输阶段,从磁盘加载 RDB 文件并发送给从节点。
2)Diskless:通过 fork 子进程生成 RDB 文件,并直接将文件发送到 socket 通道,从节点直接接收即可。如果你的磁盘读取速度慢或者你有大带宽,Diskless 拥有更强的性能,也许是不错的选择。
如果要选择 Diskless 方式,我们可以在 redis.config 配置文件中修改(默认是关闭状态):
repl-diskless-sync yes
值得注意的是,如果采用 Disk-backed 方式,仅生成一次
RDB 文件,然后发送给多个从节点。但是,当你采用 Diskless 方式的时候,由于 RDB 文件没有持久化,如果多个从节点不能同时请求过来,则每次都需要 fork 子进程生成 RDB 快照并发送到 socket 通道中。
为了解决这个问题,redis 提供延迟处理的机制,即 等待一定时间,当多个从节点请求都到了的时候,再进行处理,这样就可以做到并发的发送给多个从节点。可以通过参数指定等待时长:
repl-diskless-sync-delay 5
默认是 5 秒,如果配置为 0 则表示立即处理。
另外,在从节点(Slave端
)其实也可以采用两种方式进行加载数据:
1)接收数据后,先持久到磁盘,然后从磁盘加载 RDB 文件,这是传统方式。
2)从 socket 中一边接收数据,一边解析 RDB 文件,即 无磁盘化。
可以通过参数进行配置(默认 disabled):
repl-diskless-load disabled
有三种取值:
- disabled:不使用 diskless-load 方式,即采用磁盘化的传统方式
- on-empty-db:安全模式下使用 diskless-load(也就从节点db状态为空的时候使用)
- swapdb:表示采用 diskless-load 方式,为什么叫swap? 本质上说,从节点会先缓存一份当前db的数据,然后清空 db,再进行 socket 读取。缓存一份的目的是防止读取失败可以恢复。
值得注意的是,diskless-load 目前在实验阶段,因为 RDB 数据并没有持久化到磁盘,因此有可能造成数据丢失;另外,该模式会占用更多内存,可能会导致 OOM。
三、配置
俗话说,通过数据结构能看懂大部分核心设计,我们也来看看主从节点副本相关配置:
1.主节点:
struct redisServer {
...
/* Replication (master) */
char replid[CONFIG_RUN_ID_SIZE+1]; /* 主节点当前使用的replicationId(runid) */
char replid2[CONFIG_RUN_ID_SIZE+1]; /* 如果是从节点提升为主节点,记录的是上一个主节点的runid,用于 psync 部分重同步优化 */
long long master_repl_offset; /* 主节点 replication 目前记录的位移 */
long long second_replid_offset; /* 与 replid2搭配使用 */
int repl_ping_slave_period; /* 主节点 PING 从节点间隔周期 */
char *repl_backlog; /* 用户部分重同步的缓冲区 */
long long repl_backlog_size; /* repl_backlog 大小 */
long long repl_backlog_histlen; /* repl_backlog 实际数据长度 */
long long repl_backlog_idx; /* repl_backlog 下一个将写入数据的位移 */
long long repl_backlog_off; /* 复制缓冲区中第一个字节的复制偏移量 */
int repl_min_slaves_to_write; /* Min number of slaves to write. */
int repl_min_slaves_max_lag; /* Max lag of <count> slaves to write. */
int repl_good_slaves_count; /* Number of slaves with lag <= max_lag. */
int repl_diskless_sync; /* 直接使用 socket 传输数据 */
int repl_diskless_load; /* 从节点直接从 socket 解析 RDB 数据
* see REPL_DISKLESS_LOAD_* enum */
int repl_diskless_sync_delay; /* Delay to start a diskless repl BGSAVE. */
...
}
我们挑选几个关键的字段来看看:
- replid: Redis 服务器的运行ID,长度为 CONFIG_RUN_ID_SIZE(40)的随机字符串。
- replid2: 在优化部分已经介绍过,如果当前节点是从节点提升为 master,则记录上一个 master 的信息,用于部分重同步的优化。
- repl_ping_slave_period:主服务器和从服务器之间是通过 TCP 长连接交互数据的,就必然需要周期性地发送心跳包来检测连接有效性,该字段表示发送心跳包的周期,主服务器以此周期向所有从服务器发送心跳包。
- repl_backlog:复制缓冲区,用于缓存主服务器已执行且待发送给从服务器的命令请求;缓冲区大小由字段 repl_backlog_size 指定,其可通过配置参数 repl-backlog-size 设置,默认为1MB。
- repl_min_slaves_to_write:至少要保持指定数量的从节点连接,主节点才能对外可写
- repl_min_slaves_max_lag:与 repl_min_slaves_to_write 搭配使用,延迟时间
- repl_diskless_sync:是否开启 diskless,即无磁盘化 RDB 同步
- repl_diskless_sync_delay:与 repl_diskless_sync 搭配使用,在进行同步之前的等待时长;主要目的是等待所有从节点的到来,方便同时传输,否则就得等下一个批次。
- repl_diskless_load:从节点加载时,是否直接从 socket 进行解析 RDB(无需磁盘化)。
2.从节点:
struct redisServer {
...
/* Replication (slave) */
char *masteruser; /* 需要授权认证用户 */
char *masterauth; /* 密码 */
char *masterhost; /* 主节点ip */
int masterport; /* 主节点端口 */
int repl_timeout; /* Timeout after N seconds of master idle */
client *master; /* Client that is master for this slave */
client *cached_master; /* Cached master to be reused for PSYNC. */
int repl_syncio_timeout; /* Timeout for synchronous I/O calls */
int repl_state; /* 副本状态 */
off_t repl_transfer_size; /* 主从同步数据大小 */
off_t repl_transfer_read; /* 主从同步数据量 */
off_t repl_transfer_last_fsync_off; /* 上一次进行 fsync 的时间 */
connection *repl_transfer_s; /* Slave -> Master SYNC connection */
int repl_transfer_fd; /* 主从同步临时文件描述符 */
char *repl_transfer_tmpfile; /* 主从同步临时文件名 */
time_t repl_transfer_lastio; /* 最近读取的时间, 用于 timeout */
int repl_serve_stale_data; /* 当主从同步连接断掉后是否继续对外提供服务(从) */
int repl_slave_ro; /* 从节点是否只读 */
int repl_slave_ignore_maxmemory; /* 是否忽略maxmemory限制 */
time_t repl_down_since; /* 主从断连时间点 */
char master_replid[CONFIG_RUN_ID_SIZE+1]; /* PSYNC 同步时需要用到的,主节点的runid. */
long long master_initial_offset; /* Master PSYNC offset. */
int repl_slave_lazy_flush; /* Lazy FLUSHALL before loading DB? */
...
}
同样,我们也挑选相对常见的几个参数来看看:
- masteruser / masterauth / masterhost:权限认证相关参数
- repl_timeout:超时时间,有三种,SYNC、data / PING (master)、REPLCONF ACK pings(slave)
- repl_transfer_size / repl_transfer_read:同步的数据量相关指标
- repl_transfer_fd:RDB 文件描述符,接收 socket 传输的数据
- master_replid:主节点id,发送 PSYNC 命令的时候需要带过去。
总结
在当今的互联网环境下,服务高可用成为了一种必备保障手段,而想要高可用,一般都离不开副本机制,比如 MySQL、Redis 等。
redis 主从数据同步有三种形式:
- 完全重同步:以 RDB 的形式传输数据
- 部分重同步:通过环形缓冲池,同步增量部分数据(一般是短时间断连的情况)
- 命令传播:当主从数据状态一致之后,新的命令传播方式(和普通客户端请求类似)
redis 副本完全重同步是通过 RDB 快照来实现,具体有两种处理形式:
- 磁盘化:fork 生成 RDB 文件到磁盘,然后从磁盘将数据传输给从节点
- 无磁盘化:fork 生成的 RDB 文件直接发送到 socket 通道
另外,在从节点端,也有类似的两种处理方式,不再赘述。
由于部分重同步的场景可能比较常见,又对此做了优化,有了 PSYNC2:
- 从节点端,持久化同步的副本信息,防止重启后丢失
- 主节点端,通过 replid2 保留上一个主节点的id,因为可能出现当前节点是从节点提升至主节点,而其他从节点需要部分重同步。
主从节点同步是通过状态机的形式进行。整个流程并不是阻塞串行处理(状态间),而是依托于 redis 主事件循环来处理。
本质来说,在从库与主库成功建立连接之后便有了相关套接字描述符 fd,从库会将 fd 注册至内核并监听读事件,当主库做出了响应,从库便能监听到并进行相关处理。
相关参考:
- replication-官方文档
- redis-6.2/redis.conf
- <<Redis 设计与实现>> 「黄健宏」
- <<Redis5 设计与源码分析>>「陈雷」