一、 背景
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-test... ,單節點性能完全可以比肩 Redis 低版本中等規模集羣了。
在 Valkey8.0 版本中,除了以上重大性能提升優化以外,還在提升內存利用率、加快主從複製效率、增強 resharding 過程中高可用性、實驗性支持 RDMA,以及提升集羣的觀測性等方面都進行了多項優化。我們後續再詳細介紹。
Valkey8.0 正式版發佈至今時間還不算太長,經過一段時間的驗證後,我們也會考慮將自建 Redis server 版本逐步升級到新版本,為業務提供性能更優的緩存服務。
往期回顧
1.Java SPI機制初探|得物技術
2.得物向量數據庫落地實踐
3.Java volatile 關鍵字到底是什麼|得物技術
4.社區搜索離線回溯系統設計:架構、挑戰與性能優化|得物技術
5.從 “卡頓” 到 “秒開”:外投首屏性能優化的 6 個實戰錦囊|得物技術
文 / 竹徑
關注得物技術,每週更新技術乾貨
要是覺得文章對你有幫助的話,歡迎評論轉發點贊~
未經得物技術許可嚴禁轉載,否則依法追究法律責任。