Redis 主從複製技術原理

YangAM發表於2020-03-29

基於前面介紹的 Redis 內容,Redis 只能作為一個單機記憶體資料庫,一旦伺服器當機即不能提供服務,即便能通過持久化策略重啟恢復資料,往往也做不到百分之百還原。再一個就是,單機的 Redis 需要處理所有的客戶端請求,包括讀和寫操作,壓力很大。

說了這麼多,Redis 當然也提供瞭解決方案,主從複製技術是實現 Redis 叢集的最基本架構,叢集中有一臺或多臺主節點伺服器(master),多臺從節點伺服器(slave),slave 持續不斷的同步 master 上的資料。一旦 master 當機,我們可以切換 salve 成為新的 master 穩定提供服務,也不用擔心 master 當機導致的資料丟失。

下面我們就一起來看看主從複製技術的設計與應用,先看理論再看原始碼實現。

一、理論上的主從複製技術設計

主從複製技術有兩個版本,2.8 以前的版本,設計上有缺陷,在 slave 斷線後重連依然需要 master 重新傳送 RDB 重新進行資料更新,效率非常低。2.8 版本以後做了重新設計,通過引入偏移量同步,相對而言非常的高效,我們這裡不去討論舊版本的設計了,直接看新版本的主從複製技術設計。

每一個 Redis 啟動後,都會認為自己是一個 master 節點,你可以通過以下命令通知它成為 slave 並向 master 同步資料:

slaveof [masterip] [masterport]
複製程式碼

另一種方式就是在 Redis 啟動配置檔案中直接指明讓它作為一個 slave 節點啟動,並在啟動後同步 master 節點資料。配置項和命令是一樣的。

如果 master 配置了密碼連線,那麼還需要在 slave 的配置檔案中指明 master 的連線密碼:

masterauth <password>
複製程式碼

除此之外,salve 節點預設是隻讀的,不允許寫入資料,因為如果支援寫入資料,那麼與 master 就無法保持資料一致性,所以我們一般會把 slave 節點作為讀寫分離中讀服務提供者。當然,你也可以修改是否允許 slave 寫入資料:

slave-read-only yes/no 
複製程式碼

當然如果你的 master 當機了,你需要把某個 slave 上線成 master,你可以通過命令取消 slave 的資料同步,成為單獨的一個 master:

slaveof no one
複製程式碼

slave 同步 master 的資料主要分為兩個大步驟,全量複製和部分複製。當我們執行 slaveof 命令的時候,我們的 slave 會作為一個客戶端連線上 master 並向 master 傳送 PSYNC 命令。

master 收到命令後,會呼叫 bgsave fork 一個後臺子程式生產 RDB 檔案,待合適的時候,在 serverCron 迴圈的時候傳送給 slave節點。

slave 收到 RDB 檔案後,丟棄目前記憶體中所有的資料並阻塞自己,專心做 RDB 讀取,資料恢復。

以上就是主從複製的一個全量複製的大概流程,但是一次全量複製並不能永遠的保持主從節點資料一致,master 還需要將實時的修改命令同步到從節點才行,這就是部分複製。

在介紹部分複製之前,這裡先介紹幾個概念。第一個是複製緩衝區(repl_backlog),這是一個 FIFO 的佇列,裡面存的是最近的一些寫命令,大小預設在 1M,複製偏移量(offset),這個偏移量其實是對應複製緩衝區中的字元偏移。複製緩衝區的結構大致是這樣的:

image

在主從節點完成第一輪全量複製以後,主從節點之間已經初步實現了資料同步,往後的 master,會將收到的每一條寫命令傳送給 slave 並 新增到複製緩衝區並根據位元組數計算更新自己的偏移量,slave 收到傳輸過來的命令後也一樣更新自己的偏移量。

這樣,只要主從節點的偏移量相同就說明主從節點之間的資料是同步的。複製緩衝區大小是固定的,新的寫命令進來以後,舊的資料就會出佇列。如果某個 slave 斷線重連之後,依然向 master 傳送 PSYNC 命令並攜帶自己的偏移量,master 判斷該偏移量是否還在緩衝區區間內,如果在則直接將該偏移量往後的所有偏移量對應的命令傳送給 slave,無需重新進行全量複製。

這是新版同步複製的一個優化的設計,如果該斷線重連的 slave 的偏移量已經不在緩衝區區間內,那麼說明 master 可能已經無法找到自上次斷線後的完整更新記錄了,於是進行全量複製並將最新的偏移量發到 slave,算是完成了新的資料同步。

這就是主從複製的一個完整的設計邏輯,設計思路非常的優秀,很值得我們借鑑,下面我們看原始碼的一些實現情況。

二、看看原始碼實現

serverCron 定時函式中有這麼一段程式碼:

run_with_period(1000) replicationCron();
複製程式碼

按照預設的 server.hz 配置,每秒就需要執行一次 replicationCron。我們就來看看這個方法究竟做了什麼。

void replicationCron(void) {
    static long long replication_cron_loops = 0;

    //slave 連線 master 超時,取消連線
    if (server.masterhost &&
        (server.repl_state == REPL_STATE_CONNECTING ||
         slaveIsInHandshakeState()) &&
         (time(NULL)-server.repl_transfer_lastio) > server.repl_timeout)
    {
        serverLog(LL_WARNING,"Timeout connecting to the MASTER...");
        cancelReplicationHandshake();
    }

    //.rdb 檔案響應超時,取消連線
    if (server.masterhost && server.repl_state == REPL_STATE_TRANSFER &&
        (time(NULL)-server.repl_transfer_lastio) > server.repl_timeout)
    {
        serverLog(LL_WARNING,"Timeout receiving bulk data from MASTER... If the problem persists try to set the 'repl-timeout' parameter in redis.conf to a larger value.");
        cancelReplicationHandshake();
    }

    //已經建立連線的情況下,某個操作超時,斷開連線
    if (server.masterhost && server.repl_state == REPL_STATE_CONNECTED &&
        (time(NULL)-server.master->lastinteraction) > server.repl_timeout)
    {
        serverLog(LL_WARNING,"MASTER timeout: no data nor PING received...");
        freeClient(server.master);
    }

    //檢查配置項,是否需要向 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");
        }
    }
    //向 master 傳送自己的偏移量
    //master 判斷是否需要進行命令傳播給 slave
    if (server.masterhost && server.master &&
        !(server.master->flags & CLIENT_PRE_PSYNC))
        replicationSendAck();
    。。。。。。。
}
複製程式碼

因為不管 master 還是 slave,都是一個服務端的 Redis 程式,他們既可以成為主節點,又可以成為從節點。以上的程式碼段是當前 redis 作為一個 slave 時需要做的操作,replicationCron 後面的程式碼是當前 redis 作為一個主節點需要做的處理邏輯。

void replicationCron(void) {
    。。。。。
    listIter li;
    listNode *ln;
    robj *ping_argv[1];

    //給所有的 slave 傳送 ping 
    if ((replication_cron_loops % server.repl_ping_slave_period) == 0 &&
        listLength(server.slaves))
    {
        ping_argv[0] = createStringObject("PING",4);
        replicationFeedSlaves(server.slaves, server.slaveseldb,
            ping_argv, 1);
        decrRefCount(ping_argv[0]);
    }
    //傳送 '\n' 給所有正在等待 rdb 檔案的 slave,防止他們判定 master 超時
    listRewind(server.slaves,&li);
    while((ln = listNext(&li))) {
        client *slave = ln->value;

        int is_presync =
            (slave->replstate == SLAVE_STATE_WAIT_BGSAVE_START ||
            (slave->replstate == SLAVE_STATE_WAIT_BGSAVE_END &&
             server.rdb_child_type != RDB_CHILD_TYPE_SOCKET));

        if (is_presync) {
            if (write(slave->fd, "\n", 1) == -1) {
                /* Don't worry about socket errors, it's just a ping. */
            }
        }
    }
    //所有的 slave 並斷開所有超時的 slave
    if (listLength(server.slaves)) {
        listIter li;
        listNode *ln;

        listRewind(server.slaves,&li);
        while((ln = listNext(&li))) {
            client *slave = ln->value;

            if (slave->replstate != SLAVE_STATE_ONLINE) continue;
            if (slave->flags & CLIENT_PRE_PSYNC) continue;
            if ((server.unixtime - slave->repl_ack_time) > server.repl_timeout)
            {
                serverLog(LL_WARNING, "Disconnecting timedout slave: %s",
                    replicationGetSlaveName(slave));
                freeClient(slave);
            }
        }
    }
    //在沒有 slave 節點連線後的 N 秒,釋放複製緩衝區
    if (listLength(server.slaves) == 0 && server.repl_backlog_time_limit &&
        server.repl_backlog && server.masterhost == NULL)
    {
        time_t idle = server.unixtime - server.repl_no_slaves_since;

        if (idle > server.repl_backlog_time_limit) {
            changeReplicationId();
            clearReplicationId2();
            freeReplicationBacklog();
            serverLog(LL_NOTICE,
                "Replication backlog freed after %d seconds "
                "without connected slaves.",
                (int) server.repl_backlog_time_limit);
        }
    }
    。。。。。。
}
複製程式碼

總結一下,replicationCron 預設每一秒呼叫一次,分為兩個部分,自己如果是 slave 節點的話,那麼會判斷與 master 之間的連線情況,如果等待 rdb 超時或其他連線超時,那麼 slave 會斷開與 master 的連線,如果發現配置檔案中配置了 slaveof ,則會主動連線 master 傳送 PSYNC 命令並且會傳送自己的偏移量,期待 master 向自己傳播命令。

如果自己是一個 master 的話,它會首先向所有的 slave 傳送 ping,以免 slave 因為超時斷開與自己的連線,並且還會主動斷開一些超時連線的 slave。

除此之外我們需要補充一點的就是 redis 中非常重要的函式呼叫 call 函式,這個函式是所有命令對應的實現函式的前置呼叫。這個函式的具體邏輯我這裡暫時不去詳細介紹,但是其中有兩個重要的步驟你需要明確,一是會呼叫執行命令的實現函式,二是會將修改命令新增到 AOF 檔案並傳播給所有的 slave 節點

這樣,我們關於主從複製的完整邏輯就基本解釋通了,以上還只是一個基本的雛形,後面我們還將基於此介紹高可用的主從複製,藉助哨兵(Sentinel)完成主從節點的高可用切換,故障轉移等等,敬請期待~


關注公眾不迷路,一個愛分享的程式設計師。
公眾號回覆「1024」加作者微信一起探討學習!
每篇文章用到的所有案例程式碼素材都會上傳我個人 github
github.com/SingleYam/o…
歡迎來踩!

YangAM 公眾號

相關文章