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

Salvatore Sanfilippo

Redis 延遲飆升與 Linux 核心:更多細節

原文由 Salvatore Sanfilippo 發布,訂閱此部落格

今天我用 m3.medium 的 EC2 執行個體測試 Redis 的延遲。我成功重現了 BGSAVE 期間常見的延遲飆升,也就是行程 fork、子行程開始把資料集儲存到磁碟時會發生的情況。然而有些地方不如預期。這次的飆升並不是因為磁碟 I/O,也不是發生在 fork() 呼叫本身。

測試是在記憶體中有 1GB 資料的情況下進行的,由另一台 EC2 執行個體以每秒 15 萬次寫入的頻率,針對 500 萬個 key(均勻分布)發送請求。pipeline 設為 4。這相當於執行以下 redis-benchmark 指令:

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

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

結果我拿到的並不是包含 fork 呼叫的堆疊追蹤,而是行程總是在 fork 之後不久、父行程中執行 MOV* 運算的附近被卡住。我開始推測 Linux 在某種程度上做了「延遲 fork」(lazy forking),真正繁重的工作其實是稍後在存取記憶體、必須對頁面進行 copy-on-write 時才發生。

下一步是去閱讀 Linux 核心中 fork() 的實作。這個系統呼叫所做的,的確是複製所有已映射的區域(vm_area_struct 結構)。然而傳統的實作還會在這個時候一併複製 PTE,而這通常是由 copy_page_range() 來完成的。不過有些事情已經改變了……多年前作為一項最佳化:現在的 Linux 不僅像多數現代核心那樣做延遲的頁面複製,連 PTE 也是在發生 page 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.

基本上,只要父行程對與子行程共享的區域進行存取,在 page fault 的過程中,Linux 就會去執行那些被 fork 省略的大量工作,這也是為什麼我在堆疊追蹤中總是看到 MOV 指令。

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

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

然而這絕對不是故事的全貌。當我在自己的 Linux 主機上測試這些東西時,我想起過去在某些條件下,使用 libc 的 malloc 而非 jemalloc 時,曾測得較低的延遲尖峰。於是我試著檢查這兩者之間是否有關聯。

的確,用 MALLOC=libc 編譯後,在實體伺服器上我完全測不到延遲,而用 jemalloc 則能觀察到與 EC2 執行個體上相同的行為。為了更清楚地理解差異,我用 1500 萬個 key 和更大的 pipeline 來加重系統負擔,讓所有 mmap 區域的 page fault 更有可能在極短的時間內集中發生。接著我用 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。這反過來意味著複製這些區域的 PTE 會快得多。
</this is wrong>

EDIT: 很不幸我剛發現我完全搞錯了,huge pages 顯然只有 jemalloc 在用:我只是把輸出看反了,因為這看起來太理所當然。相反地,高延遲似乎是由於 huge pages 造成的,原因不明。所以實際上是沒有使用 huge pages 的 malloc 跑得快得多。我完全不知道這是怎麼回事,所以請忽略上面的結論。

<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 行程可以在*單一次事件迴圈迭代*的時間內就碰遍行程的所有頁面,因此把整個行程位址空間都 copy-on-write 了一遍。這意味著 huge pages 不僅對延遲非常糟糕,對記憶體使用量來說也同樣糟糕。

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

留言