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 不僅對延遲非常糟糕,對記憶體使用量來說也同樣糟糕。隨機一篇部落格
留言
登入後參與討論