http://mp.weixin.qq.com/s/M_8JYKounmZWHPOXVJFNuQ?utm_source=tuicool&utm_medium=referral
最近筆者閱讀并研究Redis源碼,在Redis客戶端與服務器端交互這個內容點上,需要參考網上一些文章,但是遺憾的是發現大部分文章都斷斷續續的非系統性的,不能給讀者此交互流程的整體把握。所以這里我嘗試,站在源碼的角度,將Redis client/server 交互流程盡可能簡單地展現給大家,同時也站在DBA的角度給出一些日常工作中注意事項。
Redis client/server 交互步驟分為以下6個步驟:
Step 1:Client 發起socket 連接
Step 2:Server 接受socket連接
Step 3:客戶端 開始寫入
Step 4:server 端接收寫入
Step 5:server 返回寫入結果
Step 6:Client收到返回結果
注:為使文章盡可能簡潔,這里只討論客戶端命令寫入的過程,不討論客戶端命令讀取的流程。
在進一步閱讀和了解互動流程之前,請大家確保已經熟練掌握了Linux Socket 建立流程和epoll I/O 多路復用技術兩個技術點,這對文章內容的理解至關重要。(這兩個技術點在文末的附錄有講解,想復習的同學請先翻到文末)
在介紹6個步驟之前,首先看一下Redis client/server 交互流程整體的程序執行流程圖:

上圖中6個步驟分別用不同的顏色箭頭表示,并且最終結果也用相對應的顏色標識。
首先看看綠色框里面的循環執行的方法,最末是epoll_wait方法,即等待事件產生的方法。然后再看第2、4、5步驟的末尾都有epoll_ctl方法,即epoll事件注冊函數。關于epoll的相關技術解析請參看文末一段。
在這里的循環還有個beforeSleep方法,其實它跟我們這次討論的話題沒有太大的關系。但是還是想給大家介紹一下。
beforeSleep方法主要做以下幾件事:
執行一次快速的主動過期檢查,檢查是否有過期的key
當有客戶端阻塞時,向所有從庫發送ACK請求
unblock 在同步復制時候被阻塞的客戶端
嘗試執行之前被阻塞客戶端的命令
將AOF緩沖區的內容寫入到AOF文件中
如果是集群,將會根據需要執行故障遷移、更新節點狀態、保存node.conf 配置文件。
如此,Redis整個事件管理機制就比較清楚了。接下來進一步探討并理解事件是如何觸發并創建。

下面正式開始介紹Redis client/server 交互的6大步驟
Step 1:Client 發起socket 連接

這里以redis-cli 客戶端為例,當執行以下語句時:
[root@zbdba redis-3.0]# ./src/redis-cli -p 6379 -h 127.0.0.1
127.0.0.1:6379>
客戶端會做如下操作:
(1) 獲取客戶端參數,如端口、ip地址、dbnum、socket等
也就是我們執行./src/redis-cli --help 中列出的參數
(2) 根據用戶指定參數確定客戶端處于哪種模式
目前共有8種模式:
Latency mode
Slave mode
Get RDB mode
Pipe mode
Find big keys
Stat mode
Scan mode
Intrinsic latency mode
例如:stat 模式
[root@zbdba redis-3.0]# ./src/redis-cli -p 6379 -h 127.0.0.1 --stat
------- data ------ --------------------- load -------------------- - child -
keys mem clients blocked requests connections
1 817.18K 2 0 1 (+0) 2
1 817.18K 2 0 2 (+1) 2
1 817.18K 2 0 3 (+1) 2
1 817.18K 2 0 4 (+1) 2
1 817.18K 2 0 5 (+1) 2
1 817.18K 2 0 6 (+1) 2
我們這里沒有指定,就是默認的模式。
(3 ) 進入上圖中step1的cliConnect 方法,cliConnect主要包含redisConnect、redisConnectUnix方法。這兩個方法分別用于TCP Socket連接以及Unix Socket連接,Unix Socket用于同一主機進程間的通信。我們上面是采用的TCP Socket連接方式也就是我們平常生產環境常用的方式,這里不討論Unix Socket連接方式,如果要使用Unix Socket連接方式,需要配置unixsocket 參數,并且按照下面方式進行連接:
[root@zbdba redis-3.0]# ./src/redis-cli -s /tmp/redis.sock
redis /tmp/redis.sock>
(4) 進入redisContextInit方法,redisContextInit方法用于創建一個Context結構體保存在內存中,如下:
/* Context for a connection to Redis */
typedef struct redisContext {
int err; /* Error flags, 0 when there is no error */
char errstr[128]; /* String representation of error when applicable */
int fd;
int flags;
char *obuf; /* Write buffer */
redisReader *reader; /* Protocol reader */
} redisContext;
主要用于保存客戶端的一些東西,最重要的就是 write buffer和redisReader,write buffer 用于保存客戶端的寫入,redisReader用于保存協議解析器的一些狀態。
(5) 進入redisContextConnectTcp 方法,開始獲取IP地址和端口用于建立連接,主要方法如下:
s = socket(p->ai_family,p->ai_socktype,p->ai_protocol
connect(s,p->ai_addr,p->ai_addrlen)
到此客戶端向服務端發起建立socket連接,并且等待服務器端響應。
當然cliConnect方法中還會調用cliAuth方法用于權限驗證、cliSelect用于db選擇,這里不著重討論。
Step 2:Server 接受socket連接

服務器接收客戶端的請求首先是從epoll_wait取出相關的事件,然后進入上圖中step2中的方法,執行acceptTcpHandler或者acceptUnixHandler方法,那么這兩個方法對應的事件是在什么時候注冊的呢?他們是在服務器端初始化的時候創建。下面看看服務器端在初始化的時候與socket相關的地方
(1) 打開TCP監聽端口
if (server.port != 0 &&
listenToPort(server.port,server.ipfd,&server.ipfd_count) == REDIS_ERR)
exit(1);
(2) 打開unix 本地端口
if (server.unixsocket != NULL) {
unlink(server.unixsocket); /* don't care if this fails */
server.sofd = anetUnixServer(server.neterr,server.unixsocket,
server.unixsocketperm, server.tcp_backlog);
if (server.sofd == ANET_ERR) {
redisLog(REDIS_WARNING, "Opening socket: %s", server.neterr);
exit(1);
}
anetNonBlock(NULL,server.sofd);
}
(3) 為TCP連接關聯連接應答處理器(accept)
for (j = 0; j < server.ipfd_count; j++) {
if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
acceptTcpHandler,NULL) == AE_ERR)
{
redisPanic(
"Unrecoverable error creating server.ipfd file event.");
}
}
(4) 為Unix Socket關聯應答處理器
if (server.sofd > 0 && aeCreateFileEvent(server.el,server.sofd,AE_READABLE,
acceptUnixHandler,NULL) == AE_ERR) redisPanic("Unrecoverable error creating server.sofd file event.");
在1/2步驟涉及到的方法中是Linux Socket的常規操作,獲取IP地址,端口。最終通過socket、bind、listen方法建立起Socket監聽。也就是上圖中acceptTcpHandler和acceptUnixHandler下面對應的方法。
在3/4步驟涉及到的方法中采用aeCreateFileEvent 方法創建相關的連接應答處理器,在客戶端請求連接的時候觸發。
所以現在整個socket連接建立流程就比較清楚了,如下:
服務器初始化建立socket監聽
服務器初始化創建相關連接應答處理器,通過epoll_ctl注冊事件
客戶端初始化創建socket connect 請求
服務器接受到請求,用epoll_wait方法取出事件
服務器執行事件中的方法(acceptTcpHandler/acceptUnixHandler)并接受socket連接
至此客戶端和服務器端的socket連接已經建立,但是此時服務器端還繼續做了2件事:
采用createClient方法在服務器端為客戶端創建一個client,因為I/O復用所以需要為每個客戶端維持一個狀態。這里的client也在內存中分配了一塊區域,用于保存它的一些信息,如套接字描述符、默認數據庫、查詢緩沖區、命令參數、認證狀態、回復緩沖區等。這里提醒一下DBA同學關于client-output-buffer-limit設置,設置不恰當將會引起客戶端中斷。
采用aeCreateFileEvent方法在服務器端創建一個文件讀事件并且綁定readQueryFromClient方法。
可以從圖中得知,aeCreateFileEvent 調用aeApiAddEvent方法最終通過epoll_ctl 方法進行注冊事件。
Step 3 :客戶端開始寫入

客戶端在與服務器端建立好socket連接之后,開始執行上圖中step3的repl方法。從圖中可知repl方法接受輸入輸出主要是采用linenoise插件。當然這是針對redis-cli客戶端哦。linenoise 是一款優秀的命令行編輯庫,被廣泛的運用在各種DB上,如Redis、MongoDB,這里不詳細討論。客戶端寫入流程分為以下幾步:
(1) linenoise等待接受用戶輸入
(2) linenoise 將用戶輸入內容傳入cliSendCommand方法,cliSendCommand方法會判斷命令是否為特殊命令,如:
help
info
cluster nodes
cluster info
client list
shutdown
monitor
subscribe
psubscribe
sync
psync
客戶端會根據以上命令設置對應的輸出格式以及客戶端的模式,因為這里我們是普通寫入,所以不會涉及到以上的情況。
(3) cliSendCommand方法會調用redisAppendCommandArgv方法,redisAppendCommandArgv方法會調用redisFormatCommandArgv和__redisAppendCommand方法
redisFormatCommandArgv方法用于將客戶端輸入的內容格式化成redis協議:
例如:
set zbdba jingbo
*3\r\n$3\r\n set\r\n $5\r\n zbdba\r\n $6\r\n jingbo
__redisAppendCommand方法用于將命令寫入到outbuf中
接著客戶端進入下一個流程,將outbuf內容寫入到套接字描述符上并傳輸到服務器端。
(4) 進入redisGetReply方法,該方法下主要有redisGetReplyFromReader和redisBufferWrite 方法,redisGetReplyFromReader主要用于讀取掛起的回復,redisBufferWrite 方法用于將當前outbuf中的內容寫入到套接字描述符中,并傳輸內容。
主要方法如下:
nwritten = write(c->fd,c->obuf,sdslen(c->obuf));
Step 4:server 端接收寫入

服務器端依然在進行事件循環,在客戶端發來內容的時候觸發,對應的文件讀取事件。這就是之前創建socket連接的時候建立的事件,該事件綁定的方法是readQueryFromClient 。此時進入step4的readQueryFromClient 方法。
readQueryFromClient 方法用于讀取客戶端的發送的內容。它的執行步驟如下:
(1) 在readQueryFromClient方法中從服務器端套接字描述符中讀取客戶端的內容到服務器端初始化client的查詢緩沖中,主要方法如下:
nread = read(fd, c->querybuf+qblen, readlen);
(2) 交給processInputBuffer處理,processInputBuffer 主要包含兩個方法,processInlineBuffer和processCommand。processInlineBuffer方法用于采用Redis協議解析客戶端內容并生成對應的命令并傳給processCommand 方法,processCommand方法則用于執行該命令
(3) processCommand方法會以下操作:
處理是否為quit命令。
對命令語法及參數會進行檢查。
這里如果采取認證也會檢查認證信息。
如果Redis為集群模式,這里將進行hash計算key所屬slot并進行轉向操作。
如果設置最大內存,那么檢查內存是否超過限制,如果超過限制會根據相應的內存策略刪除符合條件的鍵來釋放內存
如果這是一個主服務器,并且這個服務器之前執行bgsave發生了錯誤,那么不執行命令
如果min-slaves-to-write開啟,如果沒有足夠多的從服務器將不會執行命令
注:所以DBA在此的設置非常重要,建議不是特殊場景不要設置。
如果這個服務器是一個只讀從庫的話,拒絕寫入命令。
在訂閱于發布模式的上下文中,只能執行訂閱和退訂相關的命令
當這個服務器是從庫,master_link down 并且slave-serve-stale-data 為 no 只允許info 和slaveof命令
如果服務器正在載入數據到數據庫,那么只執行帶有REDIS_CMD_LOADING標識的命令
lua腳本超時,只允許執行限定的操作,比如shutdown、script kill 等
(4) 最后進入call方法。
call方法會調用setCommand,因為這里我們執行的set zbdba jingbo,set 命令對應setCommand 方法,Redis服務器端在開始初始化的時候就會初始化命令表,命令表如下:
struct redisCommand redisCommandTable[] = {
{"get",getCommand,2,"r",0,NULL,1,1,1,0,0},
{"set",setCommand,-3,"wm",0,NULL,1,1,1,0,0},
{"setnx",setnxCommand,3,"wm",0,NULL,1,1,1,0,0},
{"setex",setexCommand,4,"wm",0,NULL,1,1,1,0,0},
{"psetex",psetexCommand,4,"wm",0,NULL,1,1,1,0,0},
{"append",appendCommand,3,"wm",0,NULL,1,1,1,0,0},
{"strlen",strlenCommand,2,"r",0,NULL,1,1,1,0,0},
{"del",delCommand,-2,"w",0,NULL,1,-1,1,0,0},
{"exists",existsCommand,2,"r",0,NULL,1,1,1,0,0},
{"setbit",setbitCommand,4,"wm",0,NULL,1,1,1,0,0},
....
}
所以如果是其他的命令會調用其他相對應的方法。call方法還會做一些事件,比如發送命令到從庫、發送命令到aof、計算命令執行的時間。
(5) setCommand方法,setCommand方法會調用setGenericCommand方法,該方法首先會判斷該key是否已經過期,最后調用setKey方法。
這里需要說明一點的是,通過以上的分析。Redis的key過期包括主動檢測以及被動監測 。
主動監測
在beforeSleep方法中執行key快速過期檢查,檢查模式為ACTIVE_EXPIRE_CYCLE_FAST。周期為每個事件執行完成時間到下一次事件循環開始。
在serverCron方法中執行key過期檢查,這是key過期檢查主要的地方,檢查模式為ACTIVE_EXPIRE_CYCLE_SLOW,serverCron方法執行周期為1秒鐘執行server.hz 次,hz默認為10,所以約100ms執行一次。hz設置越大過期鍵刪除就越精準,但是cpu使用率會越高,這里我們線上Redis采用的默認值。Redis主要是在這個方法里刪除大部分的過期鍵。
被動監測
使用內存超過最大內存被迫根據相應的內存策略刪除符合條件的key。
在key寫入之前進行被動檢查,檢查key是否過期,過期就進行刪除。
還有一種不友好的方式,就是randomkey命令,該命令隨機從Redis獲取鍵,每次獲取到鍵的時候會檢查該鍵是否過期。
以上主要是讓運維的同學更加清楚Redis的key過期刪除機制。
(6) 進入setKey方法,setKey方法最終會調用dbAdd方法,其實最終就是將該鍵值對存入服務器端維護的一個字典中,該字典是在服務器初始化的時候創建,用于存儲服務器的相關信息,其中包括各種數據類型的鍵值存儲。完成了寫入方法時候,此時服務器端會給客戶端返回結果。
(7) 進入prepareClientToWrite方法然后通過調用_addReplyToBuffer方法將返回結果寫入到outbuf中(客戶端連接時創建的client)
(8) 通過aeCreateFileEvent方法注冊文件寫事件并綁定sendReplyToClient方法
Step 5:server 返回寫入結果

此時按照慣例,aeMain主函數循環,監測到新注冊的事件,調用sendReplyToClient方法。sendReplyToClient方法主要包含兩個操作:
(1) 將outbuf內容寫入到套接字描述符并傳輸到客戶端,主要方法如下:
nwritten = write(fd,c->buf+c->sentlen,c->bufpos-c->sentlen);
(2) aeDeleteFileEvent 用于刪除 文件寫事件
Step 6:Client收到返回結果

客戶端接收到服務器端的返回調用redisBufferRead方法,該方法主要用于從socket中讀取數據。主要方法如下:
nread = read(c->fd,buf,sizeof(buf));
并且將讀取的數據交由redisReaderFeed方法,該方法主要用于將數據交給回復解析器處理,也就是cliFormatReplyRaw,該方法將回復內容格式化。最終通過
fwrite(out,sdslen(out),1,stdout);
方法返回給客戶端并打印展示給用戶。
至此整個寫入流程完成。以上還有很多細節沒有說到,感興趣的朋友可以自行閱讀源碼。

在深入了解一個DB的時候,我的第一步就是去理解它執行一條命令執行的整個流程,這樣就能對它整個運行流程較為熟悉,接著我們可以去深入各個細節的部分,比如Redis的相關數據結構、持久化以及高可用相關的東西。寫這篇文章的初衷就是希望我們更加輕松的走好這第一步。這里還需要提醒的是,在我們進行Redis源碼閱讀的時候最關鍵的是需要靈活的使用GDB調試工具,它能幫我們更好地去理順相關執行步驟,從而讓我們更加容易理解其實現原理。

A:Linux Socket 建立流程

linux socket建立過程如上圖所示。在Linux編程時,無論是操作文件還是網絡操作時都是通過文件描述符來進行讀寫的,但是他們有一點區別,這里我們不具體討論,我們將網絡操作時就稱為套接字描述符。大家可以自行用c寫一個簡單的demo,這里就不詳細說明了。
這里列出幾個重要的方法:
int socket(int family,int type,int protocol);
int connect(int sockfd,const struct sockaddr * servaddr,socklen_taddrlen);
int bind(int sockfd,const struct sockaddr * myaddr,socklen_taddrlen);
int listen(int sockfd,int backlog);
int accept(int sockfd,struct sockaddr *cliaddr,socklen_t * addrlen);
Redis client/server 也是基于linux socket連接進行交互,并且最終調用以上方法綁定IP,監聽端口最終與客戶端建立連接。
B:epoll I/O 多路復用技術
這里重點介紹一下epoll,因為Redis事件管理器核心實現基本依賴于它。首先來看epoll是什么,它能做什么?
epoll是在Linux 2.6內核中引進的,是一種強大的I/O多路復用技術,上面我們已經說到在進行網絡操作的時候是通過文件描述符來進行讀寫的,那么平常我們就是一個進程操作一個文件描述符。然而epoll可以通過一個文件描述符管理多個文件描述符,并且不阻塞I/O。這使得我們單進程可以操作多個文件描述符,這就是Redis在高并發性能還如此強大的原因之一。
下面簡單介紹epoll 主要的三個方法:
int epoll_create(int size) //創建一個epoll句柄用于監聽文件描述符FD,size用于告訴內核這個監聽的數目一共有多大。該epoll句柄創建后在操作系統層面只會占用一個fd值,但是它可以監聽size+1 個文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) //epoll事件注冊函數
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout) //等待事件的產生
Redis 的事件管理器主要是基于epoll機制,先采用 epoll_ctl方法 注冊事件,然后再使用epoll_wait方法取出已經注冊的事件。
我們知道Redis支持多種平臺,那么Redis在這方面是如何兼容其他平臺的呢?Redis會根據操作系統的類型選擇對應的IO多路復用實現。
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c"
#else
#include "ae_select.c"
#endif
#endif
#endif
ae_evport.c sun solaris
ae_poll.c linux
ae_select.c unix/linux epoll是select的加強版
ae_kqueue BSD/Apple
以上只是簡單的介紹,大家需要詳細了解了epoll機制才能更好的理解后面的東西。
參考:
http://redis.io/
https://github.com/antirez/redis
http://www.tenouk.com/Module39a.html
注:本文版權歸InfoQ所有,煩請任何轉載先征得InfoQ同意,謝謝。
冬天霧霾天,小編提醒大家出門記得帶口罩哦~~~