[Redis源碼閱讀]當你輸入get/set命令的時候,Redis作了什麼

上一篇文章介紹了redis-server的啓動過程,服務端啓動以後,就啓動事件循環機制監聽新事件的到來,此時不一樣的客戶端就能夠經過發送指令的方式請求server並獲得處理結果的回覆。在開發過程當中,用到最多的就是get和set命令,那麼,當咱們輸入get/set命令時,redis作了什麼呢?html

redis-cli啓動

瞭解命令是如何使用以前,先了解下redis-client啓動時作了什麼。redis客戶端有多種實現,不一樣的語言也有本身的實現,在這裏能夠看到各類版本:redis版本,日常調試過程當中比較經常使用的是redis-client,即命令行的形式,redis-client的主要實現代碼在redis-cli.hredis-cli.c。redis-client的啓動入口是在main函數,閱讀代碼能夠看到是先給config設置屬性,而後判斷客戶端使用哪一種模式啓動,啓動模式有:Latency、Latency分佈式、從庫、獲取RDB、查找大key、管道、Stat、Scan、LRU、Intrinsic Latency、交互模式。咱們用的命令行就是交互模式。linux

在redis整個鏈接過程當中,使用了redisContext結構體來保存鏈接的上下文,看看結構體的定義:git

/* 表明一個Redis鏈接的上下文結構體 */
typedef struct redisContext {
	int err;
	char errstr[128]; 
	int fd;
	int flags;
	char *obuf;
	redisReader *reader; 
	enum redisConnectionType connection_type;
	struct timeval *timeout;
	struct {
    	char *host;
    	char *source_addr;
    	int port;
	} tcp;

	struct {
    	char *path;
	} unix_sock;
} redisContext;
複製代碼
err:操做過程當中的錯誤標誌,0表示無錯誤
errstr:錯誤信息字符串
fd:redis-client鏈接服務器後的socket文件
obuf:保存輸入的命令
tcp:保存一個tcp鏈接的信息,包括IP,協議族,端口
複製代碼

介紹完使用的數據結構後,繼續鏈接過程,在交互模式下,redis調用cliConnect函數進行鏈接。github

cliConnect函數的執行流程:redis

  • 一、調用redisConnect函數鏈接到redis服務器實例,使用redisContext保存鏈接上下文。
  • 二、經過設置KeepAlive屬性避免鏈接斷開,KeepAlive的默認時間是15s。
  • 三、鏈接成功後,進行驗證並選擇正確的DB。

鏈接創建成功後,redis-cli啓動就完成了,此時進入了交互階段,redisContext封裝了客戶端鏈接服務器的狀態,以後有關客戶端的操做都會操做這個結構體。shell

跟蹤get/set命令的全過程

客戶端啓動成功,就能夠輸入指令調用redis的命令了。 本文測試使用的key是username:1234,先輸入get username:1234數據庫

繼續閱讀代碼,發現客戶端進入交互模式以後,就調用repl讀取終端命令、發送命令到客戶端並返回結果,repl函數是交互模式的核心函數。repl函數調用linenoise函數讀取用戶輸入的命令,讀取方式是經過空格分隔多個參數,讀取到命令請求以後,就會調用issueCommandRepeat函數啓動命令執行,issueCommandRepeat函數調用cliSendCommand發送命令給服務器。數組

cliSendCommand函數調用redisAppendCommandArgv函數使用redis的協議編碼輸入的命令,而後調用cliReadReply函數發送數據到服務端並讀取服務端的返回數據。讀到這裏的時候,挺想看看使用redis協議編碼後的數據是怎樣的,因而想到使用gdb斷點調試,查看每個步驟的數據以及交互。bash

gdb調試redis準備工做

在使用gdb調試的時候,輸出其中一些變量會獲得以下結果:服務器

<value optimized out>

這是由於在編譯的時候默認使用了-O2優化選項,在這個選項下,編譯器會把它認爲冗餘的部分變量進行優化,所以看不到具體的值,要想去掉這個優化,在gcc編譯的時候指定-O0就能夠了,對於redis的編譯來講,修改makefile文件,把-O2改成-O0或者執行make noopt便可。

redis通訊協議介紹

衆所周知,HTTP有本身的協議,協議是通訊計算機雙方必須共同聽從的一組約定。 如怎麼樣創建鏈接、怎麼樣互相識別等。 只有遵照這個約定,計算機之間才能相互通訊交流。 對於redis而言,爲了保證服務器與客戶端的正常通訊,也定義了本身的通訊協議,客戶端和服務器在接收解析數據時都須要遵循這個協議才能保證通訊正常進行。

redis請求協議的通常形式:

*<參數數量> CR LF
$<參數 1 的字節數量> CR LF
<參數 1 的數據> CR LF
...
$<參數 N 的字節數量> CR LF
<參數 N 的數據> CR LF
複製代碼

回覆的協議:

狀態回覆(status reply)的第一個字節是 "+"
錯誤回覆(error reply)的第一個字節是 "-"
整數回覆(integer reply)的第一個字節是 ":"
批量回復(bulk reply)的第一個字節是 "$"
多條批量回復(multi bulk reply)的第一個字節是 "*"
複製代碼

解析命令

根據上面的描述可知,issueCommandRepeat函數是執行命令的核心實現,爲函數進行一個斷點。

(gdb) b issueCommandRepeat
Breakpoint 1 at 0x40f891: file redis-cli.c, line 1281.
(gdb) run
Starting program: /usr/local/src/redis-stable/src/redis-cli
[Thread debugging using libthread_db enabled]
Using host libthread_db library "/lib/x86_64-linux-gnu/libthread_db.so.1".
127.0.0.1:6379> get username:1234
Breakpoint 1, issueCommandRepeat (argc=2, argv=0x68a950, repeat=1) at redis-cli.c:1281
1281            config.cluster_reissue_command = 0;
複製代碼

issuseCommandRepeat的參數打印出來:

(gdb) p *argv
$1 = 0x685583 "get"
(gdb) p *(argv+1)
$2 = 0x68a973 "username:1234"
複製代碼

能夠知道,用戶輸入的命令經過lineinoise解析後經過數組傳遞給issuseCommandRepeat函數了。

繼續執行,就進入了cliSendCommand函數,這個函數主要作的事情是使用redis協議編碼發送過來的命令(調用redisAppendCommandArgv函數),而後發送給服務器,並等待服務器的回覆(調用cliReadReply函數)。

命令的解析過程是,使用redis的協議編碼,而後將結果保存到redisContext->obuf,結合前面介紹的redis通訊協議看代碼,很是直觀,編碼過程:

/* * 使用Redis協議格式化命令,經過sds字符串保存,使用sdscatfmt函數追加 * 函數接收幾個參數,參數數組以及參數長度數組 * 若是參數長度數組爲NULL,參數長度會用strlen函數計算 */
int redisFormatSdsCommandArgv(sds *target, int argc, const char **argv, const size_t *argvlen) {
	sds cmd;
	unsigned long long totlen;
	int j;
	size_t len;
	/* Abort on a NULL target */
	if (target == NULL)
		return -1;
	/* 計算總大小 */
	totlen = 1+countDigits(argc)+2;
	for (j = 0; j < argc; j++) {
		len = argvlen ? argvlen[j] : strlen(argv[j]);
		totlen += bulklen(len);
	}
	/* 初始化一個sds字符串 */
	cmd = sdsempty();
	if (cmd == NULL)
		return -1;
	/* 使用前面計算獲得的totlen分配空間 */
	cmd = sdsMakeRoomFor(cmd, totlen);
	if (cmd == NULL)
		return -1;
	/* 構造命令字符串 */
	cmd = sdscatfmt(cmd, "*%i\r\n", argc); // *%i 表示包含命令在內,共有多少個參數
	for (j=0; j < argc; j++) {
		len = argvlen ? argvlen[j] : strlen(argv[j]);
		cmd = sdscatfmt(cmd, "%u\r\n", len); // %u 表示該參數的長度
		cmd = sdscatlen(cmd, argv[j], len); // 參數的值
		cmd = sdscatlen(cmd, "\r\n", sizeof("\r\n")-1); // 最後加上\r\n
	}
	assert(sdslen(cmd)==totlen);
	*target = cmd;
	return totlen;
}
複製代碼

結合編碼過程,對於輸入的命令,獲得的編碼結果應該是:

*2\r\n$3\r\nget\r\n$13\r\nusername:1234\r\n

在gdb打印驗證一下,在執行redisAppendCommandArgv函數先後打印context->obuf結果以下,驗證成功:

(gdb)
984             redisAppendCommandArgv(context,argc,(const char**)argv,argvlen);
(gdb) p context->obuf
$3 = 0x6855a3 ""
(gdb) n
985             while (config.monitor_mode) {
(gdb) p context->obuf
4 = 0x68a9e3 "*2\r\n3\r\nget\r\n$13\r\nusername:1234\r\n"
複製代碼

把\r\n去掉,經過一種更直觀展現:

*2 // 命令參數的總數量,包含命令在內

$3 // 第一個參數的長度

get // 第一個參數的值

$13 // 第二個參數的長度

username:1234 // 第二個參數的值

發送命令

客戶端解析完命令,並對其進行編碼後,就進入下一階段,將命令發給服務器,對cliReadReply函數進行斷點:

Breakpoint 3, cliReadReply (output_raw_strings=0) at redis-cli.c:840
840     static int cliReadReply(int output_raw_strings) {
(gdb) n
843         sds out = NULL;
(gdb)
844         int output = 1;
(gdb)
846         if (redisGetReply(context,&_reply) != REDIS_OK) {
複製代碼

調用了redisGetReply函數,函數接收鏈接上下文redisContext參數,並把結果寫到_reply。在redisGetReply函數裏,函數作的事情是把命令發送給服務器,而後等待服務器返回,裏面一個I/O操做,底層調用了系統調用write和read。

調用write函數發送命令以後,請求到了服務器,上一篇文章有講到了redis服務器是怎麼啓動的,啓動以後就進入了事件循環狀態,接下來就看看服務器是怎麼處理請求的。

處理請求

服務器啓動會註冊文件事件,註冊的回調acceptTcpHandler,當服務器可讀時(即客戶端能夠write/close),acceptTcpHandler被調用,追蹤函數,調用鏈路以下:

acceptTcpHandler -> anetTcpAccept -> acceptCommonHandler

acceptTcpHandler函數會調用anetTcpAccept函數發起accept,接收客戶端請求,請求到來後,會調用acceptCommonHandler函數處理。acceptCommonHandler調用鏈:

acceptCommonHandler-> createClient -> readQueryFromClient

acceptCommonHandler函數調用createClient建立一個客戶端,註冊回調函數,若是有請求到來,會調用readQueryFromClient函數讀取客戶端的請求。客戶端建立成功後,redis會將它添加到當前服務器的客戶端鏈表中,以後若是須要對全部客戶端進行交互,都會使用這個鏈表。

梳理整個調用鏈路後,對readQueryFromClient函數加一個斷點,看看接收到的數據:

(gdb) b readQueryFromClient
Breakpoint 1 at 0x440b6b: file networking.c, line 1377.
1377        client c = (client) privdata;
(gdb) n
1383        readlen = PROTO_IOBUF_LEN;
(gdb) n
1390        if (c->reqtype == PROTO_REQ_MULTIBULK && c->multibulklen && c->bulklen != -1
(gdb) n
1398        qblen = sdslen(c->querybuf);
(gdb) n
1399        if (c->querybuf_peak < qblen) c->querybuf_peak = qblen;
(gdb) n
1400        c->querybuf = sdsMakeRoomFor(c->querybuf, readlen);
(gdb) n
1401        nread = read(fd, c->querybuf+qblen, readlen);
複製代碼

從執行步驟發現,redis-server爲請求的命令建立一個字符串結構體保存,而後發起系統調用read從當前socket讀取數據,執行完這步後,打印讀取到的字符串,以及字符串結構體在內存中的保存狀況:

(gdb) p nread
$7 = 33
(gdb) p c->querybuf
8 = (sds) 0x7ffff6b3a345 "*2\r\n3\r\nget\r\n$13\r\nusername:1234\r\n"
(gdb) x/33 c->querybuf
0x7ffff6b3a345: 42 '*'  50 '2'  13 '\r' 10 '\n' 36 '$'  51 '3'  13 '\r' 10 '\n'
0x7ffff6b3a34d: 103 'g' 101 'e' 116 't' 13 '\r' 10 '\n' 36 '$'  49 '1'  51 '3'
0x7ffff6b3a355: 13 '\r' 10 '\n' 117 'u' 115 's' 101 'e' 114 'r' 110 'n' 97 'a'
0x7ffff6b3a35d: 109 'm' 101 'e' 58 ':'  49 '1'  50 '2'  51 '3'  52 '4'  13 '\r'
0x7ffff6b3a365: 10 '\n'
複製代碼

若是根據redis協議計算解析到的字符串長度,獲得長度是33,打印c->querybuf在內存中的保存狀況,能夠看到整個命令的字節字符串都在這,每個字節都是緊挨着地保存。

由於redis是事件驅動的,在每一次有數據到來,readQueryFromClient函數都會被調用,讀取命令。本次調試用的get命令比較短,redis-server只須要一次事件循環過程就解析完整個命令了,server每次最多讀取1024*16個字節的字符放到緩衝區,若是命令長度超過緩衝最大長度,會分別在屢次事件中讀取完而後再執行。

讀取完命令後,下一步就是解析和執行了。命令是根據以前介紹的redis協議編碼的,server也是根據一樣的協議解碼,而後保存到redisClient,解析過程是一個反編碼過程,具體是在processMultibulkBuffer函數中,有興趣瞭解細節的能夠查看這個函數。

解析完命令後,經過調用processCommand函數執行命令。上一篇文章說到,服務器啓動時會加載命令表到server中,processCommand函數先在命令表查找命令是否存在,查找方式是經過以命令名稱做爲key去redis的命令字典查找,對於get命令,定義的格式以下:

{"get",getCommand,2,"rF",0,NULL,1,1,1,0,0}
複製代碼

讀取到以後,command->proc就會被設置爲getCommand函數,接着server進行一系列檢查:參數是否正確、是否受權、集羣模式處理、是否超出最大內存、若是硬盤有問題不處理等等,而後調用call函數執行命令。call函數是redis執行命令的核心函數,call函數的核心代碼:

void call(client *c, int flags) {
	-- 執行前檢查 --
	/* 調用命令執行函數 */
	dirty = server.dirty;
	start = ustime();
	c->cmd->proc(c);
	duration = ustime()-start;
	dirty = server.dirty-dirty;
	if (dirty < 0) dirty = 0;

	-- 執行後處理 --
}
複製代碼

看看上面的代碼,是redis動態分發命令調用函數的實現,在命令表中配置好每一個命令對應的執行函數,參數數量等信息,在server啓動時把命令加載到命令表server.commands,這時會將命令表中的命令函數保存到redisCommand.proc,所以,在call函數,只須要執行c->cmd->proc(c)就能夠執行執行命令對應的函數了。

getCommand實現

對於本次get命令而言,直接看getCommand函數,調用了getGenericCommand

/*
* get命令的"通用"實現
*/
int getGenericCommand(client *c) {
	robj *o;
    // 調用lookupKeyReadOrReply函數查找指定key,找不到,返回
    if ((o = lookupKeyReadOrReply(c,c->argv[1],shared.nullbulk)) == NULL)
        return C_OK;
    // 若是找到的對象類型不是string返回類型錯誤
    if (o->type != OBJ_STRING) {
        addReply(c,shared.wrongtypeerr);
        return C_ERR;
    } else {
        addReplyBulk(c,o);
        return C_OK;
    }
}

/*
* get命令
* 調用getGenericCommand函數實現具體操做
*/
void getCommand(client *c) {
    getGenericCommand(c);
}
複製代碼

getGenericCommand函數調用的lookupKeyReadOrReply(c,c->argv[1],shared.nullbulk),傳遞的參數是客戶端c,key,以及shared.nullbulk。

shared.nullbulk是由redis在服務器啓動時建立的一個共享變量,由於使用的地方較多,因此redis會建立這些共享變量,減小重複建立過程以及減小內存的損耗。它的值是:

shared.nullbulk = createObject(OBJ_STRING,sdsnew("$-1\r\n"));

lookupKeyReadOrReply函數只是作了簡單的封裝,看起來很是簡潔,實際上,底層訪問數據庫是調用了db.c/lookupKey函數,這是get命令實現的核心:

/* * 查找數據庫中指定key的對象並返回,查詢出來的對象用於讀操做 */
robj *lookupKeyReadOrReply(client *c, robj *key, robj *reply) {
    robj *o = lookupKeyRead(c->db, key);
    if (!o) addReply(c,reply);
    return o;
}

robj *lookupKey(redisDb *db, robj *key, int flags) {
	// 在字典中根據key查找字典對象
	dictEntry *de = dictFind(db->dict,key->ptr);
	if (de) {
		// 獲取字典對象的值
		robj *val = dictGetVal(de);
		/* 更新key的最新訪問時間 */
  		if (server.rdb_child_pid == -1 &&
  			server.aof_child_pid == -1 &&
  			!(flags & LOOKUP_NOTOUCH))
  		{
  			if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
  				unsigned long ldt = val->lru >> 8;
  				unsigned long counter = LFULogIncr(val->lru & 255);
  				val->lru = (ldt << 8) | counter;
  			} else {
  			val->lru = LRU_CLOCK();
  			}
		}
		return val;
  	} else {
  		return NULL;
	}
}
複製代碼

在redis中,全部的鍵值對都會用內置的哈希表保存在內存裏,所以,在lookupKey的實現裏,先使用dictFind函數查找傳進來的key是否存在哈希表中,若是找到,則調用dictGetVal獲取哈希節點對象的value屬性,不然,返回NULL,函數的時間複雜度是O(1)。

函數lookupKeyRead在接收到返回後,判斷值的類型:

若是是NULL,則將函數接收的參數shared.nullbulk返回給客戶端。shared.nullbuk是上層函數傳遞進來的reply對象,一個null共享對象,根據redis的協議,解析爲nil。

若是函數不爲空,則調用dictGetVal獲取查找到的對象的值,而後返回。

查找到的兩種結果最終都是調用函數addReply返回結果給客戶端,addReply函數將回復傳遞給客戶端,addReply函數將回復結果寫入到client的buf中,在redis的事件循環過程當中,只要buf有數據就會輸出到客戶端。客戶端獲得內容後,根據redis協議解析結果,輸出。在這個例子中,要查找的key不存在,所以客戶端顯示的是 (nil)

至此,get命令的全流程已經介紹完,接着看看set命令的執行鏈路,輸入set username:1234

set命令

set的執行流程與get的流程幾乎同樣,不一樣點在於處理請求的時候調用的是setCommandsetCommand先作了一些參數的校驗,而後會爲value作一次編碼轉換,由於保存redis字符串的有兩種編碼格式:embstr和sds,使用embstr編碼字符串,能夠節省空間,這也是redis作的其中一項優化,繼續往下看,最終是調用了setGenricCommand函數:

void setGenericCommand(client *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) {
    long long milliseconds = 0; /* 初始化,避免報錯 */
    // 若是須要設置超時時間,根據unit單位參數設置超時時間
    if (expire) {
   		// 獲取時間值
        if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != C_OK)
            return;
   		// 處理非法的時間值
        if (milliseconds <= 0) {
            addReplyErrorFormat(c,"invalid expire time in %s",c->cmd->name);
            return;
        }
        if (unit == UNIT_SECONDS) milliseconds *= 1000; // 統一用轉換成毫秒
    }
    /*
     * 處理非法狀況
     * 若是flags爲OBJ_SET_NX 且 key存在或者flags爲OBJ_SET_XX且key不存在,函數終止並返回abort_reply的值
     */
    if ((flags & OBJ_SET_NX && lookupKeyWrite(c->db,key) != NULL) ||
        (flags & OBJ_SET_XX && lookupKeyWrite(c->db,key) == NULL))
    {
        addReply(c, abort_reply ? abort_reply : shared.nullbulk);
        return;
    }
    // 設置val到key中
    setKey(c->db,key,val);
    // 增長服務器的dirty值
    server.dirty++;
    // 設置過時時間
    if (expire) setExpire(c,c->db,key,mstime()+milliseconds);
    // 通知監聽了key的數據庫,key被操做了set、expire命令
    notifyKeyspaceEvent(NOTIFY_STRING,"set",key,c->db->id);
    if (expire) notifyKeyspaceEvent(NOTIFY_GENERIC,
        "expire",key,c->db->id);
    // 返回成功的信息
    addReply(c, ok_reply ? ok_reply : shared.ok);
}

void setKey(redisDb *db, robj *key, robj *val) {
    /*
     * 若是key不在數據庫裏,新建
     * 不然,用新值覆蓋
     */
    if (lookupKeyWrite(db,key) == NULL) {
        dbAdd(db,key,val);
    } else {
        dbOverwrite(db,key,val);
    }
    incrRefCount(val); // 增長值的引用計數
    removeExpire(db,key); // 重置鍵在數據庫裏的過時時間
    signalModifiedKey(db,key); // 發送修改鍵的通知
}
複製代碼

setGenericCommand調用setKey函數將key-value的鍵值對添加到數據庫,setKey調用dictFind函數查找key是否在數據庫,若是在數據庫,就用value覆蓋舊值,不然將key-value添加到數據庫。對於本文的例子,由於username:1234這個key不存在,dictFind查找返回的是空,所以函數dbAdd被調用,dbAdd函數只會在key不存在當前數據庫的狀況下被調用。

redis中的鍵值對都會保存到dict(字典對象)數據結構中。 dict數據結構的介紹可見以前的文章:dict字典的實現。具體操做API實現直接看代碼:dict.c

追蹤dbAdd函數代碼細節能夠發現,調用了dictAdd函數執行具體的操做,_dictKeyIndex函數爲key返回合適的字典數組下標,而後分配內存保存新節點,將節點添加到哈希表中,並設置key和value的具體值。操做成功後,返回DICT_OK,不然返回DICT_ERR

如今,username:1234已經設置了值,若是再次調用命令:get username:1234,過程跟上面描述的同樣,到了dictFind階段,函數能在數據庫中找到key username:1234,函數返回的結果不爲空,所以調用dictGetVal函數獲取key的值,而後調用addReply返回對象的值。

至此,set/get命令的整個流程到此結束,通讀一遍可能還會有點懵逼,所以根據本次分享的內容再加一個圖,看完這個圖再回顧整個流程能夠加深理解。查看大圖

redis調用鏈路

總結

經過本次的學習,從外層代碼一直追溯到底層的網絡代碼實現,瞭解到了不少網絡知識和代碼封裝技巧,再次感嘆redis代碼的優美。藉此機會將學習到的內容分享出來,若是有須要查看其它命令實現或者其餘函數實現,能夠把本次的思路做爲一次參考。

參考文章:More Redis internals: Tracing a GET & SET

原創文章,文筆有限,才疏學淺,文中如有不正之處,萬望告知。

更多精彩內容,請關注我的公衆號。

相關文章
相關標籤/搜索