详解 Redis 主从复制

157 阅读9分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第5天,点击查看活动详情

主从复制技术是我们常见的提高可用性的技术,Redis 通过主从复制技术将主节点的数据同步到从节点,以此来提高服务的可用性。

主从复制过程

我们根据主从复制的关键事件把主从复制过程分为四个阶段,分别是初始化、建立连接、主从握手、复制类型判断与执行。下面我们来详细了解一下这四个阶段。

初始化

当我们把一个 Redis 实例 B 设置为另一个实例 A 的从节点时,实例 B 会完成初始化操作,主要是获取主节点的 IP 和端口号。我们有三种方式来设置主节点的信息:

  • 在实例 B 中执行 replicaof masterip masterport 命令来指定主节点的 IP (masterip)及端口号(masterport)。
  • 在实例 B 的配置文件中设置 replicaof masterip masterport,实例 B 会解析配置文件来获取主节点的 IP (masterip)及端口号(masterport)。
  • 在实例 B 启动时设置启动参数 –replicaof [masterip] [masterport]。实例 B 解析启动参数来获取主节点的 IP (masterip)及端口号(masterport)。

建立连接

如果实例 B 获取到主节点的 IP 和端口号,就会尝试与主节点建立 TCP 连接。建立好连接之后会监听是否有主节点发出的命令。

主从握手

和主节点建立好连接之后,实例 B 就会开始和主节点进行握手。握手过程主要是主从之间发送 PING-PONG 消息,同时从节点根据配置信息向主节点进行验证。最后从节点把自己的 IP、端口号以及对无盘复制和 PSYNC 2 协议的支持情况发给主库。

复制类型判断与执行

主节点会根据从节点发送的命令参数作出相应的三种回复,分别是执行全量复制、执行增量复制、发生错误。最后从节点在收到上述回复后,就会根据回复的复制类型,开始执行具体的复制操作。

基于状态机的主从复制实现

我们先看一下关于主从复制的变量有哪些:


struct redisServer {
   ...
    // 用于和主库进行验证的密码
    char *masterauth;               
    // 主库主机名
    char *masterhost;
    // 主库端口号
    int masterport;                 
    …
    // 用于和主库连接的客户端
    client *master;
    // 缓存的主库信息
    client *cached_master; 
    // 从库状态机
    int repl_state;          
   ...
}

初始化

当一个实例启动后,会使用 initServerConfig 函数来初始化 redisServer 的结构体,它会初始化状态机的状态为 REPL_STATE_NONE

...
server.repl_state = REPL_STATE_NONE;
...

当执行执行 replicaof masterip masterport 命令来指定主节点的 IP 及端口号时,会调用 replicaofCommand 函数来进行连接。replicaofCommand 函数会首先根据传入的 masteripmasterport 校验是否已经记录过主节点信息,如果记录过就返回,否则就会调用 replicationSetMaster 函数来设置主节点信息。

void replicaofCommand(client *c) {
        ...
        /* Check if we are already attached to the specified slave */
        // 校验是否已经记录过主节点信息
        if (server.masterhost && !strcasecmp(server.masterhost,c->argv[1]->ptr)
            && server.masterport == port) {
            serverLog(LL_NOTICE,"REPLICAOF would result into synchronization with the master we are already connected with. No operation performed.");
            addReplySds(c,sdsnew("+OK Already connected to specified master\r\n"));
            return;
        }
        /* There was no previous master or the user specified a different one,
         * we can continue. */
        // 设置主节点信息
        replicationSetMaster(c->argv[1]->ptr, port);
        ...
}

replicationSetMaster 函数会设置主节点信息并且将状态机的状态设置为 REPL_STATE_CONNECT

/* Set replication to the specified master address and port. */
void replicationSetMaster(char *ip, int port) {
    ...
    server.repl_state = REPL_STATE_CONNECT;
    ...
}

建立连接

通过上一阶段我们把状态机的状态设置为了 REPL_STATE_CONNECT,下面就是开始与主节点建立连接。

建立连接主要是由周期性函数 serverCron 发起的,可以看下面的代码,每 1000 毫秒会调用一次 replicationCron 来检查状态机的状态,并根据当前状态类型做出对应的操作。


int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
   …
   run_with_period(1000) replicationCron();
   …
}

下面我们来看一下 replicationCron 函数对状态为 REPL_STATE_CONNECT 做什么操作。

void replicationCron(void) {
    ...
    /* Check if we should connect to a MASTER */
    if (server.repl_state == REPL_STATE_CONNECT) {
        serverLog(LL_NOTICE,"Connecting to MASTER %s:%d",
            server.masterhost, server.masterport);
        // 连接主节点
        if (connectWithMaster() == C_OK) {
            serverLog(LL_NOTICE,"MASTER <-> REPLICA sync started");
        }
    }

    ...

}

通过上面的代码我们可以看到,当状态为 REPL_STATE_CONNECT 时,会调用 connectWithMaster 函数来连接主节点。connectWithMaster 函数主要做了三个事情,第一是调用 anetTcpNonBlockBestEffortBindConnect 函数来和主节点建立连接;第二是在这个连接上创建事件读写的监听,设置的回调函数是 syncWithMaster;第三是将状态机的状态设置为 REPL_STATE_CONNECTING

int connectWithMaster(void) {
    int fd;
    // 连接主库
    fd = anetTcpNonBlockBestEffortBindConnect(NULL,
        server.masterhost,server.masterport,NET_FIRST_BIND_ADDR);
    if (fd == -1) {
        serverLog(LL_WARNING,"Unable to connect to MASTER: %s",
            strerror(errno));
        return C_ERR;
    }
    // 设置事件监听
    if (aeCreateFileEvent(server.el,fd,AE_READABLE|AE_WRITABLE,syncWithMaster,NULL) ==
            AE_ERR)
    {
        close(fd);
        serverLog(LL_WARNING,"Can't create readable event for SYNC");
        return C_ERR;
    }

    server.repl_transfer_lastio = server.unixtime;
    server.repl_transfer_s = fd;
    // 设置状态为 REPL_STATE_CONNECTING
    server.repl_state = REPL_STATE_CONNECTING;
    return C_OK;
}

到这里连接阶段就算完成了。

主从握手

当从节点与主节点连接完成之后,从节点没有立即进行数据同步,而是先跟主节点进行握手通信。握手通信的目的主要包括从节点和主节点进行验证,以及从节点将自身的 IP 和端口号发给主节点。

这个阶段会涉及到许多状态的变更,不过逻辑并不复杂,下面是这个阶段会变更的状态。

/* --- 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 */

我们知道上一个阶段的状态已经变为 REPL_STATE_CONNECTING 且在连接中设置了回调函数 syncWithMaster。当主节点和从节点连接成功后,从节点的 syncWithMaster 函数就会被调用,当状态机的状态为 REPL_STATE_CONNECTING 时,syncWithMaster 函数就会将状态设置为 REPL_STATE_RECEIVE_PONG,并且发送 PING 命令给主节点。

    /* Send a PING to check the master is able to reply without errors. */
    if (server.repl_state == REPL_STATE_CONNECTING) {
        serverLog(LL_NOTICE,"Non blocking connect for SYNC fired the event.");
        /* Delete the writable event so that the readable event remains
         * registered and we can wait for the PONG reply. */
        aeDeleteFileEvent(server.el,fd,AE_WRITABLE);
        // 变更状态
        server.repl_state = REPL_STATE_RECEIVE_PONG;
        /* Send the PING, don't check for errors at all, we have the timeout
         * that will take care about this. */
        // 发送 PING 命令
        err = sendSynchronousCommand(SYNC_CMD_WRITE,fd,"PING",NULL);
        if (err) goto write_error;
        return;
    }

主节点收到 PING 命令之后会发送 PONG 给到从节点,从节点收到 PONG 之后会依次发送验证信息、端口号、IP 地址以及对 RDB 文件和无盘复制的支持情况发送给主节点。

主节点收到 ping 命令后调用的是 pingCommand 函数,它会发送 PONG 给到从节点。从节点收到 PONG 命令后,会将自身状态机置为 REPL_STATE_SEND_AUTH。然后判断是否需要向主节点发送验证信息,如果需要则会向主节点发送 AUTH 命令,并且将状态设置为 REPL_STATE_RECEIVE_AUTH;如果不需要直接设置为 REPL_STATE_SEND_PORT 状态。之后的端口号、IP 地址以及对 RDB 文件和无盘复制的支持情况也大体是这个过程。主节点主要的处理函数是 replconfCommand,大家有兴趣可以自己分析分析。

复制类型判断与执行

当从节点和主节点完成握手后,从节点会读取主节点返回的 CAPA 消息响应,此时状态机的状态为 REPL_STATE_RECEIVE_CAPA。从节点的状态会变为 REPL_STATE_SEND_PSYNC,表明要开始向主库发送 PSYNC 命令,开始实际的数据同步。

...
    if (server.repl_state == REPL_STATE_SEND_PSYNC) {
        // 向主库发送 PSYNC 命令
        if (slaveTryPartialResynchronization(fd,0) == PSYNC_WRITE_ERROR) {
            err = sdsnew("Write error sending the PSYNC command.");
            goto write_error;
        }
        // 设置状态
        server.repl_state = REPL_STATE_RECEIVE_PSYNC;
        return;
    }

...

从上面的代码可以看出从节点会调用 slaveTryPartialResynchronization 函数来向主节点发送 PSYNC 命令,然后将状态设置为 REPL_STATE_RECEIVE_PSYNC。下面我们来看一下slaveTryPartialResynchronization 函数主要做了什么。

int slaveTryPartialResynchronization(int fd, int read_reply) {
    char *psync_replid;
    char psync_offset[32];
    sds reply;

    /* Writing half */
    if (!read_reply) {
        // 从节点第一次和主节点同步时,设置offset为-1
        server.master_initial_offset = -1;

        if (server.cached_master) {
            psync_replid = server.cached_master->replid;
            snprintf(psync_offset,sizeof(psync_offset),"%lld", server.cached_master->reploff+1);
            serverLog(LL_NOTICE,"Trying a partial resynchronization (request %s:%s).", psync_replid, psync_offset);
        } else {
            serverLog(LL_NOTICE,"Partial resynchronization not possible (no cached master)");
            psync_replid = "?";
            memcpy(psync_offset,"-1",3);
        }

        // 发送 PSYNC 命令,将主节点 id 以及 offset 发送给 主节点
        reply = sendSynchronousCommand(SYNC_CMD_WRITE,fd,"PSYNC",psync_replid,psync_offset,NULL);
        ...
        // 等待主节点响应
        return PSYNC_WAIT_REPLY;
    }

    // 获取主节点的返回信息
    reply = sendSynchronousCommand(SYNC_CMD_READ,fd,NULL);
    ...
    // 返回 FULLRESYNC 表示全量复制
    if (!strncmp(reply,"+FULLRESYNC",11)) {
        ...
        return PSYNC_FULLRESYNC;
    }
    // 返回 CONTINUE 表示增量复制
    if (!strncmp(reply,"+CONTINUE",9)) {
        ...
        return PSYNC_CONTINUE;
    }
    
    // 返回 ERR 表示出现错误
    if (strncmp(reply,"-ERR",4)) {
        /* If it's not an error, log the unexpected event. */
        serverLog(LL_WARNING,
            "Unexpected reply to PSYNC from master: %s", reply);
    } else {
        serverLog(LL_NOTICE,
            "Master does not support PSYNC or is in "
            "error state (reply: %s)", reply);
    }
    sdsfree(reply);
    replicationDiscardCachedMaster();
    return PSYNC_NOT_SUPPORTED;
}

我们可以看到 slaveTryPartialResynchronization 函数会向主节点发送 PSYNC 命令。主节点收到命令后会根据从节点发送的主节点 id、复制进度值 offset,来判断是进行全量复制还是增量复制或者是返回错误。

    // PSYNC 结果还没有返回,先从syncWithMaster 函数返回处理其他操作
    if (psync_result == PSYNC_WAIT_REPLY) return;

    /* If the master is in an transient error, we should try to PSYNC
     * from scratch later, so go to the error path. This happens when
     * the server is loading the dataset or is not connected with its
     * master and so forth. */
    if (psync_result == PSYNC_TRY_LATER) goto error;

    
    // 如果 PSYNC 结果是 PSYNC_CONTINUE 从 syncWithMaster 函数返回,后续执行增量复制
    if (psync_result == PSYNC_CONTINUE) {
        serverLog(LL_NOTICE, "MASTER <-> REPLICA sync: Master accepted a Partial Resynchronization.");
        return;
    }

    // 如果执行全量复制的话,针对连接上的读事件,创建 readSyncBulkPayload 回调函数
    /* Setup the non blocking download of the bulk file. */
    if (aeCreateFileEvent(server.el,fd, AE_READABLE,readSyncBulkPayload,NULL)
            == AE_ERR)
    {
        serverLog(LL_WARNING,
            "Can't create readable event for SYNC: %s (fd=%d)",
            strerror(errno),fd);
        goto error;
    }
    
    ...
    //设置状态
    server.repl_state = REPL_STATE_TRANSFER;
    ...

最后我们来看一下完整的状态变更图:

状态变更.png

全量复制

下面我们看一下全量复制的流程图:

全量复制.png 从节点与主节点连接成功后,从节点向主节点发送 PSYNC ? -1 命令。主节点收到请求后会根据是否为无盘复制来决定怎样向从节点发送 RDB 文件。如果不是无盘复制从节点会调用 rdbSaveBackground 来后台生成 RDB 文件,回复 +FULLRESYNC replid offset。当从节点接收到这个回复时,表示是全量复制,主节点将会发送 RDB 文件。当主节点的 RDB 文件生成成功后会将 RDB 文件发送给从节点,从节点接收后先清空数据库中的数据,然后再加载 RDB 中的文件。之后主节点会将生成 RDB 期间的命令发送给从节点。

增量复制

小结

本文介绍了 Redis 主从复制的过程,Redis 从节点主要使用状态机来实现主从复制的过程。后续会补图,如果忘记可以提醒一下我。