面試官:為什么 Redis 要設(shè)計(jì)成單線程?我有點(diǎn)懵~
Redis 作為廣為人知的內(nèi)存數(shù)據(jù)庫,在玩具項(xiàng)目和復(fù)雜的工業(yè)級別項(xiàng)目中都看到它的身影,然而 Redis 卻是使用單線程模型進(jìn)行設(shè)計(jì)的,這與很多人固有的觀念有所沖突,為什么單線程的程序能夠抗住每秒幾百萬的請求量呢?這也是我們今天要討論的問題之一。
除此之外,Redis 4.0 之后的版本卻拋棄了單線程模型這一設(shè)計(jì),原本使用單線程運(yùn)行的 Redis 也開始選擇性使用多線程模型,這一看似有些矛盾的設(shè)計(jì)決策是今天需要討論的另一個(gè)問題。
就像在介紹中說的,這一篇文章想要討論的兩個(gè)與 Redis 有關(guān)的問題就是:
為什么 Redis 在最初的版本中選擇單線程模型?
為什么 Redis 在 4.0 之后的版本中加入了多線程的支持?
這兩個(gè)看起來有些矛盾的問題實(shí)際上并不沖突,我們會(huì)分別闡述對這個(gè)看起來完全相反的設(shè)計(jì)決策作出分析和解釋,不過在具體分析它們的設(shè)計(jì)之前,我們先來看一下不同版本 Redis 頂層的設(shè)計(jì):

Redis 作為一個(gè)內(nèi)存服務(wù)器,它需要處理很多來自外部的網(wǎng)絡(luò)請求,它使用 I/O 多路復(fù)用機(jī)制同時(shí)監(jiān)聽多個(gè)文件描述符的可讀和可寫狀態(tài),一旦受到網(wǎng)絡(luò)請求就會(huì)在內(nèi)存中快速處理,由于絕大多數(shù)的操作都是純內(nèi)存的,所以處理的速度會(huì)非常地快。
在 Redis 4.0 之后的版本,情況就有了一些變動(dòng),新版的 Redis 服務(wù)在執(zhí)行一些命令時(shí)就會(huì)使用『主處理線程』之外的其他線程,例如 UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC 等非阻塞的刪除操作。
無論是使用單線程模型還是多線程模型,這兩個(gè)設(shè)計(jì)上的決定都是為了更好地提升 Redis 的開發(fā)效率、運(yùn)行性能,想要理解兩個(gè)看起來矛盾的設(shè)計(jì)決策,我們首先需要重新梳理做出決定的上下文和大前提,從下面的角度來看,使用單線程模型和多線程模型其實(shí)也并不矛盾。
雖然 Redis 在較新的版本中引入了多線程,不過是在部分命令上引入的,其中包括非阻塞的刪除操作,在整體的架構(gòu)設(shè)計(jì)上,主處理程序還是單線程模型的;由此看來,我們今天想要分析的兩個(gè)問題可以簡化成:
為什么 Redis 服務(wù)使用單線程模型處理絕大多數(shù)的網(wǎng)絡(luò)請求?
為什么 Redis 服務(wù)增加了多個(gè)非阻塞的刪除操作,例如:
UNLINK、FLUSHALL ASYNC和FLUSHDB ASYNC?
接下來的兩個(gè)小節(jié)將從多個(gè)角度分析這兩個(gè)問題。
Redis 從一開始就選擇使用單線程模型處理來自客戶端的絕大多數(shù)網(wǎng)絡(luò)請求,這種考慮其實(shí)是多方面的,作者分析了相關(guān)的資料,發(fā)現(xiàn)其中最重要的幾個(gè)原因如下:
使用單線程模型能帶來更好的可維護(hù)性,方便開發(fā)和調(diào)試;
使用單線程模型也能并發(fā)的處理客戶端的請求;
Redis 服務(wù)中運(yùn)行的絕大多數(shù)操作的性能瓶頸都不是 CPU;
上述三個(gè)原因中的最后一個(gè)是最終使用單線程模型的決定性因素,其他的兩個(gè)原因都是使用單線程模型額外帶來的好處,在這里我們會(huì)按順序介紹上述的幾個(gè)原因。
可維護(hù)性
可維護(hù)性對于一個(gè)項(xiàng)目來說非常重要,如果代碼難以調(diào)試和測試,問題也經(jīng)常難以復(fù)現(xiàn),這對于任何一個(gè)項(xiàng)目來說都會(huì)嚴(yán)重地影響項(xiàng)目的可維護(hù)性。多線程模型雖然在某些方面表現(xiàn)優(yōu)異,但是它卻引入了程序執(zhí)行順序的不確定性,代碼的執(zhí)行過程不再是串行的,多個(gè)線程同時(shí)訪問的變量如果沒有謹(jǐn)慎處理就會(huì)帶來詭異的問題。

在網(wǎng)絡(luò)上有一個(gè)調(diào)侃多線程模型的段子,就很好地展示了多線程模型帶來的潛在問題:競爭條件 (race condition) —— 如果計(jì)算機(jī)中的兩個(gè)進(jìn)程(線程同理)同時(shí)嘗試修改一個(gè)共享內(nèi)存的內(nèi)容,在沒有并發(fā)控制的情況下,最終的結(jié)果依賴于兩個(gè)進(jìn)程的執(zhí)行順序和時(shí)機(jī),如果發(fā)生了并發(fā)訪問沖突,最后的結(jié)果就會(huì)是不正確的。
Some people, when confronted with a problem, think, “I know, I’ll use threads,” and then two they hav erpoblesms.
引入了多線程,我們就必須要同時(shí)引入并發(fā)控制來保證在多個(gè)線程同時(shí)訪問數(shù)據(jù)時(shí)程序行為的正確性,這就需要工程師額外維護(hù)并發(fā)控制的相關(guān)代碼,例如,我們會(huì)需要在可能被并發(fā)讀寫的變量上增加互斥鎖:
var (
mu Mutex // cost
data int
)
// thread 1
func() {
mu.Lock()
data += 1
mu.Unlock()
}
// thread 2
func() {
mu.Lock()
data -= 1
mu.Unlock()
}
在訪問這些變量或者內(nèi)存之前也需要先對獲取互斥鎖,一旦忘記獲取鎖或者忘記釋放鎖就可能會(huì)導(dǎo)致各種詭異的問題,管理相關(guān)的并發(fā)控制機(jī)制也需要付出額外的研發(fā)成本和負(fù)擔(dān)。
并發(fā)處理
使用單線程模型也并不意味著程序不能并發(fā)的處理任務(wù),Redis 雖然使用單線程模型處理用戶的請求,但是它卻使用 I/O 多路復(fù)用機(jī)制并發(fā)處理來自客戶端的多個(gè)連接,同時(shí)等待多個(gè)連接發(fā)送的請求。
在 I/O 多路復(fù)用模型中,最重要的函數(shù)調(diào)用就是 select 以及類似函數(shù),該方法的能夠同時(shí)監(jiān)控多個(gè)文件描述符(也就是客戶端的連接)的可讀可寫情況,當(dāng)其中的某些文件描述符可讀或者可寫時(shí),select 方法就會(huì)返回可讀以及可寫的文件描述符個(gè)數(shù)。
使用 I/O 多路復(fù)用技術(shù)能夠極大地減少系統(tǒng)的開銷,系統(tǒng)不再需要額外創(chuàng)建和維護(hù)進(jìn)程和線程來監(jiān)聽來自客戶端的大量連接,減少了服務(wù)器的開發(fā)成本和維護(hù)成本。
性能瓶頸
最后要介紹的其實(shí)就是 Redis 選擇單線程模型的決定性原因 —— 多線程技術(shù)的能夠幫助我們充分利用 CPU 的計(jì)算資源來并發(fā)的執(zhí)行不同的任務(wù),但是 CPU 資源往往都不是 Redis 服務(wù)器的性能瓶頸。哪怕我們在一個(gè)普通的 Linux 服務(wù)器上啟動(dòng) Redis 服務(wù),它也能在 1s 的時(shí)間內(nèi)處理 1,000,000 個(gè)用戶請求。
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 服務(wù)器來處理,而不是在同一個(gè) Redis 服務(wù)中引入大量的多線程操作。
簡單總結(jié)一下,Redis 并不是 CPU 密集型的服務(wù),如果不開啟 AOF 備份,所有 Redis 的操作都會(huì)在內(nèi)存中完成不會(huì)涉及任何的 I/O 操作,這些數(shù)據(jù)的讀寫由于只發(fā)生在內(nèi)存中,所以處理速度是非??斓模徽麄€(gè)服務(wù)的瓶頸在于網(wǎng)絡(luò)傳輸帶來的延遲和等待客戶端的數(shù)據(jù)傳輸,也就是網(wǎng)絡(luò) I/O,所以使用多線程模型處理全部的外部請求可能不是一個(gè)好的方案。
AOF 是 Redis 的一種持久化機(jī)制,它會(huì)在每次收到來自客戶端的寫請求時(shí),將其記錄到日志中,每次 Redis 服務(wù)器啟動(dòng)時(shí)都會(huì)重放 AOF 日志構(gòu)建原始的數(shù)據(jù)集,保證數(shù)據(jù)的持久性。
多線程雖然會(huì)幫助我們更充分地利用 CPU 資源,但是操作系統(tǒng)上線程的切換也不是免費(fèi)的,線程切換其實(shí)會(huì)帶來額外的開銷,其中包括:
保存線程 1 的執(zhí)行上下文;
加載線程 2 的執(zhí)行上下文;
頻繁的對線程的上下文進(jìn)行切換可能還會(huì)導(dǎo)致性能地急劇下降,這可能會(huì)導(dǎo)致我們不僅沒有提升請求處理的平均速度,反而進(jìn)行了負(fù)優(yōu)化,所以這也是為什么 Redis 對于使用多線程技術(shù)非常謹(jǐn)慎。
Redis 在最新的幾個(gè)版本中加入了一些可以被其他線程異步處理的刪除操作,也就是我們在上面提到的 ?UNLINK、FLUSHALL ASYNC 和 FLUSHDB ASYNC,我們?yōu)槭裁磿?huì)需要這些刪除操作,而它們?yōu)槭裁葱枰ㄟ^多線程的方式異步處理?
刪除操作
我們可以在 Redis 在中使用 DEL 命令來刪除一個(gè)鍵對應(yīng)的值,如果待刪除的鍵值對占用了較小的內(nèi)存空間,那么哪怕是同步地刪除這些鍵值對也不會(huì)消耗太多的時(shí)間。
但是對于 Redis 中的一些超大鍵值對,幾十 MB 或者幾百 MB 的數(shù)據(jù)并不能在幾毫秒的時(shí)間內(nèi)處理完,Redis 可能會(huì)需要在釋放內(nèi)存空間上消耗較多的時(shí)間,這些操作就會(huì)阻塞待處理的任務(wù),影響 Redis 服務(wù)處理請求的 PCT99 和可用性。

然而釋放內(nèi)存空間的工作其實(shí)可以由后臺線程異步進(jìn)行處理,這也就是 UNLINK 命令的實(shí)現(xiàn)原理,它只會(huì)將鍵從元數(shù)據(jù)中刪除,真正的刪除操作會(huì)在后臺異步執(zhí)行。
Redis 選擇使用單線程模型處理客戶端的請求主要還是因?yàn)?CPU 不是 Redis 服務(wù)器的瓶頸,所以使用多線程模型帶來的性能提升并不能抵消它帶來的開發(fā)成本和維護(hù)成本,系統(tǒng)的性能瓶頸也主要在網(wǎng)絡(luò) I/O 操作上;而 Redis 引入多線程操作也是出于性能上的考慮,對于一些大鍵值對的刪除操作,通過多線程非阻塞地釋放內(nèi)存空間也能減少對 Redis 主線程阻塞的時(shí)間,提高執(zhí)行的效率。

原創(chuàng)不易,燃燒秀發(fā)輸出內(nèi)容,如果有一丟丟收獲,點(diǎn)個(gè)贊鼓勵(lì)一下吧!
整理了幾百本各類技術(shù)電子書,送給小伙伴們。關(guān)注公號回復(fù)【666】自行領(lǐng)取。和一些小伙伴們建了一個(gè)技術(shù)交流群,一起探討技術(shù)、分享技術(shù)資料,旨在共同學(xué)習(xí)進(jìn)步,如果感興趣就加入我們吧!

