配置 ZRAM,實現 Linux 下的記憶體壓縮,零成本低開銷獲得成倍記憶體擴增

路人甲的世界發表於2022-03-21
由於專案需求,筆者最近在一臺 Linux 伺服器上部署了 ElasticSearch 叢集,卻發現執行過程中經常出現查詢速度突然降低的問題,登入伺服器後發現是實體記憶體不足,導致機器頻繁發生頁面交換。由於只是臨時記憶體需求,沒有提升配置的必要,而 ElasticSearch 中儲存的資料主要是文字資料,因此筆者想到了使用 ZRAM 對記憶體進行壓縮,以避免磁碟 IO 導致效能波動,效果明顯。介於網際網路上關於 Linux 配置 ZRAM 的文章少之又少,本文將為讀者介紹在 Linux 中配置與使用 ZRAM 的過程,並藉此機會介紹 ZRAM 以及 Linux 記憶體部分的運作機制。
本文原載於未命名小站,由作者本人同步至 SegmentFault ,轉載請註明原作者部落格地址或本連結,謝謝!

0x01 ZRAM 介紹

隨著現代應用程式的多樣化和複雜化發展,那個曾經只需要 640KB 記憶體就能執行市面上所有軟體的時代已經一去不復返,而比應用程式發展更快的則是使用者對多工的需求。現在的主流作業系統都提供了記憶體壓縮的功能,以保證活躍應用程式擁有儘可能多的可用記憶體:

2022/03/2022-03-16-20-32-29.png
macOS 從 OS X 10.9 之後預設開啟記憶體壓縮

2022/03/2022-03-16-20-34-24.png
Windows 從 Win10 TH2 之後預設開啟記憶體壓縮

2022/03/2022-03-16-20-37-06.png
大部分 Android 手機廠商都預設開啟了記憶體壓縮

細心的讀者會發現,在 Android 記憶體壓縮的圖中,並沒有明確標明記憶體壓縮,這是因為 Android(Linux)的記憶體壓縮是依靠 Swap 機制實現的,大部分情況下是使用 ZRAM 技術來模擬 Swap。

ZRAM 早在 2014 年就伴隨 Linux 3.14 核心合入主線,但由於 Linux 用途十分廣泛,這一技術並非預設啟用,只有 Android 和少部分的 Linux 桌面發行版如 Fedora 預設啟用了這一技術,以保證多工場景下記憶體的合理分層儲存。

0x02 ZRAM 執行機制

ZRAM 的原理是劃分一塊記憶體區域作為虛擬的塊裝置(可以理解為支援透明壓縮的記憶體檔案系統),當系統記憶體不足出現頁面交換時,可以將原本應該交換出去的頁壓縮後放在記憶體中,由於部分被『交換出去』的頁得到了壓縮,因此可用的實體記憶體就能隨之變多。

由於 ZRAM 並沒有改變 Linux 記憶體模型的基本結構,因此我們只能利用 Linux 中 Swap 的優先順序能力,將 ZRAM 作為高優先順序 Swap 看待,這也解釋了為什麼快閃記憶體比較脆弱的手機上會出現 Swap,其本質還是 ZRAM。

隨著部分手機開始使用真正的固態硬碟,也有將 Swap 放在硬碟上的手機,但是一般也都會優先使用 ZRAM。

由於這一執行機制的存在,ZRAM 可以設計得足夠簡單:記憶體交換策略交給核心、壓縮演算法交給壓縮庫,ZRAM 本身基本上只需要實現塊裝置驅動,因此具有極強的可定製性和靈活性,這也是 Windows、macOS 等系統所無法比擬的。

2022/03/2022-03-16-20-57-24.png

Linux5.16 中關於 ZRAM 的原始碼只有不到 100KB,實現非常精簡,對 Linux 驅動開發感興趣的朋友也可以從這裡開始研究:linux/Kconfig at v5.16 · torvalds/linux

0x03 ZRAM 配置與自啟動

網際網路上關於 ZRAM 的資料少之又少,因此筆者計劃從 Linux 主線中 ZRAM 相關原始碼和官方文件著手分析。

1. 確認核心是否支援&有無啟用 ZRAM

既然 ZRAM 是核心模組,就需要先檢查當前 Linux 機器的核心是否存在這一模組。

在配置之前,需要讀者先確認一下自己的核心版本是否在 3.14 以上,部分 VPS 由於依舊使用 XenOpenVZ 等虛擬/容器化技術,核心版本往往卡在 2.6,那麼這樣的機器是無法開啟 ZRAM 的。筆者用作示例的機器安裝的 Linux 核心版本為5.10,因此可以啟用 ZRAM:

2022/03/2022-03-16-21-05-16.png

但根據核心版本判斷畢竟不可靠,如 CentOS 7,雖然核心版本是 3.10,卻支援 ZRAM,也有極少數發行版或嵌入式 Linux 為了降低資源佔用,選擇不編譯 ZRAM,因此我們最好使用 modinfo 命令來檢查一下有無 ZRAM 支援:

2022/03/2022-03-16-21-11-30.png

如圖所示,雖然這臺伺服器是 CentOS 7.8,核心版本只到 3.10,卻支援 ZRAM,因此使用 modinfo 命令是比較有效的。

部分發行版會預設啟用但不配置 ZRAM,我們可以使用lsmod檢查 ZRAM 是否啟用:

lsmod | grep zram

2. 啟用 ZRAM 核心模組

如果確定 ZRAM 沒有被啟用,我們可以新建檔案 /etc/modules-load.d/zram.conf,並在其中輸入 zram。重啟機器,再執行一次 lsmod | grep zram ,當看到如下圖所示的輸出時,說明 ZRAM 已經啟用,並能支援開機自啟:

2022/03/2022-03-16-21-27-31.png

上文我們提到,ZRAM 本質上是塊裝置驅動,那麼當我們輸入 lsblk 會發生什麼呢?

2022/03/2022-03-16-21-29-20.png

可以看到,其中並沒有 zram 相關字眼,這是因為我們需要先新建一個塊裝置。

開啟 ZRAM 原始碼 中的Kconfig檔案,可以找到如下說明:

Creates virtual block devices called /dev/zramX (X = 0, 1, ...).
Pages written to these disks are compressed and stored in memory
itself. These disks allow very fast I/O and compression provides
good amounts of memory savings.

It has several use cases, for example: /tmp storage, use as swap
disks and maybe many more.

See Documentation/admin-guide/blockdev/zram.rst for more information.

其中提到了一篇位於 Documentation/admin-guide/blockdev/zram.rst說明文件

根據說明文件,我們可以使用 modprobe zram num_devices=1 的方式來讓核心在啟用 ZRAM 模組時開啟一個 ZRAM 裝置(一般只需要一個就夠了),但這樣開啟裝置的方式依舊在重啟後就會失效,並不方便。

好在modprobe說明文件 中提到了modprobe.d的存在:modprobe.d(5) - Linux manual page

繼續閱讀 modprobe.d 的文件,我們會發現它主要用於modprobe時預定義引數,即只需要輸入 modprobe zram,輔以 modprobe.d中的配置,就可以自動加上引數。由於我們使用 modules-load.d 實現了 ZRAM 模組的開機自啟,因此只需要在modprobe.d中配置引數即可。

按照上文所述的文件,新建檔案/etc/modprobe.d/zram.conf,在其中輸入options zram num_devices=1,即可配置一個 ZRAM 塊裝置,同樣重啟後生效。

3. 配置 zram0 裝置

重啟後輸入lsblk,卻發現所需的 ZRAM 裝置依舊沒有出現?

2022/03/2022-03-16-22-07-31.png

不用擔心,這是因為我們還沒有為這個塊裝置建立檔案系統,lsblk雖然名字聽起來像是列出塊裝置,但本質上讀取的確是/sys目錄裡的檔案系統資訊,再將其與udev中的裝置資訊比對。

閱讀udev的文件:udev(7) - Linux manual page,其中提到udev會從/etc/udev/rules.d目錄讀取裝置資訊,按照文件中的指示,我們新建一個名為/etc/udev/rules.d/99-zram.rules的檔案,在其中寫入如下內容:

KERNEL=="zram0",ATTR{disksize}="30G",TAG+="systemd"

其中,KERNEL屬性用於指明具體裝置,ATTR屬性用於給裝置傳遞引數,這裡我們需要閱讀zram的文件,其中提到:

Set disk size by writing the value to sysfs node 'disksize'. The value can be either in bytes or you can use mem suffixes. Examples:

# Initialize /dev/zram0 with 50MB disksize
echo $((50*1024*1024)) > /sys/block/zram0/disksize

# Using mem suffixes
echo 256K > /sys/block/zram0/disksize
echo 512M > /sys/block/zram0/disksize
echo 1G > /sys/block/zram0/disksize

Note: There is little point creating a zram of greater than twice the size of memory since we expect a 2:1 compression ratio. Note that zram uses about 0.1% of the size of the disk when not in use so a huge zram is wasteful.

即該塊裝置接受名為disksize的引數,且不建議分配記憶體容量兩倍以上的 ZRAM 空間。筆者的 Linux 環境擁有 60G 記憶體,考慮到實際使用情況,設定了 30G 的 ZRAM,這樣一來理想情況下就能獲得60G - (30G / 2) + 30G > 75G以上的記憶體空間,已經足夠使用(為什麼要這樣計算?請閱讀『ZRAM 監控』一章)。讀者可以根據自己的實際情況選擇 ZRAM 空間大小,一般來說一開始可以設定小一些,不夠用再擴大。

TAG屬性用於標記裝置型別(裝置由誰管理),根據systemd.device的文件:systemd.device(5) - Linux manual page,大部分塊裝置和網路裝置都建議標記 TAG 為systemd,這樣systemd就可以將這個裝置視作一個Unit,便於控制服務的依賴關係(如塊裝置載入成功後再啟動服務等),這裡我們也將其標記為systemd即可。

2022/03/2022-03-19-22-49-24.png
可以使用如 dev-zram0.device 或者 dev-sda1.device 等方式獲取裝置的 Device Unit

配置結束後,再次重啟 Linux,就能在lsblk 命令中看到zram0裝置了:

2022/03/2022-03-19-22-51-51.png

4. 將 zram0 裝置配置為 Swap

獲取到了一個 30G 大小的 ZRAM 裝置,接下來需要做的就是將這個裝置配置為 Swap,有經驗的讀者應該已經猜到接下來的操作了:

mkswap /dev/zram0
swapon /dev/zram0

是的,將zram0裝置配置為 Swap 和將一個普通裝置/分割槽/檔案配置為 Swap 的方式是一模一樣,但該如何讓這一操作開機自動執行呢?

首先想到的自然是使用fstab,但好巧不巧,啟用 ZRAM 核心模組使用的modules-load.d也難逃 Systemd 的魔爪:modules-load.d(5) - Linux manual page。既然從一開始就上了 Systemd 的賊船,那就貫徹到底吧!

在 Systemd 的體系下,開機自啟的命令可以被註冊為一個 Service Unit,我們新建一個檔案/etc/systemd/system/zram.service,在其中寫入如下內容:

[Unit]
Description=ZRAM
BindsTo=dev-zram0.device
After=dev-zram0.device

[Service]
Type=oneshot
RemainAfterExit=true
ExecStartPre=/sbin/mkswap /dev/zram0
ExecStart=/sbin/swapon -p 2 /dev/zram0
ExecStop=/sbin/swapoff /dev/zram0

[Install]
WantedBy=multi-user.target

接下來執行systemctl daemon-reload過載配置檔案,再執行systemctl enable zram --now,如果沒有出現報錯,可以執行swapon -s 檢視 Swap 狀態,如果看到存在名為/dev/zram0的裝置,恭喜你!現在 ZRAM 就已經配置完成並能實現自啟動了~

2022/03/2022-03-19-23-15-49.png


這裡為了幫助讀者瞭解 ZRAM 和 Systemd 的原理,因此採取了全手動的配置方式。如果讀者覺得比較麻煩,或有大規模部署的需求,可以使用 systemd/zram-generator: Systemd unit generator for zram devices,大部分預設啟用 ZRAM 的發行版(如 Fedora)都使用了這一工具,編寫配置檔案後執行systemctl enable /dev/zram0 --now即可啟用 ZRAM。

5. 配置雙層 Swap(可選)

上一節我們配置了 ZRAM,並將其設定為了 Swap,但此時 ZRAM 依舊是不生效的。為什麼呢?眼尖的讀者應該發現了,/.swapfile的優先順序高於/dev/zram0,這導致當 Linux 需要交換記憶體時,依舊會優先將頁換入/.swapfile,而非 ZRAM。

解決這個問題可以通過兩種方式:禁用 Swapfile,或者降低 Swapfile 的優先順序,這裡為了避免 ZRAM 耗盡後出現 OOM 導致服務掉線,我們採取後者,即配置雙層 Swap,當高優先順序的 ZRAM 耗盡後,會繼續使用低優先順序的 Swapfile。

我們開啟 Swapfile 的配置檔案(筆者的配置檔案在/etc/fstab中),增加如下圖所示引數:

2022/03/2022-03-20-11-26-19.png

如果使用其他方式配置 Swapfile(如 Systemd),只要保證執行swapon時攜帶-p引數即可,數字越低,優先順序越低。對於 ZRAM 同理,如上文的zram.service中就配置 ZRAM 的優先順序為 2。

設定後重啟 Linux,再次執行 swapon -s 檢視 Swap 狀態,保證 ZRAM 優先順序高於其他 Swap 優先順序即可:

2022/03/2022-03-20-11-29-38.png

0x04 ZRAM 監控

啟用 ZRAM 後,我們該如何檢視 ZRAM 的實際效用,如壓縮前後大小,以及壓縮率等狀態呢?

最直接的辦法自然是檢視驅動的 原始碼,和 文件,可以發現函式mm_stat_show()定義了/sys/block/zram0/mm_stat檔案的輸出結果,從左到右分別代表:

$ cat /sys/block/zram0/mm_stat
orig_data_size - 當前壓縮前大小 (Byte)
4096

compr_data_size - 當前壓縮後大小 (Byte)
74

mem_used_total - 當前總記憶體消耗,包含後設資料等 Overhead(Byte)
12288

mem_limit - 當前最大記憶體消耗限制(頁)
0

mem_used_max - 歷史最高記憶體用量(頁)
1223118848

same_pages - 當前相同(可被壓縮)的頁
0

pages_compacted - 歷史從 RAM 壓縮到 ZRAM 的頁
50863

huge_pages - 當前無法被壓縮的頁(巨頁)
0

該檔案適合輸出到各種監控軟體進行監控,但無論是 Byte 還是頁,這些裸數值依舊不便閱讀,好在util-linux包提供了一個名為zramctl的工具(和 systemctl 其實是雷鋒與雷峰塔的關係),在安裝util-linux後執行zramctl,即可獲得下圖所示的結果:

2022/03/2022-03-20-14-59-50.png

根據上文mm_stat的輸出,可以類推每項數值的含義,或者我們可以找到zramctl原始碼,瞭解每項輸出的含義與單位:

static const struct colinfo infos[] = {
    [COL_NAME]      = { "NAME",      0.25, 0, N_("zram device name") },
    [COL_DISKSIZE]  = { "DISKSIZE",     5, SCOLS_FL_RIGHT, N_("limit on the uncompressed amount of data") },
    [COL_ORIG_SIZE] = { "DATA",         5, SCOLS_FL_RIGHT, N_("uncompressed size of stored data") },
    [COL_COMP_SIZE] = { "COMPR",        5, SCOLS_FL_RIGHT, N_("compressed size of stored data") },
    [COL_ALGORITHM] = { "ALGORITHM",    3, 0, N_("the selected compression algorithm") },
    [COL_STREAMS]   = { "STREAMS",      3, SCOLS_FL_RIGHT, N_("number of concurrent compress operations") },
    [COL_ZEROPAGES] = { "ZERO-PAGES",   3, SCOLS_FL_RIGHT, N_("empty pages with no allocated memory") },
    [COL_MEMTOTAL]  = { "TOTAL",        5, SCOLS_FL_RIGHT, N_("all memory including allocator fragmentation and metadata overhead") },
    [COL_MEMLIMIT]  = { "MEM-LIMIT",    5, SCOLS_FL_RIGHT, N_("memory limit used to store compressed data") },
    [COL_MEMUSED]   = { "MEM-USED",     5, SCOLS_FL_RIGHT, N_("memory zram have been consumed to store compressed data") },
    [COL_MIGRATED]  = { "MIGRATED",     5, SCOLS_FL_RIGHT, N_("number of objects migrated by compaction") },
    [COL_MOUNTPOINT]= { "MOUNTPOINT",0.10, SCOLS_FL_TRUNC, N_("where the device is mounted") },
};

部分未預設輸出的數值可以通過zramctl --output-all輸出:

2022/03/2022-03-20-15-04-48.png

這個工具的輸出結果混淆了 Byte 和頁,混淆了歷史最高、累計和當前的數值,且將不設定的引數(如記憶體限制)顯示為 0B,因此輸出結果僅作為參考,可讀性依舊不高,一般來說只用瞭解DATACOMPR欄位即可。

結合zramctlmm_stat的輸出,不難發現我們配置的 ZRAM 大小其實是未壓縮的大小,而非是壓縮後的大小,前面我們提到了一個演算法,當 ZRAM 大小為 30GB 且壓縮率為 2:1 時,可以獲得60G - (30G / 2) + 30G > 75G的可用記憶體,這就是假設了 30GB 的未壓縮資料可以壓縮到 15G,佔用 15G 實體記憶體空間,即60G - (30G / 2),然後再加上 ZRAM 能儲存最大的記憶體資料 30G 計算出來。

計算壓縮率的方式為 DATA / COMPR,以引言中提到 ElasticSearch 的工作負載為例,如下圖所示,預設壓縮率為1.1G / 97M = 11.6。考慮到 ElasticSearch 的資料主要是純文字,而 JVM 的機制也是提前向系統申請記憶體,能達到這樣的壓縮率已經非常令人滿意了。

2022/03/2022-03-20-15-19-26.png

0x05 ZRAM 調優

儘管 ZRAM 的設定非常簡單,其依然提供了大量可配置項供使用者調整,如果在預設配置 ZRAM 後依舊覺得不滿意,或者想要進一步發掘 ZRAM 的潛力,就需要對其進行優化。

1. 選擇最適合的壓縮演算法

如上圖所示,ZRAM 目前的預設壓縮演算法一般是lzo-rle,但其實 ZRAM 支援的壓縮演算法有很多,我們可以通過 cat /sys/block/zram0/comp_algorithm 獲取支援的演算法,當前啟用的演算法被[]括起來:

2022/03/2022-03-20-15-25-22.png

壓縮是一個時間換空間的操作,也就意味著這些壓縮演算法並不存在絕對優劣,只存在不同情況下的取捨,有的壓縮率高、有的頻寬大、有的 CPU 消耗少……在不同的硬體上,不同的選擇也會遇到不同的瓶頸,因此只有進行真實的測試,才能幫助選擇最適合的壓縮演算法。

為了不影響生產環境,且避免 Dump 記憶體引起資料洩露,筆者使用另一臺相同硬體的虛擬機器進行測試,該虛擬機器有 2G 的 RAM 和 2G 的 ZRAM,同樣執行著 ElasticSearch(但資料量較小),然後使用stress工具對記憶體施加 1GB 的壓力,此時不活躍的記憶體(即 ElasticSearch 中的部分資料)則會被換出到 ZRAM 中,如圖所示:

2022/03/2022-03-20-16-57-57.png

使用如下指令碼獲得其中 512M 記憶體,將其 Dump 到檔案中:

pv -s 512M -S /dev/zram0 > "./test-memory.bin"

2022/03/2022-03-20-17-22-37.png

接下來,筆者釋放掉負載,空出足夠的實體記憶體,在 root 使用者下使用修改自 better_benchmarks.bash - Pastebin.com 的指令碼對不同演算法和不同page_cluster(下一節會提到)進行測試:LuRenJiasWorld/zram-config-benchmark.sh

測試過程中我們會看到新的 ZRAM 裝置被建立,這是因為指令碼會將之前 Dump 的記憶體寫入到這些裝置,每一個裝置都配置了不同的壓縮演算法:

2022/03/2022-03-20-17-34-34.png

在使用此指令碼之前,建議先閱讀一遍指令碼內容,根據自己的需要選擇不同的記憶體大小、ZRAM 裝置大小、測試演算法和引數等配置。根據配置不同,測試可能會持續 20~40 分鐘,這個過程中不建議進行任何其他操作,避免干擾測試結果。

測試結束後,使用普通使用者執行指令碼,獲取 CSV 格式的測試結果,將其儲存成檔案,匯入到 Excel 中即可檢視結果。以下是筆者環境下的測試資料:

algo   | page-cluster| "MiB/s"           | "IOPS"        | "Mean Latency (ns)"| "99% Latency (ns)"
-------|-------------|-------------------|---------------|--------------------|-------------------
lzo    | 1           | 9130.553415506836 | 1168710.932773| 5951.70401         | 27264
lzo    | 2           | 11533.120699842773| 738119.747899 | 9902.73897         | 41728
lzo    | 3           | 13018.8484358584  | 416603.10084  | 18137.20228        | 69120
lzo    | 0           | 6360.546916032226 | 1628299.941176| 3998.240967        | 18048
zstd   | 1           | 4964.754078584961 | 635488.478992 | 11483.876873       | 53504
zstd   | 2           | 5908.13019465625  | 378120.226891 | 19977.785468       | 85504
zstd   | 3           | 6350.650210083984 | 203220.823529 | 37959.488813       | 150528
zstd   | 0           | 3859.24347590625  | 987966.134454 | 7030.453683        | 35072
lz4    | 1           | 11200.088793330078| 1433611.218487| 4662.844947        | 22144
lz4    | 2           | 15353.485367975585| 982623        | 7192.215964        | 30080
lz4    | 3           | 18335.66823135547 | 586741.184874 | 12609.004058       | 45824
lz4    | 0           | 7744.197593880859 | 1982514.554622| 3203.723399        | 9920
lz4hc  | 1           | 12071.730649291016| 1545181.588235| 4335.736901        | 20352
lz4hc  | 2           | 15731.791228991211| 1006834.563025| 6973.420236        | 29312
lz4hc  | 3           | 19495.514164259766| 623856.420168 | 11793.367214       | 43264
lz4hc  | 0           | 7583.852120536133 | 1941466.478992| 3189.297915        | 9408
lzo-rle| 1           | 9641.897805606446 | 1234162.857143| 5559.790869        | 25728
lzo-rle| 2           | 11669.048803505859| 746819.092437 | 9682.723915        | 41728
lzo-rle| 3           | 13896.739553243164| 444695.663866 | 16870.123274       | 64768
lzo-rle| 0           | 6799.982996323242 | 1740795.689076| 3711.587765        | 15680
842    | 1           | 2742.551544446289 | 351046.621849 | 21805.615246       | 107008
842    | 2           | 2930.5026999082033| 187552.218487 | 41516.15757        | 193536
842    | 3           | 2974.563821231445 | 95185.840336  | 82637.91368        | 366592
842    | 0           | 2404.3125984765625| 615504.008403 | 12026.749364       | 63232

從上表我們可以獲取到非常多資訊,表頭從左到右分別為:

  • 壓縮演算法
  • page-cluster引數(下一節解釋其含義)
  • 吞吐頻寬(越大越好)
  • IOPS(越大越好)
  • 平均延遲(越小越好)
  • 99 分位延遲(越小越好)(用於獲取延遲最大值)。

由於不同page-cluster情況下的壓縮率是一樣大的,該表格未能反映此情況,我們需要執行以下命令獲取指定工作負載下每個壓縮演算法的壓縮率資訊:

$ tail -n +1 fio-bench-results/*/compratio
==> fio-bench-results/842/compratio <==
6.57

==> fio-bench-results/lz4/compratio <==
6.85

==> fio-bench-results/lz4hc/compratio <==
7.58

==> fio-bench-results/lzo/compratio <==
7.22

==> fio-bench-results/lzo-rle/compratio <==
7.14

==> fio-bench-results/zstd/compratio <==
9.48

依據以上衡量標準,筆者選出了該系統+該工作負載上最佳的配置(名稱為壓縮演算法-page_cluster):

吞吐量最大:lz4hc-3 (19495 MiB/s)
延遲最低:lz4hc-0 (3189 ns)
IOPS 最高:lz4-0 (1982514 IOPS)
壓縮率最高:zstd (9.48)
綜合最佳:lz4hc-0

根據工作負載和需求的不同,讀者可以選擇適合自己的引數,也可以結合上面提到的多級 Swap,將 ZRAM 進一步分層,使用最高效的記憶體作為高優先順序 Swap,壓縮率最高的記憶體作為中低優先順序 Swap。

如果測試機和生產環境的架構/硬體存在差異,可以將測試過程中匯出的記憶體拷貝到生產環境,前提是兩者執行相同的工作負載,否則測試記憶體無參考價值。

2. 配置 ZRAM 調優引數

上一張我們選出了綜合最佳的 ZRAM 演算法和引數,接下來就將其應用到我們的生產環境中。

2.1. 配置壓縮演算法,獲得最佳壓縮率

首先將壓縮演算法從預設的lzo-rle切換為lz4hc,根據 ZRAM 的文件,只需要將壓縮演算法寫入/sys/block/zram0/comp_algorithm即可,考慮到我們配置/sys/block/zram0/disksize時的操作,我們重新編輯/etc/udev/rules.d/99-zram.rules檔案,將其內容修改為:

- KERNEL=="zram0",ATTR{disksize}="30G",TAG+="systemd"
+ KERNEL=="zram0",ATTR{comp_algorithm}="lz4hc",ATTR{disksize}="30G",TAG+="systemd"

需要注意的是,必須先指定壓縮演算法,再指定磁碟大小,無論是在配置檔案中還是直接echo引數到/sys/block/zram0裝置上,都需要按照文件的順序進行操作。

重啟機器,再次執行cat /sys/block/zram0/comp_algorithm,就會發現當前壓縮演算法變成了lz4hc

2022/03/2022-03-20-20-33-12.png

2.2. 配置 page-cluster,避免記憶體頻寬和 CPU 資源的浪費

配置comp_algorithm後,接下來我們來配置page-cluster

上面賣了不少關子,簡單來說,page-cluster的作用就是每次從交換裝置讀取資料時多讀 2^n 頁,一些塊裝置或檔案系統有簇的概念,讀取資料也是按簇讀取,假設記憶體頁大小為 4KiB,而每次讀取的一簇資料為 32KiB,那麼把多讀出來的資料也換回記憶體,就能避免浪費,減少頻繁讀取磁碟的次數,這一點在 Linux 的文件中也有提到:linux/vm.rst · torvalds/linux。預設的page-cluster大小為 3,即每次會從磁碟讀取4K*2^3=32K的資料。

瞭解了page-cluster的原理後,我們會發現 ZRAM 並不屬於傳統的塊裝置,記憶體控制器預設設計就是按頁讀取,因此這一適用於磁碟裝置的優化,在 ZRAM 場景下卻是負優化,反而會導致過早觸及記憶體頻寬瓶頸和 CPU 解壓縮瓶頸而導致效能下降,這也就可以解釋為什麼上面的表格中隨著page-cluster的提升,吞吐量同樣提升,而 IOPS 卻變得更小,如果記憶體頻寬和 CPU 解壓縮不存在瓶頸,那麼 IOPS 理論上應該保持不變。

考慮到無論是理論上,還是實際測試,page-cluster都是一個多餘的優化,我們可以直接將其設定為 0。直接編輯/etc/sysctl.conf,在結尾新增一行:

vm.page-cluster=0

執行sysctl -p,即可讓該設定生效,無需重啟。

2.3. 讓系統更激進地使用 Swap

上面效能測試中,lz4hc壓縮引數下最小的平均讀取延遲是 3203ns,即 0.003ms。與之對比,在筆者的電腦上,直接讀取記憶體的延遲在 100ns 左右(0.0001ms,部分硬體可以到 50ns),SSD 讀取一次的延遲一般是 0.05ms,而機械硬碟讀取一次的延遲在 1ms~1000ms 之間。

2022/03/2022-03-20-20-52-27.png
不同層級儲存裝置讀寫的絕對時間與相對時間對比,可以看出它們之間指數級的差別。圖片來自 cpu 記憶體訪問速度,磁碟和網路速度,所有人都應該知道的數字 | las1991,最先出自於 Jeff Dean 的 PPT:Dean keynote-ladis2009
也可以參考 rule-of-thumb-latency-numbers-letter.pdf 獲取更詳細的資訊。

0.003ms 對比 1ms,是 300 多倍的差別,而 0.0001ms 對比 0.003ms 只有 30 多倍,這也就意味著 ZRAM 相比較 Swap 而言,更像是 RAM,既然如此,我們自然可以讓 Linux 更激進地使用 Swap,而且讓 ZRAM 儘可能早地獲得更多可用記憶體空間。

這裡我們需要了解兩個知識:swappiness和記憶體碎片。

Swappiness 是一個核心引數,用於決定『核心有多傾向於在記憶體不足時換出到 Swap』,值越大傾向越大,關於 Swappiness,下一章會繼續解釋其作用,現在我們可以先認為這個值在 ZRAM 配置下越大越好,就算是 100(%) 也無所謂。

記憶體碎片則是一種現象,出現在長期執行的作業系統中,表現為記憶體有空餘,但卻因為沒有足夠大的連續空餘導致無法申請到記憶體。無論該系統有無 MMU,都會不同程度在實體記憶體/虛擬記憶體/DMA 中遇到沒有更多的連續空間分配給所需程式這一問題(曾經可用的連續空間都因為記憶體被無規律的釋放後變得不連續,那些遺留且散落在記憶體空間中的資料就是所謂的記憶體碎片)。一般來說 Linux 會在出現記憶體碎片且認為有必要進行碎片整理時對記憶體進行整理。

為避免記憶體碎片,Linux 記憶體的分配和整理都遵循 Buddy System,即對記憶體頁進行 2^n 次方分級,從 1 到 1024 總共 11 級,每一級都可以視為一組頁框。例如一個程式需要 64 頁的記憶體,作業系統會先從 64 頁框的連結串列中尋找有無空閒,如果沒有,再去 128 頁框的連結串列中尋找,如果有,則將 128 頁框的低端 64 頁(左側)分配出去,再將高階 64 頁指向一個新的 64 頁框連結串列。當釋放記憶體時,由於申請的記憶體都是向頁框低端對齊,當記憶體釋放後,剛好也可以釋放出一整個頁框。

2022/03/2022-03-20-21-49-31.png
圖片來自 Buddy system 夥伴分配器實現 - youxin - 部落格園

Buddy System 的設計極為巧妙,實現極為優雅,感興趣的讀者可以閱讀 Buddy system 夥伴分配器實現 - youxin - 部落格園 進一步瞭解其設計,此處不做過多贅述。Linux 中還有大量這樣巧妙的設計,多瞭解有助於提升程式設計品味,筆者也處於學習過程中,希望能和讀者共同進步。

上面提到記憶體碎片一般是在記憶體得到充分且長期利用後會出現的問題,而 ZRAM 則會導致可用記憶體不足時出現換頁,這兩個降低效能的操作基本上是同時發生的,也導致系統壓力升高之後容易瞬間出現無響應現象,且可能因為來不及對記憶體進行碎片處理,導致 ZRAM 無法獲取記憶體,進一步造成系統不穩定。

因此我們需要做的另一個操作就是儘可能讓記憶體有序,儘可能在所需級別頁框不足時立即觸發碎片整理,充分壓榨記憶體。Linux 中配置記憶體碎片整理條件的引數是vm.extfrag_threshold,當頁框碎片指數低於該閾值則會觸發碎片整理。碎片指數的計算方法可以參考 關於 linux 記憶體碎片指數 - _備忘錄 - 部落格園。總的來說,

綜上所述,我們繼續在/etc/sysctl.conf檔案後新增以下兩行:

# 預設是 500(介於 0 和 1000 之間的值)
vm.extfrag_threshold=0
# 預設是 60
vm.swappiness=100

這兩個引數是筆者根據伺服器記憶體使用情況、ZRAM 使用情況和碎片整理時機的監控資訊來決定的,不一定適合所有場景,不設定這些引數也沒有任何影響,如果不確定或不想做實驗,不設定也沒關係,僅供瞭解即可。

需要注意的是,關於swappiness引數,網際網路上存在大量謬論,如swappiness最大值為 100,大多都將這個值當做一個百分數看待,接下來筆者將會解釋為什麼這個論點是錯誤的。

3. 為什麼 ZRAM 沒有被使用到

引言中提到的例子已經是一年之前的事情,當時在更小的圈子內做過一次分享。記得分享結束第二天,有朋友找上我,問為什麼他配置了 ZRAM,且配置swappiness為 100,ZRAM 卻一點沒有被使用到。

現在想想看,其實這是一個非常普遍的問題,因此儘管這一節的標題為『為什麼 ZRAM 沒有被使用到』,接下來筆者想要分享的確是『到底在什麼時候才會出現頁面交換』這一問題。

網際網路上對於如何配置軟體,往往存在大量的 Myth,尤其是與效能優化相關的場景。開源軟體尚且如此,閉源軟體如 Windows 就更不用說,過時和虛假資訊,夾雜著安慰劑效應成為了現今效能優化指南的一大特色,這些 Myth 不一定是作者主觀故意,但背後也反映出了作者和讀者都存在的惰性思維。

在瞭解swappiness之前,我們先了解 Linux 的記憶體回收機制。Linux 和其他現代作業系統一樣,會傾向於提前使用記憶體,當我們執行free -m命令時,能看到以下引數,它們分別代表的是:

2022/03/2022-03-20-22-21-59.png

  • total: 總記憶體量
  • used: 程式所使用的記憶體量(部分系統下也包含 shared+buffer/cache,此時就是被作業系統使用的記憶體量)
  • free: 沒有被作業系統使用的記憶體量
  • shared: 程式間共享記憶體量
  • buff/cache: 緩衝區和快取區
  • available: 程式可以剩餘使用的最大記憶體量

當我們說 Android 總是有多少記憶體吃多少時,我們通常指的是記憶體中 free 數量較少,這是相對於 Windows 而言,因為 Windows 將 free 定義為了 Linux 下 available 的含義,這並不代表 Windows 沒有 buff/cache 的設計:

2022/03/2022-03-20-22-32-21.png
微軟式中文經典操作:混淆 Free 和 Available,有機會筆者會分享一下 Windows 下各項記憶體引數的真實含義(比 Linux 混亂許多,而且很難自圓其說)。

簡單來說,buffer是寫入快取,而cache是讀取快取,這兩者統稱檔案頁,Linux 會定期重新整理寫入快取,但通常不會定期重新整理讀取快取,這些快取會佔據記憶體的可用空間,當程式有新的記憶體需求時,Linux 依舊會從free中分配給程式,直到free的記憶體無法滿足程式需求,此時 Linux 會選擇回收檔案頁,供程式使用。

但如果將所有的檔案頁都給程式使用,就相當於 Linux 的檔案快取機制直接失效,儘管滿足了記憶體使用,對 IO 效能卻造成了影響,因此除了回收檔案頁以外,Linux 還會選擇將頁換出,即採用 Swap 機制,將暫時沒有使用的記憶體資料(通常是匿名頁,即堆記憶體)儲存在磁碟中,稱作換出。

一般來說,Linux 只有在記憶體緊張時才會選擇回收記憶體,那麼該如何衡量緊張呢?到達緊張狀態後,又以什麼標準退出緊張狀態呢?這就要提到 Linux 的一個設計:記憶體水位線(Watermark)。

負責回收記憶體的核心執行緒kswapd0定義了一套衡量記憶體壓力的標準,即記憶體水位線,我們可以通過/proc/zoneinfo檔案獲取水位線引數:

這個檔案和上文提到的/proc/buddyinfo有一個共性,即它們都將記憶體進行了分割槽,如 DMA、DMA32、Normal 等,具體可以閱讀 Linux 記憶體管理機制簡析 - 艦隊 - 部落格園,此處不再贅述,x86_64 架構下,這裡我們只看 Normal 區域的記憶體即可。
$ cat /proc/zoneinfo
...
Node 0, zone   Normal
  pages free     12273184   # 空閒記憶體頁數,此處大概空閒 46GB
        min      16053      # 最小水位線,大概為 62MB
        low      1482421    # 低水位線,大概為 5.6GB
        high     2948789    # 高水位線,大概為 11.2GB
...
      nr_free_pages 12273184         # 同 free pages
      nr_zone_inactive_anon 1005909  # 不活躍的匿名頁
      nr_zone_active_anon 60938      # 活躍的匿名頁
      nr_zone_inactive_file 878902   # 不活躍的檔案頁
      nr_zone_active_file 206589     # 活躍的檔案頁
      nr_zone_unevictable 0          # 不可回收頁
...
需要注意的是,由於 NUMA 架構下,每個 Node 都有自己的記憶體空間,因此如果存在多個 CPU,每個記憶體區域的水位線和統計資訊是獨立的。

首先我們來解釋水位線,這裡存在四種情況:

  1. pages free小於pages min,說明所有記憶體耗盡,此時說明記憶體壓力過大,會開始觸發同步回收,表現為系統卡死,分配記憶體被阻塞,開始嘗試碎片整理、記憶體壓縮,如果都不奏效,則開始執行 OOM Killer,直到pages free大於pages high
  2. pages freepages minpages low之間,說明記憶體壓力較大,kswapd0執行緒開始回收記憶體,直到pages free大於pages high
  3. pages freepages lowpages high之間,說明記憶體壓力一般,一般不會執行操作。
  4. pages free大於pages high,說明記憶體基本上沒有壓力,無需回收記憶體。

接下來我們介紹swappiness這個引數的作用。從上面的過程可以得知,swappiness引數決定了kswapd0執行緒回收記憶體的策略。由於存在兩類可被回收的記憶體頁:匿名頁和檔案頁,swappiness決定的則是匿名頁相比較檔案頁被換出的比率,因為檔案頁的換出是直接將其回寫到磁碟或銷燬,這一引數也可以被解釋為『Linux 在記憶體不足時回收匿名頁的激程式度』。

同世間的其他事物一樣,不存在絕對的好或壞,也很難給這些事物採用百分制打分,但我們可以通過利害關係來評估做一件事情的價效比,以最小的代價和最高的收益來實現目標,Linux 同樣如此。根據mm/vmscan部分的 原始碼,我們可以發現 Linux 將swappiness帶入如下演算法:

anon_cost = total_cost + sc->anon_cost;
file_cost = total_cost + sc->file_cost;
total_cost = anon_cost + file_cost;

ap = swappiness * (total_cost + 1);
ap /= anon_cost + 1;

fp = (200 - swappiness) * (total_cost + 1);
fp /= file_cost + 1;

其中anon_costfile_cost分別指對匿名頁和檔案頁進行 LRU 掃描的難度。

假設程式需要記憶體,掃描兩種記憶體頁難度相同,且剩餘記憶體小於低水位線,即當swappiness預設為 60 時,Linux 會選擇回收檔案頁更多,當swappiness等於 100 時,Linux 會平等回收檔案頁和匿名頁,而當swappiness大於 100,Linux 會選擇回收匿名頁更多。

根據 Linux 的 文件,如果使用 ZRAM 等比傳統磁碟更快的 IO 裝置,可以將swappiness設定到超過 100 的值,其計算方法如下:

For example, if the random IO against the swap device is on average 2x faster than IO from the filesystem, swappiness should be 133 (x + 2x = 200, 2x = 133.33).

經過這樣的解釋,相信讀者應該既理解了swappiness的含義,也理解了為什麼系統負載不高時,不管怎麼設定swappiness,新申請的記憶體空間依舊不會被寫入 ZRAM 中。

只有當記憶體不足,且swappiness較高時,配置 ZRAM 才會有比較可觀的收益,但swappiness也不宜過高,否則檔案頁將會駐留在記憶體中,造成匿名頁大量堆積在較慢的 ZRAM 裝置中,反而降低效能。而且由於swappiness無法區分不同級別的 Swap 裝置,如果使用了 ZRAM 和 Swapfile 分層,也需要將這一引數設定得更保守一些,通常來說 100 就是最合適的值。

4. 沒有銀彈

經過上文的詳細解釋,希望讀者已經對 ZRAM 和 Linux 的記憶體模型有了較為詳細和系統的理解。那麼 ZRAM 是萬能的嗎?答案同樣是否定的。

如果 ZRAM 是萬能的,那麼所有的發行版都應該預設啟用 ZRAM,但情況並非如此。先不提 ZRAM 的配置取決於系統硬體與架構,不存在一招鮮吃遍天的引數,關鍵問題在 ZRAM 的效能。我們回到前面做效能測試輸出的那張表格中,拿最好的 ZRAM 引數和直接訪問記憶體盤的資料做對比,我們會得到以下結果:

algo   | page-cluster| "MiB/s"           | "IOPS"        | "Mean Latency (ns)"| "99% Latency (ns)"
-------|-------------|-------------------|---------------|--------------------|-------------------
lz4hc  | 0           | 7583.852120536133 | 1941466.478992| 3189.297915        | 9408
raw    | 0           | 22850.29382917787 | 6528361.294582| 94.28362           | 190

可以看到,在效能方面,就算是最佳 ZRAM 配置,相比直接訪問記憶體,依舊存在三倍以上的效能差距,更別提 30 倍的訪存延遲(Apple Silcon 晶片之所以能夠靠較低的功耗在各項效能上持平甚至領先 Intel,訪存以及記憶體/視訊記憶體複製的低延遲+誇張的記憶體頻寬功不可沒)。

成功的效能優化,絕不是一勞永逸的配置,如果真的那麼簡單,為什麼軟體不出廠就優化好呢?筆者認為,效能優化需要對架構和原理的充分了解,對需求和目標的提前預估,以及不斷嘗試、試驗和對比。

這三者缺一不少,也並非互相孤立,往往做出最優選擇需要在其中進行不斷的取捨,正如上文在對比各種壓縮演算法和引數時,筆者列出來的那幾項『吞吐量最大』、『延遲最低』、『IOPS 最高』、『壓縮率最高』。後來筆者分別嘗試過這幾項,在對 ElasticSearch 進行基準測試時,效能反而不如預設的壓縮演算法,那這些所謂的『最』,到底有什麼意義呢?


本文系筆者利用疫情隔離的週末閒暇,陸陸續續花費了 20 多小時寫作完成,過程中為了避免出現錯漏,查詢了大量的資料,也進行了不少的實驗,只希望帶給讀者綜合的感官享受,讓知識分享能更有趣、更深入,能啟發讀者進行擴充套件思考,結合自己的認知,讓這篇文章能發揮知識分享以外的價值就再好不過。其中部分內容由於筆者個人能力限制、時間限制、審校不當等原因,可能存在疏漏甚至謬誤,如您有更好的見解,歡迎指正,筆者一定虛心聽取,有則改之,無則加勉。

相關文章