1. 程式人生 > 其它 >最近沉迷Redis網路模型,無法自拔!終於知道Redis為啥這麼快了

最近沉迷Redis網路模型,無法自拔!終於知道Redis為啥這麼快了

1. 使用者空間和核心態空間

1.1 為什麼要區分使用者和核心

伺服器大多都採用 Linux 系統,這裡我們以 Linux 為例來講解:

ubuntu 和 Centos 都是 Linux 的發行版,發行版可以看成對 linux 包了一層殼,任何 Linux 發行版,其系統核心都是 Linux 。我們的應用都需要通過 Linux 核心與硬體互動

使用者的應用,比如 redis ,mysql 等其實是沒有辦法去執行訪問我們作業系統的硬體的,所以我們可以通過發行版的這個殼子去訪問核心,再通過核心去訪問計算機硬體

計算機硬體包括,如 cpu,記憶體,網絡卡等等,核心(通過定址空間)可以操作硬體的,但是核心需要不同裝置的驅動,有了這些驅動之後,核心就可以去對計算機硬體去進行 記憶體管理,檔案系統的管理,程序的管理等等

我們想要使用者的應用來訪問,計算機就必須要通過對外暴露的一些介面,才能訪問到,從而簡介的實現對核心的操控,但是核心本身上來說也是一個應用,所以他本身也需要一些記憶體,cpu 等裝置資源,使用者應用本身也在消耗這些資源,如果不加任何限制,使用者去操作隨意的去操作我們的資源,就有可能導致一些衝突,甚至有可能導致我們的系統出現無法執行的問題,因此我們需要把使用者和核心隔離開

1.2 程序定址空間

程序的定址空間劃分成兩部分:核心空間、使用者空間

什麼是定址空間呢?我們的應用程式也好,還是核心空間也好,都是沒有辦法直接去實體記憶體的,而是通過分配一些虛擬記憶體對映到實體記憶體中,我們的核心和應用程式去訪問虛擬記憶體的時候,就需要一個虛擬地址,這個地址是一個無符號的整數。

比如一個 32 位的作業系統,他的頻寬就是 32,他的虛擬地址就是 2 的 32 次方,也就是說他定址的範圍就是 0~2 的 32 次方, 這片定址空間對應的就是 2 的 32 個位元組,就是 4GB,這個 4GB,會有 3 個 GB 分給使用者空間,會有 1GB 給核心系統

在 linux 中,他們許可權分成兩個等級,0 和 3,使用者空間只能執行受限的命令(Ring3),而且不能直接呼叫系統資源,必須通過核心提供的介面來訪問核心空間可以執行特權命令(Ring0),呼叫一切系統資源,所以一般情況下,使用者的操作是執行在使用者空間,而核心執行的資料是在核心空間的,而有的情況下,一個應用程式需要去呼叫一些特權資源,去呼叫一些核心空間的操作,所以此時他倆需要在使用者態和核心態之間進行切換。

比如:

Linux 系統為了提高 IO 效率,會在使用者空間和核心空間都加入緩衝區:

  • 寫資料時,要把使用者緩衝資料拷貝到核心緩衝區,然後寫入裝置
  • 讀資料時,要從裝置讀取資料到核心緩衝區,然後拷貝到使用者緩衝區

針對這個操作:我們的使用者在寫讀資料時,會去向核心態申請,想要讀取核心的資料,而核心資料要去等待驅動程式從硬體上讀取資料,當從磁碟上載入到資料之後,核心會將資料寫入到核心的緩衝區中,然後再將資料拷貝到使用者態的 buffer 中,然後再返回給應用程式,整體而言,速度慢,就是這個原因,為了加速,我們希望 read 也好,還是 wait for data 也最好都不要等待,或者時間儘量的短。

2. 網路模型

2.1 阻塞IO

  • 過程 1:應用程式想要去讀取資料,他是無法直接去讀取磁碟資料的,他需要先到核心裡邊去等待核心操作硬體拿到資料,這個過程是需要等待的,等到核心從磁碟上把資料加載出來之後,再把這個資料寫給使用者的快取區。
  • 過程 2:如果是阻塞 IO,那麼整個過程中,使用者從發起讀請求開始,一直到讀取到資料,都是一個阻塞狀態。

使用者去讀取資料時,會去先發起 recvform 一個命令,去嘗試從核心上載入資料,如果核心沒有資料,那麼使用者就會等待,此時核心會去從硬體上讀取資料,核心讀取資料之後,會把資料拷貝到使用者態,並且返回 ok,整個過程,都是阻塞等待的,這就是阻塞 IO

總結如下:

顧名思義,阻塞 IO 就是兩個階段都必須阻塞等待:

階段一:

  • 使用者程序嘗試讀取資料(比如網絡卡資料)
  • 此時資料尚未到達,核心需要等待資料
  • 此時使用者程序也處於阻塞狀態

階段二:

  • 資料到達並拷貝到核心緩衝區,代表已就緒
  • 將核心資料拷貝到使用者緩衝區
  • 拷貝過程中,使用者程序依然阻塞等待
  • 拷貝完成,使用者程序解除阻塞,處理資料

可以看到,阻塞 IO 模型中,使用者程序在兩個階段都是阻塞狀態。

2.2 非阻塞 IO

顧名思義,非阻塞 IO 的 recvfrom 操作會立即返回結果而不是阻塞使用者程序

階段一:

  • 使用者程序嘗試讀取資料(比如網絡卡資料)
  • 此時資料尚未到達,核心需要等待資料
  • 返回異常給使用者程序
  • 使用者程序拿到 error 後,再次嘗試讀取
  • 迴圈往復,直到資料就緒

階段二:

  • 將核心資料拷貝到使用者緩衝區
  • 拷貝過程中,使用者程序依然阻塞等待
  • 拷貝完成,使用者程序解除阻塞,處理資料
  • 可以看到,非阻塞 IO 模型中,使用者程序在第一個階段是非阻塞,第二個階段是阻塞狀態。雖然是非阻塞,但效能並沒有得到提高。而且忙等機制會導致 CPU 空轉,CPU 使用率暴增。

2.3 訊號驅動

訊號驅動 IO 是與核心建立 SIGIO 的訊號關聯並設定回撥,當核心有 FD 就緒時,會發出 SIGIO 訊號通知使用者,期間使用者應用可以執行其它業務,無需阻塞等待。

階段一:

  • 使用者程序呼叫 sigaction ,註冊訊號處理函式
  • 核心返回成功,開始監聽 FD
  • 使用者程序不阻塞等待,可以執行其它業務
  • 當核心資料就緒後,回撥使用者程序的 SIGIO 處理函式

階段二:

  • 收到 SIGIO 回撥訊號
  • 呼叫 recvfrom ,讀取
  • 核心將資料拷貝到使用者空間
  • 使用者程序處理資料


當有大量 IO 操作時,訊號較多,SIGIO 處理函式不能及時處理可能導致訊號佇列溢位,而且核心空間與使用者空間的頻繁訊號互動效能也較低。

2.4 非同步 IO

這種方式,不僅僅是使用者態在試圖讀取資料後,不阻塞,而且當核心的資料準備完成後,也不會阻塞

他會由核心將所有資料處理完成後,由核心將資料寫入到使用者態中,然後才算完成,所以效能極高,不會有任何阻塞,全部都由核心完成,可以看到,非同步 IO 模型中,使用者程序在兩個階段都是非阻塞狀態。

2.5 IO 多路複用

場景引入

為了更好的理解 IO ,現在假設這樣一種場景:一家餐廳

  • A 情況:這家餐廳中現在只有一位服務員,並且採用客戶排隊點餐的方式,就像這樣:


每排到一位客戶要吃到飯,都要經過兩個步驟:

思考要吃什麼
顧客開始點餐,廚師開始炒菜

由於餐廳只有一位服務員,因此一次只能服務一位客戶,並且還需要等待當前客戶思考出結果,這浪費了後續排隊的人非常多的時間,效率極低。這就是阻塞 IO。

當然,為了緩解這種情況,老闆完全可以多僱幾個人,但這也會增加成本,而在極大客流量的情況下,仍然不會有很高的效率提升

  • B 情況: 這家餐廳中現在只有一位服務員,並且採用客戶排隊點餐的方式。

每排到一位客戶要吃到飯,都要經過兩個步驟:

  1. 思考要吃什麼
  2. 顧客開始點餐,廚師開始炒菜

與 A 情況不同的是,此時服務員會不斷詢問顧客:“你想吃番茄雞蛋蓋澆飯嗎?那滑蛋牛肉呢?那肉末茄子呢?……”

雖然服務員在不停的問,但是在網路中,這並不會增加資料的就緒速度,主要還是等顧客自己確定。所以,這並不會提高餐廳的效率,說不定還會招來更多差評。這就是非阻塞 IO。

  • C 情況: 這家餐廳中現在只有一位服務員,但是不再採用客戶排隊的方式,而是顧客自己獲取選單並點餐,點完後通知服務員,就像這樣:

每排到一位客戶要吃到飯,還是都要經過兩個步驟:

  1. 看著選單,思考要吃什麼
  2. 通知服務員,我點好了

與 A B 不同的是,這種情況服務員不必再等待顧客思考吃什麼,只需要在收到顧客通知後,去接收選單就好。這樣相當於餐廳在只有一個服務員的情況下,同時服務了多個人,而不像 A B,同一時刻只能服務一個人。此時餐廳的效率自然就提高了很多。

對映到我們的網路服務中,就是這樣:

  • 客人:客戶端請求
  • 點餐內容:客戶端傳送的實際資料
  • 老闆:作業系統
  • 人力成本:系統資源
  • 選單:檔案狀態描述符。作業系統對於一個程序能夠同時持有的檔案狀態描述符的個數是有限制的,在 linux 系統中 $ulimit -n 檢視這個限制值,當然也是可以 (並且應該) 進行核心引數調整的。
  • 服務員:作業系統核心用於 IO 操作的執行緒 (核心執行緒)
  • 廚師:應用程式執行緒 (當然廚房就是應用程式程序咯)
  • 餐單傳遞方式:包括了阻塞式和非阻塞式兩種。
    • 方法 A: 阻塞 IO
    • 方法 B: 非阻塞 IO
    • 方法 C: 多路複用 IO

2.6 多路複用 IO 的實現

目前流程的多路複用 IO 實現主要包括四種: select、poll、epoll、kqueue。下表是他們的一些重要特性的比較:

IO 模型 相對效能 關鍵思路 作業系統 JAVA 支援情況
select 較高 Reactor windows/Linux 支援,Reactor 模式 (反應器設計模式)。Linux 作業系統的 kernels 2.4 核心版本之前,預設使用 select;而目前 windows 下對同步 IO 的支援,都是 select 模型
poll 較高 Reactor Linux Linux 下的 JAVA NIO 框架,Linux kernels 2.6 核心版本之前使用 poll 進行支援。也是使用的 Reactor 模式
epoll Reactor/Proactor Linux Linux kernels 2.6 核心版本及以後使用 epoll 進行支援;Linux kernels 2.6 核心版本之前使用 poll 進行支援;另外一定注意,由於 Linux 下沒有 Windows 下的 IOCP 技術提供真正的 非同步 IO 支援,所以 Linux 下使用 epoll 模擬非同步 IO
kqueue Proactor Linux 目前 JAVA 的版本不支援

多路複用 IO 技術最適用的是 “高併發” 場景,所謂高併發是指 1 毫秒內至少同時有上千個連線請求準備好。其他情況下多路複用 IO 技術發揮不出來它的優勢。另一方面,使用 JAVA NIO 進行功能實現,相對於傳統的 Socket 套接字實現要複雜一些,所以實際應用中,需要根據自己的業務需求進行技術選擇。

2.6.1 select

select 是 Linux 最早是由的 I/O 多路複用技術:

linux 中,一切皆檔案,socket 也不例外,我們把需要處理的資料封裝成 FD,然後在使用者態時建立一個 fd_set 的集合(這個集合的大小是要監聽的那個 FD 的最大值 + 1,但是大小整體是有限制的 ),這個集合的長度大小是有限制的,同時在這個集合中,標明出來我們要控制哪些資料。

其內部流程:

使用者態下:

  1. 建立 fd_set 集合,包括要監聽的 讀事件、寫事件、異常事件 的集合
  2. 確定要監聽的 fd_set 集合
  3. 將要監聽的集合作為引數傳入 select () 函式中,select 中會將 集合複製到核心 buffer 中

核心態:

  1. 核心執行緒在得到 集合後,遍歷該集合
  2. 沒資料就緒,就休眠
  3. 當資料來時,執行緒被喚醒,然後再次遍歷集合,標記就緒的 fd 然後將整個集合,複製回用戶 buffer 中
  4. 使用者執行緒遍歷 集合,找到就緒的 fd ,再發起讀請求。

不足之處:

  • 集合大小固定為 1024 ,也就是說最多維持 1024 個 socket,在海量資料下,不夠用
  • 集合需要在 使用者 buffer 和核心 buffer 中反覆複製,涉及到 使用者態和核心態的切換,非常影響效能

2.6.2 poll

poll 模式對 select 模式做了簡單改進,但效能提升不明顯。

IO 流程:

  • 建立 pollfd 陣列,向其中新增關注的 fd 資訊,陣列大小自定義
  • 呼叫 poll 函式,將 pollfd 陣列拷貝到核心空間,轉連結串列儲存,無上限
  • 核心遍歷 fd ,判斷是否就緒
  • 資料就緒或超時後,拷貝 pollfd 陣列到使用者空間,返回就緒 fd 數量 n
  • 使用者程序判斷 n 是否大於 0, 大於 0 則遍歷 pollfd 陣列,找到就緒的 fd

與 select 對比:

  • select 模式中的 fd_set 大小固定為 1024,而 pollfd 在核心中採用連結串列,理論上無上限,但實際上不能這麼做,因為的監聽 FD 越多,每次遍歷消耗時間也越久,效能反而會下降

2.6.3 epoll

epoll 模式是對 select 和 poll 的改進,它提供了三個函式:eventpoll 、epoll_ctl 、epoll_wait

  • eventpoll 函式內部包含了兩個東西 :
    • 紅黑樹 :用來記錄所有的 fd
    • 連結串列 : 記錄已就緒的 fd
  • epoll_ctl 函式 ,將要監聽的 fd 新增到 紅黑樹 上去,並且給每個 fd 繫結一個監聽函式,當 fd 就緒時就會被觸發,這個監聽函式的操作就是 將這個 fd 新增到 連結串列中去。
  • epoll_wait 函式,就緒等待。一開始,使用者態 buffer 中建立一個空的 events 陣列,當就緒之後,我們的回撥函式會把 fd 新增到連結串列中去,當函式被呼叫的時候,會去檢查連結串列(當然這個過程需要參考配置的等待時間,可以等一定時間,也可以一直等),如果連結串列中沒有有 fd 則 fd 會從紅黑樹被新增到連結串列中,此時再將連結串列中的的 fd 複製到 使用者態的空 events 中,並且返回對應的運算元量,使用者態此時收到響應後,會從 events 中拿到已經準備好的資料,在呼叫 讀方法 去拿資料。

2.6.4 總結:

select 模式存在的三個問題:

  • 能監聽的 FD 最大不超過 1024
  • 每次 select 都需要把所有要監聽的 FD 都拷貝到核心空間
  • 每次都要遍歷所有 FD 來判斷就緒狀態

poll 模式的問題:

  • poll 利用連結串列解決了 select 中監聽 FD 上限的問題,但依然要遍歷所有 FD,如果監聽較多,效能會下降

epoll 模式中如何解決這些問題的?

  • 基於 epoll 例項中的紅黑樹儲存要監聽的 FD,理論上無上限,而且增刪改查效率都非常高
  • 每個 FD 只需要執行一次 epoll_ctl 新增到紅黑樹,以後每次 epol_wait 無需傳遞任何引數,無需重複拷貝 FD 到核心空間
  • 利用 ep_poll_callback 機制來監聽 FD 狀態,無需遍歷所有 FD,因此效能不會隨監聽的 FD 數量增多而下降

2.7 基於 epoll 的伺服器端流程

一張圖搞定:

我們來梳理一下這張圖

  1. 伺服器啟動以後,服務端會去呼叫 epoll_create,建立一個 epoll 例項,epoll 例項中包含兩個資料
  • 紅黑樹(為空):rb_root 用來去記錄需要被監聽的 FD

  • 連結串列(為空):list_head,用來存放已經就緒的 FD

  1. 建立好了之後,會去呼叫 epoll_ctl 函式,此函式會會將需要監聽的 fd 新增到 rb_root 中去,並且對當前這些存在於紅黑樹的節點設定回撥函式。

  2. 當這些被監聽的 fd 一旦準備就緒,與之相關聯的回撥函式就會被呼叫,而呼叫的結果就是將紅黑樹的 fd 新增到 list_head 中去 (但是此時並沒有完成)

  3. fd 新增完成後,就會呼叫 epoll_wait 函式,這個函式會去校驗是否有 fd 準備就緒(因為 fd 一旦準備就緒,就會被回撥函式新增到 list_head 中),在等待了一段時間 (可以進行配置)。

  4. 如果等夠了超時時間,則返回沒有資料,如果有,則進一步判斷當前是什麼事件,如果是建立連線事件,則呼叫 accept () 接受客戶端 socket ,拿到建立連線的 socket ,然後建立起來連線,如果是其他事件,則把資料進行寫出。

2.8 五種網路模型對比:

最後用一幅圖,來說明他們之間的區別

3. redis 通訊協議

3.1 RESP 協議

Redis 是一個 CS 架構的軟體,通訊一般分兩步(不包括 pipeline 和 PubSub):

  • 客戶端(client)向服務端(server)傳送一條命令,服務端解析並執行命令
  • 返回響應結果給客戶端,因此客戶端傳送命令的格式、服務端響應結果的格式必須有一個規範,這個規範就是通訊協議。

而在 Redis 中採用的是 RESP(Redis Serialization Protocol)協議:

  • Redis 1.2 版本引入了 RESP 協議
  • Redis 2.0 版本中成為與 Redis 服務端通訊的標準,稱為 RESP2
  • Redis 6.0 版本中,從 RESP2 升級到了 RESP3 協議,增加了更多資料型別並且支援 6.0 的新特性–客戶端快取

但目前,預設使用的依然是 RESP2 協議。在 RESP 中,通過首位元組的字元來區分不同資料型別,常用的資料型別包括 5 種:

  • 單行字串:首位元組是 ‘+’ ,後面跟上單行字串,以 CRLF( “\r\n” )結尾。例如返回”OK”: “+OK\r\n”
  • 錯誤(Errors):首位元組是 ‘-’ ,與單行字串格式一樣,只是字串是異常資訊,例如:”-Error message\r\n”
  • 數值:首位元組是 ‘:’ ,後面跟上數字格式的字串,以 CRLF 結尾。例如:”:10\r\n”
  • 多行字串:首位元組是 ‘$’ ,表示二進位制安全的字串,最大支援 512MB:
    • 如果大小為 0,則代表空字串:”$0\r\n\r\n”
    • 如果大小為 - 1,則代表不存在:”$-1\r\n”
  • 陣列:首位元組是 ‘*’,後面跟上陣列元素個數,再跟上元素,元素資料型別不限 :

本文由傳智教育博學谷教研團隊釋出。

如果本文對您有幫助,歡迎關注點贊;如果您有任何建議也可留言評論私信,您的支援是我堅持創作的動力。

轉載請註明出處!