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

閃客sun發表於2020-11-16

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 <seconds>] [PX <milliseconds>] */
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 命令之前的堆疊資訊

總之當客戶端傳送來一個 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[] 我們知道是表示具體值的,這個肯定必不可少。那剩下兩個欄位 lenfree 有什麼作用呢?

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 的請求流程堆疊

建立 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元紙幣的重量 * 1元

相關文章