介绍完主从复制的核心原理之后,从这节开始,我们将介绍 Redis 主从复制的核心实现。在上一节中提到,Redis 主从结构最开始,是由从库向主库发起建连请求的,所以这里就先以从库视角来看看主从复制的整个流程。

从库建连

设置主库地址

明确了从库是主从复制的主动发起方之后,我们再来看看从库是如何确认自己要连接哪个主库的,下面有两种设置主库的方式。

  • 一种是从库在配置文件(或是启动参数)中添加了 replicaof 配置或者 slaveof 配置,replicaof 出现在 Redis 5.0 版本中,用于替换 slaveof 配置,slaveof 目前已经被标记为废弃 。在 Redis 从库启动过程中,loadServerConfigFromString() 函数中会解析 redis.conf 文件(以及启动参数)中的 replicaof(或 slaveof)配置,将主库的网络地址记录到 redisServer.masterhost 和 redisServer.masterport 中。

  • 另一种是在从库启动之后,通过客户端向 Redis 服务发送 replicaof(或者 slaveof) 命令。当 Redis 从库接收到 replicaof (或者 slaveof)命令时,会在 replicaofCommand() 函数中解析命令中携带的主库地址,然后记录 redisServer.masterhost 和 redisServer.masterport 字段中。

建连触发时机

设置好主库的地址之后,Redis 从库会开始尝试连接主库,该逻辑位于 connectWithMaster() 函数中,下图展示了其调用栈:

我们从上图中可以看出一些触发从库发起连接的时机

  • 从库在收到 replicaof 命令的时候,对应上图中的 replicaofCommand() 函数,会触发从库发起连接。

  • 在主从连接断开的时候,对应上图中的 replicationHandleMasterDisconnection() 函数,会触发从库发起连接。

  • 在主库地址更新的时候,对应上图中的 nodeUpdateAddressIfNeeded() 函数,会触发从库发起连接。

  • 在主库宕机发生故障转移的时候,对应上图中的 updateFailoverStatus() 函数,也会触发从库发起连接。

  • 上图中的 replicationCron() 函数是在 serverCron() 函数中被调用的,也就是说,replicationCron() 函数会周期性地执行,它里面会检查主从复制的各个状态,并根据这些状态执行与主库断开、重连等操作。比如说,如果现在是通过 replicaof 配置指定了主库地址,从库在启动的时候,loadServerConfigFromString() 函数中会将 redisServer.repl_state 字段设置为 REPL_STATE_CONNECT 状态,表示等待从库对主库发起建连请求,然后由 replicationCron() 定时触发连接主库的操作,相关片段如下:

1
2
3
4
5
6
7
8
9
10
11
void replicationCron(void) {

... // 省略其他逻辑

if (server.repl_state == REPL_STATE_CONNECT) { // 检查repl_state状态

connectWithMaster(); // 连接主库

}

}

建连过程

了解了从库发起建连请求的时机之后,接下来就可以深入分析一下 connectWithMaster() 函数,它总共做了下面三件事。

  1. 第一件事就是创建一个 connection 实例,用来表示从库与主库之间用于复制数据的连接,该 connection 实例会记录到 redisServer.repl_transfer_s 字段中。

  2. 第二件事是建立网络连接。这里会调用对应 ConnectionType 的 connect() 函数建立网络连接,例如,CT_Socket 的实现是先调用 socket() 函数创建一个非阻塞的 TCP 连接,然后调用 connect() 函数发起建连请求,将 connection 的状态更新为 CONN_STATE_CONNECTING,表示正在建连。同时,通过 aeCreateFileEvent() 函数添加该连接的可写事件监听,一旦建连成功,就会调用相应的回调。

  3. 第三件事是更新 redisServer 中主从复制相关的字段,比如,下面展示的 repl_transfer_lastio 字段是记录了从库最后一次收到主库响应的时间戳,主要用于判断读请求以及建连请求是否超时;repl_state 字段记录了主从复制的状态,这里会把它设置为 REPL_STATE_CONNECTING,表示从库正在与主库建连。

下面是 connectWithMaster() 函数的关键代码以及解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int connectWithMaster(void) {

// 1、初始化server.repl_transfer_s这个连接实例

server.repl_transfer_s = server.tls_replication ? connCreateTLS() :

connCreateSocket();

// 2、建立网络连接,并注册可写事件的监听,可写事件触发后的回调为syncWithMaster()函数

if (connConnect(server.repl_transfer_s, server.masterhost,

server.masterport, server.bind_source_addr, syncWithMaster) == C_ERR) {

... // 省略其他异常处理逻辑

return C_ERR;

}

// 3、更新server.repl_state记录主从复制状态,更新repl_transfer_lastio时间戳

server.repl_transfer_lastio = server.unixtime;

server.repl_state = REPL_STATE_CONNECTING;

return C_OK;

}

主从握手

正常情况下,在从库发起建连请求之后,主库会进行响应,并完成 TCP 连接的建连。在从库监听到主从之间的网络连接建立成功之后,会触发该连接上的可写事件,也就是回调 syncWithMaster() 函数,这个函数中,从库与主库之间会进行一系列握手流程,核心逻辑如下图所示:

image.png

下面我们展开一步步来分析 syncWithMaster() 函数的核心逻辑,其中同学们可以先关注上图最左边的redisServer.repl_state 字段,该字段表示了主从复制的状态,从库在不同状态下执行不同的逻辑,组合起来就是从库这一侧在主从复制中的状态机。

CONNECTING 状态

建连之后,从库处于 REPL_STATE_CONNECTING 状态,此时会注册对连接可读事件的监听(回调依旧是 syncWithMaster() 函数)并向主库发送 PING 命令。向主库发送命令的逻辑封装在 sendCommand() 函数中,后续发送其他命令也是由 sendCommand() 函数完成的,调用栈如下所示:

sendCommand() 函数里面调用的是 ConnectionType->sync_write() 函数,在第 29 讲《内核解析篇:Redis 网络建连逻辑详解》介绍 connection 结构体的时候提到过,sync_write() 会阻塞等待请求发送完成,底层是通过带超时时间的 poll() 重载来阻塞等待连接可写,然后通过 write() 函数发送请求,具体代码这里就不展开分析了,感兴趣的小伙伴可以查看 connSocketSyncWrite() 函数的实现。

RECEIVE_PING_REPLY 状态

发送 PING 请求之后,从库进入 REPL_STATE_RECEIVE_PING_REPLY 状态等待主库的响应。主库在收到 PING 命令之后,会回复 PONG 响应,从库收到 PONG 响应之后,说明主从连接正常,会进入 REPL_STATE_SEND_HANDSHAKE 状态。

这里从库读取主库 PONG 响应的逻辑,封装在 receiveSynchronousResponse() 函数中,它是通过 ConnectionType->sync_readline() 实现的,这个函数会阻塞读取主库的响应,其底层通过带超时时间的 poll() 重载来阻塞等待可读事件,然后通过 read() 函数读取连接中的数据。这里的阻塞读取以及上面介绍的阻塞写入的超时时间默认都为 5 秒。具体代码这里就不展开分析了,感兴趣的小伙伴可以查看 connSocketSyncReadLine() 函数的源码实现。

SEND_HANDSHAKE 状态

从库进入 REPL_STATE_SEND_HANDSHAKE 状态之后,会阻塞发送 AUTH 请求,其中携带了从库配置的用户名、密码用于主库鉴权。

之后,从库会阻塞发送一条 REPLCONF 命令,将当前从库的端口号发送给主库。正常情况下,这里发送的是 redisServer.port 字段的值,但是在某些场景(例如,接口转发)中,redisServer.port 与实际的端口号不一致,此时我们可以通过 slave-announce-port 配置指定 REPLCONF 命令中实际发送的端口号。

正常情况下,主库是可以直接通过主从复制连接获取从库的 IP(底层调用 inet_ntop() 函数),但是在 某些场景(例如,NAT 环境)中,主库获取不到从库的正确 IP,此时从库可以通过 slave-announce-ip 配置项指定一个 IP,从库就会再次发送一条 REPLCONF 命令将该配置指定的 IP 上报给主库,主库后续会将该指定 IP 与 port 的组合起来唯一标识从库。

最后,从库会发送第三条 REPLCONF 命令,其中携带了从库的一些基本能力说明。例如,eof 表示这个从库支持结束符方式传输 RDB 数据,psync2 表示这个从库支持 PSYNC 2 协议,目前 Redis 从库只需要说明这两种能力即可。

RECEIVE_*_REPLY 状态

在发送完上述命令之后,从库开始接收主库响应。从库会按照发送命令的顺序,依次进入 RECEIVE_AUTH_REPLY、RECEIVE_PORT_REPLY、RECEIVE_IP_REPLY、RECEIVE_CAPA_REPLY 状态,依次等待 AUTH 命令以及三条 REPLCONF 命令的响应,正常情况下都会收到 “+OK” 的响应。

SEND_PSYNC 状态

收到 AUTH 命令以及三条 REPLCONF 命令的响应之后,从库进入 SEND_PSYNC 状态。在该状态下,从库会先向主库发送 PSYNC 命令并等待主库响应,这部分逻辑位于 slaveTryPartialResynchronization() 函数中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
int slaveTryPartialResynchronization(connection *conn, int read_reply) {

char *psync_replid; // PSYNC命令中的Replication ID值

char psync_offset[32]; // PSYNC命令中的Replication Offset值

sds reply;

if (!read_reply) {

// 如果从库暂存了之前使用的Replication ID信息,就使用这个值

if (server.cached_master) {

psync_replid = server.cached_master->replid;

snprintf(psync_offset,sizeof(psync_offset),"%lld",

server.cached_master->reploff+1);

} else {

// 如果未缓存任何主库信息,则Master Replication ID为?,offset为-1

psync_replid = "?";

memcpy(psync_offset,"-1",3);

}



if (server.failover_state == FAILOVER_IN_PROGRESS) {

... // 暂时忽略FAILOVER的相关逻辑

} else {

// 发送命令

reply = sendCommand(conn,"PSYNC",psync_replid,psync_offset,NULL);

}

return PSYNC_WAIT_REPLY;

}

... // 下面是处理主库返回的PSYNC响应的逻辑,这里暂时省略,后面会展开分析

}

为了看懂 PSYNC 命令的含义,这里我们需要展开看一下 redisServer 中与主从复制相关的几个核心字段。

  • replid、replid2 字段:这两个字段是长度固定为 41 的一个 char 类型的数组,两者都是用来记录 Replication ID 的,其中 replid 用来记录当前使用的 Replication ID 值,也就是前面说的 Main ID,replid2 用来记录前面说的 Secondary ID。

  • master_repl_offset 字段:记录了当前自身的 Replication Offset,单位是字节。虽然这个字段开头是 master,但是在从库中,也是表示自身的 Replication Offset。

  • master、cached_master 字段:client 类型,这两个 client 实例可能对应不同的主库。其中,master 是当前从库与主库交互的 client 实例,cached_master 字段是当前从库与上一个主库交互的 client 实例。举个例子,假设从库与主库的网络出现瞬时断开的情况,在网络断开时,从库会将 master 字段缓存到 cached_master 中并清空 redisServer.master 字段。等到之后当前从库与主库重新连接之后,PSYNC 命令需要携带 Replication ID 和 Replication Offset 进行请求,期望进行部分同步,这就是我们在上面展示的 slaveTryPartialResynchronization() 函数片段中使用 cached_master 字段的原因。

我们再进一步,看一下 cached_master 字段初始化的位置,如下图所示:

其中,replicationCacheMaster() 是在 freeClient() 中被调用的,也就是主从连接断开时被调用;replicationCacheMasterUsingMyself() 是在主库转换为从库时被触发。这两个函数的实现我们后面碰到了,再展开细说,这里只需要知道 master 和 cached_master 之间的关系即可。

通过对 redisServer.master 等字段的介绍,我们会发现,在 client 中也记录了一些与主从复制相关的信息,例如,client->replid、client->reploff 字段,这两个字段记录了 Replication ID 以及 Replication Offset。

RECEIVE_PSYNC_REPLY 状态

发送完成 PSYNC 命令之后,从库进入到 RECEIVE_PSYNC_REPLY 状态,等待主库返回 PSYNC 命令的响应。从库处理 PSYNC 响应的逻辑依旧位于 slaveTryPartialResynchronization() 函数中,核心逻辑如下:

image.png

下面来看看 slaveTryPartialResynchronization() 函数是如何分别处理不同响应值的。

如果主库返回 +FULLRESYNC 响应,对应上图最左边的分支,表示前面发送的 PSYNC 命令中携带的 Replication ID 和 Replication Offset 不符合部分同步的条件,从库会读取 +FULLRESYNC 响应中携带新 Replication ID 和 Replication Offset 值,并更新到 redisServer.master_replid 和 server.master_initial_offset 字段中。因为上一任主库的复制信息已经无效,所以这里会释放掉 server.cached_master 这个 client 实例。

如果主库返回 +CONTINUE,对应上图中左边第二条分支,表示前面发送的 PSYNC 命令中携带的 Replication ID 和 Replication Offset 符合部分同步的条件。如果 +CONTINUE 响应中携带了新库的 Replication ID 值,含义是如下图所示:

image.png

从库 C 之前是与主库 A 同步,但主库 A 下线之后,从库 B 升级为主库,它会在 server.replid2 字段中暂存了上一任主库 A 的 Replication ID 等信息,并复制了主库 A 的缓冲队列,当收到从库 C 的 PSYNC 请求(携带上一任主库 A 的 Replication ID)时,依旧也是可以与从库 C 进行部分同步的,这部分逻辑就是前面描述的 PSYNC2 的优化。B 这个新主库需要通过 +CONTINUE 响应,将自己新生成的 Replication ID 通知到从库 C。

从库 C 收到主库 B 的新 Replication ID,就会更新到 redisServer.replid 和 redisServer.cached_master->replid 中,而上一任主库使用的 Replication ID 会迁移到 redisServer.replid2 字段中,这样,新的主从库信息就保持一致了。

完成新主库的 Replication ID 的同步之后,从库开始构造与新主库交互的 client 实例,这里部分逻辑位于 replicationResurrectCachedMaster() 函数中,其中会重新将 redisServer.cached_master 赋值回 master 来重用该 client 实例,并对底层连接(也就是 redisServer.repl_transfer_s)添加可读可写事件的监听,具体实现片段如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
void replicationResurrectCachedMaster(connection *conn) {

server.master = server.cached_master; // 重用cached_master实例

server.cached_master = NULL; // 清空cached_master字段

server.master->conn = conn; //设置新的connection连接

// 将client实例绑定到connection连接的private_data字段中,

// 后续使用connection的时候可以通过connGetPrivateData()拿到client实例

connSetPrivateData(server.master->conn, server.master);

... // 省略其他字段的设置逻辑

server.repl_state = REPL_STATE_CONNECTED; // 更新repl_state状态



linkClient(server.master); // master这个client添加到server.clients列表

// 添加可读事件和可写事件的监听,可读事件回调为readQueryFromClient,

// 可写事件回调会sendReplyToClient

if (connSetReadHandler(server.master->conn, readQueryFromClient)) {...}

if (clientHasPendingReplies(server.master)) {

if (connSetWriteHandler(server.master->conn, sendReplyToClient)) {...}

}

}

如果主库返回 -NOMASTERLINK、-LOADING 响应,说明主库无法立刻响应从库的复制请求;如果主库返回 -ERR 或是其他未知响应,说明主库不支持 PSYNC 请求。这两个分支对应上图中最右边的两条分支。

到此为止,主库和从库之间的握手操作就执行完了。

总结

这一节中,我们重点以从库的视角,分析了 Redis 主从复制的核心实现。首先,我们站在从库的角度,深入分析了 Redis 主从建连的关键流程;然后,结合 Redis 源码,分析了 Redis 主从握手流程中,从库状态机的流转过程。

下一节中,我们将继续以从库的视角,分析 Redis 主从建立之后的全量同步过程以及进入 CONNECTED 状态之后的正常数据同步流程。