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
解析命令就會失敗,如果發現是數據缺失但是命令格式正確的情況下,當前的內容會佔時存儲在 clinet 的querybuf
中等待下次數據包到來再繼續解析,還記得我們前面在介紹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
中回覆的數據會被暫存在client的reply
和buf
字段中,分別表示輸出鏈表與輸出緩衝區。那麼什麼時候會發送數據給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的多線程做進一步的瞭解。