Redis源碼剖析和註釋(二十二)--- Redis 複製(replicate)源碼詳細解析

Redis 複製(replicate)實現

1. 複製的介紹

Redis爲了解決單點數據庫問題,會把數據複製多個副本部署到其他節點上,通過複製,實現Redis高可用性,實現對數據的冗餘備份,保證數據和服務的高度可靠性

關於複製的詳細配置和如何建立複製,請參考:Redis 複製功能詳解

2. 複製的實現

本文主要剖析:

  • 第一次執行復制所進行全量同步的全過程
  • 部分重同步的實現

replication.c文件詳細註釋:Redis 複製代碼註釋

2.1 主從關係的建立

複製的建立方法有三種。

  1. redis.conf文件中配置slaveof <masterip> <masterport>選項,然後指定該配置文件啓動Redis生效。
  2. redis-server啓動命令後加上--slaveof <masterip> <masterport>啓動生效。
  3. 直接使用 slaveof <masterip> <masterport>命令在從節點執行生效。

無論是通過哪一種方式來建立主從複製,都是從節點來執行slaveof命令,那麼從節點執行了這個命令到底做了什麼,我們上源碼:

// SLAVEOF host port命令實現
void slaveofCommand(client *c) {
    // 如果當前處於集羣模式,不能進行復制操作
    if (server.cluster_enabled) {
        addReplyError(c,"SLAVEOF not allowed in cluster mode.");
        return;
    }

    // SLAVEOF NO ONE命令使得這個從節點關閉複製功能,並從從節點轉變回主節點,原來同步所得的數據集不會被丟棄。
    if (!strcasecmp(c->argv[1]->ptr,"no") &&
        !strcasecmp(c->argv[2]->ptr,"one")) {
        // 如果保存了主節點IP
        if (server.masterhost) {
            // 取消複製操作,設置服務器爲主服務器
            replicationUnsetMaster();
            // 獲取client的每種信息,並以sds形式返回,並打印到日誌中
            sds client = catClientInfoString(sdsempty(),c);
            serverLog(LL_NOTICE,"MASTER MODE enabled (user request from '%s')",
                client);
            sdsfree(client);
        }
    // SLAVEOF host port
    } else {
        long port;

        // 獲取端口號
        if ((getLongFromObjectOrReply(c, c->argv[2], &port, NULL) != C_OK))
            return;

        // 如果已存在從屬於masterhost主節點且命令參數指定的主節點和masterhost相等,端口也相等,直接返回
        if (server.masterhost && !strcasecmp(server.masterhost,c->argv[1]->ptr)
            && server.masterport == port) {
            serverLog(LL_NOTICE,"SLAVE OF 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;
        }
        // 第一次執行設置端口和ip,或者是重新設置端口和IP
        // 設置服務器複製操作的主節點IP和端口
        replicationSetMaster(c->argv[1]->ptr, port);
        // 獲取client的每種信息,並以sds形式返回,並打印到日誌中
        sds client = catClientInfoString(sdsempty(),c);
        serverLog(LL_NOTICE,"SLAVE OF %s:%d enabled (user request from '%s')",
            server.masterhost, server.masterport, client);
        sdsfree(client);
    }
    // 回覆ok
    addReply(c,shared.ok);
}

當從節點的client執行SLAVEOF命令後,該命令會被構建成Redis協議格式,發送給從節點服務器,然後節點服務器會調用slaveofCommand()函數執行該命令。

具體的命令接受和回覆請參考:Redis 網絡連接庫剖析

SLAVEOF命令做的操作並不多,主要以下三步:

  • 判斷當前環境是否在集羣模式下,因爲集羣模式下不行執行該命令。
  • 是否執行的是SLAVEOF NO ONE命令,該命令會斷開主從的關係,設置當前節點爲主節點服務器。
  • 設置從節點所屬主節點的IPport。調用了replicationSetMaster()函數。

SLAVEOF命令能做的只有這麼多,我們來具體看下replicationSetMaster()函數的代碼,看看它做了哪些與複製相關的操作。

// 設置複製操作的主節點IP和端口
void replicationSetMaster(char *ip, int port) {
    // 按需清除原來的主節點信息
    sdsfree(server.masterhost);
    // 設置ip和端口
    server.masterhost = sdsnew(ip);
    server.masterport = port;
    // 如果有其他的主節點,在釋放
    // 例如服務器1是服務器2的主節點,現在服務器2要同步服務器3,服務器3要成爲服務器2的主節點,因此要釋放服務器1
    if (server.master) freeClient(server.master);
    // 解除所有客戶端的阻塞狀態
    disconnectAllBlockedClients(); /* Clients blocked in master, now slave. */
    // 關閉所有從節點服務器的連接,強制從節點服務器進行重新同步操作
    disconnectSlaves(); /* Force our slaves to resync with us as well. */
    // 釋放主節點結構的緩存,不會執行部分重同步PSYNC
    replicationDiscardCachedMaster(); /* Don't try a PSYNC. */
    // 釋放複製積壓緩衝區
    freeReplicationBacklog(); /* Don't allow our chained slaves to PSYNC. */
    // 取消執行復制操作
    cancelReplicationHandshake();
    // 設置複製必須重新連接主節點的狀態
    server.repl_state = REPL_STATE_CONNECT;
    // 初始化複製的偏移量
    server.master_repl_offset = 0;
    // 清零連接斷開的時長
    server.repl_down_since = 0;
}

由代碼知,replicationSetMaster()函數執行操作的也很簡單,總結爲兩步:

  • 清理之前所屬的主節點的信息。
  • 設置新的主節點IPport等。

因爲,當前從節點有可能之前從屬於另外的一個主節點服務器,因此要清理所有關於之前主節點的緩存、關閉舊的連接等等。然後設置該從節點的新主節點,設置了IPport,還設置了以下狀態:

// 設置複製必須重新連接主節點的狀態
server.repl_state = REPL_STATE_CONNECT;
// 初始化全局複製的偏移量
server.master_repl_offset = 0;

然後,就沒有然後了,然後就會執行復制操作嗎?這也沒有什麼關於複製的操作執行了,那麼複製操作是怎麼開始的呢?

2.2 主從網絡連接建立

slaveof命令是一個異步命令,執行命令時,從節點保存主節點的信息,確立主從關係後就會立即返回,後續的複製流程在節點內部異步執行。那麼,如何觸發複製的執行呢?

週期性執行的函數:replicationCron()函數,該函數被服務器的時間事件的回調函數serverCron()所調用,而serverCron()函數在Redis服務器初始化時,被設置爲時間事件的處理函數。

// void initServer(void) Redis服務器初始化
aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL)

Redis 單機服務器實現源碼剖析

Redis 文件事件和時間事件處理實現源碼剖析

replicationCron()函數執行頻率爲1秒一次:

// 節選自serverCron函數
// 週期性執行復制的任務
run_with_period(1000) replicationCron();

主從關係建立後,從節點服務器的server.repl_state被設置爲REPL_STATE_CONNECT,而replicationCron()函數會被每秒執行一次,該函數會發現我(從節點)現在有主節點了,而且我要的狀態是要連接主節點(REPL_STATE_CONNECT)

replicationCron()函數處理這以情況的代碼如下:

/* 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 <-> SLAVE sync started");
    }
}

replicationCron()函數根據從節點的狀態,調用connectWithMaster()非阻塞連接主節點。代碼如下:

// 以非阻塞的方式連接主節點
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;
    }

    // 監聽主節點fd的可讀和可寫事件的發生,並設置其處理程序爲syncWithMaster
    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;
    }

    // 最近一次讀到RDB文件內容的時間
    server.repl_transfer_lastio = server.unixtime;
    // 從節點和主節點的同步套接字
    server.repl_transfer_s = fd;
    // 處於和主節點正在連接的狀態
    server.repl_state = REPL_STATE_CONNECTING;
    return C_OK;
}

connectWithMaster()函數執行的操作可以總結爲:

  • 根據IPport非阻塞的方式連接主節點,得到主從節點進行通信的文件描述符fd,並保存到從節點服務器server.repl_transfer_s中,並且將剛纔的REPL_STATE_CONNECT狀態設置爲REPL_STATE_CONNECTING
  • 監聽fd的可讀和可寫事件,並且設置事件發生的處理程序syncWithMaster()函數。

至此,主從網絡建立就完成了。

2.3 發送PING命令

主從建立網絡時,同時註冊fdAE_READABLE|AE_WRITABLE事件,因此會觸發一個AE_WRITABLE事件,調用syncWithMaster()函數,處理寫事件。

根據當前的REPL_STATE_CONNECTING狀態,從節點向主節點發送PING命令,PING命令的目的有:

  1. 檢測主從節點之間的網絡是否可用。
  2. 檢查主從節點當前是否接受處理命令。

syncWithMaster()函數中相關操作的代碼如下:

/* Send a PING to check the master is able to reply without errors. */
// 如果複製的狀態爲REPL_STATE_CONNECTING,發送一個PING去檢查主節點是否能正確回覆一個PONG
if (server.repl_state == REPL_STATE_CONNECTING) {
    serverLog(LL_NOTICE,"Non blocking connect for SYNC fired the event.");
    // 暫時取消接聽fd的寫事件,以便等待PONG回覆時,註冊可讀事件
    aeDeleteFileEvent(server.el,fd,AE_WRITABLE);
    // 設置複製狀態爲等待PONG回覆
    server.repl_state = REPL_STATE_RECEIVE_PONG;
    // 發送一個PING命令
    err = sendSynchronousCommand(SYNC_CMD_WRITE,fd,"PING",NULL);
    if (err) goto write_error;
    return;
}

發送PING命令主要的操作是:

  • 先取消監聽fd的寫事件,因爲接下來要讀主節點服務器發送過來的PONG回覆,因此只監聽可讀事件的發生。
  • 設置從節點的複製狀態爲REPL_STATE_RECEIVE_PONG。等待一個主節點回復一個PONG命令。
  • 以寫的方式調用sendSynchronousCommand()函數發送一個PING命令給主節點。

主節點服務器從fd會讀到一個PING命令,然後會回覆一個PONG命令到fd中,執行的命令就是addReply(c,shared.pong);

此時,會觸發fd的可讀事件,調用syncWithMaster()函數來處理,此時從節點的複製狀態爲REPL_STATE_RECEIVE_PONG,等待主節點回復PONGsyncWithMaster()函數中處理這一狀態的代碼如下:

/* Receive the PONG command. */
// 如果複製的狀態爲REPL_STATE_RECEIVE_PONG,等待接受PONG命令
if (server.repl_state == REPL_STATE_RECEIVE_PONG) {
   // 從主節點讀一個PONG命令sendSynchronousCommand
   err = sendSynchronousCommand(SYNC_CMD_READ,fd,NULL);

    // 只接受兩種有效的回覆。一種是 "+PONG",一種是認證錯誤"-NOAUTH"。
    // 舊版本的返回有"-ERR operation not permitted"
    if (err[0] != '+' &&
        strncmp(err,"-NOAUTH",7) != 0 &&
        strncmp(err,"-ERR operation not permitted",28) != 0)
    {   // 沒有收到正確的PING命令的回覆
        serverLog(LL_WARNING,"Error reply to PING from master: '%s'",err);
        sdsfree(err);
        goto error;
     } else {
       serverLog(LL_NOTICE,"Master replied to PING, replication can continue...");
     }
     sdsfree(err);
     // 已經收到PONG,更改狀態設置爲發送認證命令AUTH給主節點
     server.repl_state = REPL_STATE_SEND_AUTH;
}

在這裏,以讀的方式調用sendSynchronousCommand(),並將讀到的"+PONG\r\n"返回到err中,如果從節點正確接收到主節點發送的PONG命令,會將從節點的複製狀態設置爲server.repl_state = REPL_STATE_SEND_AUTH。等待進行權限的認證。

2.4 認證權限

權限認證會在syncWithMaster()函數繼續執行,緊接着剛纔的代碼:

/* AUTH with the master if required. */
// 如果需要,發送AUTH認證給主節點
if (server.repl_state == REPL_STATE_SEND_AUTH) {
   // 如果服務器設置了認證密碼
   if (server.masterauth) {
        // 寫AUTH給主節點
        err = sendSynchronousCommand(SYNC_CMD_WRITE,fd,"AUTH",server.masterauth,NULL);
        if (err) goto write_error;
        // 設置狀態爲等待接受認證回覆
        server.repl_state = REPL_STATE_RECEIVE_AUTH;
        return;
    // 如果沒有設置認證密碼,直接設置複製狀態爲發送端口號給主節點
    } else {
        server.repl_state = REPL_STATE_SEND_PORT;
    }
}

如果從節點的服務器設置了認證密碼,則會以寫方式調用sendSynchronousCommand()函數,將AUTH命令和密碼寫到fd中,並且將從節點的複製狀態設置爲server.repl_state = REPL_STATE_RECEIVE_AUTH,接受AUTH的驗證。

如果從節點服務器沒有設置認證密碼,就直接將從節點的複製狀態設置爲server.repl_state = REPL_STATE_SEND_PORT,準發發送一個端口號。

主節點會讀取到AUTH命令,調用authCommand()函數來處理,主節點服務器會比較從節點發送過來的server.masterauth和主節點服務器保存的server.requirepass是否一致,如果一致,會回覆一個"+OK\r\n"

當主節點將回複寫到fd時,又會觸發從節點的可讀事件,緊接着調用syncWithMaster()函數來處理接收AUTH認證結果:

/* Receive AUTH reply. */
// 接受AUTH認證的回覆
if (server.repl_state == REPL_STATE_RECEIVE_AUTH) {
    // 從主節點讀回覆
    err = sendSynchronousCommand(SYNC_CMD_READ,fd,NULL);
    // 回覆錯誤,認證失敗
    if (err[0] == '-') {
        serverLog(LL_WARNING,"Unable to AUTH to MASTER: %s",err);
        sdsfree(err);
        goto error;
    }
    sdsfree(err);
    // 設置複製狀態爲發送端口號給主節點
    server.repl_state = REPL_STATE_SEND_PORT;
}

以讀方式從fd中讀取一個回覆,判斷認證是否成功,認證成功,則會將從節點的複製狀態設置爲server.repl_state = REPL_STATE_SEND_PORT表示要發送一個端口號給主節點。這和沒有設置認證的情況結果相同。

2.5 發送端口號

從節點在認證完權限後,會繼續在syncWithMaster()函數執行,處理髮送端口號的狀態。

/* Set the slave port, so that Master's INFO command can list the
 * slave listening port correctly. */
// 如果複製狀態是,發送從節點端口號給主節點,主節點的INFO命令就能夠列出從節點正在監聽的端口號
if (server.repl_state == REPL_STATE_SEND_PORT) {
    // 獲取端口號
    sds port = sdsfromlonglong(server.slave_announce_port ?
        server.slave_announce_port : server.port);
    // 將端口號寫給主節點
    err = sendSynchronousCommand(SYNC_CMD_WRITE,fd,"REPLCONF","listening-port",port, NULL);
    sdsfree(port);
    if (err) goto write_error;
    sdsfree(err);
    // 設置複製狀態爲接受端口號
    server.repl_state = REPL_STATE_RECEIVE_PORT;
    return;
}

發送端口號,以REPLCONF listening-port命令的方式,寫到fd中。然後將複製狀態設置爲server.repl_state = REPL_STATE_RECEIVE_PORT,等待接受主節點的回覆。

主節點從fd中讀到REPLCONF listening-port <port>命令,調用replconfCommand()命令來處理,而replconfCommand()函數的定義就在replication.c文件中,REPLCONF命令可以設置多種不同的選項,解析到端口號後,將端口號保存從節點對應client狀態的c->slave_listening_port = port中。最終回覆一個"+OK\r\n"狀態的回覆,寫在fd中。

當主節點將回複寫到fd時,又會觸發從節點的可讀事件,緊接着調用syncWithMaster()函數來處理接受端口號,驗證主節點是否正確的接收到從節點的端口號。

/* Receive REPLCONF listening-port reply. */
// 複製狀態爲接受端口號
if (server.repl_state == REPL_STATE_RECEIVE_PORT) {
    // 從主節點讀取端口號
    err = sendSynchronousCommand(SYNC_CMD_READ,fd,NULL);
    /* Ignore the error if any, not all the Redis versions support
     * REPLCONF listening-port. */
    // 忽略所有的錯誤,因爲不是所有的Redis版本都支持REPLCONF listening-port命令
    if (err[0] == '-') {
        serverLog(LL_NOTICE,"(Non critical) Master does not understand REPLCONF listening-port: %s", err);
    }
    sdsfree(err);
    // 設置複製狀態爲發送IP
    server.repl_state = REPL_STATE_SEND_IP;
}

如果主節點正確的接收到從節點的端口號,會將從節點的複製狀態設置爲server.repl_state = REPL_STATE_SEND_IP表示要送一個IP給主節點。

2.6 發送 IP 地址

從節點發送完端口號並且正確收到主節點的回覆後,緊接着syncWithMaster()函數執行發送IP的代碼。發送IP和發送端口號過程幾乎一致。

// 複製狀態爲發送IP
if (server.repl_state == REPL_STATE_SEND_IP) {
    // 將IP寫給主節點
    err = sendSynchronousCommand(SYNC_CMD_WRITE,fd,"REPLCONF","ip-address",server.slave_announce_ip, NULL);
    if (err) goto write_error;
    sdsfree(err);
    // 設置複製狀態爲接受IP
    server.repl_state = REPL_STATE_RECEIVE_IP;
    return;
}

同樣是以REPLCONF ip-address命令的方式,將從節點的IP寫到fd中。並且設置從節點的複製狀態爲server.repl_state = REPL_STATE_RECEIVE_IP,等待接受主節點的回覆。然後就直接返回,等待fd可讀發生。

主節點仍然會調用replication.c文件中實現的replconfCommand()函數來處理REPLCONF命令,解析出REPLCONF ip-address ip命令,保存從節點的ip到主節點的對應從節點的client的c->slave_ip中。然後回覆"+OK\r\n"狀態,寫到fd中。

此時,從節點監聽到fd觸發了可讀事件,會調用syncWithMaster()函數來處理,驗證主節點是否正確接收到從節點的IP

/* Receive REPLCONF ip-address reply. */
// 複製狀態爲接受IP回覆
if (server.repl_state == REPL_STATE_RECEIVE_IP) {
    // 從主節點讀一個IP回覆
    err = sendSynchronousCommand(SYNC_CMD_READ,fd,NULL);
    // 錯誤回覆
    if (err[0] == '-') {
        serverLog(LL_NOTICE,"(Non critical) Master does not understand REPLCONF ip-address: %s", err);
    }
    sdsfree(err);
    // 設置複製狀態爲發送一個capa(能力?能否解析出RDB文件的EOF流格式)
    server.repl_state = REPL_STATE_SEND_CAPA;
}

如果主節點正確接收了從節點IP,就會設置從節點的複製狀態server.repl_state = REPL_STATE_SEND_CAPA表示發送從節點的能力(capability)。

2.7 發送能力(capability)

發送能力和發送端口和IP也是如出一轍,緊接着syncWithMaster()函數執行發送capa的代碼。

// 複製狀態爲發送capa,通知主節點從節點的能力
if (server.repl_state == REPL_STATE_SEND_CAPA) {
    // 將從節點的capa寫給主節點
    err = sendSynchronousCommand(SYNC_CMD_WRITE,fd,"REPLCONF","capa","eof",NULL);
    if (err) goto write_error;
    sdsfree(err);
    // 設置複製狀態爲接受從節點的capa
    server.repl_state = REPL_STATE_RECEIVE_CAPA;
    return;
}

從節點將REPLCONF capa eof命令發送給主節點,寫到fd中。

目前只支持一種能力,就是能夠解析出RDB文件的EOF流格式。用於無盤複製的方式中。

主節點仍然會調用replication.c文件中實現的replconfCommand()函數來處理REPLCONF命令,解析出REPLCONF capa eof命令,將eof對應的標識,按位與到主節點的對應從節點的client的c->slave_capa中。然後回覆"+OK\r\n"狀態,寫到fd中。

此時,從節點監聽到fd觸發了可讀事件,會調用syncWithMaster()函數來處理,驗證主節點是否正確接收到從節點的capa

/* Receive CAPA reply. */
// 複製狀態爲接受從節點的capa回覆
if (server.repl_state == REPL_STATE_RECEIVE_CAPA) {
    // 從主節點讀取capa回覆
    err = sendSynchronousCommand(SYNC_CMD_READ,fd,NULL);
    // 錯誤回覆
    if (err[0] == '-') {
        serverLog(LL_NOTICE,"(Non critical) Master does not understand REPLCONF capa: %s", err);
    }
    sdsfree(err);
    // 設置複製狀態爲發送PSYNC命令
    server.repl_state = REPL_STATE_SEND_PSYNC;
}

如果主節點正確接收了從節點capa,就會設置從節點的複製狀態server.repl_state = REPL_STATE_SEND_PSYNC表示發送一個PSYNC命令。

2.8 發送PSYNC命令

replication.c文件詳細註釋:Redis 複製代碼註釋

從節點發送PSYNC命令給主節點,嘗試進行同步主節點的數據集。同步分爲兩種:

  • 全量同步:第一次執行復制的場景。
  • 部分同步:用於主從複製因爲網絡中斷等原因造成數據丟失的場景。

因爲這是第一次執行同步,因此會進行全量同步。

// 複製狀態爲發送PSYNC命令。嘗試進行部分重同步。
// 如果沒有緩衝主節點的結構,slaveTryPartialResynchronization()函數將會至少嘗試使用PSYNC去進行一個全同步,這樣就能得到主節點的運行runid和全局複製偏移量。並且在下次重連接時可以嘗試進行部分重同步。
if (server.repl_state == REPL_STATE_SEND_PSYNC) {
    // 向主節點發送一個部分重同步命令PSYNC,參數0表示不讀主節點的回覆,只獲取主節點的運行runid和全局複製偏移量
    if (slaveTryPartialResynchronization(fd,0) == PSYNC_WRITE_ERROR) {
        // 發送PSYNC出錯
        err = sdsnew("Write error sending the PSYNC command.");
        goto write_error;
    }
    // 設置複製狀態爲等待接受一個PSYNC回覆
    server.repl_state = REPL_STATE_RECEIVE_PSYNC;
    return;
}

從節點調用slaveTryPartialResynchronization()函數嘗試進行重同步,注意第二個參數是0。因爲slaveTryPartialResynchronization()分成兩部分,一部分是寫,一部分是讀,因爲第二個參數是0,因此執行寫的一部分,發送一個PSYNC命令給主節點。只列舉出寫的部分

/* Writing half */
// 如果read_reply爲0,則該函數往socket上會寫入一個PSYNC命令
if (!read_reply) {
    // 將repl_master_initial_offset設置爲-1表示主節點的run_id和全局複製偏移量是無效的。
    // 如果能使用PSYNC命令執行一個全量同步,會正確設置全複製偏移量,以便這個信息被正確傳播主節點的所有從節點中
    server.repl_master_initial_offset = -1;

    // 主節點的緩存不爲空,可以嘗試進行部分重同步。PSYNC <master_run_id> <repl_offset>
    if (server.cached_master) {
        // 保存緩存runid
        psync_runid = server.cached_master->replrunid;
        // 獲取已經複製的偏移量
        snprintf(psync_offset,sizeof(psync_offset),"%lld", server.cached_master->reploff+1);
        serverLog(LL_NOTICE,"Trying a partial resynchronization (request %s:%s).", psync_runid, psync_offset);
    // 主節點的緩存爲空,發送PSYNC ? -1。請求全量同步
    } else {
        serverLog(LL_NOTICE,"Partial resynchronization not possible (no cached master)");
        psync_runid = "?";
        memcpy(psync_offset,"-1",3);
    }

    /* Issue the PSYNC command */
    // 發送一個PSYNC命令給主節點
    reply = sendSynchronousCommand(SYNC_CMD_WRITE,fd,"PSYNC",psync_runid,psync_offset,NULL);
    // 寫成功失敗會返回一個"-"開頭的字符串
    if (reply != NULL) {
        serverLog(LL_WARNING,"Unable to send PSYNC to master: %s",reply);
        sdsfree(reply);
        // 刪除文件的可讀事件,返回寫錯誤PSYNC_WRITE_ERROR
        aeDeleteFileEvent(server.el,fd,AE_READABLE);
        return PSYNC_WRITE_ERROR;
    }
    // 返回等待回覆的標識PSYNC_WAIT_REPLY,調用者會將read_reply設置爲1,然後再次調用該函數,執行下面的讀部分。
    return PSYNC_WAIT_REPLY;
}

由於從節點是第一次和主節點進行同步操作,因此從節點緩存的主節點client狀態erver.cached_master爲空,所以就會發送一個PSYNC ? -1命令給主節點,表示進行一次全量同步。

主節點會接收到PSYNC ? -1命令,然後調用replication.c文件中實現的syncCommand()函數處理PSYNC命令。

syncCommand()函數先會判斷執行的是PSYNC還是SYNC命令,如果是PSYNC命令會調用masterTryPartialResynchronization()命令執行部分同步,但是由於這是第一次執行復制操作,所以會執行失敗。進而執行全量同步。

syncCommand()函數的代碼如下:

/* SYNC and PSYNC command implemenation. */
// SYNC and PSYNC 命令實現
void syncCommand(client *c) {
    ..........//爲了簡潔,刪除一些判斷條件的代碼

    // 嘗試執行一個部分同步PSYNC的命令,則masterTryPartialResynchronization()會回覆一個 "+FULLRESYNC <runid> <offset>",如果失敗則執行全量同步
    // 所以,從節點會如果和主節點連接斷開,從節點會知道runid和offset,隨後會嘗試執行PSYNC
    // 如果是執行PSYNC命令
    if (!strcasecmp(c->argv[0]->ptr,"psync")) {
        // 主節點嘗試執行部分重同步,執行成功返回C_OK
        if (masterTryPartialResynchronization(c) == C_OK) {
            // 可以執行PSYNC命令,則將接受PSYNC命令的個數加1
            server.stat_sync_partial_ok++;
            // 不需要執行後面的全量同步,直接返回
            return; /* No full resync needed, return. */
        // 不能執行PSYNC部分重同步,需要進行全量同步
        } else {
            char *master_runid = c->argv[1]->ptr;
            // 從節點以強制全量同步爲目的,所以不能執行部分重同步,因此增加PSYNC命令失敗的次數
            if (master_runid[0] != '?') server.stat_sync_partial_err++;
        }
    // 執行SYNC命令
    } else {
        // 設置標識,執行SYNC命令,不接受REPLCONF ACK
        c->flags |= CLIENT_PRE_PSYNC;
    }
    // 全量重同步次數加1
    server.stat_sync_full++;

    // 設置client狀態爲:從服務器節點等待BGSAVE節點的開始
    c->replstate = SLAVE_STATE_WAIT_BGSAVE_START;
    // 執行SYNC命令後是否關閉TCP_NODELAY
    if (server.repl_disable_tcp_nodelay)
        // 是的話,則啓用nagle算法
        anetDisableTcpNoDelay(NULL, c->fd); /* Non critical if it fails. */
    // 保存主服務器傳來的RDB文件的fd,設置爲-1
    c->repldbfd = -1;
    // 設置client狀態爲從節點,標識client是一個從服務器
    c->flags |= CLIENT_SLAVE;
    // 添加到服務器從節點鏈表中
    listAddNodeTail(server.slaves,c);

    /* CASE 1: BGSAVE is in progress, with disk target. */
    // 情況1. 正在執行 BGSAVE ,且是同步到磁盤上
    if (server.rdb_child_pid != -1 &&
        server.rdb_child_type == RDB_CHILD_TYPE_DISK)
    {
        client *slave;
        listNode *ln;
        listIter li;

        listRewind(server.slaves,&li);
        // 遍歷從節點鏈表
        while((ln = listNext(&li))) {
            slave = ln->value;
            // 如果有從節點已經創建子進程執行寫RDB操作,等待完成,那麼退出循環
            // 從節點的狀態爲 SLAVE_STATE_WAIT_BGSAVE_END 在情況三中被設置
            if (slave->replstate == SLAVE_STATE_WAIT_BGSAVE_END) break;
        }
        // 對於這個從節點,我們檢查它是否具有觸發當前BGSAVE操作的能力
        if (ln && ((c->slave_capa & slave->slave_capa) == slave->slave_capa)) {
            // 將slave的輸出緩衝區所有內容拷貝給c的所有輸出緩衝區中
            copyClientOutputBuffer(c,slave);
            // 設置全量重同步從節點的狀態,設置部分重同步的偏移量
            replicationSetupSlaveForFullResync(c,slave->psync_initial_offset);
            serverLog(LL_NOTICE,"Waiting for end of BGSAVE for SYNC");
        } else {
            serverLog(LL_NOTICE,"Can't attach the slave to the current BGSAVE. Waiting for next BGSAVE for SYNC");
        }

    /* CASE 2: BGSAVE is in progress, with socket target. */
    // 情況2. 正在執行BGSAVE,且是無盤同步,直接寫到socket中
    } else if (server.rdb_child_pid != -1 &&
               server.rdb_child_type == RDB_CHILD_TYPE_SOCKET)
    {
        // 雖然有子進程在執行寫RDB,但是它直接寫到socket中,所以等待下次執行BGSAVE
        serverLog(LL_NOTICE,"Current BGSAVE has socket target. Waiting for next BGSAVE for SYNC");

    /* CASE 3: There is no BGSAVE is progress. */
    // 情況3:沒有執行BGSAVE的進程
    } else {
        // 服務器支持無盤同步
        if (server.repl_diskless_sync && (c->slave_capa & SLAVE_CAPA_EOF)) {
            // 無盤同步複製的子進程被創建在replicationCron()中,因爲想等待更多的從節點可以到來而延遲
            if (server.repl_diskless_sync_delay)
                serverLog(LL_NOTICE,"Delay next BGSAVE for diskless SYNC");
        // 服務器不支持無盤複製
        } else {
            // 如果沒有正在執行BGSAVE,且沒有進行寫AOF文件,則開始爲複製執行BGSAVE,並且是將RDB文件寫到磁盤上
            if (server.aof_child_pid == -1) {
                startBgsaveForReplication(c->slave_capa);
            } else {
                serverLog(LL_NOTICE,
                    "No BGSAVE in progress, but an AOF rewrite is active. BGSAVE for replication delayed");
            }
        }
    }

    // 只有一個從節點,且backlog爲空,則創建一個新的backlog
    if (listLength(server.slaves) == 1 && server.repl_backlog == NULL)
        createReplicationBacklog();
    return;
}

首先先明確,主節點執行處理從節點發來PSYNC命令的操作。所以主節點會將從節點視爲自己的從節點客戶端來操作。會將從節點的複製設置爲SLAVE_STATE_WAIT_BGSAVE_START狀態表示

主節點執行全量同步的情況有三種:

  1. 主節點服務器正在執行BGSAVE命令,且將RDB文件寫到磁盤上。
    • 這種情況,如果有已經設置過全局重同步偏移量的從節點,可以共用輸出緩衝區的數據。
  2. 主節點服務器正在執行BGSAVE命令,且將RDB文件寫到網絡socket上,無盤同步。
    • 由於本次BGSAVE命令直接將RDB寫到socket中,因此只能等待下一BGSAVE
  3. 主節點服務器沒有正在執行BGSAVE
    • 如果也沒有進行AOF持久化的操作,那麼開始爲複製操作執行BGSAVE,生成一個寫到磁盤上的RDB文件。

我們針對第三種情況來分析。調用了startBgsaveForReplication()來開始執行BGSAVE命令。我們貼出主要的代碼:

// 開始爲複製執行BGSAVE,根據配置選擇磁盤或套接字作爲RDB發送的目標,在開始之前確保沖洗腳本緩存
// mincapa參數是SLAVE_CAPA_*按位與的結果
int startBgsaveForReplication(int mincapa) {
    int retval;
    // 是否直接寫到socket
    int socket_target = server.repl_diskless_sync && (mincapa & SLAVE_CAPA_EOF);
    listIter li;
    listNode *ln;

    if (socket_target)
        // 直接寫到socket中
        // fork一個子進程將rdb寫到 狀態爲等待BGSAVE開始 的從節點的socket中
        retval = rdbSaveToSlavesSockets();
    else
        // 否則後臺進行RDB持久化BGSAVE操作,保存到磁盤上
        retval = rdbSaveBackground(server.rdb_filename);

    ......

    // 如果是直接寫到socket中,rdbSaveToSlavesSockets()已經會設置從節點爲全量複製
    // 否則直接寫到磁盤上,執行以下代碼
    if (!socket_target) {
        listRewind(server.slaves,&li);
        // 遍歷從節點鏈表
        while((ln = listNext(&li))) {
            client *slave = ln->value;
            // 設置等待全量同步的從節點的狀態
            if (slave->replstate == SLAVE_STATE_WAIT_BGSAVE_START) {
                    // 設置要執行全量重同步從節點的狀態
                    replicationSetupSlaveForFullResync(slave,
                            getPsyncInitialOffset());
            }
        }
    }
}

該函數主要乾了兩件事:

  • 調用rdbSaveBackground()函數爲複製操作生成一個RDB文件,我們分析的情況,該文件是被保存在磁盤上。
  • 調用replicationSetupSlaveForFullResync()函數,將等待開始的從節點設置爲全量同步的狀態,並且發送給從節點+FULLRESYNC命令,還發送了主節點的運行IDserver.runid和主節點的全局複製偏移量server.master_repl_offset

replicationSetupSlaveForFullResync()函數源碼如下:

int replicationSetupSlaveForFullResync(client *slave, long long offset) {
    char buf[128];
    int buflen;

    // 設置全量重同步的偏移量
    slave->psync_initial_offset = offset;
    // 設置從節點複製狀態,開始累計差異數據
    slave->replstate = SLAVE_STATE_WAIT_BGSAVE_END;
    // 將slaveseldb設置爲-1,是爲了強制發送一個select命令在複製流中
    server.slaveseldb = -1;

    // 如果從節點的狀態是CLIENT_PRE_PSYNC,則表示是Redis是2.8之前的版本,則不將這些信息發送給從節點。
    // 因爲在2.8之前只支持SYNC的全量複製同步,而在之後的版本提供了部分的重同步
    if (!(slave->flags & CLIENT_PRE_PSYNC)) {
        buflen = snprintf(buf,sizeof(buf),"+FULLRESYNC %s %lld\r\n",
                          server.runid,offset);
        // 否則會將全量複製的信息寫給從節點
        if (write(slave->fd,buf,buflen) != buflen) {
            freeClientAsync(slave);
            return C_ERR;
        }
    }
    return C_OK;
}

哇,主節點終於回覆從節點的PSYNC命令了,回覆了一個+FULLRESYNC,寫到主從同步的fd。表示要進行全量同步啊!!!

此時,從節點的複製狀態一定爲REPL_STATE_RECEIVE_PSYNCfd的讀事件發生,調用syncWithMaster()函數進行處理。

處理這種情況的代碼如下:

// 那麼嘗試進行第二次部分重同步,從主節點讀取指令來決定執行部分重同步還是全量同步
psync_result = slaveTryPartialResynchronization(fd,1);

這次的第二個參數是1,因此會執行該函數的讀部分。(因爲這個函數有兩個部分,上一次執行了寫部分,因爲第二個參數是0)

/* Reading half */
// 從主節點讀一個命令保存在reply中
reply = sendSynchronousCommand(SYNC_CMD_READ,fd,NULL);
if (sdslen(reply) == 0) {
    // 主節點爲了保持連接的狀態,可能會在接收到PSYNC命令後發送一個空行
    sdsfree(reply);
    // 所以就返回PSYNC_WAIT_REPLY,調用者會將read_reply設置爲1,然後再次調用該函數。
    return PSYNC_WAIT_REPLY;
}
// 如果讀到了一個命令,刪除fd的可讀事件
aeDeleteFileEvent(server.el,fd,AE_READABLE);

// 接受到的是"+FULLRESYNC",表示進行一次全量同步
if (!strncmp(reply,"+FULLRESYNC",11)) {
    char *runid = NULL, *offset = NULL;
    // 解析回覆中的內容,將runid和複製偏移量提取出來
    runid = strchr(reply,' ');
    if (runid) {
        runid++;    //定位到runid的地址
        offset = strchr(runid,' ');
        if (offset) offset++;   //定位offset
    }
    // 如果runid和offset任意爲空,那麼發生不期望錯誤
    if (!runid || !offset || (offset-runid-1) != CONFIG_RUN_ID_SIZE) {
        serverLog(LL_WARNING,"Master replied with wrong +FULLRESYNC syntax.");
        // 將主節點的運行ID重置爲0
        memset(server.repl_master_runid,0,CONFIG_RUN_ID_SIZE+1);
    // runid和offset獲取成功
    } else {
        // 設置服務器保存的主節點的運行ID
        memcpy(server.repl_master_runid, runid, offset-runid-1);
        server.repl_master_runid[CONFIG_RUN_ID_SIZE] = '\0';
        // 主節點的偏移量
        server.repl_master_initial_offset = strtoll(offset,NULL,10);
        serverLog(LL_NOTICE,"Full resync from master: %s:%lld",server.repl_master_runid,          server.repl_master_initial_offset);
    }
    // 執行全量同步,所以緩存的主節點結構沒用了,將其清空
    replicationDiscardCachedMaster();
    sdsfree(reply);
    // 返回執行的狀態
    return PSYNC_FULLRESYNC;
}

// 接受到的是"+CONTINUE",表示進行一次部分重同步
if (!strncmp(reply,"+CONTINUE",9)) {
    serverLog(LL_NOTICE,"Successful partial resynchronization with master.");
    sdsfree(reply);
    // 因爲執行部分重同步,因此要使用緩存的主節點結構,所以將其設置爲當前的主節點,被同步的主節點
    replicationResurrectCachedMaster(fd);
    // 返回執行的狀態
    return PSYNC_CONTINUE;
}

// 接收到了錯誤,兩種情況。
// 1. 主節點不支持PSYNC命令,Redis版本低於2.8
// 2. 從主節點讀取了一個不期望的回覆
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();
// 發送不支持PSYNC命令的狀態
return PSYNC_NOT_SUPPORTED;

至此,從節點監聽主節點的讀命令事件已經完成,所以取消監聽了讀事件。等到主節點開始傳送數據給從節點時,從節點會新創建讀事件。

該函數可以解析出主節點發過來的命令是哪一個,一共有三種:

  1. “+FULLRESYNC”:代表要進行一次全量複製。
  2. “+CONTINUE”:代表要進行一次部分重同步。
  3. “-ERR”:發生了錯誤。有兩種可能:Redis版本過低不支持PSYNC命令和從節點讀到一個錯誤回覆。

我們關注第一個全量同步的操作。如果讀到了主節點發來的"+FULLRESYNC",那麼會將同時發來的主節點運行ID和全局的複製偏移量保存到從節點的服務器屬性中server.repl_master_runidserver.repl_master_initial_offset。然後返回PSYNC_FULLRESYNC

回到syncWithMaster函數,繼續處理全量同步。由於要進行全量同步,如果當前從節點還作爲其他節點的主節點,因此要斷開所有從節點的連接,讓他們也重新同步當前節點。

    // 執行到這裏,psync_result == PSYNC_FULLRESYNC或PSYNC_NOT_SUPPORTED
    // 準備一個合適臨時文件用來寫入和保存主節點傳來的RDB文件數據
    while(maxtries--) {
        // 設置文件的名字
        snprintf(tmpfile,256,
            "temp-%d.%ld.rdb",(int)server.unixtime,(long int)getpid());
        // 以讀寫,可執行權限打開臨時文件
        dfd = open(tmpfile,O_CREAT|O_WRONLY|O_EXCL,0644);
        // 打開成功,跳出循環
        if (dfd != -1) break;
        sleep(1);
    }
    /* Setup the non blocking download of the bulk file. */
    // 監聽一個fd的讀事件,並設置該事件的處理程序爲readSyncBulkPayload
    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;
    }

    // 複製狀態爲正從主節點接受RDB文件
    server.repl_state = REPL_STATE_TRANSFER;
    // 初始化RDB文件的大小
    server.repl_transfer_size = -1;
    // 已讀的大小
    server.repl_transfer_read = 0;
    // 最近一個執行fsync的偏移量爲0
    server.repl_transfer_last_fsync_off = 0;
    // 傳輸RDB文件的臨時fd
    server.repl_transfer_fd = dfd;
    // 最近一次讀到RDB文件內容的時間
    server.repl_transfer_lastio = server.unixtime;
    // 保存RDB文件的臨時文件名
    server.repl_transfer_tmpfile = zstrdup(tmpfile);
    return;

準備好了所有,接下來就要等待主節點來發送RDB文件了。因此上面做了這三件事:

  • 打開一個臨時文件,用來保存主節點發來的RDB文件數據的。
  • 監聽fd的讀事件,等待主節點發送RDB文件數據,觸發可讀事件執行readSyncBulkPayload()函數,該函數就會把主節點發來的數據讀到一個緩衝區中,然後將緩衝區的數據寫到剛纔打開的臨時文件中,接着要載入到從節點的數據庫中,最後同步到磁盤中。
  • 設置複製操作的狀態爲server.repl_state = REPL_STATE_TRANSFER。並且初始化複製的信息,例如:RDB文件的大小,偏移量,等等。(具體看上面的代碼)

主節點要發送RDB文件,但是回覆完”+FULLRESYNC”就再也沒有操作了。而子節點創建了監聽主節點寫RDB文件的事件,等待主節點來寫,才調用readSyncBulkPayload()函數來處理。這又有問題了,到底主節點什麼時候發送RDB文件呢?如果不是主動執行,那麼一定就在週期性函數內被執行。

它的調用關係如下:

serverCron()->backgroundSaveDoneHandler()->backgroundSaveDoneHandlerDisk()->updateSlavesWaitingBgsave()

updateSlavesWaitingBgsave()函數定義在replication.c中,主要操作有兩步,我們簡單介紹:

  • 只讀打開主節點的臨時RDB文件,然後設置從節點client複製狀態爲SLAVE_STATE_SEND_BULK
  • 立刻創建監聽可寫的事件,並設置sendBulkToSlave()函數爲可寫事件的處理程序。

當主節點執行週期性函數時,主節點會先清除之前監聽的可寫事件,然後立即監聽新的可寫事件,這樣就會觸發可寫的事件,調用sendBulkToSlave()函數將RDB文件寫入到fd中,觸發從節點的讀事件,從節點調用readSyncBulkPayload()函數,來將RDB文件的數據載入數據庫中,至此,就保證了主從同步了。

我們來簡單介紹sendBulkToSlave()函數在寫RDB文件時做了什麼:

  • 將RDB文件的大小寫給從節點,以協議格式的字符串表示的大小。
  • 從RDB文件的repldbfd中讀出RDB文件數據,然後寫到主從同步的fd中。
  • 寫入完成後,又一次取消監聽文件可寫事件,等待下一次發送緩衝區數據時在監聽觸發,並且調用putSlaveOnline()函數將從節點client的複製狀態設置爲SLAVE_STATE_ONLINE。表示已經發送RDB文件完畢,發送緩存更新。

2.9 發送輸出緩衝區數據

主節點發送完RDB文件後,調用putSlaveOnline()函數將從節點client的複製狀態設置爲SLAVE_STATE_ONLINE,表示已經發送RDB文件完畢,要發送緩存更新了。於是會新創建一個事件,監聽寫事件的發生,設置sendReplyToClient爲可寫的處理程序,而且會將從節點client當做私有數據闖入sendReplyToClient()當做發送緩衝區的對象。

aeCreateFileEvent(server.el, slave->fd, AE_WRITABLE,sendReplyToClient, slave)

創建可寫事件的時候,就會觸發第一次可寫,執行sendReplyToClient(),該函數還直接調用了riteToClient(fd,privdata,1)函數,於是將從節點client輸出緩衝區的數據發送給了從節點服務器。

riteToClient()函數數據Redis網絡連接庫的函數,定義在network.c中,具體分析請看:Redis 網絡連接庫源碼分析

這樣就保證主從服務器的數據庫狀態一致了。

2.10 命令傳播

主從節點在第一次全量同步之後就達到了一致,但是之後主節點如果執行了寫命令,主節點的數據庫狀態就又可能發生變化,導致主從再次不一致。爲了讓主從節點回到一致狀態,主機的執行命令後都需要將命令傳播到從節點。

傳播時會調用server.c中的propagate()函數,如果傳播到從節點會調用replicationFeedSlaves(server.slaves,dbid,argv,argc)函數,該函數則會將執行的命令以協議的傳輸格式寫到從節點client的輸出緩衝區中,這就是爲什麼主節點會將從節點client的輸出緩衝區發送到從節點(具體見標題2.9),也會添加到server.repl_backlog中。

我們來看看replicationFeedSlaves()函數的實現:

// 將參數列表中的參數發送給從服務器
void replicationFeedSlaves(list *slaves, int dictid, robj **argv, int argc) {
    listNode *ln;
    listIter li;
    int j, len;
    char llstr[LONG_STR_SIZE];

    // 如果沒有backlog且沒有從節點服務器,直接返回
    if (server.repl_backlog == NULL && listLength(slaves) == 0) return;

    /* We can't have slaves attached and no backlog. */
    serverAssert(!(listLength(slaves) != 0 && server.repl_backlog == NULL));

    // 如果當前從節點使用的數據庫不是目標的數據庫,則要生成一個select命令
    if (server.slaveseldb != dictid) {
        robj *selectcmd;

        // 0 <= id < 10 ,可以使用共享的select命令對象
        if (dictid >= 0 && dictid < PROTO_SHARED_SELECT_CMDS) {
            selectcmd = shared.select[dictid];
        // 否則自行按照協議格式構建select命令對象
        } else {
            int dictid_len;

            dictid_len = ll2string(llstr,sizeof(llstr),dictid);
            selectcmd = createObject(OBJ_STRING,
                sdscatprintf(sdsempty(),
                "*2\r\n$6\r\nSELECT\r\n$%d\r\n%s\r\n",
                dictid_len, llstr));
        }
        // 將select 命令添加到backlog中
        if (server.repl_backlog) feedReplicationBacklogWithObject(selectcmd);

        // 發送給從服務器
        listRewind(slaves,&li);
        // 遍歷所有的從服務器節點
        while((ln = listNext(&li))) {
            client *slave = ln->value;
            // 從節點服務器狀態爲等待BGSAVE的開始,因此跳過回覆,遍歷下一個節點
            if (slave->replstate == SLAVE_STATE_WAIT_BGSAVE_START) continue;
            // 添加select命令到當前從節點的回覆中
            addReply(slave,selectcmd);
        }
        // 釋放臨時對象
        if (dictid < 0 || dictid >= PROTO_SHARED_SELECT_CMDS)
            decrRefCount(selectcmd);
    }
    // 設置當前從節點使用的數據庫ID
    server.slaveseldb = dictid;

    // 將命令寫到backlog中
    if (server.repl_backlog) {
        char aux[LONG_STR_SIZE+3];

        // 將參數個數構建成協議標準的字符串
        // *<argc>\r\n
        aux[0] = '*';
        len = ll2string(aux+1,sizeof(aux)-1,argc);
        aux[len+1] = '\r';
        aux[len+2] = '\n';
        // 添加到backlog中
        feedReplicationBacklog(aux,len+3);

        // 遍歷所有的參數
        for (j = 0; j < argc; j++) {
            // 返回參數對象的長度
            long objlen = stringObjectLen(argv[j]);

            // 構建成協議標準的字符串,並添加到backlog中
            // $<len>\r\n<argv>\r\n
            aux[0] = '$';
            len = ll2string(aux+1,sizeof(aux)-1,objlen);
            aux[len+1] = '\r';
            aux[len+2] = '\n';
            // 添加$<len>\r\n
            feedReplicationBacklog(aux,len+3);
            // 添加參數對象<argv>
            feedReplicationBacklogWithObject(argv[j]);
            // 添加\r\n
            feedReplicationBacklog(aux+len+1,2);
        }
    }
    // 將命令寫到每一個從節點中
    listRewind(server.slaves,&li);
    // 遍歷從節點鏈表
    while((ln = listNext(&li))) {
        client *slave = ln->value;

        // 從節點服務器狀態爲等待BGSAVE的開始,因此跳過回覆,遍歷下一個節點
        if (slave->replstate == SLAVE_STATE_WAIT_BGSAVE_START) continue;

        // 將命令寫給正在等待初次SYNC的從節點(所以這些命令在輸出緩衝區中排隊,直到初始SYNC完成),或已經與主節點同步
        /* Add the multi bulk length. */
        // 添加回復的長度
        addReplyMultiBulkLen(slave,argc);

        // 將所有的參數列表添加到從節點的輸出緩衝區
        for (j = 0; j < argc; j++)
            addReplyBulk(slave,argv[j]);
    }
}

AOF持久化一樣,再給從節點client寫命令時,會將SELECT命令強制寫入,以保證命令正確讀到數據庫中。

不僅寫入了從節點client的輸出緩衝區,而且還會將命令記錄到主節點服務器的複製積壓緩衝區server.repl_backlog中,這是爲了網絡閃斷後進行部分重同步。

3. 部分重同步實現

replication.c文件詳細註釋:Redis 複製代碼註釋

剛纔剖析完全量同步,但是沒有考慮特殊的情況。如果在傳輸RDB文件的過程中,網絡發生故障,主節點和從節點的連接中斷,Redis會咋麼做呢?

Redis 2.8 版本之前會在進行一次連接然後進行全量複製,但是這樣效率非常地下,之後的版本都提供了部分重同步的實現。那麼我們就分析一下部分重同步的實現過程。

部分重同步在複製的過程中,相當於標題2.8的發送PSYNC命令的部分,其他所有的部分都要進行,他只是主節點回復從節點的命令不同,回覆+CONTINUE則執行部分重同步,回覆+FULLRESYNC則執行全量同步。

3.1 心跳機制

主節點是如何發現和從節點連接中斷?在主從節點建立連接後,他們之間都維護者長連接並彼此發送心跳命令。主從節點彼此都有心跳機制,各自模擬成對方的客戶端進行通信。

  • 主節點默認每隔10秒發送PING命令,判斷從節點的連接狀態。
    • 文件配置項:repl-ping-salve-period,默認是10
// 首先,根據當前節點發送PING命令給從節點的頻率發送PING命令 
// 如果當前節點是某以節點的 主節點 ,那麼發送PING給從節點
if ((replication_cron_loops % server.repl_ping_slave_period) == 0) {
    // 創建PING命令對象
    ping_argv[0] = createStringObject("PING",4);
    // 將PING發送給從服務器
    replicationFeedSlaves(server.slaves, server.slaveseldb, ping_argv, 1);
    decrRefCount(ping_argv[0]);
}
  • 從節點在主線程中每隔1秒發送REPLCONF ACK <offset>命令,給主節點報告自己當前複製偏移量。
// 定期發送ack給主節點,舊版本的Redis除外
if (server.masterhost && server.master && !(server.master->flags & CLIENT_PRE_PSYNC))
    // 發送一個REPLCONF ACK命令給主節點去報告關於當前處理的offset。
    replicationSendAck();

在週期性函數replicationCron(),每次都要檢查和主節點處於連接狀態的從節點和主節點的交互時間是否超時,如果超時則會調用cancelReplicationHandshake()函數,取消和主節點的連接。等到下一個週期在和主節點重新建立連接,進行復制。

3.2 複製積壓緩衝區(backlog)

複製積壓緩衝區是一個大小爲1M的循環隊列。主節點在命令傳播時,不僅會將命令發送給所有的從節點,還會將命令寫入複製積壓緩衝區中(具體請看標題2.10)。

也就是說,複製積壓緩衝區最多可以備份1M大小的數據,如果主從節點斷線時間過長,複製積壓緩衝區的數據會被新數據覆蓋,那麼當從主從中斷連接起,主節點接收到的數據超過1M大小,那麼從節點就無法進行部分重同步,只能進行全量複製。

在標題2.8,介紹的syncCommand()命令中,調用masterTryPartialResynchronization()函數會進行嘗試部分重同步,在我們之前分析的第一次全量同步時,該函數會執行失敗,然後返回syncCommand()函數執行全量同步,而在進行恢復主從連接後,則會進行部分重同步,masterTryPartialResynchronization()函數代碼如下:

// 該函數從主節點接收到部分重新同步請求的角度處理PSYNC命令
// 成功返回C_OK,否則返回C_ERR
int masterTryPartialResynchronization(client *c) {
    long long psync_offset, psync_len;
    char *master_runid = c->argv[1]->ptr;   //主節點的運行ID
    char buf[128];
    int buflen;

    // 主節點的運行ID是否和從節點執行PSYNC的參數提供的運行ID相同。
    // 如果運行ID發生了改變,則主節點是一個不同的實例,那麼就不能進行繼續執行原有的複製進程
    if (strcasecmp(master_runid, server.runid)) {
        /* Run id "?" is used by slaves that want to force a full resync. */
        // 如果從節點的運行ID是"?",表示想要強制進行一個全量同步
        if (master_runid[0] != '?') {
            serverLog(LL_NOTICE,"Partial resynchronization not accepted: "
                "Runid mismatch (Client asked for runid '%s', my runid is '%s')",
                master_runid, server.runid);
        } else {
            serverLog(LL_NOTICE,"Full resync requested by slave %s",
                replicationGetSlaveName(c));
        }
        goto need_full_resync;
    }

    // 從參數對象中獲取psync_offset
    if (getLongLongFromObjectOrReply(c,c->argv[2],&psync_offset,NULL) !=
       C_OK) goto need_full_resync;
    // 如果psync_offset小於repl_backlog_off,說明backlog所備份的數據的已經太新了,有一些數據被覆蓋,則需要進行全量複製
    // 如果psync_offset大於(server.repl_backlog_off + server.repl_backlog_histlen),表示當前backlog的數據不夠全,則需要進行全量複製
    if (!server.repl_backlog ||
        psync_offset < server.repl_backlog_off ||
        psync_offset > (server.repl_backlog_off + server.repl_backlog_histlen))
    {
        serverLog(LL_NOTICE,
            "Unable to partial resync with slave %s for lack of backlog (Slave request was: %lld).", replicationGetSlaveName(c), psync_offset);
        if (psync_offset > server.master_repl_offset) {
            serverLog(LL_WARNING,
                "Warning: slave %s tried to PSYNC with an offset that is greater than the master replication offset.", replicationGetSlaveName(c));
        }
        goto need_full_resync;
    }

    // 執行到這裏,則可以進行部分重同步
    // 1. 設置client狀態爲從節點
    // 2. 向從節點發送 +CONTINUE 表示接受 partial resync 被接受
    // 3. 發送backlog的數據給從節點

    // 設置client狀態爲從節點
    c->flags |= CLIENT_SLAVE;
    // 設置複製狀態爲在線,此時RDB文件傳輸完成,發送差異數據
    c->replstate = SLAVE_STATE_ONLINE;
    // 設置從節點收到ack的時間
    c->repl_ack_time = server.unixtime;
    // slave向master發送ack標誌設置爲0
    c->repl_put_online_on_ack = 0;
    // 將當前client加入到從節點鏈表中
    listAddNodeTail(server.slaves,c);
    // 向從節點發送 +CONTINUE
    buflen = snprintf(buf,sizeof(buf),"+CONTINUE\r\n");
    if (write(c->fd,buf,buflen) != buflen) {
        freeClientAsync(c);
        return C_OK;
    }
    // 將backlog的數據發送從節點
    psync_len = addReplyReplicationBacklog(c,psync_offset);
    serverLog(LL_NOTICE,
        "Partial resynchronization request from %s accepted. Sending %lld bytes of backlog starting from offset %lld.", replicationGetSlaveName(c), psync_len, psync_offset);
    // 計算延遲值小於min-slaves-max-lag的從節點的個數
    refreshGoodSlavesCount();
    return C_OK; /* The caller can return, no full resync needed. */

need_full_resync:
    return C_ERR;
}

如果可以進行部分重同步,主節點則會發送"+CONTINUE\r\n"作爲從節點發送PSYNC回覆(看標題2.8)。然後調用addReplyReplicationBacklog()函數,將backlog中的數據發送給從節點。於是就完成了部分重同步。

addReplyReplicationBacklog()函數所做的就是將backlog寫到從節點的client的輸出緩衝區中。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章