深入淺出 Redis client/server互動流程

weixin_34127717發表於2018-02-03

申明

本文由筆者首發於InfoQ:《深入淺出 Redis client/server互動流程》

由於自己開了blog,所以將之前比較好的文章挪過來便於大家瀏覽。

綜述

最近筆者閱讀並研究redis原始碼,在redis客戶端與伺服器端互動這個內容點上,需要參考網上一些文章,但是遺憾的是發現大部分文章都斷斷續續的非系統性的,不能給讀者此互動流程的整體把握。所以這裡我嘗試,站在原始碼的角度,將redis client/server 互動流程儘可能簡單地展現給大家,同時也站在DBA的角度給出一些日常工作中注意事項。

Redis client/server 互動步驟分為以下6個步驟:

一、Client 發起socket 連線

二、Server 接受socket連線

三、客戶端 開始寫入

四、server 端接收寫入

五、server 返回寫入結果

六、Client收到返回結果

注:為使文章儘可能簡潔,這裡只討論客戶端命令寫入的過程,不討論客戶端命令讀取的流程。

在進一步閱讀和了解互動流程之前,請大家確保已經熟練掌握了Linux Socket 建立流程和epoll I/O 多路複用技術兩個技術點,這對文章內容的理解至關重要。

互動的整體流程

在介紹6個步驟之前,首先看一下redis client/server 互動流程整體的程式執行流程圖:

clipboard.png

上圖中6個步驟分別用不同的顏色箭頭表示,並且最終結果也用相對應的顏色標識。

首先看看綠色框裡面的迴圈執行的方法,最末是epoll_wait方法,即等待事件產生的方法。然後再看第2、4、5步驟的末尾都有epoll_ctl方法,即epoll事件註冊函式。關於epoll的相關技術解析請參看文末一段。

在這裡的迴圈還有個beforeSleep方法,其實它跟我們這次討論的話題沒有太大的關係。但是還是想給大家介紹一下。

beforeSleep方法主要做以下幾件事:

  • 執行一次快速的主動過期檢查,檢查是否有過期的key
  • 當有客戶端阻塞時,向所有從庫傳送ACK請求
  • unblock 在同步複製時候被阻塞的客戶端
  • 嘗試執行之前被阻塞客戶端的命令
  • 將AOF緩衝區的內容寫入到AOF檔案中
  • 如果是叢集,將會根據需要執行故障遷移、更新節點狀態、儲存node.conf 配置檔案。

如此,redis整個事件管理器機制就比較清楚了。接下來進一步探討並理解事件是如何觸發並建立。

互動的六大步驟

下面正式開始介紹redis client/server 互動的6大步驟

一、Client 發起socket 連線

clipboard.png

這裡以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、根據使用者指定引數確定客戶端處於哪種模式

目前共有:

Latency mode/Slave mode/Get RDB mode/Pipe mode/Find big keys/Stat mode/Scan mode/Intrinsic latency mode

以上8種模式

例如: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選擇,這裡不著重討論。

二、Server 接受socket連線

clipboard.png

伺服器接收客戶端的請求首先是從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 方法進行註冊事件。

三、客戶端開始寫入

clipboard.png

客戶端在與伺服器端建立好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));

此時客戶端等待伺服器端接收寫入。

四、server 端接收寫入

clipboard.png

伺服器端依然在進行事件迴圈,在客戶端發來內容的時候觸發,對應的檔案讀取事件。這就是之前建立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方法

五、server 返回寫入結果

clipboard.png

此時按照慣例,aeMain主函式迴圈,監測到新註冊的事件,呼叫sendReplyToClient方法。sendReplyToClient方法主要包含兩個操作:

1、將outbuf內容寫入到套接字描述符並傳輸到客戶端,主要方法如下:

nwritten = write(fd,c->buf+c->sentlen,c->bufpos-c->sentlen);

2、aeDeleteFileEvent 用於刪除 檔案寫事件

六、Client收到返回結果

clipboard.png

客戶端接收到伺服器端的返回撥用redisBufferRead方法,該方法主要用於從socket中讀取資料。主要方法如下:

nread = read(c->fd,buf,sizeof(buf));

並且將讀取的資料交由redisReaderFeed方法,該方法主要用於將資料交給回覆解析器處理,也就是cliFormatReplyRaw,該方法將回復內容格式化。最終通過

fwrite(out,sdslen(out),1,stdout);

方法返回給客戶端並列印展示給使用者。

至此整個寫入流程完成。以上還有很多細節沒有說到,感興趣的朋友可以自行閱讀原始碼。

結語

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

附錄:兩個相關重要知識點

1、Linux Socket 建立流程

clipboard.png

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,監聽埠最終與客戶端建立連線。

2、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/Module3...

相關文章