面試官問:爲什麼 Redis 選擇單線程模型?

目錄

概述

設計

單線程模型

可維護性

併發處理

性能瓶頸

刪除操作

總結


以下文章來源於公衆號:真沒什麼邏輯 ,作者Draveness

Redis 作爲廣爲人知的內存數據庫,在玩具項目和複雜的工業級別項目中都看到它的身影,然而 Redis 卻是使用單線程模型進行設計的,這與很多人固有的觀念有所衝突,爲什麼單線程的程序能夠抗住每秒幾百萬的請求量呢?這也是我們今天要討論的問題之一。

除此之外,Redis 4.0 之後的版本卻拋棄了單線程模型這一設計,原本使用單線程運行的 Redis 也開始選擇性使用多線程模型,這一看似有些矛盾的設計決策是今天需要討論的另一個問題。

However with Redis 4.0 we started to make Redis more threaded. For now this is limited to deleting objects in the background, and to blocking commands implemented via Redis modules. For the next releases, the plan is to make Redis more and more threaded.

概述

就像在介紹中說的,這一篇文章想要討論的兩個與 Redis 有關的問題就是:

爲什麼 Redis 在最初的版本中選擇單線程模型?爲什麼 Redis 在 4.0 之後的版本中加入了多線程的支持?這兩個看起來有些矛盾的問題實際上並不衝突,我們會分別闡述對這個看起來完全相反的設計決策作出分析和解釋,不過在具體分析它們的設計之前,我們先來看一下不同版本 Redis 頂層的設計:

Redis 作爲一個內存服務器,它需要處理很多來自外部的網絡請求,它使用 I/O 多路複用機制同時監聽多個文件描述符的可讀和可寫狀態,一旦受到網絡請求就會在內存中快速處理,由於絕大多數的操作都是純內存的,所以處理的速度會非常地快。

在 Redis 4.0 之後的版本,情況就有了一些變動,新版的 Redis 服務在執行一些命令時就會使用『主處理線程』之外的其他線程,例如 UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC 等非阻塞的刪除操作。

設計

無論是使用單線程模型還是多線程模型,這兩個設計上的決定都是爲了更好地提升 Redis 的開發效率、運行性能,想要理解兩個看起來矛盾的設計決策,我們首先需要重新梳理做出決定的上下文和大前提,從下面的角度來看,使用單線程模型和多線程模型其實也並不矛盾。

雖然 Redis 在較新的版本中引入了多線程,不過是在部分命令上引入的,其中包括非阻塞的刪除操作,在整體的架構設計上,主處理程序還是單線程模型的;由此看來,我們今天想要分析的兩個問題可以簡化成:

爲什麼 Redis 服務使用單線程模型處理絕大多數的網絡請求?爲什麼 Redis 服務增加了多個非阻塞的刪除操作,例如:UNLINK、FLUSHALL ASYNC 和 FLUSHDB ASYNC?接下來的兩個小節將從多個角度分析這兩個問題。

單線程模型

Redis 從一開始就選擇使用單線程模型處理來自客戶端的絕大多數網絡請求,這種考慮其實是多方面的,作者分析了相關的資料,發現其中最重要的幾個原因如下:

  • 使用單線程模型能帶來更好的可維護性,方便開發和調試;
  • 使用單線程模型也能併發的處理客戶端的請求;
  • Redis 服務中運行的絕大多數操作的性能瓶頸都不是 CPU;

上述三個原因中的最後一個是最終使用單線程模型的決定性因素,其他的兩個原因都是使用單線程模型額外帶來的好處,在這裏我們會按順序介紹上述的幾個原因。

可維護性

可維護性對於一個項目來說非常重要,如果代碼難以調試和測試,問題也經常難以復現,這對於任何一個項目來說都會嚴重地影響項目的可維護性。多線程模型雖然在某些方面表現優異,但是它卻引入了程序執行順序的不確定性,代碼的執行過程不再是串行的,多個線程同時訪問的變量如果沒有謹慎處理就會帶來詭異的問題。

在網絡上有一個調侃多線程模型的段子,就很好地展示了多線程模型帶來的潛在問題:競爭條件 (race condition) —— 如果計算機中的兩個進程(線程同理)同時嘗試修改一個共享內存的內容,在沒有併發控制的情況下,最終的結果依賴於兩個進程的執行順序和時機,如果發生了併發訪問衝突,最後的結果就會是不正確的。

Some people, when confronted with a problem, think, “I know, I’ll use threads,” and then two they hav erpoblesms.

引入了多線程,我們就必須要同時引入併發控制來保證在多個線程同時訪問數據時程序行爲的正確性,這就需要工程師額外維護併發控制的相關代碼,例如,我們會需要在可能被併發讀寫的變量上增加互斥鎖:

var (
    mu Mutex // cost
    data int
)
 
// thread 1
func() {
    mu.Lock()
    data += 1
    mu.Unlock()
}
 
// thread 2
func() {
    mu.Lock()
    data -= 1
    mu.Unlock()
}

在訪問這些變量或者內存之前也需要先對獲取互斥鎖,一旦忘記獲取鎖或者忘記釋放鎖就可能會導致各種詭異的問題,管理相關的併發控制機制也需要付出額外的研發成本和負擔。

併發處理

使用單線程模型也並不意味着程序不能併發的處理任務,Redis 雖然使用單線程模型處理用戶的請求,但是它卻使用 I/O 多路複用機制併發處理來自客戶端的多個連接,同時等待多個連接發送的請求。

在 I/O 多路複用模型中,最重要的函數調用就是 select 以及類似函數,該方法的能夠同時監控多個文件描述符(也就是客戶端的連接)的可讀可寫情況,當其中的某些文件描述符可讀或者可寫時,select 方法就會返回可讀以及可寫的文件描述符個數。

使用 I/O 多路複用技術能夠極大地減少系統的開銷,系統不再需要額外創建和維護進程和線程來監聽來自客戶端的大量連接,減少了服務器的開發成本和維護成本。

性能瓶頸

最後要介紹的其實就是 Redis 選擇單線程模型的決定性原因 —— 多線程技術的能夠幫助我們充分利用 CPU 的計算資源來併發的執行不同的任務,但是 CPU 資源往往都不是 Redis 服務器的性能瓶頸。哪怕我們在一個普通的 Linux 服務器上啓動 Redis 服務,它也能在 1s 的時間內處理 1,000,000 個用戶請求。

It’s not very frequent that CPU becomes your bottleneck with Redis, as usually Redis is either memory or network bound. For instance, using pipelining Redis running on an average Linux system can deliver even 1 million requests per second, so if your application mainly uses O(N) or O(log(N)) commands, it is hardly going to use too much CPU.

如果這種吞吐量不能滿足我們的需求,更推薦的做法是使用分片的方式將不同的請求交給不同的 Redis 服務器來處理,而不是在同一個 Redis 服務中引入大量的多線程操作。

簡單總結一下,Redis 並不是 CPU 密集型的服務,如果不開啓 AOF 備份,所有 Redis 的操作都會在內存中完成不會涉及任何的 I/O 操作,這些數據的讀寫由於只發生在內存中,所以處理速度是非常快的;整個服務的瓶頸在於網絡傳輸帶來的延遲和等待客戶端的數據傳輸,也就是網絡 I/O,所以使用多線程模型處理全部的外部請求可能不是一個好的方案。

AOF 是 Redis 的一種持久化機制,它會在每次收到來自客戶端的寫請求時,將其記錄到日誌中,每次 Redis 服務器啓動時都會重放 AOF 日誌構建原始的數據集,保證數據的持久性。

多線程雖然會幫助我們更充分地利用 CPU 資源,但是操作系統上線程的切換也不是免費的,線程切換其實會帶來額外的開銷,其中包括:

  • 保存線程 1 的執行上下文;
  • 加載線程 2 的執行上下文;

頻繁的對線程的上下文進行切換可能還會導致性能地急劇下降,這可能會導致我們不僅沒有提升請求處理的平均速度,反而進行了負優化,所以這也是爲什麼 Redis 對於使用多線程技術非常謹慎。

引入多線程 Redis 在最新的幾個版本中加入了一些可以被其他線程異步處理的刪除操作,也就是我們在上面提到的 UNLINK、FLUSHALL ASYNC 和 FLUSHDB ASYNC,我們爲什麼會需要這些刪除操作,而它們爲什麼需要通過多線程的方式異步處理?

刪除操作

我們可以在 Redis 在中使用 DEL 命令來刪除一個鍵對應的值,如果待刪除的鍵值對佔用了較小的內存空間,那麼哪怕是同步地刪除這些鍵值對也不會消耗太多的時間。

但是對於 Redis 中的一些超大鍵值對,幾十 MB 或者幾百 MB 的數據並不能在幾毫秒的時間內處理完,Redis 可能會需要在釋放內存空間上消耗較多的時間,這些操作就會阻塞待處理的任務,影響 Redis 服務處理請求的 PCT99 和可用性。

然而釋放內存空間的工作其實可以由後臺線程異步進行處理,這也就是 UNLINK 命令的實現原理,它只會將鍵從元數據中刪除,真正的刪除操作會在後臺異步執行。

總結

Redis 選擇使用單線程模型處理客戶端的請求主要還是因爲 CPU 不是 Redis 服務器的瓶頸,所以使用多線程模型帶來的性能提升並不能抵消它帶來的開發成本和維護成本,系統的性能瓶頸也主要在網絡 I/O 操作上;而 Redis 引入多線程操作也是出於性能上的考慮,對於一些大鍵值對的刪除操作,通過多線程非阻塞地釋放內存空間也能減少對 Redis 主線程阻塞的時間,提高執行的效率

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章