Redis-bgsave導致的介面響應延遲波動(深入分析Linux的fork()機制)

Mr羽墨青衫發表於2019-03-14

近期線上有個介面響應延遲P99波動較大,後對其進行了優化。響應延遲折線圖如下:

優化前後對比

在12月11號11點左右優化完成後,P99趨於平穩,平均在70ms左右。

下面來說一下優化過程。

1 思考介面的執行過程

這個介面一共會經過三個服務,最終返回給客戶端。執行流程如下:

服務結構

按照箭頭所示流程,先訪問服務1,服務1的結果返回給介面層,在請求服務2,服務2請求服務3,然後將結果返回給介面層。

2 分析

然後分別觀察了服務1、服務2、服務3,主要觀察的指標如下:

  • 服務對外響應延遲
  • CPU負載
  • 網路抖動

觀察後,服務2和服務3的這幾個指標都沒啥問題。

服務2的對外響應延遲波動情況與介面的波動頗為相似,再針對服務2分析。服務2是個IO密集型的服務,平均QPS在3K左右。

主要的幾個IO操作包括:

  • 單點Redis的讀取
  • 叢集Redis的讀取
  • 資料庫的讀取
  • 兩個http介面的拉取
  • 一次其他服務的呼叫

叢集Redis的響應很快,平均在5ms左右(加上來回的網路消耗),資料庫在10ms左右,http介面只有偶爾的慢請求,其他服務的呼叫也沒問題。

最後發現單點的Redis響應時間過長

P99響應

如圖所示,服務2接受到的每次請求會訪問三次這個單點redis,這三次加起來有接近100ms,然後針對這個單點redis進行分析。

發現這臺redis的CPU有如下波動趨勢

CPU波動

基本上每一分鐘會波動一次。

馬上反應過來是開啟了bgsave引起的(基本1分鐘bgsave一次),因為之前有過類似的經驗,就直接關掉bgsave再觀察

關閉bgsave後的CPU波動

至此,業務平穩下來。

3 解決方案

線上的bgsave不能一直關閉,萬一出現故障,會造成大量資料丟失。

具體方案如下:

  • 先開啟這臺機器的bgsave
  • 申請一臺從伺服器,並從這臺機器上同步資料
  • 同步完成後,主節點關閉bgsave,從節點開啟bgsave

這樣一來,主節點的讀寫不再受bgsave影響,同時也能用從節點保證資料不丟失。

4 bgsave引起CPU波動原因探索

首先要說一下bgsave的執行機制。執行bgsave時(無論以哪種方式執行),會先fork出一個子程式來,由子程式把資料庫的快照寫入硬碟,父程式會繼續處理客戶端的請求。

所以在平時沒有bgsave的時候,程式狀態如下:

無bgsave的程式狀態

bgsave時,程式狀態如下:

開啟bgsave的程式狀態

最上面CPU佔用100%的就是fork出來的子程式,在執行bgsave,同時他完全獨佔了一個CPU(上面的紅框)。

所以得出結論,這個CPU的波動是正常的,每一個波峰都是子程式bgsave所致。

5 bgsave引起的介面相應延遲探索

關於fork,在redis官網有這麼一段描述:

RDB disadvantages

  • RDB is NOT good if you need to minimize the chance of data loss in case Redis stops working (for example after a power outage). You can configure different save points where an RDB is produced (for instance after at least five minutes and 100 writes against the data set, but you can have multiple save points). However you'll usually create an RDB snapshot every five minutes or more, so in case of Redis stopping working without a correct shutdown for any reason you should be prepared to lose the latest minutes of data.
  • RDB needs to fork() often in order to persist on disk using a child process. Fork() can be time consuming if the dataset is big, and may result in Redis to stop serving clients for some millisecond or even for one second if the dataset is very big and the CPU performance not great. AOF also needs to fork() but you can tune how often you want to rewrite your logs without any trade-off on durability.

這裡說了RDB的劣勢,第二點說明了fork會造成的問題。

大意是:RDB為了將資料持久化到硬碟,需要經常fork一個子程式出來。資料集如果過大的話,fork()的執行可能會非常耗時,如果資料集非常大的話,可能會導致Redis伺服器產生幾毫秒甚至幾秒鐘的拒絕服務,並且CPU的效能會急劇下降。

這個停頓的時間長短取決於redis所在的系統,對於真實硬體、VMWare虛擬機器或者KVM虛擬機器來說,Redis程式每佔用1個GB的記憶體,fork子程式的時間就增加10-20ms,對於Xen虛擬機器來說,Redis程式每佔用1個GB的記憶體,fork子程式的時間需要增加200-300ms。

但對於一個訪問量大的Redis來說,10-20ms已經是很長時間了(我們的redis佔用了10個G左右記憶體,估計停頓時間在100ms左右)。

至此,造成介面響應延遲的原因就明確了:

由於redis是單程式執行的,在fork子程式時,如果耗時過多,造成伺服器的停頓,導致redis無法繼續處理請求,進一步就會導致向redis發請求的客戶端全都hang住,介面響應變慢。

6 深入分析fork機制

知道原因後,來看一下redis執行bgsave的原始碼(fork部分):

註釋中分析瞭如果fork卡住,會造成的影響。

// 執行bgsave
int rdbSaveBackground(char * filename, rdbSaveInfo * rsi) {
    pid_t childpid;
    long long start;
    if (server.aof_child_pid != -1 || server.rdb_child_pid != -1) return C_ERR;
    server.dirty_before_bgsave = server.dirty;
    server.lastbgsave_try = time(NULL);
    openChildInfoPipe();

    // 記錄執行fork的起始時間,用於計算fork的耗時
    start = ustime();
    // 在這裡執行fork !!
    // 由此可見,如果fork卡住,下面執行父程式的else條件就會卡住,子程式的執行也需要fork完成後才會開始
    if ((childpid = fork()) == 0) {
        // fork()返回了等於0的值,說明執行成功,
        int retval;
        // 下面是子程式的執行過程
        /* Child */
        closeListeningSockets(0);
        redisSetProcTitle("redis-rdb-bgsave");
        // 子程式執行硬碟的寫操作
        retval = rdbSave(filename, rsi);
        if (retval == C_OK) {
            size_t private_dirty = zmalloc_get_private_dirty( - 1);
            if (private_dirty) {
                serverLog(LL_NOTICE, "RDB: %zu MB of memory used by copy-on-write", private_dirty / (1024 * 1024));
            }
            server.child_info_data.cow_size = private_dirty;
            sendChildInfo(CHILD_INFO_TYPE_RDB);
        }
        // 子程式執行完畢退出,返回執行結果給父程式,0 - 成功,1 - 失敗
        exitFromChild((retval == C_OK) ? 0 : 1);
    } else {
        // 下面是父程式的執行過程
        /* Parent */
        // 計算fork的執行時間
        server.stat_fork_time = ustime() - start;
        server.stat_fork_rate = (double) zmalloc_used_memory() * 1000000 / server.stat_fork_time / (1024 * 1024 * 1024);
        /* GB per second. */
        latencyAddSampleIfNeeded("fork", server.stat_fork_time / 1000);
        if (childpid == -1) { // fork出錯,列印錯誤日誌
            closeChildInfoPipe();
            server.lastbgsave_status = C_ERR;
            serverLog(LL_WARNING, "Can't save in background: fork: %s", strerror(errno));
            return C_ERR;
        }
        serverLog(LL_NOTICE, "Background saving started by pid %d", childpid);
        server.rdb_save_time_start = time(NULL);
        server.rdb_child_pid = childpid;
        server.rdb_child_type = RDB_CHILD_TYPE_DISK;
        updateDictResizePolicy();
        return C_OK;
    }
    return C_OK;
    /* unreached */
}
複製程式碼

fork()方法返回值的描述:

Return Value

On success, the PID of the child process is returned in the parent, and 0 is returned in the child. On failure, -1 is returned in the parent, no child process is created, and errno is set appropriately.

意思是,如果fork成功,此程式的PID會返回給父程式,並且會給fork出的子程式返回一個0。如果fork失敗,給父程式返回-1,沒有子程式建立,並設定一個系統錯誤碼。

由此可見,fork的執行流程如下:

fork的執行流程

再來看看Linux中關於fork()的注意事項。

Notes

Under Linux, fork() is implemented using copy-on-write pages, so the only penalty that it incurs is the time and memory required to duplicate the parent's page tables, and to create a unique task structure for the child. Since version 2.3.3, rather than invoking the kernel's fork() system call, the glibc fork() wrapper that is provided as part of the NPTL threading implementation invokes clone(2) with flags that provide the same effect as the traditional system call. (A call to fork() is equivalent to a call to clone(2) specifying flags as just SIGCHLD.) The glibc wrapper invokes any fork handlers that have been established using pthread_atfork(3).

第一段描述了fork()的一些問題。大意如下:

在Linux系統下,fork()通過copy-on-write策略實現,因此,他會帶來的問題是:複製父程式和為子程式建立唯一的程式結構所需要的時間和記憶體。

7 補充-程式的記憶體模型

系統核心會為每一個程式開闢一塊虛擬記憶體空間,其分佈如下

虛擬記憶體空間圖示

fork的子程式相當於父程式的一個clone,可見,如果父程式中資料量比較多的話,clone的耗時會比較長。

參考文件

  • Redis in Action. Josiah L. Carison
  • Redis官網:Redis Persistence, 連結:https://redis.io/topics/persistence
  • Redis原始碼:rdb.c, 連結:https://github.com/antirez/redis/blob/unstable/src/rdb.c
  • Linux man page: fork(), 連結:https://linux.die.net/man/2/fork

歡迎關注我的微信公眾號

公眾號

相關文章