1. Redis是屬於多線程還是單線程?不同版本之間有什么區別?


Redis 是屬於單線程還是多線程?

Redis 是普及率最高的技術之一,不僅是面試會被經常問到,在工作中也是非常常用的,下面我們就深入的了解一下Redis。

首先,Redis 使用的到底是多線程還是單線程?

不同版本的 Redis 是不同的,在 Redis4.0 之前,Redis 是單線程運行的,但是單線程並不代表效率就低,像 Nginx、Nodejs 也是單線程程序,但是它們的效率並不低,因為底層采用了基於 epoll 的 IO 多路復用(后面說)。

此外 Redis 是基於內存操作的,它的瓶頸在於機器的內存、網絡帶寬,而不是 CPU,因為在你 CPU 還沒達到瓶頸時你的內存可能就先滿了、或者帶寬達到瓶頸了。因此 CPU 不是主要原因,那么自然就采用單線程了。更何況使用多線程還會面臨一些額外的問題,比如共享資源的保護等等,對於一個 CPU 不是主要瓶頸的鍵值對數據庫而言,采用單線程是非常合適的。

簡單來說,Redis 在 4.0 之前使用單線程的模式是因為以下三個原因:

  • 使用單線程模式的 Redis,其開發和維護會更簡單,因為單線程模型方便開發和調試
  • 即使使用單線程模型也能夠並發地處理多客戶端的請求,因為 Redis 內部使用了基於 epoll 的多路復用
  • 對於 Redis 而言,主要的性能瓶頸是內存或者網絡帶寬,而並非 CPU

但 Redis 在 4.0 以及之后的版本中引入了惰性刪除(也叫異步刪除),這是由額外的線程執行的,意思就是我們可以使用異步的方式對 Redis 中的數據執行刪除操作了,例如:unlink key、flushdb async、flushall async,舉個例子:

127.0.0.1:6379> set name hanser
OK
127.0.0.1:6379> get name
"hanser"
127.0.0.1:6379> unlink name  # 這里是異步刪除一個 key,同步的話則是 del name
(integer) 1
127.0.0.1:6379> flushdb async
OK
127.0.0.1:6379> flushall async
OK

這樣處理的好處就是不會使 Redis 的主線程卡頓,會把這些刪除操作交給后台線程來執行。

通常情況下使用 del 指令可以很快的刪除數據,但是當被刪除的 key 是一個非常大的對象時,例如:刪除的是包含了成千上萬個元素的 hash 集合,那么 del 指令就會造成 Redis 主線程卡頓,因此使用惰性刪除可以有效的避免 Redis 卡頓的問題。

除了惰性刪除,像持久化、集群數據同步等等,都是由額外的子線程執行的,而 Redis 主線程則專注於網絡 IO 和鍵值對讀寫。

在面試的時候,一旦涉及到 Redis,那么關於 Redis 線程模型的問題(單線程或多線程)幾乎是必問的問題之一,但是回答好的人卻並不多。大部分的人只能回答上來 Redis 是單線程的,以及說出單線程的眾多好處,但對於 Redis4.0 和 Redis6.0 中,尤其是 Redis6.0 中多線程的特點,能夠准確回答出來的人則非常少。關於單線程和多線程的相關知識,我們下面就來介紹。

 

Redis 既然是單線程的,為什么還這么快?

正如上面所說,Redis4.0 之前是單線程的,那既然是單線程為什么速度還能那么快?吞吐量還能那么高?

原因有以下幾點:

  • 基於內存操作:Redis 的所有數據都在內存中,因此所有的運算都是內存級別的,所以它的性能比較高
  • 數據結構簡單:Redis 的數據結構是為自身專門量身打造的,而這些數據結構的查找和操作的時間復雜度都是 O(1)
  • 多路復用和非阻塞 I/O:Redis 使用 I/O 多路復用功能來監聽多個 socket 連接客戶端,這樣就可以使用一個線程來處理多個情況,從而減少線程切換帶來的開銷,同時也避免了 I/O 阻塞操作,從而大大地提高了 Redis 的性能
  • 避免上下文切換:因為是單線程模型,因此就避免了不必要的上下文切換和多線程競爭,這就省去了多線程切換帶來的時間和性能上的開銷,而且單線程不會導致死鎖的問題發生

 

非阻塞 I/O 和 I/O 多路復用是什么?

首先我們可以使用 get 命令,獲取一個 key 對應的 value,比如:

127.0.0.1:6379> get name
"hanser"

那么問題來了,以上對於 Redis 服務端而言,都發生了哪些事情呢?

服務端必須要先監聽客戶端請求(bind/listen),然后當客戶端到來時與其建立連接(accept),從 socket 中讀取客戶端的請求(recv),對請求進行解析(parse),這里解析出的請求類型是 get、key 是 "name",再根據 key 獲取對應 value,最后返回給客戶端,也就是向 socket 寫入數據(send)。

以上所有操作都是由 Redis 主線程依次執行的,但是里面會有潛在的阻塞點,分別是 accept 和 recv。當 Redis 監聽到一個客戶端有連接請求、但卻一直未能成功建立連接,那么主線程會一直阻塞在 accept 函數這里,導致其它客戶端無法和 Redis 建立連接。類似的,當 Redis 通過 recv 從客戶端讀取數據時,如果數據一直沒有到達,那么 Redis 主線程也會一直阻塞在 recv 這一步,因此這就導致了 Redis 的效率會變得低下。

非阻塞 I/O

但很明顯,Redis 不會允許這種情況發生,因為以上都是阻塞 I/O 會面臨的情況,而 Redis 采用的是非阻塞 I/O,也就是將 socket 設置成了非阻塞模式。首先在 socket 模型中,調用 socket() 方法會返回 "主動套接字",調用 bind() 方法綁定 IP 和 端口,再調用 listen() 方法將 "主動套接字" 轉化為 "監聽套接字",最后 "監聽套接字" 調用 accept() 方法等待客戶端連接的到來,當和客戶端建立連接時再返回 "已連接套接字",而后續就通過 "已連接套接字" 來和客戶端進行數據的接收與發送。

但是注意:我們說在 listen() 這一步,會將 "主動套接字" 轉化為 "監聽套接字",而此時的 "監聽套接字" 的類型是阻塞的,阻塞類型的 "監聽套接字" 在調用 accept() 方法時,如果沒有客戶端來連接的話,就會一直處於阻塞狀態,那么此時主線程就沒法干其它事情了。所以在 listen() 的時候可以將其設置為非阻塞,而非阻塞的 "監聽套接字" 在調用 accept() 時,如果沒有客戶端連接請求到達時,那么主線程就不會傻傻地等待了,而是會直接返回,然后去做其它的事情。

類似的,我們在創建 "已連接套接字" 的時候也可以將其類型設置為非阻塞,因為阻塞類型的 "已連接套接字" 在調用 send() / recv() 的時候也會處於阻塞狀態,比如當客戶端一直不發數據的時候,"已連接套接字" 就會一直阻塞在 rev() 這一步。如果是非阻塞類型的 "已連接套接字",那么當調用 recv() 但卻收不到數據時,也不用處於阻塞狀態,同樣可以直接返回去做其它事情。

但是有兩點需要注意:

  • 雖然 accept() 不阻塞了,在沒有客戶端連接時 Redis 主線程可以去做其它事情,但如果后續有客戶端連接,Redis 要如何得知呢?因此必須要有一種機制,能夠繼續在 "監聽套接字" 上等待后續連接請求,並在請求到來時通知 Redis。
  • send() / recv() 不阻塞了,相當於 I/O 的讀寫流程不再是阻塞的,讀寫方法都會瞬間完成並且返回,也就是它會采用能讀多少就讀多少、能寫多少就寫多少的策略來執行 I/O 操作,這顯然更符合我們對性能的追求。但這樣會面臨一個問題,那就是當我們執行讀取操作時,有可能只讀取了一部分數據,剩余的數據客戶端還沒發過來,那么這些這些數據何時可讀呢?同理寫數據也是這種情況,當緩沖區滿了,而我們的數據還沒有寫完,那么剩下的數據又何時可寫呢?因此同樣要有一種機制,能夠在 Redis 主線程做別的事情的時候繼續監聽 "已連接套接字",並且有數據可讀寫的時候通知 Redis。

這樣才能保證 Redis 線程既不會像基本 IO 模型中一直在阻塞點等待,也不會無法處理實際到達的客戶端連接請求和可讀寫的數據,而上面所提到的機制便是 I/O 多路復用。

I/O 多路復用

I/O 多路復用機制是指一個線程處理多個 IO 流,也就是我們經常聽到的 select/poll/epoll,而 Linux 采用的是 epoll。簡單來說,在 Redis 只運行單線程的情況下,該機制允許內核中同時存在多個監聽套接字和已連接套接字。內核會一直監聽這些套接字上的連接請求或數據請求,一旦有請求到達就會交給 Redis 線程處理,這樣就實現了一個 Redis 線程處理多個 IO 流的效果。

上圖就是基於多路復用的 Redis IO 模型,圖中的 FD 就是套接字,可以是 "監聽套接字"、也可以是 "已連接套接字",Redis 會通過 epoll 機制來讓內核幫忙監聽這些套接字。而此時 Redis 線程或者說主線程,不會阻塞在某一個特定的套接字上,也就是說不會阻塞在某一個特定的客戶端請求處理上。因此 Redis 可以同時和多個客戶端連接並處理請求,從而提升並發性。

但為了在請求到達時能夠通知 Redis 線程,epoll 提供了基於事件的回調機制,即針對不同事件的發生,調用相應的處理函數。

那么回調機制是怎么工作的呢?以上圖為例,首先 epoll 一旦監測到 FD 上有請求到達時,就會觸發相應的事件。這些事件會被放進一個隊列中,Redis 主線程會對該事件隊列不斷進行處理,這樣一來 Redis 就無需一直輪詢是否有請求發生,從而避免資源的浪費。同時,Redis 在對事件隊列中的事件進行處理時,會調用相應的處理函數,這就實現了基於事件的回調。因為 Redis 一直在對事件隊列進行處理,所以能及時響應客戶端請求,提升 Redis 的響應性能。

我們以實際的連接請求和數據讀取請求為例,再解釋一下。連接請求和數據讀取請求分別對應 Accept 事件和 Read 事件,Redis 分別對這兩個事件注冊 accept 和 get 回調函數,當 Linux 內核監聽到有連接請求或數據讀取請求時,就會觸發 Accept 事件或 Read 事件,然后內核就會回調 Redis 注冊的 accept 函數或 get 函數。

就像病人去醫院看病,在醫生實際診斷之前每個病人(類似於請求)都需要先分診、測體溫、登記等等。如果這些工作都有醫生完成,那么醫生的工作效率就會很低。所以醫院設置了分診台,分診台會一直處理這些診斷前的工作(類似於 Linux 內核監聽請求),然后再轉交給醫生做實際診斷,這樣即使一個醫生(相當於 Redis 的主線程)也能有很高的效率。

需要注意的時,不同的操作系統有着不同的多路復用實現,除了 Linux 的 epoll,還有 FreeBSD 的 kqueue、以及 Solaris 的 evport。

嗦一下 Redis 6.0 中的多線程

Redis 6.0 引入了一些新特性,其中非常受關注的一個特性就是多線程。在 4.0 之前 Redis 是單線程的,因為單線程的優點很明顯,不但降低了 Redis 內部實現的復雜性,也讓所有操作都可以在無鎖的情況下進行,並且不存在死鎖和線程切換帶來的性能以及時間上的消耗。但是其缺點也很明顯,單線程機制導致 Redis 的 QPS(Query Per Second,每秒查詢數)很難得到有效的提高(雖然已經夠快了,但人畢竟還是要有更高的追求的)。

而 Redis 從 4.0 版本開始引入了多線程,但是此版本的多線程主要用於大數據量的異步刪除,對於非刪除操作的意義並不是很大。

但 Redis 6.0 中的多線程則是真正為了提高 I/O 的讀寫性能而引入的,它的主要實現思路是將主線程的 I/O 讀寫任務拆分給一組獨立的子線程去執行,也就是說從 socket 中讀數據和寫數據不再由主線程負責,而是交給了多個子線程,這樣就可以使多個 socket 的讀寫並行化了。這么做的原因就在於,雖然在 Redis 中使用了 I/O 多路復用和非阻塞 I/O,但我們知道數據在內核態空間和用戶態空間之間的拷貝是無法避免的,而數據的拷貝這一步是阻塞的,並且當數據量越大時拷貝所需要的時間就越多。所以 Redis 在 6.0 引入了多線程,用於分攤同步讀寫 I/O 壓力,從而提升 Redis 的 QPS。但是注意:Redis 的命令本身依舊是由 Redis 主線程串行執行的,只不過具體的讀寫操作交給獨立的子線程去執行了(后面會詳細說明 Redis 的主線程和子線程之間是如何協同的),而這么做的好處就是不需要為 Lua 腳本、事務的原子性而額外開發多線程互斥機制,這樣一來 Redis 的線程模型實現起來就簡單多了。因為和之前一樣,所有的命令依舊是由主線程串行執行的,只不過具體的讀寫任務交給了子線程。

除了引入多線程,還可以將內核網絡協議棧換成用戶態網絡協議棧(DPDK),讓網絡請求不在內核里進行,直接在用戶態完成。因為 socket 無論是發送數據還是接收數據都需要經過內核,發送數據時會將數據從用戶態拷貝到內核態的緩沖區中,再由內核進行發送;接收數據,也是由內核負責接收,然后再將數據從內核態的緩沖區中拷貝到用戶態。因此兩個節點上的應用程序進行 socket 通信時,實際上是兩個節點的內核進行交互,至於每個節點的應用程序則都是和自己的內核進行交互。

雖然替換協議棧可以避免頻繁地讓內核參與網絡請求處理,提升請求處理效率,但是 Redis 並沒有采用這種做法。原因就是該做法要求 Redis 的整體架構中,需要添加對用戶態網絡協議棧的支持,需要修改 Redis 源碼中和網絡相關的部分,這會帶來很多額外的開發工作量;而且新增代碼還可以引入 bug,導致 Redis 程序不穩定,因此 Redis 6.0 中沒有采用這種做法。

請再具體嗦一下 Redis 6.0 的主線程和子線程之間是如何協同的?

整體可以分為四個階段:

階段一:服務端和客戶端建立 socket 連接,並分配子線程(處理線程)

首先,主線程負責接收建立連接請求,當有客戶端請求到達時,主線程會創建和客戶端的 scoket 連接,該 socket 連接就是用來和客戶端進行數據的傳輸的。只不過這一步不由主線程來做,主線程要做的事情是將該 socket 放入到全局等待隊列中,然后通過輪訓的方式選擇子線程,並將隊列中的 socket 連接分配給它,所以無論是從客戶端讀數據還是向客戶端寫數據,都由子線程來做。因為我們說 Redis 6.0 中引入多線程就是為了緩解主線程的 I/O 讀寫壓力,而 I/O 讀寫這一步是阻塞的,所以應該交給子線程並行操作。

階段二:子線程讀取並解析請求

主線程一旦把 socket 連接分配給子線程,那么會進行阻塞狀態,等待子線程完成客戶端請求的讀取和解析,得到具體的命令操作。由於可以有多個子線程,所以這個操作很快就能完成。

階段三:主線程執行命令操作

等到子線程讀取到客戶端請求並解析完畢之后,然后再由主線程以單線程的方式執行命令操作,I/O 讀寫雖然交給了子線程,但是命令本身還是由 Redis 主線程執行的。

階段四:子線程回寫 socket、主線程清空全局隊列

當主線程執行完命令操作時,還需要將結果寫入緩沖區,而這一步顯然要由子線程來做,因為是 I/O 讀寫。此時主線程會陷入阻塞,直到子線程將這些結果寫回 socket 並返回給客戶端。

和讀取一樣,子線程將數據寫回 socket 時,也是有多個線程在並行執行,所以寫回 socket 的速度也很快。之后主線程會清空全局隊列,等待客戶端的后續請求。

在 Redis 6.0 中如何開啟多線程?

在了解了 Redis 6.0 中的多線程機制之后,我們要如何開啟多線程呢?在 Redis 6.0 中,多線程機制默認是關閉的,如果想啟動的話,需要修改 redis.conf 中的兩個配置。

1. 設置 io-thread-do-reads 配置項為 yes,表示啟用多線程。

io-thread-do-reads yes

2. 通過 io-threads 設置子線程的數量。

io-threads 3

表示開啟 3 個子線程,但是注意,線程數要小於機器的 CPU 核數,線程數並不是越大越好。關於線程數的設置,官方的建議是如果為 4 核的 CPU,那么設置子線程數為 2 或 3;如果為 8 核的CPU,那么設置子線程數為 6。

如果你在實際應用中,發現 Redis 實例的 CPU 開銷不大,吞吐量卻沒有提升,可以考慮使用 Redis 6.0 的多線程機制,加速 IO 讀寫處理,進而提升實例的吞吐量。

最后關於 Redis 的性能,Redis 的作者在 2019 的 RedisConf 大會上提到,Redis6.0 引入的多線程 I/O 特性對性能的提升至少是一倍以上。國內也有人在阿里雲使用 4 個線程的 Redis 版本和單線程的 Redis 版本進行比較測試,發現測試的結果和 Redis 作者說的一致,性能基本可以提高一倍。

總結

以上我們就介紹了 Redis 在 4.0 之前明明采用單線程但卻依然快的原因:基於內存操作、量身打造的數據結構、I/O 多路復用和非阻塞 I/O、避免了不必要的線程上下文切換。並且在 Redis4.0 開始支持多線程,主要體現在大數據的異步刪除上面,例如:unlink key、flushdb async、flushall async 等。而 Redis6.0 的多線程則增加了對 I/O 讀寫的並發能力,因為數據在用戶態和內核態之間穿梭是需要進行拷貝的,而這一步是阻塞的,所以通過多個線程並行操作從而更好的提升 Redis 的性能。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM