1. 程式人生 > >Redis 資料結構之字串的那些騷操作 -- 像讀小說一樣讀原始碼

Redis 資料結構之字串的那些騷操作 -- 像讀小說一樣讀原始碼

Redis 字串底層用的是 sds 結構,該結構同 c 語言的字串相比,其優點是可以節省記憶體分配的次數,還可以... 這樣寫是不是讀起來很無聊?這些都是別人咀嚼過後,經過一輪兩輪三輪的再次咀嚼,吐出來的**精華**,這就是為什麼好多文章你覺得乾貨滿滿,但就是記不住說了什麼。我希望把這個咀嚼的過程,也講給你,希望以後再提到 Redis 字串時,它是活的。 **前置知識**:本篇文章的閱讀需要你瞭解 Redis 的編碼型別,知道有這麼回事就行,如果比較困惑可以先讀一下 《面試官問我 redis 資料型別,我回答了 8 種》 這篇文章 **原始碼選擇**:Redis-3.0.0 **文末總結**:本文行為邏輯是邊探索邊出結論,但文末會有很精簡的總結,所以不用怕看的時候記不住,放心看,像讀小說一樣就行,不用邊讀邊記。 文末還有上一期趣味題的答案喲 ## 我研究 Redis 原始碼時的小插曲 我下載了 Redis-3.0.0 的原始碼,找到了 set 命令對應的真正執行儲存操作的原始碼方法 setCommand。其實 Redis 所有的指令,其核心原始碼的位置都是叫 xxxCommand,所以還是挺好找的。 t_string.c ``` /* SET key value [NX] [XX] [EX ] [PX ] */ void setCommand(redisClient *c) { int j; robj *expire = NULL; int unit = UNIT_SECONDS; int flags = REDIS_SET_NO_FLAGS; for (j = 3; j < c->argc; j++) { // 這裡省略無數行 ... } c->argv[2] = tryObjectEncoding(c->argv[2]); setGenericCommand(c,flags,c->argv[1],c->argv[2],expire,unit,NULL,NULL); } ``` 不知道為什麼,看到字串這麼長的原始碼(主要是下面那兩個方法展開很多),我就想難道這不會嚴重影響效能麼?我於是做了如下兩個壓力測試。 未修改原始碼時的壓力測試 ``` [root@VM-0-12-centos src]# ./redis-benchmark -n 10000 -q ... SET: 112359.55 requests per second GET: 105263.16 requests per second INCR: 111111.11 requests per second LPUSH: 109890.11 requests per second ... ``` 觀察到 set 指令可以達到 112359 QPS,可以,這個和官方宣傳的 Redis 效能也差不多。 我又將 setCommand 的原始碼修改了下,在第一行加入了一句直接返回的程式碼,也就是說在執行 set 指令時直接就返回,我想看看這個 set 效能會不會提高。 ``` void setCommand(redisClient *c) { // 這裡我直接返回一個響應 ok addReply(c, shared.ok); return; // 下面是省略的 Redis 自己的程式碼 ... } ``` 將 setCommand 改為立即返回後的壓力測試 ``` [root@VM-0-12-centos src]# ./redis-benchmark -n 10000 -q ... SET: 119047.62 requests per second GET: 105263.16 requests per second INCR: 113636.37 requests per second LPUSH: 90090.09 requests per second ... ``` 和我預期的不太一樣,效能幾乎沒有提高,又連續測了幾次,有時候還有下降的趨勢。 說明這個 setCommand 裡面寫了這麼多判斷呀、跳轉什麼的,對 QPS 幾乎沒有影響。想想也合理,現在 CPU 都太牛逼了,幾乎效能瓶頸都是在 IO 層面,這個 setCommand 裡面寫了這麼多程式碼,執行速度同直接返回相比,都幾乎沒有什麼差別。 ## 跟我在原始碼裡走一遍 set 的全流程 客戶端執行指令 ``` 127.0.0.1:6379> set name tom ``` #### 別深入,先看骨架 原始碼沒那麼嚇人,多走幾遍你就會發現看原始碼比看文件容易了,因為最直接,且閱讀量也最少,沒有那麼多腦筋急轉彎一樣的比喻。 真的全流程,應該把前面的 **建立 socket 連結 --> 建立 client --> 註冊 socket 讀取事件處理器 --> 從 socket 讀資料到緩衝區 --> 獲取命令** 也加上,也就是面試中的常考題 **單執行緒的 Redis 為啥那麼快** 這個問題的答案。不過本文專注於 Redis 字串在資料結構層面的處理,請求流程後面會專門去講,這裡只把前面步驟的 debug 堆疊資訊給大家看下 ![setCommand 命令之前的堆疊資訊](https://imgkr2.cn-bj.ufileos.com/d32c4ce2-a857-4056-afb4-3fef46c5c019.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=FVPTp9g%252FalVVPntNQc%252B1o3PrNpI%253D&Expires=1605618327) 總之當客戶端傳送來一個 `set name tom` 指令後,Redis 服務端歷經千山萬水,找到了 setCommand 方法進來。 ``` // 注意入參是個 redisClient 結構 void setCommand(redisClient *c) { int flags = REDIS_SET_NO_FLAGS; // 前面部分完全不用看 ... // 下面兩行是主幹,先確定編碼型別,再執行通用的 set 操作函式 c->argv[2] = tryObjectEncoding(c->argv[2]); setGenericCommand(c,flags,c->argv[1],c->argv[2],expire,unit,NULL,NULL); } ``` 好長的程式碼被我縮短到只有兩行了,因為前面部分真的不用看,前面是根據 set 的額外引數來設定 flags 的值,但是像如 `set key value EX seconds` 這樣的指令,一般都直接被更常用的 `setex key seconds value` 代替了,而他們都有專門對應的更簡潔的方法。 ``` void setnxCommand(redisClient *c) { c->argv[2] = tryObjectEncoding(c->argv[2]); setGenericCommand(c,REDIS_SET_NX,c->argv[1],c->argv[2],NULL,0,shared.cone,shared.czero); } void setexCommand(redisClient *c) { c->argv[3] = tryObjectEncoding(c->argv[3]); setGenericCommand(c,REDIS_SET_NO_FLAGS,c->argv[1],c->argv[3],c->argv[2],UNIT_SECONDS,NULL,NULL); } void psetexCommand(redisClient *c) { c->argv[3] = tryObjectEncoding(c->argv[3]); setGenericCommand(c,REDIS_SET_NO_FLAGS,c->argv[1],c->argv[3],c->argv[2],UNIT_MILLISECONDS,NULL,NULL); } ``` 先看入參,這個 redisClient 的欄位非常多,但我們看到下面幾乎只用到了 **argv** 這個欄位,他是 **robj** 結構,而且是個陣列,我們看看 argv 都是啥 | 屬性 | argv[0] | argv[1] | argv[2] | | -------- | ------- | ------- | ------- | | type | string | string | string | | encoding | embstr | embstr | embstr | | ptr | "set" | "name | "tom" | 字元編碼的知識還是去 《面試官問我 redis 資料型別,我回答了 8 種》 這裡補一下哦。 我們可以斷定,這些 argv 引數就是**將我們輸入的指令一個個的包裝成了 robj 結構體**傳了進來,後面怎麼用的,那就再說咯。 骨架了解的差不多了,總結起來就是,Redis 來一個 `set` 指令,千辛萬苦走到 `setCommand` 方法裡,`tryObjectEncoding` 一下,再 `setGenericCommand` 一下,就完事了。至於那兩個方法幹嘛的,我也不知道,看名字再結合上一講中的編碼型別的知識,大概猜測先是處理下編碼相關的問題,然後再執行一個 set、setnx、setex 都通用的方法。 那繼續深入這兩個方法,即可,一步步來 #### 進入 tryObjectEncoding 方法 ``` c->argv[2] = tryObjectEncoding(c->argv[2]); ``` 我們可以看到呼叫方把 `argv[2]`,也就是我們指令中 value 字串 "tom" 包裝成的 robj 結構,傳進了 `tryObjectEncoding`,之後將返回值又賦回去了。一個合理的猜測就是**可能 argv[2] 什麼都沒變就返回去了,也可能改了點什麼東西返回去更新了自己**。那要是什麼都不變,就又可以少研究一個方法啦。 抱著這個僥倖心理,進入方法內部看看。 ``` /* Try to encode a string object in order to save space */ robj *tryObjectEncoding(robj *o) { long value; sds s = o->ptr; size_t len; ... len = sdslen(s); // 如果這個值能轉換成整型,且長度小於21,就把編碼型別替換為整型 if (len <= 21 && string2l(s,len,&value)) { // 這個 if 的優化,有點像 Java 的 Integer 常量池,感受下 if (value >
= 0 && value < REDIS_SHARED_INTEGERS) { ... return shared.integers[value]; } else { ... o->encoding = REDIS_ENCODING_INT; o->ptr = (void*) value; return o; } } // 到這裡說明值肯定不是個整型的數,那就嘗試字串的優化 if (len <= REDIS_ENCODING_EMBSTR_SIZE_LIMIT) { robj *emb; // 本次的指令,到這一行就返回了 if (o->encoding == REDIS_ENCODING_EMBSTR) return o; emb = createEmbeddedStringObject(s,sdslen(s)); ... return emb; } ... return o; } ``` 別看這麼長,這個方法就一個作用,就是**選擇一個合適的編碼型別**而已。功能不用說,如果你感興趣的話,從中可以提取出一個小的騷操作: 在選擇整型返回的時候,不是直接轉換為一個 long 型別,而是先看看這個數值大不大,如果不大的話,從常量池裡面選一個返回這個引用,這和 Java Integer 常量池的思想差不多,由於業務上可能大部分用到的整型都沒那麼大,這麼做至少可以節省好多空間。 #### 進入 setGenericCommand 方法 看完上個方法很開心,因為就只是做了編碼轉換而已,這用 Redis 編碼型別的知識很容易就理解了。看來重頭戲在這個方法裡呀。 方法不長,這回我就沒省略全粘過來看看 ``` void setGenericCommand(redisClient *c, int flags, robj *key, robj *val, robj *expire, int unit, robj *ok_reply, robj *abort_reply) { long long milliseconds = 0; /* initialized to avoid any harmness warning */ if (expire) { if (getLongLongFromObjectOrReply(c, expire, &milliseconds, NULL) != REDIS_OK) return; if (milliseconds <= 0) { addReplyErrorFormat(c,"invalid expire time in %s",c->cmd->name); return; } if (unit == UNIT_SECONDS) milliseconds *= 1000; } if ((flags & REDIS_SET_NX && lookupKeyWrite(c->db,key) != NULL) || (flags & REDIS_SET_XX && lookupKeyWrite(c->db,key) == NULL)) { addReply(c, abort_reply ? abort_reply : shared.nullbulk); return; } setKey(c->db,key,val); server.dirty++; if (expire) setExpire(c->db,key,mstime()+milliseconds); notifyKeyspaceEvent(REDIS_NOTIFY_STRING,"set",key,c->db->id); if (expire) notifyKeyspaceEvent(REDIS_NOTIFY_GENERIC, "expire",key,c->db->id); addReply(c, ok_reply ? ok_reply : shared.ok); } ``` 我們只是 `set key value`, 沒設定過期時間,也沒有 nx 和 xx 這種額外判斷,也先不管 notify 事件處理,整個程式碼就瞬間只剩一點了。 ``` void setGenericCommand(redisClient *c, robj *key, robj *val, robj *expire) { ... setKey(c->db,key,val); ... addReply(c, ok_reply ? ok_reply : shared.ok); } ``` addReply 看起來是響應給客戶端的,和字串本身的記憶體操作關係應該不大,所以看來重頭戲就是這個 setKey 方法啦,我們點進去。由於接下來都是小方法連續呼叫,我直接列出主線。 ``` void setKey(redisDb *db, robj *key, robj *val) { if (lookupKeyWrite(db,key) == NULL) { dbAdd(db,key,val); } else { dbOverwrite(db,key,val); } ... } void dbAdd(redisDb *db, robj *key, robj *val) { sds copy = sdsdup(key->ptr); int retval = dictAdd(db->dict, copy, val); ... } int dictAdd(dict *d, void *key, void *val) { dictEntry *entry = dictAddRaw(d,key); if (!entry) return DICT_ERR; dictSetVal(d, entry, val); return DICT_OK; } ``` 這一連串方法見名知意,最終我們可以看到,在一個字典結構 dictEntry 裡,添加了一條記錄。這也說明了 Redis 底層確實是用字典(hash 表)來儲存 key 和 value 的。 跟了一遍 set 的執行流程,我們對 redis 的過程有個大致的概念了,其實和我們預料的也差不多嘛,那下面我們就重點看一下 Redis 字串用的資料結構 sds ## 字串的底層資料結構 sds 關於字元編碼之前說過了,Redis 中的字串對應了三種編碼型別,如果是數字,則轉換成 INT 編碼,如果是短的字串,轉換為 EMBSTR 編碼,長字串轉換為 RAW 編碼。 不論是 EMBSTR 還是 RAW,他們只是記憶體分配方面的優化,具體的資料結構都是 sds,即簡單動態字串。 #### sds 結構長什麼樣 很多書中說,字串底層的資料結構是 **SDS**,中文翻譯過來叫 **簡單動態字串**,程式碼中也確實有這種賦值的地方證明這一點 ``` sds s = o->ptr; ``` 但下面這段定義讓我曾經非常迷惑 sds.h ``` typedef char *sds; struct sdshdr { unsigned int len; unsigned int free; char buf[]; }; ``` 將一個字串變數的地址賦給了一個 char\* 的 sds 變數,但結構 sdshdr 才是表示 sds 結構的結構體,而 sds 只是一個 char\* 型別的字串而已,這兩個東西怎麼就對應上了呢 其實再往下讀兩行,就豁然開朗了。 ``` static size_t sdslen(const sds s) { struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr))); return sh->len; } ``` 原來 sds 確實就是指向了一段字串地址,就相當於 sdshdr 結構裡的 buf,而其 len 和 free 變數就在一定的記憶體偏移處。 #### 結構與優點 盯著這個結構看 10s,你腦子裡想到的是什麼?如果你什麼都想不到,那建議之後和我的公眾號一起,多多閱讀原始碼。如果瞬間明白了這個結構的意義,那請聯絡我,收我為徒吧! ``` struct sdshdr { unsigned int len; unsigned int free; char buf[]; }; ``` 回過頭來說這個 sds 結構,`char buf[]` 我們知道是表示具體值的,這個肯定必不可少。那剩下兩個欄位 `len` 和 `free` 有什麼作用呢? **len**:表示字串長度。由於 c 語言的字串無法表示長度,所以變數 len 可以以**常數的時間複雜度獲取字串長度**,來優化 Redis 中需要計算字串長度的場景。而且,由於是以 len 來表示長度,而不是通過字串結尾標識來判斷,所以可以用來儲存原封不動的二進位制資料而不用擔心被截斷,這個叫**二進位制安全**。 **free**:表示 buf 陣列中未使用的位元組數。同樣由於 c 語言的字串每次變更(變長、變短)都需要重新分配記憶體地址,分配記憶體是個耗時的操作,尤其是 Redis 面對經常更新 value 的場景。那有辦法優化麼? 能想到的一種辦法是:在字串變長時,每次多分配一些空間,以便下次變長時可能由於 buf 足夠大而不用重新分配,這個叫**空間預分配**。在字串變短時,並不立即重新分配記憶體而回收縮短後多出來的字串,而是用 free 來記錄這些空閒出來的位元組,這又減少了記憶體分配的次數,這叫**惰性空間釋放**。 不知不覺,多出了四個名詞可以和麵試官扯啦,哈哈。現在記不住沒關係,看文末的總結筆記就好。 #### 上原始碼簡單證明一下 老規矩,看原始碼證明一下,不能光說結論,我們拿**空間預分配**來舉例。 由於將字串變長時才能觸發 Redis 的這個技能,所以感覺應該看下 append 指令對應的方法 `appendCommand`。 跟著跟著發現有個這樣的方法 ``` /* Enlarge the free space at the end of the sds string so that the caller * is sure that after calling this function can overwrite up to addlen * bytes after the end of the string, plus one more byte for nul term. * Note: this does not change the *length* of the sds string as returned * by sdslen(), but only the free buffer space we have. */ sds sdsMakeRoomFor(sds s, size_t addlen) { struct sdshdr *sh, *newsh; size_t len, newlen; // 空閒空間夠,就直接返回 size_t free = sdsavail(s); if (free >= addlen) return s; // 再多分配一倍(+1)的空間作為空閒空間 len = sdslen(s); sh = (void*) (s-(sizeof(struct sdshdr))); newlen = (len+addlen); newlen *= 2; newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1); .. return newsh->buf; } ``` 本段程式碼就是說,如果增長了字串,假如增長之後字串的長度是 15,那麼就同樣也分配 15 的空閒空間作為 free,總 buf 的大小為 `15+15+1=31`(額外 1 位元組用於儲存空字元) 最上面的原始碼中的英文註釋,就說明了一切,留意哦~ ## 總結 敲重點敲重點,課代表來啦~ #### 一次 set 的請求流程堆疊 ![](https://imgkr2.cn-bj.ufileos.com/44bac313-2cf3-4a83-8da5-d8f5f350d907.png?UCloudPublicKey=TOKEN_8d8b72be-579a-4e83-bfd0-5f6ce1546f13&Signature=r%252FV7t64bwn4mUjFUmWqoqx9HrZw%253D&Expires=1605618503) 建立 socket 連結 --> 建立 client --> 註冊 socket 讀取事件處理器 --> 從 socket 讀資料到緩衝區 --> 獲取命令 --> **執行命令(字串編碼、寫入字典)**--> 響應 #### 數值型字串一個小騷操作 在選擇整型返回的時候,不是直接轉換為一個 long 型別,而是先看看這個數值大不大,如果不大的話,從常量池裡面選一個返回這個引用,這和 Java Integer 常量池的思想差不多,由於業務上可能大部分用到的整型都沒那麼大,這麼做至少可以節省好多空間。 #### 字串底層資料結構 SDS 字串底層資料結構是 SDS,簡單動態字串 ``` struct sdshdr { unsigned int len; unsigned int free; char buf[]; }; ``` **優點如下**: 1. **常數時間複雜度計算長度**:可以通過 len 直接獲取到字串的長度,而不需要遍歷 2. **二進位制安全**:由於是以 len 來表示長度,而不是通過字串結尾標識來判斷,所以可以用來儲存原封不動的二進位制資料而不用擔心被截斷 3. **空間預分配**:在字串變長時,每次多分配一些空間,以便下次變長時可能由於 buf 足夠大而不用重新分配 4. **惰性空間釋放**:在字串變短時,並不立即重新分配記憶體而回收縮短後多出來的字串,而是用 free 來記錄這些空閒出來的位元組,這又減少了記憶體分配的次數。 #### 字串操作指令 這個我就直接 copy 網上的了 - **SET key value**:設定指定 key 的值 - **GET key**:獲取指定 key 的值。 - **GETRANGE key start end**:返回 key 中字串值的子字元 - **GETSET key value**:將給定 key 的值設為 value ,並返回 key 的舊值(old value)。 - **GETBIT key offset**:對 key 所儲存的字串值,獲取指定偏移量上的位(bit)。 - **MGET key1 [key2..]**:獲取所有(一個或多個)給定 key 的值。 - **SETBIT key offset value**:對 key 所儲存的字串值,設定或清除指定偏移量上的位(bit)。 - **SETEX key seconds value**:將值 value 關聯到 key ,並將 key 的過期時間設為 seconds (以秒為單位)。 - **SETNX key value**:只有在 key 不存在時設定 key 的值。 - **SETRANGE key offset value**:用 value 引數覆寫給定 key 所儲存的字串值,從偏移量 offset 開始。 - **STRLEN key**:返回 key 所儲存的字串值的長度。 - **MSET key value [key value ...]**:同時設定一個或多個 key-value 對。 - **MSETNX key value [key value ...]**:同時設定一個或多個 key-value 對,當且僅當所有給定 key 都不存在。 - **PSETEX key milliseconds value**:這個命令和 SETEX 命令相似,但它以毫秒為單位設定 key 的生存時間,而不是像 SETEX 命令那樣,以秒為單位。 - **INCR key**:將 key 中儲存的數字值增一。 - **INCRBY key increment**:將 key 所儲存的值加上給定的增量值(increment) 。 - **INCRBYFLOAT key increment**:將 key 所儲存的值加上給定的浮點增量值(increment) 。 - **DECR key**:將 key 中儲存的數字值減一。 - **DECRBY key decrement**:key 所儲存的值減去給定的減量值(decrement) 。 - **APPEND key value**:如果 key 已經存在並且是一個字串, APPEND 命令將指定的 value 追加到該 key 原來值(value)的末尾。 ## 趣味題答案 **問**:1 斤 100 元的紙幣和 100 斤 1 元的紙幣,你選拿個? **答**: 100 元的重,選 1 元的合適。 因為 1 斤 100 元的價值 = 1 斤 / 100元紙幣的重量 \* 100元 100 斤 1 元的價值 = 100 斤 / 1元紙幣的重量