Redis源碼閱讀【8-命令處理生命週期-4】

Redis源碼閱讀【1-簡單動態字符串】
Redis源碼閱讀【2-跳躍表】
Redis源碼閱讀【3-Redis編譯與GDB調試】
Redis源碼閱讀【4-壓縮列表】
Redis源碼閱讀【5-字典】
Redis源碼閱讀【6-整數集合】
Redis源碼閱讀【7-quicklist】
Redis源碼閱讀【8-命令處理生命週期-1】
Redis源碼閱讀【8-命令處理生命週期-2】
Redis源碼閱讀【8-命令處理生命週期-3】
Redis源碼閱讀【8-命令處理生命週期-4】
Redis源碼閱讀【番外篇-Redis的多線程】
建議搭配源碼閱讀源碼地址

1、介紹

在前面的幾篇《命令處理生命週期》的文章中我們分別介紹了:生命週期有關的結構體Redis相關的事件 ,以及 服務端啓動的過程 ,那麼這篇文章我們主要講解命令的處理過程。在Redis中,服務端啓動完成後就是等待客戶端的連接,並處理來自客戶端的命令,最終響應客戶端,整個過程涉及多個方面,我們主要從以下幾個方面入手:命令解析命令調用返回結果

2、命令獲取解析與執行

TCP是一個基於字節流的通信協議,因此會產生半包和粘包的情況,如下圖所示:
在這裏插入圖片描述
客戶端會發送三個數據:數據1數據2數據3,但是在TCP傳輸層真正發送數據的時候,可能會出現一個應用層數據橫跨多個包的情況,因此服務端接收到的數據可能就不是一個完整數據包的情況。

爲了區分一個完整的數據包,通常有以下三個方法:1、固定長度的數據包2、特定的字符分隔符號3、在數據包頭部設置數據長度 來區分數據包大小。

Redis則是使用特定的協議來區分數據大小,這個協議稱之爲RESP協議(其實我個人感覺是爲了簡化開發,使用字符替代協議編解碼以及黏包等工作量,畢竟C開發可拓展的網絡指令並不是那麼靈活),比如當客戶端輸入如下命令的時候:

SET key value

客戶端會將該協議轉換爲以下的格式,然後發送給服務器:

*3\r\n$3\r\nSET\r\n$3\r\nkey\r\n$5\r\nvalue\r\n
  • " $ " 字節,後跟組成字符串的字節數(帶前綴的長度),由CRLF終止
  • 協議的不同部分始終以"\ r \ n"(CRLF)結尾
  • 對於簡單字符串,回覆的第一個字節爲" +"
  • 對於錯誤,回覆的第一個字節爲"-"
  • 對於整數,答覆的第一個字節爲""
  • 對於散裝字符串,回覆的第一個字節爲" $"
  • 對於數組,回覆的第一個字節爲" *"
  • *3在頭部代表這個命令有三個參數,分別爲 set,key,value

需要注意的是,Redis是可以使用telent會話的方式使用的,只是此時沒有了請求協議中的*來聲明參數的數量,因此必須使用空格來分隔各個參數,服務器在接收到數據之後,會將空格作爲參數分隔符解析命令請求,可以看出 RESP協議中是沒有空格存在 的,這個特點會在解析命令的時候使用上。

Redis服務器接收到的命令請求首先存儲在客戶端 client 對象的querybuf輸入緩衝區中,然後解析命令請求各個參數,並存儲在客戶端對象的argv(參數對象數組)和argc(參數數量)字段中。從上一篇文章我們知道,處理客戶端請求的函數是readQueryFromClient,其會讀取socket數據並存儲到客戶端對象的輸入緩衝區querybuf中,並調用processInputBufferAndReplicate函數中的processInputBuffer去處理。processInputBuffer函數邏輯如下圖所示:
在這裏插入圖片描述
實際內部流程圖:
在這裏插入圖片描述
時序圖:
在這裏插入圖片描述

結合前面的圖片會發現,命令解析並不是一次性完成的,由於存在拆包和粘包的情況,收到的數據可能並不是一個完整的,那麼在readQueryFromClient方法讀取完socket中的數據命令可能由於拆包導致不完整,那麼在調用processInlineBuffer或者processMultibulkBuffer解析命令就會失敗,如果發現是數據缺失但是命令格式正確的情況下,當前的內容會佔時存儲在 clinetquerybuf中等待下次數據包到來再繼續解析,還記得我們前面在介紹client隊列裏面的成員屬性的時候有一個屬性qb_pos它是用來記錄當前命令解析到哪個位置的標記便於下次再去讀querybuf的時候進行數組的快速定位。外部整體代碼實現如下(networking.c

//processInputBuffer處理socket輸入的數據解析成命令並且執行
void processInputBuffer(client *c) {
    //當緩衝區仍然有東西的時候,需要繼續處理
    while (c->qb_pos < sdslen(c->querybuf)) {
    ............................一堆校驗........................
        if (c->reqtype == PROTO_REQ_INLINE) {
            //telent的內聯和客戶端
            if (processInlineBuffer(c) != C_OK) break;
            //如果是Gopher模式,只會有0/1個參數
            if (server.gopher_enabled &&
                ((c->argc == 1 && ((char *) (c->argv[0]->ptr))[0] == '/') ||
                 c->argc == 0)) {
                processGopherRequest(c);
                resetClient(c);
                c->flags |= CLIENT_CLOSE_AFTER_REPLY;
                break;
            }
        } else if (c->reqtype == PROTO_REQ_MULTIBULK) {
            //普通正常客戶端,如果解析不成功退出循環,等待下次繼續解析
            if (processMultibulkBuffer(c) != C_OK) break;
        } else {
            serverPanic("Unknown request type");
        }

        /* Multibulk processing could see a <= 0 length. */
        if (c->argc == 0) {
            //如果解析出來的參數爲0,釋放client
            resetClient(c);
        } else {
            //標記client爲需要等待處理命令的狀態,證明本次解析不完整,等待下次數據包到來再繼續解析
            if (c->flags & CLIENT_PENDING_READ) {
                c->flags |= CLIENT_PENDING_COMMAND;
                break;
            }

            //解析完成成並執行命令
            if (processCommandAndResetClient(c) == C_ERR) {           
                //執行失敗,無需再繼續外層的while循環,直接退出,快速失敗機制
                return;
            }
        }
    }
    //修建qb_pos標記位置
    if (c->qb_pos) {
        sdsrange(c->querybuf, c->qb_pos, -1);
        c->qb_pos = 0;
    }
}

2.1、命令解析

我們剛剛看了,命令解析的整體流程以及外部調用方法processInputBuffer的整體實現,下面來詳細講解以下命令解析的整體流程,命令解析本質上可以分爲以下兩個步驟:

1、解析命令請求參數的數目;
2、循環解析每個請求參數

解析命令請求參數數目
我們這裏以processMultibulkBuffer方法爲例(普通命令解析),querybuf指向命令請求首地址,假設命令請求參數數目的協議格式爲*3\r\n,即首字符必須是*,並且可以使用字符\r定位到行尾位置。解析後的參數數目暫存在客戶端對象的multibulklen字段,表示等待解析的參數數目,變量pos記錄已解析命令請求的長度,代碼如下所示:

 if (c->multibulklen == 0) {
     ........................校驗...............................
        //定位到行尾
        newline = strchr(c->querybuf + c->qb_pos, '\r');
        if (newline == NULL) {
            //緩衝區存儲的數據太大
            if (sdslen(c->querybuf) - c->qb_pos > PROTO_INLINE_MAX_SIZE) {
                addReplyError(c, "Protocol error: too big mbulk count string");
                setProtocolError("too big mbulk count string", c);
            }
            return C_ERR;
        }

        // Buffer 應該要包含 \n
        if (newline - (c->querybuf + c->qb_pos) > (ssize_t) (sdslen(c->querybuf) - c->qb_pos - 2))
            return C_ERR;
            
        //解析命令請求參數數目,並存儲在客戶端對象multibulklen字段
        //注意這裏的 c->qb_pos 會是 0 ,因爲上面在方法執行結束的時候有一個裁剪 qb_pos的操作,會設置成0
        serverAssertWithInfo(c, NULL, c->querybuf[c->qb_pos] == '*');
        ok = string2ll(c->querybuf + 1 + c->qb_pos, newline - (c->querybuf + 1 + c->qb_pos), &ll);
        //非法長度
        if (!ok || ll > 1024 * 1024) {
            addReplyError(c, "Protocol error: invalid multibulk length");
            setProtocolError("invalid mbulk count", c);
            return C_ERR;
        }
        //記錄解析到的位置
        c->qb_pos = (newline - c->querybuf) + 2;

        if (ll <= 0) return C_OK;
        //解析出來的參數個數
        c->multibulklen = ll;

        /* 先清空空間 */
        if (c->argv) zfree(c->argv);
        //分配參數存儲空間
        c->argv = zmalloc(sizeof(robj *) * c->multibulklen);
    }

循環解析每個請求參數
假設命令請求各參數的協議格式爲:$3\r\nSET\r\n,即首字符必須是$。解析當前參數之前需要解出參數的字符串長度,可以使用字符\r定位到行尾位置;注意,解析參數長度時,字符串開始位置爲querybuf+pos+1;字符串參數長度暫存在客戶端對象的bulklen字段,同時更新已解析字符串qp_pos,代碼如下:

	//循環解析每個請求參數
    while (c->multibulklen) {
        //如果沒有長度,先讀取該參數的長度
        if (c->bulklen == -1) {
            //定位到行尾
            newline = strchr(c->querybuf + c->qb_pos, '\r');
            if (newline == NULL) {
                //緩衝區數據過大
                if (sdslen(c->querybuf) - c->qb_pos > PROTO_INLINE_MAX_SIZE) {
                    addReplyError(c,
                                  "Protocol error: too big bulk count string");
                    setProtocolError("too big bulk count string", c);
                    return C_ERR;
                }
                break;
            }

            //Buffer 需要包含 \n字符
            if (newline - (c->querybuf + c->qb_pos) > (ssize_t) (sdslen(c->querybuf) - c->qb_pos - 2))
                break;
            //解析當前參數字符串長度,字符串首字符偏移量爲qb_pos
            if (c->querybuf[c->qb_pos] != '$') {
                addReplyErrorFormat(c,
                                    "Protocol error: expected '$', got '%c'",
                                    c->querybuf[c->qb_pos]);
                setProtocolError("expected $ but got something else", c);
                return C_ERR;
            }
            ok = string2ll(c->querybuf + c->qb_pos + 1, newline - (c->querybuf + c->qb_pos + 1), &ll);
            if (!ok || ll < 0 || ll > server.proto_max_bulk_len) {
                addReplyError(c, "Protocol error: invalid bulk length");
                setProtocolError("invalid bulk length", c);
                return C_ERR;
            }

            c->qb_pos = newline - c->querybuf + 2;
            if (ll >= PROTO_MBULK_BIG_ARG) {              
                if (sdslen(c->querybuf) - c->qb_pos <= (size_t) ll + 2) {
                    sdsrange(c->querybuf, c->qb_pos, -1);
                    c->qb_pos = 0;                   
                    //sds擴容
                    c->querybuf = sdsMakeRoomFor(c->querybuf, ll + 2);
                }
            }
            //解析出字符串長度
            c->bulklen = ll;
        }

        //讀取參數
        if (sdslen(c->querybuf) - c->qb_pos < (size_t) (c->bulklen + 2)) {          
            //數據不足夠,沒有到\r\n 可能被拆包了
            break;
        } else {
            //querybuf 可以直接賦給argv 這個相當於之前的sds的複用,清空sds,並且填入新數據,避免sds的內存空間頻繁分配和釋放
            if (c->qb_pos == 0 &&
                c->bulklen >= PROTO_MBULK_BIG_ARG &&
                sdslen(c->querybuf) == (size_t) (c->bulklen + 2)) {
                c->argv[c->argc++] = createObject(OBJ_STRING, c->querybuf);
                sdsIncrLen(c->querybuf, -2); /* remove CRLF */
                c->querybuf = sdsnewlen(SDS_NOINIT, c->bulklen + 2);
                //清空sds
                sdsclear(c->querybuf);
            } else {
                //解析參數,創建新的sds
                c->argv[c->argc++] =
                        createStringObject(c->querybuf + c->qb_pos, c->bulklen);
                c->qb_pos += c->bulklen + 2;
            }
            c->bulklen = -1;
            //待解析參數數目減1
            c->multibulklen--;
        }
    }

multibulklen值更新爲0時,說明參數解析完成,結束循環。此外待解析參數需要存儲在 client 裏面,而不放在函數的局部變量中,因爲 TCP 存在半包的情況,函數局部變量在退出函數後會丟失,此時需要放在 client 裏面,並需要記錄該命令請求待解析的參數數目,以及待解析參數的長度;而剩餘解析的參數會繼續存儲在客戶端的輸入緩衝區中。

2.2、命令調用

解析命令請求之後,就是執行命令的時候,此時會調用processCommand處理該命令請求,而處理命令請求之前還有很多校驗邏輯,比如客戶端是否已經完成認證命令請求參數是否合法。下面簡要列出若干校驗規則

  • ⭐1、如果是quit命令直接返回並且關閉客戶端
	if (!strcasecmp(c->argv[0]->ptr,"quit")) {
        addReply(c,shared.ok);
        c->flags |= CLIENT_CLOSE_AFTER_REPLY;
        return C_ERR;
    }
  • ⭐2、執行函數lookupCommand查找命令之後,如果命令不存在或者參數錯誤返回錯誤

注意:命令結構體的arity用於檢驗數目是否合法,當arity小於0時,表示命令參數數目大於等於arity的絕對值;當arity大於0時,表示命令參數數目必須爲arity。

 	//查找命令
    c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);
    if (!c->cmd) {
        //如果找不到返回錯誤
        flagTransaction(c);
        sds args = sdsempty();
        int i;
        for (i = 1; i < c->argc && sdslen(args) < 128; i++)
            args = sdscatprintf(args, "`%.*s`, ", 128 - (int) sdslen(args), (char *) c->argv[i]->ptr);
        addReplyErrorFormat(c, "unknown command `%s`, with args beginning with: %s",
                            (char *) c->argv[0]->ptr, args);
        sdsfree(args);
        return C_OK;
    } else if ((c->cmd->arity > 0 && c->cmd->arity != c->argc) ||
               (c->argc < -c->cmd->arity)) {
        //參數數量錯誤也返回錯誤
        flagTransaction(c);
        addReplyErrorFormat(c, "wrong number of arguments for '%s' command",
                            c->cmd->name);
        return C_OK;
    }
  • ⭐3、如果配置文件中使用指令requirepass password設置密碼,且客戶端未認證通過,只能執行auth命令和hello命令,命令格式爲AUTH password
int auth_required = (!(DefaultUser->flags & USER_FLAG_NOPASS) ||
                           DefaultUser->flags & USER_FLAG_DISABLED) &&
                        !c->authenticated;
    if (auth_required) {
        //需要認證的情況下只能使用 auth  和 hello
        if (c->cmd->proc != authCommand && c->cmd->proc != helloCommand) {
            flagTransaction(c);
            addReply(c,shared.noautherr);
            return C_OK;
        }
    }
  • ⭐4、如果配置文件中使用指令maxmemory <bytes>設置了最大內存限制,且當前內存使用量超過了該配置的門限,服務器會拒絕執行帶有m標識的指令 (CMD_DENYOOM),如SET命令,APPEND命令和LPUSH命令等。
 //拒絕執行帶有m標識的命令,到內存到達上限的時候的內存保護機制
    if (server.maxmemory && !server.lua_timedout) {
        //先調用freeMemoryIfNeededAndSafe進行一次內存釋放
        int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;
        //釋放內存可能會清空主從同步slave的緩衝區,這可能會導致釋放一個活躍的slave客戶端
        if (server.current_client == NULL) return C_ERR;
        //當內存釋放也不能解決內存問題的時候,客戶端試圖執行命令在OOM的情況下被拒絕
        // 或者客戶端處於MULTI/EXEC的上下文中
        if (out_of_memory &&
            (c->cmd->flags & CMD_DENYOOM ||
             (c->flags & CLIENT_MULTI &&
              c->cmd->proc != execCommand &&
              c->cmd->proc != discardCommand)))
        {
            flagTransaction(c);
            //回覆的內容OOM
            addReply(c, shared.oomerr);
            return C_OK;
        }
    }
  • ⭐5、除了上面的5種校驗,還有很多其它的校驗,例如集羣相關的校驗,持久化校驗,主從複製校驗,發佈訂閱校驗以及事務操作等等,所有的邏輯都在processCommand這個函數裏面大家可以自行查閱。

當所有校驗通過後纔會開始進行真正的命令執行環節,首先會判斷當前的命令是否是在事務中,如果是會添加到commands隊列裏面,按照事務的方式執行,否則直接執行,此外exec multi watch discard這些命令也是直接執行不用添加到隊列,代碼如下:

    //執行命令,前面已經把找到的命令放到了client 的cmd裏面了
    //如果當前開啓事務,命令會被添加到commands隊列中去
    //這裏也發現 exec multi watch discard的命令是不用進入隊列的,因爲需要直接執行
    if (c->flags & CLIENT_MULTI &&
        c->cmd->proc != execCommand && c->cmd->proc != discardCommand &&
        c->cmd->proc != multiCommand && c->cmd->proc != watchCommand)
    {
        //將命令添加到待執行隊列種,證明Redis會使用事務的方式執行指令
        queueMultiCommand(c);
        addReply(c,shared.queued);
    } else {
        //不進入隊列的直接執行
        call(c,CMD_CALL_FULL);
        c->woff = server.master_repl_offset;
        if (listLength(server.ready_keys))
            handleClientsBlockedOnKeys();
    }
    return C_OK;

這裏注意,最終執行命令是在call中調用的,在call中會執行命令,並且計時,如果指令執行時間過長,會作爲慢查詢記錄到日誌中去。執行完成後如果有必要還需要更新統計信息,記錄慢查詢日誌,AOF持久化該命令請求,傳播命令請求給所有的從服務器等。基本代碼如下所示(call方法)

    //計時
    start = server.ustime;
    //執行命令
    c->cmd->proc(c);
    duration = ustime()-start;
    dirty = server.dirty-dirty;
    if (dirty < 0) dirty = 0;
.........................................
    if (flags & CMD_CALL_SLOWLOG && !(c->cmd->flags & CMD_SKIP_SLOWLOG)) {
        char *latency_event = (c->cmd->flags & CMD_FAST) ?
                              "fast-command" : "command";
        //AOF持久化相關
        latencyAddSampleIfNeeded(latency_event,duration/1000);
        //記錄慢查詢日誌
        slowlogPushEntryIfNeeded(c,c->argv,c->argc,duration);
    }

此外還有一個問題,命令的參數和個數是存儲在client的,當下次命令到來的時候會被覆蓋,那麼在事務執行的情況下,每條指令的相關內容是保存在哪的呢?其實Redis提供了兩個結構體去處理這個問題,在queueMultiCommand中的命令是被添加到commands中去的commands本身是在結構體multiState下面commands自身的結構體是multiCmd,其定義如下:

typedef struct multiState {
    multiCmd *commands;   //需要執行的命令  
    int count;            //命令的數量  
    int cmd_flags;          
    int minreplicas;       
    time_t minreplicas_timeout;
} multiState;

typedef struct multiCmd {
    robj **argv; //參數對象
    int argc;    //參數個數
    struct redisCommand *cmd; //對應的指令
} multiCmd;

事務模式,命令執行隊列其真實結構如下圖所示:
在這裏插入圖片描述

2.3、返回結果

Redis服務器返回結果類型不同,協議格式不同,而客戶端可以根據返回結果的第一個字符判斷返回類型。Redis返回結果可以分爲5類。

  • 1、狀態回覆,第一個字符是+;例如SET命令執行完成會向客戶端回覆+OK\r\n
 addReply(c, ok_reply ? ok_reply : shared.ok);

變量ok_reply 通常爲NULL,則返回的是共享變量shared.ok,在服務器啓動時就完成共享變量的初始化。

  • 2、錯誤回覆,第一個字符是-。例如,當客戶端請求命令不存在時,會向客戶端返回-ERR unknown command 'testcmd'
 addReplyErrorFormat(c,"unknown command `%s`, with args beginning with: %s",
            (char*)c->argv[0]->ptr, args);

而函數addReplyErrorFormat內部實現會拼裝錯誤回覆字符串:

void addReplyErrorFormat(client *c, const char *fmt, ...) {
   .................................
   	//拼裝字符串
    addReplyErrorLength(c, s, sdslen(s));
    sdsfree(s);
}
void addReplyErrorLength(client *c, const char *s, size_t len) {
	//開頭就是 -ERR
    if (!len || s[0] != '-') addReplyProto(c, "-ERR ", 5);
    addReplyProto(c, s, len);
    addReplyProto(c, "\r\n", 2);
    
    if (c->flags & (CLIENT_MASTER | CLIENT_SLAVE) && !(c->flags & CLIENT_MONITOR)) {
        char *to = c->flags & CLIENT_MASTER ? "master" : "replica";
        char *from = c->flags & CLIENT_MASTER ? "replica" : "master";
        char *cmdname = c->lastcmd ? c->lastcmd->name : "<unknown>";
        serverLog(LL_WARNING, "== CRITICAL == This %s is sending an error "
                              "to its %s: '%s' after processing the command "
                              "'%s'", from, to, s, cmdname);
    }
}
  • 3、整數回覆,第一個字符是: 。例如,INCR命令執行完畢向客戶端返回:100\r\n
	//這個就是冒號:
    addReply(c,shared.colon);
    addReply(c,new);
    addReply(c,shared.crlf);
  • 4、回覆字符串,第一個字符是$。例如,GET命令查找鍵向客戶端返回結果$5\r\nhello\r\n
void addReplyBulk(client *c, robj *obj) {
    //計算長度放在頭部,使用$標記
    addReplyBulkLen(c, obj);
    //回覆的內容
    addReply(c, obj);
    addReply(c, shared.crlf);
}
  • 5、多條字符串回覆,第一個字符是*。例如,LRANGE命令可能會返回多個值,格式爲*3\r\n$6\r\nnvalueA\r\n$6\r\nvalueB\r\n$6\r\nvalueC\r\n,與命令請求協議格式相同,*3表示返回值數目,$6表示當前返回值字符串長度,基本格式如下*[返回數目]\r\n$[字符長度]......\r\n
 	//計算需要返回對象數量*開頭
    addReplyArrayLen(c,rangelen);
    if (o->encoding == OBJ_ENCODING_QUICKLIST) {
        listTypeIterator *iter = listTypeInitIterator(o, start, LIST_TAIL);
        //循環添加第一個是$
        while(rangelen--) {
            listTypeEntry entry;
            listTypeNext(iter, &entry);
            quicklistEntry *qe = &entry.entry;
            if (qe->value) {
                addReplyBulkCBuffer(c,qe->value,qe->sz);
            } else {
                addReplyBulkLongLong(c,qe->longval);
            }
        }
        listTypeReleaseIterator(iter);
    } else {
        serverPanic("List encoding is not QUICKLIST!");
    }

這裏可以看到5種類型的回覆都使用了addReply,在addReply中回覆的數據會被暫存在clientreplybuf字段中,分別表示輸出鏈表與輸出緩衝區。那麼什麼時候會發送數據給client呢?下一篇文章會介紹Redis的多線程IO。

3、總結

Redis爲了保證命令的執行的順序性,在服務端維護了client對象,其中裏面存儲了各種客戶端的狀態以及當前執行命令的情況,此外client還充當緩衝區的作用,從而能一定程度的提高Redis的吞吐量,由於Redis的命令執行是基於單線程的,所以基本上沒有發現作者有使用鎖的情況,都是流水線式的指令執行,整體流程我通過下圖展示出來:
本章實際執行程圖:
在這裏插入圖片描述
時序圖:
在這裏插入圖片描述

Redis爲例簡化通信開發難度,使用了RESP協議的方式與客戶端進行通信,通過RESP的解析可以很好的處理TCP的半包問題 (這塊是個人見解)。其實我們也發現RESP的本質其實就是解析字符串通過關鍵標記表示參數和對應的長度數量,由於Redis的作者希望Redis能足夠輕量,所以放棄使用了一些C的TCP內庫,基本上TCP通信這塊都是自行實現的通過調用Linux的內核接口實現 ,通過這種方式實現的Redis本身有很好的拓展性,也方便開發者後續進一步拓展Redis支持的指令集合,讓其支持更多的功能。此外在這裏我們也發現Redis的一些多線程的影子,從Redis6開始,Redis是開始支持多線程,下一篇文章我們也會對Redis的多線程做進一步的瞭解。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章