Valkey 單點性能比肩 Redis 集群了?Valkey8.0 新特性分析
一、背景
二、異步 IO 線程
1. Redis 6.0 多線程 IO
2. Valkey 8.0 異步 IO 線程
3. 卸載更多任務到 IO 線程
三、數據預取(Prefetch)與內存訪問分攤(MAA)
1. 數據預取(Prefetch)
2. 內存訪問分攤(MAA)
3. Valkey8.0 預取數據應用
四、總結
一、背景
Valkey 社區于 2024 年 09 月發布了 Valkey8.0 正式版,在之前的文章《Redis 是單線程模型?》中,我們提到,Redis 社區在 Redis6.0 中引入了多線程 IO 特性,將 Redis 單節點訪問請求從 10W/s 提升到 20W/s,而在 Valkey8.0 版本中,通過引入異步 IO 線程、內存預取(Prefetch)、內存訪問分攤(MAA)等新特性,并且除了將讀寫網絡數據卸載到 IO 線程執行外,還會將 event 事件循環、對象內存釋放等耗時動作也卸載到 IO 線程執行,使得 Valkey 單節點訪問請求可以提升到 100W/s,大幅提升 Valkey 單節點性能。
Valkey 8.0中引入的異步 IO 與 Redis 6.0 中的多線程 IO 有什么區別?Valkey8.0 中如何應用內存預取和內存訪問分攤技術進一步來提升性能的?本篇文章讓我們來一起看看。
- 2024 年,Redis 商業支持公司 Redis Labs 宣布 Redis 核心代碼的許可證從 BSD 變更為 RSALv2 ,明確禁止云廠商提供 Redis 托管服務,這一決定直接導致社區分裂。
- 為維護開源自由,Linux 基金會聯合多家科技公司(包括 AWS、Google Cloud、Oracle 等)宣布支持 Valkey ,Valkey 基于 Redis 7.2.4 開發,作為 Redis 的替代分支。
- Valkey8.0 為 Valkey 社區發布的首個主要大版本。
- 最新消息,在 Redis 項目創始人 antirez 今年加入 Redis 商業公司 5 個月后,Redis 宣傳從 Redis8 開始,Redis 項目重新開源。
二、異步 IO 線程背景
Redis6.0多線程IO
在 Redis 6.0 中引入了多線程 IO 特性,用來處理網絡數據的讀寫和協議解析,讀寫數據執行流程如下所示:
圖片
在 Redis6.0 中,讀數據流程是主線程先將所有可讀客戶端加入一個隊列,全部處理完后,再通過 RR 算法將這些可讀客戶端分配給 IO 線程,由 IO 線程執行讀數據;寫數據流程類似處理。
盡管引入多線程 IO 大幅提升了 Redis 性能,但是 Redis6.0 的多線程 IO 仍然存在一些不足:
- 主線程在處理客戶端命令時,IO 線程會均處于空閑狀態;由于主線程會阻塞等待所有 IO 線程完成讀寫數據,主線程在執行 IO 相關任務期間的性能受到最慢 IO 線程速度的限制
- 由于主線程同步等待 IO 線程,IO 線程僅執行讀取解析和寫入操作,主線程仍然承擔大部分 IO 任務
Valkey 8.0 異步 IO 線程
Valkey8.0 通過使用任務隊列使主線程向 IO 線程發送任務,IO 線程異步并行執行任務提升整體性能。Valkey 8.0 異步 IO 線程工作流程整體設計圖如下所示:
圖片
IO 線程初始化
在 Valkey 啟動時進行初始化的時候,根據配置的線程數量server.io_threads_num 決定是否創建異步 IO 線程,如果server.io_threads_num == 1表示不開啟,另外,IO 線程數量最大不超過 15 個;如果配置開啟異步 IO 線程,則初始化的時候按需創建異步 IO 線程。
線程間通信
Valkey 初始化創建 IO 線程的時候,會給每個 IO 線程創建一個靜態、無鎖、固定大小(大小為 2048)的環形緩沖區作為任務隊列,用于主線程發送任務,以及 IO 線程接收任務。
環形緩沖區是從主線程到 IO 線程的單向通道。當發生讀/寫事件時,主線程會發送一個讀/寫任務,然后在進入 event 事件監測休眠之前,它會遍歷所有待處理的讀/寫客戶端,檢查每個客戶端的 IO 線程是否已經處理完畢。IO 線程通過切換客戶端結構體上的原子標志 read_state / write_state 來表示它已經處理完一個客戶端的讀/寫操作。
讀數據流程
讀數據流程如下圖所示:
圖片
主線程監測到有讀事件時,檢查是否開啟 IO 線程,如果開啟了 IO 線程,會根據算法選擇一個 IO 線程,檢查選中的 IO 線程任務隊列是否已滿,如果任務隊列未滿,則將該待讀事件客戶端加入IO 線程的任務隊列。
如果未開啟 IO 線程,或者選中的 IO 線程任務隊列已滿,則由主線程完成讀數據操作并執行命令。
IO 線程循環從任務隊列獲取任務,如果是讀數據任務,則執行讀數據流程。先讀取數據,然后解析命令,并從命令列表中查找命令并保存在指定字段(這里也是把本來由主線程在執行命令時執行的動作卸載到 IO 線程完成)。
主線程在進入 event 事件監聽睡眠前,循環遍歷所有在等待 IO 線程讀數據的客戶端,檢查數據是否讀取完成,如果是則加入批量預取數據數組,當全部客戶端都檢查完成或者批量預取數據數組存滿,則批量執行命令。
在 Redis6.0 中,需要先將所有可讀客戶端存入一個隊列,再遍歷可讀客戶端列表通過 RR 算法將可讀事件分配到不同的 IO 線程中,然后主線程設置 IO 線程開啟讀數據,在主線程執行這些操作期間,IO 線程均處于空閑狀態。
在 Valkey 8.0 中,每監測到一個可讀事件,立即通過任務隊列發送到一個 IO 線程,IO 線程立即可以開始讀數據操作,主線程遍歷后續可讀事件期間,IO 線程異步在執行讀取操作。
寫數據流程
主線程執行完每個命令時,將客戶端加入等待等寫隊列clients_pending_write,將響應客戶端的數據寫入到響應緩存 buf 或者 reply 鏈表。
主線程處理完所有命令后,循環遍歷等待寫隊列clients_pending_write,將通過算法選擇一個 IO 線程,如果選中的 IO 線程任務隊列未滿,將該客戶端寫數據任務加入 IO 線程的任務隊列。
IO 線程循環從任務隊列獲取任務,如果是寫數據任務,則執行寫數據流,將數據寫回給用戶。
動態調整 IO 線程數量
每次在有可讀事件或者可寫事件需要執行前,Valkey 會根據可讀/寫事件數量,動態調整活躍 IO 線程數量,最大活躍 IO 線程數量不超過設置的允許 IO 線程數量(固定為 15)。
根據可讀/寫事件數量、每個 IO 線程可執行事件數量(可配置)、以及最大允許活躍 IO 線程數量,計算需要的目標活躍 IO 線程數量,當前活躍 IO 線程數量小于目標數量時,可增加活躍 IO 線程,當前活躍 IO 線程數量大于目標數量時,可減少活躍 IO 線程。
動態增加或者減少活躍 IO 線程數量,減少活躍 IO 線程并不會直接關閉創建出來的 IO 線程,而是通過加鎖使當前沒有任務可執行的 IO 線程暫停輪詢查找任務,避免 IO 線程不必要的空輪詢;同樣增加活躍 IO 線程只需要主線程釋放鎖即可,IO 線程獲取到鎖后,開始輪詢獲取是否有可執行任務需要執行。
- 盡管 I/O 線程數量可動態調整,具有動態特性,但主線程仍保持線程親和性,確保在可能的情況下由同一個 I/O 線程處理同一客戶端的 I/O 請求,從而提高內存訪問的局部性。
卸載更多任務到 IO 線程
在 Valkey 8.0 中,除了讀取解析數據/寫入操作之外,還將很多額外的工作卸載到 I/O 線程,以便更好地利用 I/O 線程并減少主線程的負載。
事件輪詢卸載到 IO 線程
在 Valkey 中使用了 IO 多路復用模型實現在主線程中來高效處理所有來自客戶端的連接讀寫訪問,而套接字輪詢系統調用(例如epoll_wait)是開銷很大的過程,僅由主線程來執行會消耗大量主線程時間。
在 Valkey8.0 中,當主線程有待處理的 I/O 操作或要執行的命令時,主線程都會將套接字輪詢系統調用調度到 IO 線程執行,否則由主線程自身來執行。
為避免競爭條件,在任何給定時間,最多只有一個線程(io_thread 或主線程)執行epoll_wait,當主線程將事件輪詢系統調用分配給一個 IO 線程執行后,主線程執行完命令處理后,不再執行事件輪詢系統調用,而是直接檢查 IO 線程的輪詢等待結果,查看是否有可讀寫事件。
對象釋放卸載到 IO 線程
在 Valkey 讀取客戶端數據后,命令解析過程中會分配大量命令參數對象,在命令處理完成后,需要釋放為這些命令參數分配的內存空間,在 Valkey8.0 中,將這些命令參數內存空間釋放分配給 IO 線程執行,并且會分配給執行該參數解析(內存分配)的同一個 IO 線程來執行(通過客戶端 ID 進行標識)。
命令查找卸載
如前面在讀數據流程中提到的,當 IO 線程解析來自客戶端的 Querybuf 的命令時,它可以在命令字典中執行命令查找,并且 IO 線程會將查找到的命令存儲在客戶端的指定字段中,后續主線程執行命令時直接使用即可,可以節省主線程執行命令的時間。
三、數據預取(Prefetch)與內存訪問分攤(MAA)
在 Valkey8.0 中引入異步 IO 線程提高并行度,并且將更多的工作轉移到 IO 線程,將主線程執行的 I/O 操作量降至最低,此時,經過測試,單個 Valkey 節點每秒處理請求可達 80W。
通過分析開啟 IO 線程后 Valkey 性能,主線程大部分時間都花銷在訪問內存查找 key,這是因為 Valkey 字典是一個簡單但低效的鏈式哈希實現,在遍歷哈希鏈表時,每次訪問 dictEntry 結構體、指向鍵的指針或值對象,都很可能需要進行昂貴的外部內存訪問。
于是在 Valkey8.0 中引入了數據預取(Prefetch)和內存訪問分攤(MAA)技術,進一步提升 Valkey 單節點訪問性能。
數據預取(Prefetch)
隨著摩爾定律在過去 30 年間的持續生效,CPU 的運算速度大幅提升,而存儲器(主要是內存)的速度提升相對較慢,這導致了存儲器與 CPU 之間的速度差異。當 CPU 執行指令時,如果需要從內存中讀取數據或指令,由于存儲器速度的限制,CPU 可能需要等待訪問存儲器操作完成,從而導致性能瓶頸。
圖片
為了解決訪問存儲器瓶頸這一問題,現代計算機系統采用了多級緩存及內存層次結構,包括 L1、L2、L3 緩存以及主存等。盡管高速緩存(Cache)能夠提供更快的訪問速度,但其容量有限,當 CPU 訪問的數據無法在高速緩存中找到時,就需要從更慢的內存層級中獲取數據,這會導致較高的訪問延遲,并降低整體性能。
數據預取(Prefetching)技術可以在一定程度上解決訪問存儲器成為 CPU 性能瓶頸的問題。數據預取是一種提前將數據或指令從內存中預先加載到高速緩存中的技術。通過預取,CPU 可以在實際使用之前將數據預先加載到緩存中,從而減少對內存的訪問延遲。這樣可以提高訪問存儲器的效率,減少 CPU 等待訪問存儲器的時間,從而提升整體性能。
__builtin_prefetch() 是 gcc 編輯器提供的一個內置函數,它通過對數據手工預取到 CPU 的緩存中,減少了讀取延遲,從而提高程序的執行效率。
在 Valkey8.0 中,主線程在執行命令之前,通過使用 __builtin_prefetch() 命令,對所有即將操作的命令參數、key 及對應的 value 進行批量預取,提高主線程執行命令的效率。
內存訪問分攤(MAA)
內存訪問攤銷 (MAA) 是一種旨在通過降低內存訪問延遲的影響來優化動態數據結構性能的技術。它適用于需要并發執行多個操作的情況。其背后的原理是,對于某些動態數據結構,批量執行操作比單獨執行每個操作更高效。
這種方法并非按順序執行操作,而是將所有操作交錯執行。具體做法是,每當某個操作需要訪問內存時,程序都會預取必要的內存并切換到另一個操作。這確保了當一個操作因等待內存訪問而被阻塞時,其他內存訪問可以并行執行,從而降低平均訪問延遲。
Valkey8.0 預取數據應用
Valkey 是一個鍵值對數據庫,在 Valkey 中的鍵值對是由字典(也稱為 hash 表)保存的,如下圖所示的鏈式哈希表。
圖片
在 Valkey8.0 之前,在哈希表中查找一個 key 及對應的 value 步驟如下描述:
- 計算 key 的 hash 值,找到對應的 bucket
- 遍歷存儲在 bucket 中通過鏈表連接的 entry,直到找到需要的 key
- 如果找到 key,再訪問 key 映射的 RedisObj(也就是存儲的 value),如果存儲的 value 是OBJ_ENCODING_RAW類型,還需要進一步訪問內存地址獲取真正的數據
每一步操作都需要等待前面的步驟完成內存數據讀取,整個訪問過程是一個串行步驟,這種動態數據結構會阻礙處理器推測未來可以并行執行的內存加載指令的能力,因此訪問內存成為 Valkey 處理數據的性能瓶頸。
在 Valkey8.0 中,對于具有可執行命令的客戶端(即 IO 線程已解析命令的客戶端),主線程將創建一個最多包含 16 條命令的批次,批量處理這些命令。并且執行命令前,先將命令參數預取到主線程的一級緩存中,再將所有命令所需的字典條目 entry 和值 value 都從字典中預取。
同時,預取命令所需的字典條目 entry 和值 value 時遍歷字典的方式與上述查找 key 過程類似,不同的是,每個 key 每次只執行一步,然后不等待從內存中完成讀取數據,而只是預取數據,然后繼續執行下一個 key 的下一次預取動作。這樣當所有 key 都遍歷完成第一步后,開始執行第二步的時候,執行第二步所需的第一步數據已經預取到了 L1 高速緩存。這樣通過交錯執行所有 key,并且結合預取,達到分攤訪問內存的效果。
單個 key 預取流程如下所示:
圖片
每批次多個 key 預取流程則是循環遍歷每個 key 交錯執行上述步驟,先預取其中一個 key 的 bucket,然后不會執行預取該 key 的 entry,因為此時如果接著流程預取該 key 的 entry,需要等待將該 key 的 bucket 內存讀取出來;而是執行下一個 key 的預取動作。也就是達成所有 key 的預取動作一直在并行執行效果,分攤內存訪問時間。
多個 key 批量預取流程如下所示:
圖片
循環遍歷每個 key 交錯執行上述步驟,先執行一個 key 的預取動作,然后交錯執行另一個 key 的預取動作,所有 key 的預取動作并行執行,降低所有 key 訪問內存總時間。
同一批次所有 key 和 value 都完成預取后,主線程開始批量執行命令。相比在 Valkey8.0 之前的版本中,主線程逐個處理每個客戶端命令,批量預取數據加上批量處理,大幅提升單節點 Valkey 服務器性能,社區測試單節點 Valkey 訪問請求可以達到每秒 120W。
四、總結
本文分析了在 Valkey8.0 中通過引入異步 IO 線程、內存預取(Prefetch)、內存訪問分攤(MAA)等新特性,極大的提升了 Valkey 單節點性能,這些技術手段和算法思想也值得我們在實際業務開發中借鑒和使用。
Valkey8.0 中以上性能提升特性由亞馬遜貢獻,亞馬遜也做了一系列壓測對比,在增強 IO 多路復用的加持下,Valkey 單節點 QPS 最大可以超過 100W,壓測數據可以參考《推陳出新 – Valkey 性能測試:探索版本變遷與云托管的效能提升》(https://aws.amazon.com/cn/blogs/china/valkey-performance-testing-exploring-version-changes-and-cloud-hosting-performance-improvements/),單節點性能完全可以比肩 Redis 低版本中等規模集群了。
在 Valkey8.0 版本中,除了以上重大性能提升優化以外,還在提升內存利用率、加快主從復制效率、增強 resharding 過程中高可用性、實驗性支持 RDMA,以及提升集群的觀測性等方面都進行了多項優化。我們后續再詳細介紹。
Valkey8.0 正式版發布至今時間還不算太長,經過一段時間的驗證后,我們也會考慮將自建 Redis server 版本逐步升級到新版本,為業務提供性能更優的緩存服務。
































