本文介紹了 redis 核心原理和架構:基于事件驅動的模型。事件模型是構成 Redis 內核的引擎,Redis 的豐富功能和組件都是構建在這個模型上的。如果你使用過 Redis,那么本文可以為你打開一道進入 Redis 內部世界的門,窺探 Redis 如何構建它的帝國。
本文先對 Redis 使用的事件模型和原理進行介紹,然后按以下主題順序展開:
- Redis 主程序啟動流程
- 事件循環(eventloop)
- 事件處理器 (event handler)
- 事件處理流程
最后以一次客戶端 SET 命令操作為例子,講解一個請求在 Redis 內部的流轉是如何完成的。
閱讀之前
為了方便公眾號上進行閱讀,幫助讀者快速掌握 Redis 核心原理,本文對 Redis 模型進行了簡化,去掉了大量的檢查和異常處理流程,并且僅在必要的時候通過代碼說明。
本文參考的源碼基于編寫時的最新分支 Redis 5.0.3,實際對照中發現 Redis 的核心邏輯在歷史版本迭代中變化不大,也體現了 Redis 的這個核心邏輯的地位。
一、Redis 事件驅動模型
1.1 事件驅動模型
事件驅動,顧名思義,只有在發生某些事件的時候,程序才會有所行動。
事件驅動模型在架構設計領域也稱為 Reactor 模式,體現的是一種被動響應的特征。
事件驅動模型通常可以抽象為如下圖所示流程:
主程序處于一個阻塞狀態的事件循環(event loop)中等待事件(event),當有事件發生時,根據事件的屬性分發到相應的處理函數進行處理。事件以并發的方式發送到服務處理器 (service handler),服務處理器將事件整合到一個有序隊列中(這過程稱為 demultiplexes),并分發到具體的請求處理器 (request handler)進行處理。
為了閱讀的方便,因為「事件」這個詞在中文中較常見,所以下文針對事件模型中的「事件」等專用術語,會進行特定的標識,如:事件循環 (event loop),事件 (event),處理器 (handler)等。
1.2 Redis 核心原理
Redis 在事件驅動模型下工作,當有來自外部或內部的請求的時候,才會執行相關的流程。
Redis 程序的整個運作都是圍繞事件循環 (event loop)進行的。
事件循環對于 Redis 而言,就像是一臺車的引擎一樣,提供了整個系統所需的流轉動力。所有其他的組件都是基于這個引擎的基礎上組合和構建起來的。可以說理解了 Redis 的事件循環就能了解 Redis 的工作原理的核心。
Redis 事件模型如下圖所示:
事件循環 eventloop同時監控多個事件,這里的事件本質上是 Redis 對于連接套接字的抽象。
當套接字變為可讀或者可寫狀態時,就會觸發該事件,把就緒的事件放在一個待處理事件的隊列中,以有序 (sequentially)、同步 (synchronously) 的方式發送給事件處理器進行處理。這個過程在 Redis 中被稱為Fire。
Redis 的事件循環會保存兩個列表:events和fired列表,前者表示正在監聽的事件,后者表示就緒事件,可以被進一步執行。
在具體實現時,Redis 采用 IO 多路復用 (multiplexing) 的方式,封裝了操作系統底層 select/epoll 等函數,實現對多個套接字 (socket) 的監聽,這些套接字就是對應多個不同客戶端的連接。
最后由對應的處理器將處理的結果返回給客戶端去。
Redis事件的來源有兩種:文件事件和時間事件,限于篇幅問題,本文主要介紹文件事件的處理流程,時間事件會在文章最后做簡要的說明。
以上就概括了Redis 處理用戶請求的大致過程。從這個過程我們可以發現:
- Redis 處理所有命令都是順序執行的,其中包括來自客戶端的連接請求。所以當 Redis 在處理一個復雜度高、時間很長的請求(比如 KEYS 命令)的時候,其他客戶端的連接都沒辦法相應。
- Redis 內部定時執行的任務也是放在順序隊列中處理,其中也可能包含時間較長的任務,比如自動刪除一個過期的大 Key,比如很大 list, hash, set 等。所以有時候會遇到明明業務沒有主動操作復雜,但也會出現卡頓的問題。
1.3 事件驅動模型的優勢
有利于架構解耦和模塊化開發
有利于功能架構實現上更加解耦,模塊的可重用性更高。因事件循環的流程本身和具體的處理邏輯之間是獨立的,只要在創建事件的時候關聯特定的處理邏輯(事件處理器),就可以完成一次事件的創建和處理。
有利于減小高并發量情況下對性能的影響
根據論文 SEDA: An Architecture for Well-Conditioned, Scalable Internet Services 的測試結果顯示,相比一個連接分配一個線程的模型, Reactor 模式(固定線程數)在連接數增大的情況下吞吐量不會明顯降低,延時也不會也受到顯著的影響。
二、事件循環的 Redis 實現
下面開始,會對 Redis 如何實現事件循環進行說明,會涉及到一些源碼的實現部分,如果不感興趣可以直接跳到第三節看 Redis 怎么利用事件處理模型來處理具體的命令。
2.1 Redis 事件循環 Event Loop
Redis 的事件循環,最直觀的理解,就是一個在不斷等待事件的一個無限循環,直到 Redis 程序退出。
Redis 實現事件循環主要涉及三個源碼文件:server.c, ae.c, networking.c。
- server.c 的 main()函數是整個 Redis 程序的開始,我們也從這里開始觀察 Redis 的行為。
- ae.c實現事件循環和事件的相關功能。
- networking.c則負責處理網絡IO相關的功能。
a. 初始化 Redis 配置
初始化的過程主要做三個事情:
- 加載配置
- 創建事件循環
- 執行事件循環
簡化后的代碼如下:(跳過不影響理解)
// 0. 定義服務器主要結構體, 加載服務器配置 struct redisServer server; initServerConfig(); loadServerConfig(); // 1. 根據配置參數初始化, initServer() { // 1.1 實際創建事件循環 server.el = aeCreateEventLoop(); // 1.2 為事件循環注冊一個可讀事件,用于響應外部客戶端請求 aeCreateFileEvent(server.el, AE_READABLE, acceptTcpHandler) } // 2. 執行事件循環,等待連接和命令請求 aeMain(server.el);
初始化過程中被創建的server.el包含了兩個事件的列表,它的結構體實現如下:
typedef struct aeEventLoop { aeFileEvent events[AE_SETSIZE]; /* 注冊的事件,被 eventloop 監聽 */ aeFiredEvent fired[AE_SETSIZE]; /* 有讀寫操作需要執行的事件(就緒事件) */ } aeEventLoop;
b. 創建事件循環
主循環體aeMain()在ae.c文件中被實現,簡化后的代碼如下:
void aeMain(aeEventLoop *eventLoop) { while (!eventLoop->stop) { aeProcessEvents(eventLoop, AE_ALL_EVENTS); } }
事件循環主要就是一個while循環,不斷去輪詢是否有就緒的事件需要處理,具體的處理函數是aeProcessEvents,接下來會有對這個函數有更詳細的介紹。
c. 創建用于監聽端口的事件
在上述 Redis 在初始化時,程序會創建一個關聯了acceptTcpHandler處理器的可讀事件:
aeCreateFileEvent(server.el, AE_READABLE, acceptTcpHandler)
這個可讀事件注冊到事件循環中,就實現了 Redis 對外提供的服務地址和端口的連接服務。具體的內容下一個小節事件處理器中介紹。
2.2 事件處理器 Event Handler
所有事件被創建時,都會關聯一個處理器 (handler),并注冊到事件循環中,事件處理器用于具體的讀寫操作。
Redis 的常用幾個事件處理器有:
- 響應連接的處理器acceptTcpHandler()
- 讀取客戶端命令的處理器readQueryFromClient()
- 返回處理結果的處理器sendReplyToClient()
以上處理器均在networking.c文件下實現,該文件負責 Redis 所有網絡 IO 功能的實現。
一個客戶端一次正常的連接和命令操作流程,可以通過上述三個處理器完成。
當 Redis 需要監聽某個套接字的時候,就會創建一個事件,并注冊到事件循環中進行監聽,Redis 將處理器以參數的方式關聯到事件中。
比如以下是注冊一個可讀事件的操作:
aeCreateFileEvent(server.el, fd, AE_READABLE, readQueryFromClient, c)
- server.el:事件循環 eventloop,一個服務器只有一個el
- fd:表示這個客戶端連接的文件描述符,每個客戶端連接對應一個
- AE_READABLE:表示這是一個可讀事件,可以理解為客戶端準備進行寫操作
- readQueryFromClient: 這個事件關聯的處理器,當事件就緒后,就會調用此處理器
- c:表示這個客戶端在Redis中指向的變量
注冊完畢后,事件循環就會將這個事件(套接字)加入到監聽的范圍,當事件可讀時,Redis 就會將這個事件發送到待處理事件隊列中等待處理,等到可讀就緒時,會被readQueryFromClient處理器處理。
可以看到整個過程中事件循環和不同處理器之間是解耦的,互不干擾。這樣實現提高了代碼的簡潔和重用。
2.3 事件處理 Process Events
在 Redis 完成初始化、創建事件循環后,就會處于等待和處理事件的狀態:無限循環aeProcessEvents()函數。
這個函數在ae.c中實現,該文件主要負責事件循環的實現,在aeProcessEvents()中具體做了幾個事情:
- 調用IO多路復用函數(select, epoll, evport, kqueue中的其中一種),阻塞等待事件變成就緒狀態或者直到超時,如果有事件就緒,就會將相應事件加入到eventLoop的待處理事件隊列 eventLoop->fired 中,然后進入下一個循環。
numevents = aeApiPoll(eventLoop, tvp);
- 如果在上一步中,發現有numevents個事件被觸發,就會將就緒隊列的事件一個個按順序進行處理,處理的函數為
for (j = 0; j < numevents; j++) { aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd]; fe->rfileProc() // 讀事件處理 fe->wfileProc() // 寫事件處理 }
fe就是要處理的文件事件 file event,對應讀操作或寫操作。至于處理的具體操作,則由創建事件時自身關聯的處理器決定的,事件循環不需要關注。
- 最后一步:如果有時間事件,則進行時間事件的處理:
processTimeEvents(eventLoop);
至此,Redis 的事件循環的機制已經介紹完畢,可以觀察到整個事件循環的邏輯過程都沒有涉及具體的命令操作,只需要定義事件的類型和處理器即可。可以說這部分就是Reactor 模式體現出來的一個好處:接收事件和處理流程的實現相互解耦。
三、一次命令操作的完整流程
本章是建立在 Redis 已經完成了初始化工作,主要是創建事件循環之后,Redis 接受一個客戶端操作的完整流程的介紹。如果對初始化過程還有問題,請參考上文。
本章主要分為兩個階段:
- 第一個階段:一個外部客戶端與 Redis 服務器建立 TCP 連接。
- 比如我們常用的 Telnet 到 Redis 端口的操作。
? ~ telnet 127.0.0.1 6379 Trying 127.0.0.1... Connected to 127.0.0.1. Escape character is '^]'.
- 第二階段:已經建立連接的客戶端,對Redis 發起一次SET命令的操作。
set a 1 +OK
3.1 一個客戶端連接進服務器的過程
如圖,展示一個新的外部客戶端與 Redis 服務器建立連接的過程。
當有客戶端連接到 Redis 服務器的時候,注冊在事件循環中的監聽服務端口的事件就會變成讀就緒狀態,從而觸發這個事件到待處理事件隊列中,準備調用acceptTcpHandler進行處理。
- 為在服務器端創建一個對應本次連接的套接字。
- 把服務端套接字的文件描述符cfd作為參數,創建client變量。
- 為該客戶端連接創建并注冊一個關聯了readQueryFromClient處理器的可讀事件到事件循環,用于下一步接收并執行命令的工作。
3.2 一次客戶端連接和調用命令的執行流程
如圖展示一個客戶端已經完成了連接,對 Redis 服務器發起一次SET操作后,Redis 處理命令的完整流程。
在上一節中提到,當一個客戶端建立連接后,會有一個可讀事件關聯到事件循環,等待接收命令。當有客戶端發起一次命令操作后,Redis 就會調用readQueryFromClient處理器,對用戶發送過來的請求,按 RESP (REdis Serialization Protocol) 進行解析處理后,調用相關的命令進行處理。
- 調用命令的函數主要做兩個事情:(1)查找對應的命令,比如這里的SET(2)調用該命令關聯的函數進行處理,這里就是setCommand。
- setCommand函數將客戶端傳進來的參數,變更數據庫對應 KEY 的值,然后回復客戶端。
- 回復客戶端addReply函數將返回給客戶端的內容,寫到客戶端變量的輸出緩沖client.buf中,等待發送給客戶端。
返回結果給客戶端
以上是整個SET命令的事件處理,不過在這個時候,返回給用戶的回復內容,只存放于服務器的客戶端變量輸出緩沖中。至于將結果返回給用戶的過程,取決于版本,有不同的操作。
在 4.0 以前,每次的addReply操作會創建一個寫事件,然后放到事件循環中執行。
而 4.0 開始,在每次重新進入一個新的循環之前,就是eventLoop->beforesleep();這個操作,Redis 會嘗試直接發送給客戶端,只有當發送的內容超過一定大小,無法一次發送完成的時候,才會去創建一個可寫事件。
有興趣的讀者可以去看下 Redis 作者的這個 commit:
antirez in commit 1c7d87d: Avoid installing the client write handler when possible.
目的是減少一次系統調用,適用于大部分操作類命令的回復。
可以觀察到,整個操作的實現過程,和事件循環本身沒有交集的(沒有涉及到ae.c),開發者只需要關心具體命令的處理邏輯即可。
四、補充說明
- 事件都是來源于外部客戶端嗎?
- 這要看怎么定義“外部客戶端”了。首先事件本身分為兩種大類:文件事件和時間事件。本文主要介紹文件事件。而文件事件的產生可以是來源于網絡客戶端的連接,正如本文所描述的,也可以來自 Redis 集群內部運行需要,會使用一些偽客戶端來觸發一些文件事件。
- 舉個例子,當有從節點 (slave/replica) 向主節點 (master) 發起一次同步的時候,在 Redis 就會產生一個需要處理同步數據的事件。不過嚴格意義上來講,這個從節點對于主節點 Redis 來說,也屬于“外部客戶端”。正常情況下,Redis 自身不會主動產生文件事件。
- Redis 是怎么定期更新狀態、刪除過期KEY的?
- 讀者大概猜到我要引出時間事件這個概念了。Redis 會定期執行服務器的檢查,以及一些周期操作,這個周期由參數hz決定,默認情況下是100毫秒觸發一次檢查,執行該周期內的時間事件。
- 時間事件 是 Redis 也是核心流程中重要的一個組成部分,限于篇幅不在這里詳細介紹。但有了對事件循環的認識,要理解時間事件本身也不會太困難。