正式支持多線程!Redis 6.0與老版性能對比評測
導讀:Redis 6.0將在今年年末發佈,其中引入的最重大的改變就是多線程IO。本文做者深刻閱讀並解析了關鍵代碼,而且作了基準測試,揭示多線程 IO 特性對Redis性能的提高,十分值得一讀。
林添毅,美圖技術經理, 主要負責 NoSQL/消息隊列/中間件等基礎服務相關研發。在加入美圖以前,曾就任於新浪微博架構平臺從事基礎服務的研發。
redis
前天晚上不經意間看到 Redis 做者 Salvatore 在 RedisConf 2019 分享,其中一段展現了 Redis 6 引入的多線程 IO 特性對性能提高至少是一倍以上,心裏非常激動,火燒眉毛地去看了一下相關的代碼實現。性能優化
目前對於單線程 Redis 來講,性能瓶頸主要在於網絡的 IO 消耗, 優化主要有兩個方向:
1.提升網絡 IO 性能,典型的實現像使用 DPDK 來替代內核網絡棧的方式
2.使用多線程充分利用多核,典型的實現像 Memcached
微信
協議棧優化的這種方式跟 Redis 關係不大,多線程特性在社區也被反覆提了好久後終於在 Redis 6 加入多線程,Salvatore 在本身的博客 An update about Redis developments in 2019 也有簡單的說明。但跟 Memcached 這種從 IO 處理到數據訪問多線程的實現模式有些差別。Redis 的多線程部分只是用來處理網絡數據的讀寫和協議解析,執行命令仍然是單線程。之因此這麼設計是不想由於多線程而變得複雜,須要去控制 key、lua、事務,LPUSH/LPOP 等等的併發問題。總體的設計大致以下:
網絡
代碼實現
多線程 IO 的讀(請求)和寫(響應)在實現流程是同樣的,只是執行讀仍是寫操做的差別。同時這些 IO 線程在同一時刻所有是讀或者寫,不會部分讀或部分寫的狀況,因此下面以讀流程做爲例子。分析過程當中的代碼只是爲了輔助理解,因此只會覆蓋核心邏輯而不是所有細節。若是想徹底理解細節,建議看完以後再次看一次源碼實現。多線程
加入多線程 IO 以後,總體的讀流程以下:架構
- 主線程負責接收建連請求,讀事件到來(收到請求)則放到一個全局等待讀處理隊列
- 主線程處理完讀事件以後,經過 RR(Round Robin) 將這些鏈接分配給這些 IO 線程,而後主線程忙等待(spinlock 的效果)狀態
- IO 線程將請求數據讀取並解析完成(這裏只是讀數據和解析並不執行)
- 主線程執行全部命令並清空整個請求等待讀處理隊列(執行部分串行)
上面的這個過程是徹底無鎖的,由於在 IO 線程處理的時主線程會等待所有的 IO 線程完成,因此不會出現 data race 的場景。併發
注意:若是對於代碼實現沒有興趣的能夠直接跳過下面內容,對了解 Redis 性能提高並無傷害。ide
下面的代碼分析和上面流程是對應的,當主線程收到請求的時候會回調 network.c 裏面的 readQueryFromClient 函數:函數
- void readQueryFromClient(aeEventLoop el, int fd, void privdata, int mask) {
- /* Check if we want to read from the client later when exiting from
-
- the event loop. This is the case if threaded I/O is enabled. */
- if (postponeClientRead(c)) return;
- ...
- }
readQueryFromClient 以前的實現是負責讀取和解析請求並執行命令,加入多線程 IO 以後加入了上面的這行代碼,postponeClientRead 實現以下:微服務
int postponeClientRead(client *c) { if (io_threads_active && // 多線程 IO 是否在開啓狀態,在待處理請求較少時會中止 IO 多線程 server.io_threads_do_reads && // 讀是否開啓多線程 IO !(c->flags & (CLIENT_MASTER|CLIENT_SLAVE|CLIENT_PENDING_READ))) // 主從庫複製請求不使用多線程 IO { // 鏈接標識爲 CLIENT_PENDING_READ 來控制不會反覆被加隊列, // 這個標識做用在後面會再次提到 c->flags |= CLIENT_PENDING_READ; // 鏈接加入到等待讀處理隊列 listAddNodeHead(server.clients_pending_read,c); return 1; } else { return 0; } }
postponeClientRead 判斷若是開啓多線程 IO 且不是主從複製鏈接的話就放到隊列而後返回 1,在 readQueryFromClient 函數會直接返回不進行命令解析和執行。接着主線程在處理完讀事件(注意是讀事件不是讀數據)以後將這些鏈接經過 RR 的方式分配給這些 IO 線程:
int handleClientsWithPendingReadsUsingThreads(void) { ... // 將等待處理隊列的鏈接按照 RR 的方式分配給多個 IO 線程 listRewind(server.clients_pending_read,&li); int item_id = 0; while((ln = listNext(&li))) { client *c = listNodeValue(ln); int target_id = item_id % server.io_threads_num; listAddNodeTail(io_threads_list[target_id],c); item_id++; } ... // 一直忙等待直到全部的鏈接請求都被 IO 線程處理完 while(1) { unsigned long pending = 0; for (int j = 0; j < server.io_threads_num; j++) pending += io_threads_pending[j]; if (pending == 0) break; }
代碼裏面的 io_threads_list 用來存儲每一個 IO 線程對應須要處理的鏈接,而後主線程將這些鏈接經過 RR 的方式分配給這些 IO 線程後進入忙等待狀態(至關於主線程 blocking 住)。IO 處理線程入口是 IOThreadMain 函數:
void *IOThreadMain(void *myid) { while(1) { // 遍歷線程 id 獲取線程對應的待處理鏈接列表 listRewind(io_threads_list[id],&li); while((ln = listNext(&li))) { client *c = listNodeValue(ln); // 經過 io_threads_op 控制線程要處理的是讀仍是寫請求 if (io_threads_op == IO_THREADS_OP_WRITE) { writeToClient(c->fd,c,0); } else if (io_threads_op == IO_THREADS_OP_READ) { readQueryFromClient(NULL,c->fd,c,0); } else { serverPanic("io_threads_op value is unknown"); } } listEmpty(io_threads_list[id]); io_threads_pending[id] = 0; } }
IO 線程處理根據全局 io_threads_op 狀態來控制當前 IO 線程應該處理讀仍是寫事件,這也是上面提到的所有 IO 線程同一時刻只會執行讀或者寫。另外,心細的同窗可能注意處處理線程會調用 readQueryFromClient 函數,而鏈接就是由這個回調函數加到隊列的,那不就死循環了?這個的答案在 postponeClientRead 函數,已經加到等待處理隊列的鏈接會被設置 CLIENT_PENDING_READ 標識。postponeClientRead 函數不會把鏈接再次加到隊列,那麼 readQueryFromClient 會繼續執行讀取和解析請求。readQueryFromClient 函數讀取請求數據並調用 processInputBuffer 函數進行解析命令,processInputBuffer 會判斷當前鏈接是否來自 IO 線程,若是是的話就只解析不執行命令,代碼就不貼了。
你們去看 IOThreadMain 實現會發現這些 io 線程是沒有任何 sleep 機制,在空閒狀態也會致使每一個線程的 CPU 跑到 100%,但簡單 sleep 則會致使讀寫處理不及時而致使性能更差。Redis 當前的解決方式是經過在等待處理鏈接比較少的時候關閉這些 IO 線程。爲何不適用條件變量來控制呢?我也沒想明白,後面能夠到社區提問。
性能對比
壓測配置:
Redis Server: 阿里雲 Ubuntu 18.04,8 CPU 2.5 GHZ, 8G 內存,主機型號 ecs.ic5.2xlarge
Redis Benchmark Client: 阿里雲 Ubuntu 18.04,8 2.5 GHZ CPU, 8G 內存,主機型號 ecs.ic5.2xlarge
多線程 IO 版本剛合併到 unstable 分支一段時間,因此只能使用 unstable 分支來測試多線程 IO,單線程版本是 Redis 5.0.5。多線程 IO 版本須要新增如下配置:
io-threads 4 # 開啓 4 個 IO 線程 io-threads-do-reads yes # 請求解析也是用 IO 線程
壓測命令:
redis-benchmark -h 192.168.0.49 -a foobared -t set,get -n 1000000 -r 100000000 --threads 4 -d ${datasize} -c 256
從上面能夠看到 GET/SET 命令在 4 線程 IO 時性能相比單線程是幾乎是翻倍了。另外,這些數據只是爲了簡單驗證多線程 IO 是否真正帶來性能優化,並無針對嚴謹的延時控制和不一樣併發的場景進行壓測。數據僅供驗證參考而不能做爲線上指標,且只是目前的 unstble分支的性能,不排除後續發佈的正式版本的性能會更好。
注意: Redis Benchmark 除了 unstable 分支以外都是單線程,對於多線程 IO 版原本說,壓測發包性能會成爲瓶頸,務必本身編譯 unstable 分支的 redis-benchmark 來壓測,並配置 --threads 開啓多線程壓測。另外,若是發現編譯失敗也莫慌,這是由於 Redis 用了 Atomic_ 特性,更新版本的編譯工具才支持,好比 GCC 5.0 以上版本。
總結
Redis 6.0 預計會在 2019 年末發佈,將在性能、協議以及權限控制都會有很大的改進。Salvatore 今年全身心投入在優化 Redis 和集羣的功能,特別值得期待。另外,今年年末社區也會同時發佈第一個版本 redis cluster proxy 來解決多語言 SDK 兼容的問題,期待在具有 proxy 功能以後 cluster 能在國內有更加普遍的應用。
參考閱讀:
- 全面!一文理解微服務高可用的經常使用手段
- 一百人研發團隊的難題:研發管理、績效考覈、組織文化和OKR
- 也許是最簡潔版本,一篇文章上手Go語言
- 5G創新應用實踐-賦能萬物互聯的引擎
- KISS原則在訂單裝運模型中的應用
- 知乎已讀服務的前生今世與將來
技術原創及架構實踐文章,歡迎經過公衆號菜單「聯繫咱們」進行投稿。轉載請註明來自高可用架構「ArchNotes」微信公衆號及包含如下二維碼。