正式支持多線程!Redis 6.0與老版性能對比評測

正式支持多線程!Redis 6.0與老版性能對比評測

導讀:Redis 6.0將在今年年末發佈,其中引入的最重大的改變就是多線程IO。本文做者深刻閱讀並解析了關鍵代碼,而且作了基準測試,揭示多線程 IO 特性對Redis性能的提高,十分值得一讀。
正式支持多線程!Redis 6.0與老版性能對比評測
林添毅,美圖技術經理, 主要負責 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 等等的併發問題。總體的設計大致以下:
正式支持多線程!Redis 6.0與老版性能對比評測
網絡

代碼實現


多線程 IO 的讀(請求)和寫(響應)在實現流程是同樣的,只是執行讀仍是寫操做的差別。同時這些 IO 線程在同一時刻所有是讀或者寫,不會部分讀或部分寫的狀況,因此下面以讀流程做爲例子。分析過程當中的代碼只是爲了輔助理解,因此只會覆蓋核心邏輯而不是所有細節。若是想徹底理解細節,建議看完以後再次看一次源碼實現。多線程

加入多線程 IO 以後,總體的讀流程以下:架構

  1. 主線程負責接收建連請求,讀事件到來(收到請求)則放到一個全局等待讀處理隊列
  2. 主線程處理完讀事件以後,經過 RR(Round Robin) 將這些鏈接分配給這些 IO 線程,而後主線程忙等待(spinlock 的效果)狀態
  3. IO 線程將請求數據讀取並解析完成(這裏只是讀數據和解析並不執行)
  4. 主線程執行全部命令並清空整個請求等待讀處理隊列(執行部分串行)

上面的這個過程是徹底無鎖的,由於在 IO 線程處理的時主線程會等待所有的 IO 線程完成,因此不會出現 data race 的場景。併發

注意:若是對於代碼實現沒有興趣的能夠直接跳過下面內容,對了解 Redis 性能提高並無傷害。ide

下面的代碼分析和上面流程是對應的,當主線程收到請求的時候會回調 network.c 裏面的 readQueryFromClient 函數:函數

  1. void readQueryFromClient(aeEventLoop el, int fd, void privdata, int mask) {
  2. /* 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. */
  3. if (postponeClientRead(c)) return;
  4. ...
  5. }

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

正式支持多線程!Redis 6.0與老版性能對比評測
正式支持多線程!Redis 6.0與老版性能對比評測
從上面能夠看到 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」微信公衆號及包含如下二維碼。

高可用架構

改變互聯網的構建方式

正式支持多線程!Redis 6.0與老版性能對比評測

相關文章
相關標籤/搜索