Redis latency spikes and the Linux kernel: a few more details

Salvatore Sanfilippo

Redis 延遲突波與 Linux 核心:更多細節

今天我使用 m3.medium EC2 執行個體測試 Redis 的延遲。我能夠重現 BGSAVE 期間常見的延遲突波,也就是行程 fork、子行程開始將資料集儲存到磁碟的時候。然而有些狀況不如預期。突波並非來自磁碟 I/O,也不是發生在 fork() 呼叫本身。

測試是在記憶體中有 1GB 資料的情況下進行,由另一台 EC2 執行個體以每秒 150k 次寫入、對 500 萬個鍵(平均分散)發起。管線(pipeline)設為 4 個命令。這對應到 redis-benchmark 的下列命令列:

    ./redis-benchmark -P 4 -t set -r 5000000 -n 1000000000

每次觸發 BGSAVE,我都能看到約 300 毫秒、來源不明的延遲突波,因為 fork 只花了 6 毫秒。幸好 Redis 有軟體看門狗 software watchdog(軟體看門狗)功能,能在延遲事件發生時產生行程的堆疊追蹤。這是個相當簡單卻非常有效的技巧:我們設定由核心遞送 SIGALRM。每次呼叫 serverCron() 函式時,就會清除已排程的信號,所以如果控制權夠快回到 Redis 行程,實際上 Redis 永遠不會收到它。反之,若存在阻塞狀況,核心就會遞送該信號,而信號處理器會印出堆疊追蹤。

我得到的堆疊追蹤並非停在 fork 呼叫,而是在 fork 之後、父行程脈絡中執行 MOV* 操作附近,行程總是阻塞於此。我開始推測 Linux 在某種程度上是「lazy forking(惰性分叉)」,真正繁重的工作是在之後存取記憶體、必須對分頁進行 copy-on-write(寫入時複製)時才發生。

下一步是閱讀 Linux 核心中 fork() 的實作。這個系統呼叫的確會複製所有已映射的區域(vm_area_struct 結構)。然而傳統實作在這個時間點也會複製 PTEs(分頁表項目),這部分傳統上是由 copy_page_range() 完成。不過多年前作為一項最佳化,有些事情改變了:現在 Linux 不只是像多數現代核心那樣進行惰性分頁複製,PTEs 也會在發生缺頁(fault)時以惰性方式複製。以下是 copy_range_range() 開頭的註解:

         * Don't copy ptes where a page fault will fill them correctly.
         * Fork becomes much lighter when there are big shared or private
         * readonly mappings. The tradeoff is that copy_page_range is more
         * efficient than faulting.

基本上,只要父行程對與子行程共享的區域進行存取,在缺頁處理期間,Linux 就會補做 fork 時跳過的大量工作,這就是為什麼我在堆疊追蹤中總是看到 MOV 指令的原因。

雖然這種行為對 Redis 不利,因為一次性複製所有 PTEs 會更有效率,但對於 POSIX 系統上 fork() 的傳統使用情境——也就是 fork()+exec*() 來產生新行程——卻好得多。

這個問題並非 EC2 獨有,不過虛擬化執行個體在複製 PTEs 時速度較慢,因此在實體伺服器上問題較不明顯。

然而這絕對不是全貌。當我在自己的 Linux 機器上測試這些東西時,我想起過去在某些條件下,使用 libc malloc 而非 jemalloc 時,曾量測到較小的延遲突波。因此我試著檢查看看兩者是否有關聯。

的確,使用 MALLOC=libc 編譯時,我在實體伺服器上量不到任何延遲,而使用 jemalloc 時,卻能觀察到與 EC2 執行個體相同的行為。為了更了解其中的差異,我設定了一個包含 1500 萬個鍵、並使用較大管線的測試,以更重地壓測系統,並讓所有 mmap 區域的缺頁更有可能在極短時間內集中發生。接著我用 jemalloc 與 libc malloc 重複進行相同的測試:

bare metal, 675k/sec writes to 15 million keys, jemalloc: max spike 339 milliseconds.
bare metal, 675k/sec writes to 15 million keys, malloc: max spike 21 milliseconds.

我很快也在 EC2 上嘗試重現相同的結果,情況一樣,使用 malloc 時突波只剩下零頭。

有了這些發現後,下一個合乎邏輯的步驟是檢視使用 libc malloc 與使用 jemalloc 的 Redis 系統在記憶體配置上有何差異。Linux 的 proc 檔案系統很適合用來探究行程內部(在此案例中我使用了 /proc/<pid>/smaps 檔案)。

jemalloc 的記憶體配置在此區域:

7f8002c00000-7f8062400000 rw-p 00000000 00:00 0
Size:            1564672 kB
Rss:             1564672 kB
Pss:             1564672 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:   1564672 kB
Referenced:      1564672 kB
Anonymous:       1564672 kB
AnonHugePages:   1564672 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Locked:                0 kB
VmFlags: rd wr mr mw me ac sd

而 libc 的大型區域看起來像這樣:

0082f000-8141c000 rw-p 00000000 00:00 0                                  [heap]
Size:            2109364 kB
Rss:             2109276 kB
Pss:             2109276 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:   2109276 kB
Referenced:      2109276 kB
Anonymous:       2109276 kB
AnonHugePages:         0 kB
Swap:                  0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Locked:                0 kB
VmFlags: rd wr mr mw me ac sd

看起來這裡有幾個不同之處。

1) 只有 libc malloc 的第一行有 [heap]。
2) AnonHugePages 欄位在 libc malloc 為零,但在 jemalloc 的情況下則設為該區域的大小。

<this is wrong>
基本上,延遲的差異似乎是由於 malloc 正在使用 transparent huge pages(透明大頁),這是一項核心功能,可透明地將多個一般的 4k 分頁黏合成少數巨大的分頁,每個為 2048k。這反過來意味著複製這些區域的 PTEs 會快得多。
</this is wrong>

EDIT:不幸的是我剛發現我完全錯了,顯然只有 jemalloc 在使用 huge pages:我只是因為這看起來太理所當然而誤讀了輸出。所以恰恰相反,高延遲似乎是出於某種未知原因由 huge pages 所導致。所以實際上是 malloc 在未使用 huge pages 的情況下,速度反而快得多。我完全不清楚這裡發生了什麼,因此請忽略上述結論。

<wrong advice, read later EDIT2>
同時,對於低延遲應用,你可能會想用「make MALLOC=libc」來建置 Redis,不過請務必事先執行「make distclean」,並留意視工作負載而定,libc malloc 比 jemalloc 更容易產生記憶體碎片。
</wrong>

很快會有更多消息……

EDIT2:喔等等……既然問題是 huge pages,這就好辦多了,因為我們可以將它停用。而且我剛驗證過這樣確實有效:

echo never > /sys/kernel/mm/transparent_hugepage/enabled

這顯然是新的 Redis 真言。

UPDATE:雖然這對我來說似乎不太真實,但我已透過實驗驗證,huge pages 的記憶體突波是由於以下事實所致:當 50 個客戶端同時寫入、每個各有 N 個排隊請求時,Redis 行程可以在*單一次事件迴圈迭代*的期間內觸及行程的所有分頁,因此會對整個行程位址空間進行寫入時複製。這意味著 huge pages 不僅對延遲極為不利,對記憶體使用量也同樣糟糕。

原文由 Salvatore Sanfilippo 發布

本文章由 muse-spark-1.2-contributor 進行翻譯