Redis 是如何處理命令的(客戶端)

Draveness發表於2017-01-01

在使用 Redis 的過程中經常會好奇,在 Redis-Cli 中鍵入 SET KEY MSG 並回車之後,Redis 客戶端和服務是如何對命令進行解析處理的,而在內部的實現過程是什麼樣的。

這兩篇文章會分別介紹 Redis 客戶端和服務端分別對命令是如何處理的,本篇文章介紹的是 Redis 客戶端如何處理輸入的命令、向服務傳送命令以及取得服務端回覆並輸出到終端等過程。

Redis 是如何處理命令的(客戶端)
redis-client-serve

文章中會將 Redis 服務看做一個輸入為 Redis 命令,輸出為命令執行結果的黑箱,對從命令到結果的過程不做任何解釋,只會著眼於客戶端的邏輯,也就是上圖中的 1 和 4 兩個過程。

從 main 函式開始

與其它的 C 語言框架/服務類似,Redis 的客戶端 redis-cli 也是從 main 函式開始執行的,位於 redis-cli.c 檔案的最後:

int main(int argc, char **argv) {
    ...
    if (argc == 0 && !config.eval) {
        repl();
    }
    ...
}複製程式碼

在一般情況下,Redis 客戶端都會進入 repl 模式,對輸入進行解析;

Redis 中有好多模式,包括:Latency、Slave、Pipe、Stat、Scan、LRU test 等等模式,不過這些模式都不是這篇文章關注的重點,我們只會關注最常見的 repl 模式。

static void repl(void) {
    char *line;
    int argc;
    sds *argv;

    ...

    while((line = linenoise(context ? config.prompt : "not connected> ")) != NULL) {
        if (line[0] != '\0') {
            argv = cliSplitArgs(line,&argc);

            if (argv == NULL) {
                printf("Invalid argument(s)\n");
                continue;
            } 
            if (strcasecmp(argv[0],"???") == 0) {
                ...
            } else {
                issueCommandRepeat(argc, argv, 1);
            }
        }
    }
    exit(0);
}複製程式碼

在上述程式碼中,我們省略了大量的實現細節,只保留整個 repl 中迴圈的主體部分,方便進行理解和分析,在 while 迴圈中的條件你可以看到 linenoise 方法的呼叫,通過其中的 promptnot connected> 可以判斷出,這裡向終端中輸出了提示符,同時會呼叫 fgets 從標準輸入中讀取字串:

127.0.0.1:6379>複製程式碼

全域性搜一下 config.prompt 不難發現這一行程式碼,也就是控制命令列提示的 prompt

anetFormatAddr(config.prompt, sizeof(config.prompt),config.hostip, config.hostport);複製程式碼

接下來執行的 cliSplitArgs 函式會將 line 中的字串分割成幾個不同的引數,然後根據字串 argv[0] 的不同執行的命令,在這裡省略了很多原有的程式碼:

if (strcasecmp(argv[0],"quit") == 0 ||
    strcasecmp(argv[0],"exit") == 0)
{
    exit(0);
} else if (argv[0][0] == ':') {
    cliSetPreferences(argv,argc,1);
    continue;
} else if (strcasecmp(argv[0],"restart") == 0) {
    ...
} else if (argc == 3 && !strcasecmp(argv[0],"connect")) {
    ...
} else if (argc == 1 && !strcasecmp(argv[0],"clear")) {
} else {
    issueCommandRepeat(argc, argv, 1);
}複製程式碼

在遇到 quitexit 等跟客戶端狀態有關的命令時,就會直接執行相應的程式碼;否則就會將命令和引數 issueCommandRepeat 函式。

追蹤一次命令的執行

Redis Commit: 790310d89460655305bd615bc442eeaf7f0f1b38

lldb: lldb-360.1.65

macOS 10.11.6

在繼續分析 issueCommandRepeat 之前,我們先對 Redis 中的這部分程式碼進行除錯追蹤,在使用 make 編譯了 Redis 原始碼,啟動 redis-server 之後;啟動 lldb 對 Redis 客戶端進行除錯:

$ lldb src/redis-cli
(lldb) target create "src/redis-cli"
Current executable set to 'src/redis-cli' (x86_64).
(lldb) b redis-cli.c:1290
Breakpoint 1: where = redis-cli`repl + 228 at redis-cli.c:1290, address = 0x0000000100008cd4
(lldb) process launch
Process 8063 launched: '~/redis/src/redis-cli' (x86_64)
127.0.0.1:6379>複製程式碼

redis-cli.c:1290 也就是下面這行程式碼的地方打斷點之後:

-> 1290            if (line[0] != '\0') {複製程式碼

執行 process launch 啟動 redis-cli,然後輸入 SET KEY MSG 回車以及 Ctrl-C:

在 lldb 中除錯時,回車的輸入經常會有問題,在這裡輸入 Ctrl-C 進入訊號處理器,在通過 continue 命令進入斷點:

127.0.0.1:6379> SET KEY MSG
^C
8063 stopped
* thread #1: tid = 0xa95147, 0x00007fff90923362 libsystem_kernel.dylib`read + 10, stop reason = signal SIGSTOP
    frame #0: 0x00007fff90923362 libsystem_kernel.dylib`read + 10
libsystem_kernel.dylib`read:
->  0x7fff90923362 <+10>: jae    0x7fff9092336c            ; <+20>
    0x7fff90923364 <+12>: movq   %rax, %rdi
    0x7fff90923367 <+15>: jmp    0x7fff9091c7f2            ; cerror
    0x7fff9092336c <+20>: retq
(lldb) c
Process 8063 resuming

Process 8063 stopped
* thread #1: tid = 0xa95147, 0x0000000100008cd4 redis-cli`repl + 228 at redis-cli.c:1290, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
    frame #0: 0x0000000100008cd4 redis-cli`repl + 228 at redis-cli.c:1290
   1287
   1288        cliRefreshPrompt();
   1289        while((line = linenoise(context ? config.prompt : "not connected> ")) != NULL) {
-> 1290            if (line[0] != '\0') {
   1291                argv = cliSplitArgs(line,&argc);
   1292                if (history) linenoiseHistoryAdd(line);
   1293                if (historyfile) linenoiseHistorySave(historyfile);
(lldb)複製程式碼

輸入兩次 n 之後,列印 argvargc 的值:

(lldb) p argc
(int) $1 = 3
(lldb) p *argv
(sds) $2 = 0x0000000100106cc3 "SET"
(lldb) p *(argv+1)
(sds) $3 = 0x0000000100106ce3 "KEY"
(lldb) p *(argv+2)
(sds) $4 = 0x0000000100106cf3 "MSG"
(lldb) p line
(char *) $5 = 0x0000000100303430 "SET KEY MSG\n"複製程式碼

cliSplitArgs 方法成功將 line 中的字串分隔成字串引數,在多次執行 n 之後,進入 issueCommandRepeat 方法:

-> 1334                        issueCommandRepeat(argc-skipargs, argv+skipargs, repeat);複製程式碼

對輸入命令的處理

上一階段執行 issueCommandRepeat 的函式呼叫棧中,會發現 Redis 並不會直接把所有的命令傳送到服務端:

issueCommandRepeat
    cliSendCommand
        redisAppendCommandArgv
            redisFormatCommandArgv
            __redisAppendCommand複製程式碼

而是會在 redisFormatCommandArgv 中對所有的命令進行格式化處理,將字串轉換為符合 RESP 協議的資料。

RESP 協議

Redis 客戶端與 Redis 服務進行通訊時,會使用名為 RESP(REdis Serialization Protocol) 的協議,它的使用非常簡單,並且可以序列化多種資料型別包括整數、字串以及陣列等。

對於 RESP 協議的詳細介紹可以看官方文件中的 Redis Protocol specification,在這裡對這個協議進行簡單的介紹。

在將不同的資料型別序列化時,會使用第一個 byte 來表示當前資料的資料型別,以便在客戶端或伺服器在處理時能恢復原來的資料格式。

Redis 是如何處理命令的(客戶端)
redis-resp-data-byte

舉一個簡單的例子,字串 OK 以及錯誤Error Message 等不同種類的資訊的 RESP 表示如下:

Redis 是如何處理命令的(客戶端)
redis-resp-type-and-examples

在這篇文章中我們需要簡單瞭解的就是 RESP “資料格式”的第一個位元組用來表示資料型別,然後邏輯上屬於不同部分的內容通過 CRLF(\r\n)分隔

資料格式的轉換

redisFormatCommandArgv 方法中幾乎沒有需要刪減的程式碼,所有的命令都會以字串陣列的形式傳送到客戶端:

int redisFormatCommandArgv(char **target, int argc, const char **argv, const size_t *argvlen) {
    char *cmd = NULL;
    int pos;
    size_t len;
    int totlen, j;

    totlen = 1+intlen(argc)+2;
    for (j = 0; j < argc; j++) {
        len = argvlen ? argvlen[j] : strlen(argv[j]);
        totlen += bulklen(len);
    }

    cmd = malloc(totlen+1);
    if (cmd == NULL)
        return -1;

    pos = sprintf(cmd,"*%d\r\n",argc);
    for (j = 0; j < argc; j++) {
        len = argvlen ? argvlen[j] : strlen(argv[j]);
        pos += sprintf(cmd+pos,"$%zu\r\n",len);
        memcpy(cmd+pos,argv[j],len);
        pos += len;
        cmd[pos++] = '\r';
        cmd[pos++] = '\n';
    }
    assert(pos == totlen);
    cmd[pos] = '\0';

    *target = cmd;
    return totlen;
}複製程式碼

SET KEY MSG 這一命令,經過這個方法的處理會變成:

*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$3\r\nMSG\r\n複製程式碼

你可以這麼理解上面的結果:

*3\r\n
    $3\r\nSET\r\n
    $3\r\nKEY\r\n
    $3\r\nMSG\r\n複製程式碼

這是一個由三個字串組成的陣列,陣列中的元素是 SETKEY 以及 MSG 三個字串。

如果在這裡打一個斷點並輸出 target 中的內容:

Redis 是如何處理命令的(客戶端)
redis-lldb-cmd

到這裡就完成了對輸入命令的格式化,在格式化之後還會將當前命令寫入全域性的 redisContextwrite 緩衝區 obuf 中,也就是在上面的緩衝區看到的第二個方法:

int __redisAppendCommand(redisContext *c, const char *cmd, size_t len) {
    sds newbuf;

    newbuf = sdscatlen(c->obuf,cmd,len);
    if (newbuf == NULL) {
        __redisSetError(c,REDIS_ERR_OOM,"Out of memory");
        return REDIS_ERR;
    }

    c->obuf = newbuf;
    return REDIS_OK;
}複製程式碼

redisContext

再繼續介紹下一部分之前需要簡單介紹一下 redisContext 結構體:

typedef struct redisContext {
    int err;
    char errstr[128];
    int fd;
    int flags;
    char *obuf;
    redisReader *reader;
} redisContext;複製程式碼

每一個 redisContext 的結構體都表示一個 Redis 客戶端對服務的連線,而這個上下文會在每一個 redis-cli 中作為靜態變數僅儲存一個:

static redisContext *context;複製程式碼

obuf 中包含了客戶端未寫到服務端的資料;而 reader 是用來處理 RESP 協議的結構體;fd 就是 Redis 服務對應的檔案描述符;其他的內容就不多做解釋了。

到這裡,對命令的格式化處理就結束了,接下來就到了向服務端傳送命令的過程了。

向伺服器傳送命令

與對輸入命令的處理差不多,向伺服器傳送命令的方法也在 issueCommandRepeat 的呼叫棧中,而且藏得更深,如果不仔細閱讀原始碼其實很難發現:

issueCommandRepeat
    cliSendCommand
        cliReadReply
            redisGetReply
               redisBufferWrite複製程式碼

Redis 在 redisGetReply 中完成對命令的傳送:

int redisGetReply(redisContext *c, void **reply) {
    int wdone = 0;
    void *aux = NULL;

    if (aux == NULL && c->flags & REDIS_BLOCK) {
        do {
            if (redisBufferWrite(c,&wdone) == REDIS_ERR)
                return REDIS_ERR;
        } while (!wdone);

        ...
        } while (aux == NULL);
    }

    if (reply != NULL) *reply = aux;
    return REDIS_OK;
}複製程式碼

上面的程式碼向 redisBufferWrite 函式中傳遞了全域性的靜態變數 redisContext,其中的 obuf 中儲存了沒有向 Redis 服務傳送的命令:

int redisBufferWrite(redisContext *c, int *done) {
    int nwritten;

    if (sdslen(c->obuf) > 0) {
        nwritten = write(c->fd,c->obuf,sdslen(c->obuf));
        if (nwritten == -1) {
            if ((errno == EAGAIN && !(c->flags & REDIS_BLOCK)) || (errno == EINTR)) {
            } else {
                __redisSetError(c,REDIS_ERR_IO,NULL);
                return REDIS_ERR;
            }
        } else if (nwritten > 0) {
            if (nwritten == (signed)sdslen(c->obuf)) {
                sdsfree(c->obuf);
                c->obuf = sdsempty();
            } else {
                sdsrange(c->obuf,nwritten,-1);
            }
        }
    }
    if (done != NULL) *done = (sdslen(c->obuf) == 0);
    return REDIS_OK;
}複製程式碼

程式碼的邏輯其實十分清晰,呼叫 write 向 Redis 服務代表的檔案描述符傳送寫緩衝區 obuf 中的資料,然後根據返回值做出相應的處理,如果命令傳送成功就會清空 obuf 並將 done 指標標記為真,然後返回,這樣就完成了向伺服器傳送命令這一過程。

Redis 是如何處理命令的(客戶端)
redis-lldb-nwritten

獲取伺服器回覆

其實獲取伺服器回覆和上文中的傳送命令過程基本上差不多,呼叫棧也幾乎完全一樣:

issueCommandRepeat
    cliSendCommand
        cliReadReply
            redisGetReply
                redisBufferRead
                redisGetReplyFromReader
            cliFormatReplyRaw
            fwrite複製程式碼

同樣地,在 redisGetReply 中獲取伺服器的響應:

int redisGetReply(redisContext *c, void **reply) {
    int wdone = 0;
    void *aux = NULL;

    if (aux == NULL && c->flags & REDIS_BLOCK) {
        do {
            if (redisBufferWrite(c,&wdone) == REDIS_ERR)
                return REDIS_ERR;
        } while (!wdone);

        do {
            if (redisBufferRead(c) == REDIS_ERR)
                return REDIS_ERR;
            if (redisGetReplyFromReader(c,&aux) == REDIS_ERR)
                return REDIS_ERR;
        } while (aux == NULL);
    }

    if (reply != NULL) *reply = aux;
    return REDIS_OK;
}複製程式碼

redisBufferWrite 成功傳送命令並返回之後,就會開始等待服務端的回覆,總共分為兩個部分,一是使用 redisBufferRead 從服務端讀取原始格式的回覆(符合 RESP 協議):

int redisBufferRead(redisContext *c) {
    char buf[1024*16];
    int nread;

    nread = read(c->fd,buf,sizeof(buf));
    if (nread == -1) {
        if ((errno == EAGAIN && !(c->flags & REDIS_BLOCK)) || (errno == EINTR)) {
        } else {
            __redisSetError(c,REDIS_ERR_IO,NULL);
            return REDIS_ERR;
        }
    } else if (nread == 0) {
        __redisSetError(c,REDIS_ERR_EOF,"Server closed the connection");
        return REDIS_ERR;
    } else {
        if (redisReaderFeed(c->reader,buf,nread) != REDIS_OK) {
            __redisSetError(c,c->reader->err,c->reader->errstr);
            return REDIS_ERR;
        }
    }
    return REDIS_OK;
}複製程式碼

read 從檔案描述符中成功讀取資料並返回之後,我們可以列印 buf 中的內容:

Redis 是如何處理命令的(客戶端)
redis-lldb-read

剛剛向 buf 中寫入的資料還需要經過 redisReaderFeed 方法的處理,擷取正確的長度;然後存入 redisReader 中:

int redisReaderFeed(redisReader *r, const char *buf, size_t len) {
    sds newbuf;

    if (buf != NULL && len >= 1) {
        if (r->len == 0 && r->maxbuf != 0 && sdsavail(r->buf) > r->maxbuf) {
            sdsfree(r->buf);
            r->buf = sdsempty();
            r->pos = 0;
            assert(r->buf != NULL);
        }

        newbuf = sdscatlen(r->buf,buf,len);
        if (newbuf == NULL) {
            __redisReaderSetErrorOOM(r);
            return REDIS_ERR;
        }

        r->buf = newbuf;
        r->len = sdslen(r->buf);
    }

    return REDIS_OK;
}複製程式碼

最後的 redisGetReplyFromReader 方法會從 redisContext 中取出 reader,然後反序列化 RESP 物件,最後列印出來。

Redis 是如何處理命令的(客戶端)
process-end

當我們從終端的輸出中看到了 OK 以及這個命令的執行的時間時,SET KEY MSG 這一命令就已經處理完成了。

總結

處理命令的過程在客戶端還是比較簡單的:

  1. 在一個 while 迴圈中,輸出提示符;
  2. 接收到輸入命令時,對輸入命令進行格式化處理;
  3. 通過 write 傳送到 Redis 服務,並呼叫 read 阻塞當前程式直到服務端返回為止;
  4. 對服務端返回的資料反序列化;
  5. 將結果列印到終端。

用一個簡單的圖表示,大概是這樣的:

Redis 是如何處理命令的(客戶端)
redis-client-process-commands

References

Follow: Draveness · GitHub

Source: draveness.me/redis-cli

相關文章