VLDB 頂會論文 Async-fork 解讀與 Redis 實踐
1、背景
在 Redis 中,在 AOF 文件重寫、生成 RDB 備份文件以及主從全量同步過程中,都需要使用系統(tǒng)調(diào)用 fork 創(chuàng)建一個子進程來獲取內(nèi)存數(shù)據(jù)快照,在 fork() 函數(shù)創(chuàng)建子進程的時候,內(nèi)核會把父進程的「頁表」復(fù)制一份給子進程,如果頁表很大,復(fù)制頁表的過程耗時會非常長,那么在此期間,業(yè)務(wù)訪問 Redis 讀寫延遲會大幅增加。
最近,阿里云聯(lián)合上海交大,在數(shù)據(jù)庫頂級會議 VLDB 上發(fā)表了一篇文章《Async-fork: Mitigating Query Latency Spikes Incurred by the Fork-based Snapshot Mechanism from the OS Level》,文章介紹到,他們設(shè)計了一個新的 fork(稱為 Async-fork),將 fork 調(diào)用過程中最耗時的頁表拷貝部分從父進程移動到子進程,父進程因而可以快速返回用戶態(tài)處理用戶查詢,子進程則在此期間完成頁表拷貝,從而減少 fork 期間到達請求的尾延遲。所以該特性在類似 Redis 類型的內(nèi)存數(shù)據(jù)庫上均能取得不錯的效果。
2、基本概念
2.1 物理內(nèi)存地址
也即實際的物理內(nèi)存地址空間。
2.2 虛擬地址空間
虛擬地址空間(Virtual Address Space)是每一個程序被加載運行起來后,操作系統(tǒng)為進程分配的虛擬內(nèi)存,它為每個進程提供了一個假象,即每個進程都在獨占地使用主存。
每個進程所能訪問的最大的虛擬地址空間由計算機的硬件平臺決定,具體地說是由 CPU 的位數(shù)決定的。比如 32 位的 CPU 就是我們常說的 4GB 虛擬內(nèi)存空間。
程序訪問內(nèi)存地址使用虛擬地址空間,然后由操作系統(tǒng)將這個虛擬地址映射到適當?shù)奈锢韮?nèi)存地址上。這樣,只要操作系統(tǒng)處理好虛擬地址到物理內(nèi)存地址的映射,就可以保證不同的程序最終訪問的內(nèi)存地址位于不同的區(qū)域,彼此沒有重疊,就可以達到內(nèi)存地址空間隔離的效果。
當進程創(chuàng)建時,每個進程都會有一個自己的 4GB 虛擬地址空間。要注意的是這個 4GB 的地址空間是“虛擬”的,并不是真實存在的,而且每個進程只能訪問自己虛擬地址空間中的數(shù)據(jù),無法訪問別的進程中的數(shù)據(jù),通過這種方法實現(xiàn)了進程間的地址隔離。
對于 Linux,4GB 的虛擬地址空間包含用戶態(tài)虛擬內(nèi)存空間和內(nèi)核態(tài)虛擬內(nèi)存空間兩部分,默認分配狀態(tài)如下:
2.3 內(nèi)存頁表
「頁表」保存的是虛擬內(nèi)存地址與物理內(nèi)存地址的映射關(guān)系。
CPU 訪問數(shù)據(jù)的時候,CPU 發(fā)出的地址是虛擬地址,CPU 中內(nèi)存管理單元(MMU)通過查詢頁表,把虛擬地址轉(zhuǎn)換為物理地址,再去訪問物理內(nèi)存條。
2.3.1 內(nèi)存分頁
分頁是把整個虛擬和物理內(nèi)存空間切成一段段固定尺寸的大小,這樣一個連續(xù)并且尺寸固定的內(nèi)存空間,我們叫頁(Page)。在 Linux 下,每一頁的大小為 4KB。
在 32 位的環(huán)境下,虛擬地址空間共有 4GB,假設(shè)一個頁的大小是 4KB(2^12),那么就需要大約 100 萬(2^20)個頁,每個「頁表項」需要 4 個字節(jié)大小來存儲,那么整個 4GB 空間的映射就需要有 4MB 的內(nèi)存來存儲頁表。
這 4MB 大小的頁表,看起來也不是很大。但是每個進程都是有自己的虛擬地址空間,也就說都有自己的頁表。每個機器上同時運行多個進程,頁表將占用大量內(nèi)存。
2.3.2 多級頁表
要解決上面提到的存儲進程頁表項占用大量內(nèi)存空間的問題,就需要采用一種叫作多級頁表(Multi-Level Page Table)的解決方案。
我們把這個 100 多萬個「頁表項」的單級頁表再分頁,將頁表(一級頁表)分為 1024 個頁表(二級頁表),每個二級頁表中包含 1024 個「頁表項」,形成二級分頁。這樣,一級頁表就可以覆蓋整個 4GB 虛擬地址空間,但如果某個一級頁表的頁表項沒有被用到,也就不需要創(chuàng)建這個頁表項對應(yīng)的二級頁表了,即可以在需要時才創(chuàng)建二級頁表。也就是,內(nèi)存中只需要保存一級頁表以及使用到的二級頁表,大量的未被使用的二級頁表則不需要分配內(nèi)存并加載在內(nèi)存中,因此,達到節(jié)省頁表占用內(nèi)存空間的目的。
對于 64 位的系統(tǒng),使用四級分頁目錄,分別是:
- 頁全局目錄項 PGD(Page Global Directory);
- 頁上級目錄項 PUD(Page Upper Directory);
- 頁中間目錄項 PMD(Page Middle Directory);
- 頁表項 PTE(Page Table Entry);
2.4 虛擬內(nèi)存區(qū)域(VMA)
進程的虛擬內(nèi)存空間包含一段一段的虛擬內(nèi)存區(qū)域(Virtual memory area, 簡稱 VMA),每個 VMA 描述虛擬內(nèi)存空間中一段連續(xù)的區(qū)域,每個 VMA 由許多虛擬頁組成,即每個 VMA 包含許多頁表項 PTE。
3、Fork 原理
在默認 fork 的調(diào)用過程中,父進程需要將許多進程元數(shù)據(jù)(例如文件描述符、信號量、頁表等)復(fù)制到子進程,而頁表的復(fù)制是其中最耗時的部分(占據(jù) fork 調(diào)用耗時的 97% 以上)。
Linux 的 fork() 使用寫時拷貝 (copy-on-write) 頁的方式實現(xiàn)。寫時拷貝是一種可以推遲甚至避免拷貝數(shù)據(jù)的技術(shù)。在創(chuàng)建子進程的過程中,操作系統(tǒng)會把父進程的「頁表」復(fù)制一份給子進程,這個頁表記錄著虛擬地址和物理地址映射關(guān)系,此時,操作系統(tǒng)并不復(fù)制整個進程的物理內(nèi)存,而是讓父子進程共享同一個物理內(nèi)存。同時,操作系統(tǒng)內(nèi)核會把共享的所有的內(nèi)存頁的權(quán)限都設(shè)為 read-only。
那什么時候會發(fā)生物理內(nèi)存的復(fù)制呢?
當父進程或者子進程在向共享內(nèi)存發(fā)起寫操作時,內(nèi)存管理單元 MMU 檢測到內(nèi)存頁是 read-only 的,于是觸發(fā)缺頁中斷異常(page-fault),處理器會從中斷描述符表(IDT)中獲取到對應(yīng)的處理程序。在中斷程序中,內(nèi)核就會把觸發(fā)異常的物理內(nèi)存頁復(fù)制一份,并重新設(shè)置其內(nèi)存映射關(guān)系,將父子進程的內(nèi)存讀寫權(quán)限設(shè)置為可讀寫,于是父子進程各自持有獨立的一份,之后進程才會對內(nèi)存進行寫操作,這個過程也被稱為寫時復(fù)制(Copy On Write)。
4、Fork 的痛點
在原生 fork 下,在父進程調(diào)用 fork() 創(chuàng)建子進程的過程中,雖然使用了寫時復(fù)制頁表的方式進行優(yōu)化,但由于要復(fù)制父進程的頁表,還是會造成父進程出現(xiàn)短時間阻塞,阻塞的時間跟頁表的大小有關(guān),頁表越大,阻塞的時間也越長。
我們在測試中很容易觀察到 fork 產(chǎn)生的阻塞現(xiàn)象,以及 fork 造成的 Redis 訪問抖動現(xiàn)象。
4.1 測試環(huán)境
Redis 版本:優(yōu)化前 Redis-server
機器操作系統(tǒng):無 Async-fork 特性的系統(tǒng)
測試數(shù)據(jù)量:21.63G
4.2 阻塞現(xiàn)象復(fù)現(xiàn)
在使用 Redis-benchmark 壓測的過程中,手動執(zhí)行 bgsave 命令,觀察 fork 耗時和壓測指標 TP100。
使用 info stats 返回上次 fork 耗時:latest_fork_usec:183632,可以看到 fork 耗時 183 毫秒。
在壓測過程中分別不執(zhí)行 bgsave 和執(zhí)行 bgsave,結(jié)果如下:
從壓測數(shù)據(jù)可以看到,單機環(huán)境下壓測,壓測時未執(zhí)行 bgsave,TP100 約 1 毫秒;如果壓測過程中,手動執(zhí)行 bgsave 命令,觸發(fā) fork 操作,TP100 達到 187 毫秒。
4.3 Strace 跟蹤 fork 過程耗時
strace 常用來跟蹤進程執(zhí)行時的系統(tǒng)調(diào)用和所接收的信號。
由于 Linux 中通過 clone() 系統(tǒng)調(diào)用實現(xiàn) fork();我們可以看到追蹤到 clone 系統(tǒng)調(diào)用,并且耗時 183 毫秒,與 info stats統(tǒng)計的 fork 耗時一致。
5、Async-fork
鑒于以上 linux 原生 fork 系統(tǒng)調(diào)用的痛點,對于像 Redis 這樣的高性能內(nèi)存數(shù)據(jù)庫,將會增加 fork 期間的用戶訪問延遲,論文中設(shè)計了一個新的 fork(稱為 Async-fork)來解決上述問題。
Async-fork 設(shè)計的核心思想是將 fork 調(diào)用過程中最耗時的頁表拷貝工作從父進程移動到子進程,縮短父進程調(diào)用 fork 時陷入內(nèi)核態(tài)的時間,父進程因而可以快速返回用戶態(tài)處理用戶查詢,子進程則在此期間完成頁表拷貝。與 Linux 中的默認原生 fork 相比,Async-fork 顯著減少了 Redis 快照期間到達請求的尾延遲。
5.1 Async-fork 的挑戰(zhàn)
然而,Async-fork 的實現(xiàn)過程中,實際工作并非描述的這么簡單。頁表的異步復(fù)制操作可能導(dǎo)致快照不一致。以下圖為例,Redis 在 T0 時刻保存內(nèi)存快照,而某個用戶請求在 T2 時刻向 Redis 插入了新的鍵值對(k2, v2),這將導(dǎo)致父進程修改它的頁表項(PTE2)。假如 T2 時刻這個被修改的頁表項(PTE2)還沒有被子進程復(fù)制完成, 這個修改后的內(nèi)存頁表項及對應(yīng)內(nèi)存頁后續(xù)將被復(fù)制到子進程,這個新插入的鍵值對將被子進程最終寫入硬盤,破壞了快照一致性。(快照文件應(yīng)該記錄的是保存拍攝內(nèi)存快照那一刻的內(nèi)存數(shù)據(jù))
圖片來源于:參考資料[1] 第 8 頁
5.2 Async-fork 詳解
前面提到,每個進程都有自己的虛擬內(nèi)存空間,Linux 使用一組虛擬內(nèi)存區(qū)域 VMA 來描述進程的虛擬內(nèi)存空間,每個 VMA 包含許多頁表項。
在默認 fork 中,父進程遍歷每個 VMA,將每個 VMA 復(fù)制到子進程,并自上而下地復(fù)制該 VMA 對應(yīng)的頁表項到子進程,對于 64 位的系統(tǒng),使用四級分頁目錄,每個 VMA 包括 PGD、PUD、PMD、PTE,都將由父進程逐級復(fù)制完成。在 Async-fork 中,父進程同樣遍歷每個 VMA,但只負責將 PGD、PUD 這兩級頁表項復(fù)制到子進程。
隨后,父進程將子進程放置到某個 CPU 上使子進程開始運行,父進程返回到用戶態(tài),繼續(xù)響應(yīng)用戶請求。由子進程負責每個 VMA 剩下的 PMD 和 PTE 兩級頁表的復(fù)制工作。
如果在父進程返回用戶態(tài)后,子進程復(fù)制內(nèi)存頁表期間,父進程需要修改還未完成復(fù)制的頁表項,怎樣避免上述提到的破壞快照一致性問題呢?
圖片來源于:參考資料[1] 第 7 頁
5.2.1 主動同步機制
父進程返回用戶態(tài)后,父進程的 PTE 可能被修改。如果在子進程復(fù)制內(nèi)存頁表期間,父進程檢測到了 PTE 修改,則會觸發(fā)主動同步機制,也就是父進程也加入頁表復(fù)制工作,來主動完成被修改的相關(guān)頁表復(fù)制,該機制用來確保 PTE 在修改前被復(fù)制到子進程。
當一個 PTE 將被修改時,父進程不僅復(fù)制這一個 PTE,還同時將位于同一個頁表上的所有 PTE(一共 512 個 PTE),連同它的父級 PMD 項復(fù)制到子進程。
父進程中的 PTE 發(fā)生修改時,如果子進程已經(jīng)復(fù)制過了這個 PTE,父進程就不需要復(fù)制了,否則會發(fā)生重復(fù)復(fù)制。怎么區(qū)分 PTE 是否已經(jīng)復(fù)制過?
Async-fork 使用 PMD 項上的 RW 位來標記是否被復(fù)制。具體而言,當父進程第一次返回用戶態(tài)時,它所有 PMD 項被設(shè)置為寫保護(RW=0),代表這個 PMD 項以及它指向的 512 個 PTE 還沒有被復(fù)制到子進程。當子進程復(fù)制一個 PMD 項時,通過檢查這個 PMD 是否為寫保護,即可判斷該 PMD 是否已經(jīng)被復(fù)制到子進程。如果還沒有被復(fù)制,子進程將復(fù)制這個 PMD,以及它指向的 512 個 PTE。
在完成 PMD 及其指向的 512 個 PTE 復(fù)制后,子進程將父進程中的該 PMD 設(shè)置為可寫(RW=1),代表這個 PMD 項以及它指向的 512 個 PTE 已經(jīng)被復(fù)制到子進程。當父進程觸發(fā)主動同步時,也通過檢查 PMD 項是否為寫保護判斷是否被復(fù)制,并在完成復(fù)制后將 PMD 項設(shè)置為可寫。同時,在復(fù)制 PMD 項和 PTE 時,父進程和子進程都鎖定 PTE 表,因此它們不會出現(xiàn)同時復(fù)制同一 PMD 項指向的 PTE。
在操作系統(tǒng)中,PTE 的修改分為兩類:
1)VMA 級的修改。例如,創(chuàng)建、合并、刪除 VMA 等操作作用于特定 VMA 上,VMA 級的修改通常會導(dǎo)致大量的 PTE 修改,因此涉及大量的 PMD。
2)PMD 級的修改。PMD 級的修改僅涉及一個 PMD。
5.2.2 錯誤處理
Async-fork 在復(fù)制頁表時涉及到內(nèi)存分配,難免會發(fā)生錯誤。例如,由于內(nèi)存不足,進程可能無法申請到新的 PTE 表。當錯誤發(fā)生時,應(yīng)該將父進程恢復(fù)到它調(diào)用 Async-fork 之前的狀態(tài)。
在 Async-fork 中,父進程 PMD 項目的 RW 位可能會被修改。因此,當發(fā)生錯誤時,需要將 PMD 項全部回滾為可寫。
6、Redis 優(yōu)化實踐
6.1 Async-fork 阻塞現(xiàn)象
在支持 Async-fork 的操作系統(tǒng)(即 Tair 專屬操作系統(tǒng)鏡像)機器上測試,理論上來說,按照文章的預(yù)期,用戶不需要作任何修改(Async-fork 使用了原生 fork 相同的接口,沒有另外新增接口),就可以享受 Async-fork 優(yōu)化帶來的優(yōu)勢,但是,使用 Redis 實際測試過程中,結(jié)果不符合預(yù)期,在 Redis 壓測過程中手動執(zhí)行 bgsave 命令觸發(fā) fork 操作,還是觀察到了 TP100 抖動現(xiàn)象。
測試環(huán)境
Redis 版本:優(yōu)化前 Redis-Server
機器操作系統(tǒng):Tair 專屬操作系統(tǒng)鏡像
測試數(shù)據(jù)量:54.38G
問題現(xiàn)象
現(xiàn)象:fork 耗時正常,但是壓測過程中執(zhí)行 bgsave,TP100 不正常
在壓測過程中執(zhí)行 bgsave,使用 info stats 返回上次 fork 耗時:latest_fork_usec:426
TP100 結(jié)果如下:
也就是說,觀察到的 fork 耗時正常,但是壓測過程中 Redis 依然出現(xiàn)了尾延遲,這顯然不符合預(yù)期。
追蹤過程
使用 strace 命令進行分析,結(jié)果如下:
可以觀察到,clone 耗時 380 微秒,已經(jīng)大幅降低,也就 fork 快速返回了用戶態(tài)響應(yīng)用戶請求。然而,注意到,緊接著出現(xiàn)了一個 mmap 耗時 358 毫秒,與 TP100 數(shù)據(jù)接近。
由于 mmap 系統(tǒng)調(diào)用會在當前進程的虛擬地址空間中,尋找一段滿足大小要求的虛擬地址,并且為此虛擬地址分配一個虛擬內(nèi)存區(qū)域( vm_area_struct 結(jié)構(gòu)),也就是會觸發(fā) VMA 級虛擬頁表變化,也就觸發(fā)父進程主動同步機制,父進程主動幫助完成相應(yīng)頁表復(fù)制動作。VMA 級虛擬頁表變化,需要將對應(yīng)的三級和四級所有頁目錄都復(fù)制到子進程,因此,耗時比較高。
那么,這個 mmap 調(diào)用又是哪里來的呢?
定位問題
perf 是 Linux下的一款性能分析工具,能夠進行函數(shù)級與指令級的熱點查找。
通過 perf trace 可以看到響應(yīng)調(diào)用堆棧及耗時,分析結(jié)果如下:
也就可以看到,在 bgsave 執(zhí)行邏輯中,有一處打印日志中的 fprintf 調(diào)用了 mmap,很顯然這應(yīng)該是 fork 返回父進程后,父進程中某處調(diào)用。
6.2 Async-fork 適配優(yōu)化
針對找出來的代碼位置,可以進行相應(yīng)優(yōu)化,針對此處的日志影響,我們可以屏蔽日志或者將日志移動到子進程進行打印,通過同樣的分析手段,如果存在其他影響,均可進行對應(yīng)優(yōu)化。進行相應(yīng)適配優(yōu)化修改后,我們再次進行測試。
測試環(huán)境
Redis 版本:優(yōu)化后 Redis-Server
機器操作系統(tǒng):Tair 專屬操作系統(tǒng)鏡像
測試數(shù)據(jù)量:54.38G
現(xiàn)象
在壓測過程中執(zhí)行 bgsave,fork 耗時和 TP100 均正常。
使用 info stats 返回上次 fork 耗時:latest_fork_usec:414
TP100 結(jié)果如下:
跟蹤驗證
再次使用 strace 和 perf 工具跟蹤驗證
strace 跟蹤父進程只看到 clone,并且耗時只有 378 微秒,
Perf trace 跟蹤父進程也只看到 clone 調(diào)用
由于我們的優(yōu)化是將觸發(fā) mmap 的相關(guān)日志修改到子進程中,使用 Perf trace 跟蹤 fork 產(chǎn)生的子進程,命令為:
strace -p 14697 -T -tt -f -ff -o strace05.out
通過 Redis 日志文件找到子進程 pid 為 15931;打開對應(yīng)生成的保存子進程 strace 信息的文件strace05.out.15931(父進程 strace 信息保存在文件strace05.out.14697)
在子進程中看到了 mmap 調(diào)用,子進程中調(diào)用不會影響父進程對業(yè)務(wù)訪問的響應(yīng)。
7性能測試
修改 Redis 代碼,針對 Async-fork 適配優(yōu)化后,我們針對 fork 與 Async-fork 進行了性能對比測試;測試包含不同數(shù)據(jù)量下 fork() 命令耗時與 fork() 操作對壓測過程中 TP100 的影響。
7.1 fork() 命令耗時
fork() 命令耗時,即針對 Redis 執(zhí)行 bgsave 命令后,通過 Redis 提供的 info stats命令觀察到的latest_fork_usec用時。
注:由于 fork 與 Async-fork 系統(tǒng)下,fork() 操作產(chǎn)生的latest_fork_usec數(shù)據(jù)差距懸殊非常大,使用單縱軸會導(dǎo)致 Async-fork 的數(shù)據(jù)在圖表中顯示不明顯,不方便查看,因此,該圖表使用了雙縱軸;雖然 Async-fork 的圖表看起來比較高,但是實際右縱軸范圍小,所以數(shù)據(jù)小
從圖表可以看出,使用支持 Async-fork 的操作系統(tǒng),fork() 操作產(chǎn)生的耗時非常小,不管數(shù)據(jù)量多大,耗時都非常穩(wěn)定,基本在 200 微秒左右;而原生 fork 產(chǎn)生的耗時會隨著數(shù)據(jù)量增長而增長,而且是從幾十毫秒增長到幾百毫秒。
7.2 TP100 抖動
在使用 Redis-benchmark 壓測過程中,手動執(zhí)行 bgsave 命令,觸發(fā)操作系統(tǒng) fork() 操作,觀察不同數(shù)據(jù)量下,fork 與 Async-fork 對 Redis 壓測時 TP100 的影響。
從圖上可以看出,使用支持 Async-fork 的操作系統(tǒng),fork() 操作對 Redis 壓測產(chǎn)生的性能影響非常小,性能提升非常明顯,不管數(shù)據(jù)量多大,耗時都非常穩(wěn)定,基本在 1-2 毫秒左右;而原生 fork 產(chǎn)生的抖動影響時間會隨著數(shù)據(jù)量增長而增長, TP100 從幾十毫秒增長到幾百毫秒。
8、總結(jié)
通過不同數(shù)據(jù)量下對比測試,我們可以看到,Async-fork 相比原生 fork,阻塞時間大大減少,性能提升非常明顯。而且阻塞時間非常穩(wěn)定,不會因為數(shù)據(jù)量的增長出現(xiàn)倍數(shù)級增長。
在單機測試場景下,8G 數(shù)據(jù)量大小下,TP100 和latest_fork_usec 耗時均減少 98% 以上。
基于論文中 Async-fork 的設(shè)計思想,Tair 專屬操作系統(tǒng)鏡像已支持該特性,并且將該特性集成在原生 fork 中,沒有新增系統(tǒng)調(diào)用接口,理論上用戶只需要使用支持 Async-fork 的操作系統(tǒng),程序無需做任何修改,就可以享受到 Async-fork 特性帶來的性能提升。對于 Redis 而言,我們也只需要對 Redis 稍加適配就可以獲得該技術(shù)帶來的紅利。
在 Redis 應(yīng)用場景中,在添加從節(jié)點、RDB 文件備份、AOF 持久化文件重寫等場景下,應(yīng)用支持 Async-fork 的操作系統(tǒng),都將極大的減少對業(yè)務(wù)的影響。