一個簡單的字串,為什麼 Redis 要設計的如此特別

雙子孤狼發表於2021-01-11

Redis 的 9 種資料型別

本文GitHub已收錄:https://zhouwenxing.github.io/

Redis 中支援的資料型別到 5.0.5 版本,一共有 9 種。分別是:

  • 1、Binary-safe strings(二進位制安全字串)
  • 2、Lists(列表)
  • 3、Sets(集合)
  • 4、Sorted sets(有序集合)
  • 5、Hashes(雜湊)
  • 6、Bit arrays (or simply bitmaps)(點陣圖)
  • 7、HyperLogLogs
  • 8、 geospatial
  • 9、Streams

雖然這裡列出了 9 種,但是基礎型別就是前面 5 種。後面的 4 種是基於前面 5 種基本型別及特定的演算法來實現的特殊型別。

而在 5 種基礎型別之中,又尤其以字串型別最為常用,且 key 值只能為字串物件,所以要想深入的瞭解 Redis 的特性,字串物件是首先需要學習的。

五種基本資料型別之字串物件

Redis 當中有五種基礎資料型別,而字串物件又是最重要最常用的一種型別。

二進位制安全字串

Redis 是基於 C 語言進行開發的,而 C 語言中的字串是二進位制不安全的,所以 Redis 就沒有直接使用 C 語言的字串,而是自己編寫了一個新的資料結構來表示字串,這種資料結構稱之為:簡單動態字串(Simple dynamic string),簡稱 SDS

什麼是二進位制安全的字串

C 語言中,字串採用的是一個 char 陣列(柔性陣列)來儲存字串,而且字串必須要以一個空字串 \0 來結尾。而且字串並不記錄長度,所以如果想要獲取一個字串的長度就必須遍歷整個字串,直到遇到第一個 \0 為止(\0 不會計入字串長度),故而獲取字串長度的時間複雜度為 O(n)

正因為 C 語言中是以遇到的第一個空字元 \0 來識別是否到了字串末尾,因此其只能儲存文字資料,不能儲存圖片,音訊,視訊和壓縮檔案等二進位制資料,否則可能出現字串不完整的問題,所以其是二進位制不安全的。

Redis 中為了實現二進位制安全的字串,對原有 C 語言中的字串實現做了改進。如下所示就是一箇舊版本的 sds 字串的結構定義:

struct sdshdr{
  int len;//記錄buf陣列已使用的長度,即SDS的長度(不包含末尾的'\0')
  int free;//記錄buf陣列中未使用的長度
  char buf[];//位元組陣列,用來儲存字串
}

經過改進之後,如果想要獲取 sds 的長度不用去遍歷 buf 陣列了,直接讀取 len 屬性就可以得到長度,時間複雜度一下就變成了 O(1),而且因為判斷字串長度不再依賴空字元 \0,所以其能儲存圖片,音訊,視訊和壓縮檔案等二進位制資料,不用擔心讀取到的字串不完整。

需要注意的是,sds 依然遵循了 C 語言字串以 \0 結尾的慣例,這麼做是為了方便複用 C 語言字串原生的一些API,換言之就是在 C 語言中會以碰到的第一個 \0 字元當做當前字串物件的結尾,所以如果一些二進位制資料就會可能出現讀取字串不完整的現象,而 sds 會以長度來判斷是否到字串末尾。

Redis 3.2 之後的版本,Redissds 又做了優化,按照儲存空間的大小拆分成為了 sdshdr5sdshdr8sdshdr16sdshdr32sdshdr64,分別用來儲存大小為:32 位元組(25 次方),256 位元組(28 次方),64KB216 次方),4GB 大小(232 次方)以及 264 次方大小的字串(因為目前版本 keyvalue 都限制了最大 512MB,所以 sdshdr64 暫時並未使用到)。 sdshdr5 只被應用在了 Redis 中的 key 中,value 中不會被使用到,因為sdshdr5和其他型別也不一樣,其並沒有儲存未使用空間,所以其是比較適用於使用大小固定的場景(比如 key 值):

任意選擇其中一種資料型別,其欄位代表含義如下:

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; //已使用空間大小
    uint8_t alloc; //總共申請的空間大小(包括未使用的)
    unsigned char flags; //用來表示當前sds型別是sdshdr8還是sdshdr16等
    char buf[]; //真實儲存字串的位元組陣列
};

可以看到相比較於 Redis 3.2 版本之前的 sds 主要是修改了 free 屬性然後新增了一個 flags 標記來區分當前的 sds 型別。

sds 空間分配策略

C 語言中因為字串內部沒有記錄長度,所以如果擴充字串的時候非常容易造成緩衝區溢位(buffer overflow)

請看下面這張圖,假設下面這張圖就是記憶體裡面的連續空間,可以很明顯的看到,此時 wolfRedis 兩個字串之間只有三個空位,那麼這時候如果我們要將 wolf 字串修改為 lonelyWolf,那麼就需要 6 個空間,這時候下面這個空間是放不下的,所以必須要重新申請空間,但是假如說程式設計師忘了申請空間,或者說申請到的空間依然不夠,那麼就會出現後面的 Redis 字串中的 Red 被覆蓋了:

同樣的,假如要縮小字串的長度,那麼也需要重新申請釋放記憶體。否則,字串一直佔據著未使用的空間,會造成記憶體洩露

C 語言避免快取區溢位和記憶體洩露完全依賴於人為,很難把控,但是使用 sds 就不會出現這兩個問題,因為當我們操作 sds時,其內部會自動執行空間分配策略,從而避免了上述兩種情況的出現。

空間預分配

空間預分配指的是當我們通過 apisds 進行擴充套件空間的時候,假如未使用空間不夠用,那麼程式不僅會為 sds 分配必須要的空間,還會額外分配未使用空間,未使用空間分配大小主要有兩種情況:

  • 1、假如擴大長度之後的 len 屬性小於等於 1MB (即 1024 * 1024),那麼就會同時分配和 len 屬性一樣大小的未使用空間(此時 buf 陣列已使用空間 = 未使用空間)。
  • 2、假如擴大長度之後的 len 屬性大於 1MB,那麼就會分配 1MB 未使用空間大小。

執行空間預分配策略的好處是提前分配了未使用空間備用後,就不需要每次增大字串都需要分配空間,減少了記憶體重分配的次數。

惰性空間釋放

惰性空間釋放指的是當我們需要通過 api 減小 sds 長度的時候,程式並不會立即釋放未使用的空間,而只是更新 free 屬性的值,這樣空間就可以留給下一次使用。而為了防止出現記憶體溢位的情況,sds 單獨提供給了 api 讓我們在有需要的時候去真正的釋放記憶體。

sds 和 C 語言字串區別

下面表格中列舉了 Redis 中的 sdsC 語言中實現的字串的區別:

C 字串 SDS
只能儲存文字類不含空字串 \0 資料 可以儲存文字或者二進位制資料,允許包含空字串 \0
獲取字串長度的複雜度為 O(n) 獲取字串長度的複雜度為 O(1)
操作字串可能會造成緩衝區溢位 不會出現緩衝區溢位情況
修改字串長度 N 次,必然需要 N次記憶體重分配 修改字串長度 N 次,最多需要 N 次記憶體重分配
可以使用 C 字串相關的所有函式 可以使用 C 字串相關的部分函式

sds 是如何被儲存的

Redis 中所有的資料型別都是將對應的資料結構再進行了再一次包裝,建立了一個字典物件來儲存的,sds也不例外。每次建立一個 key-value 鍵值對,Redis 都會建立兩個物件,一個是鍵物件,一個是值物件。而且需要注意的是Redis 中,值物件並不是直接儲存,而是被包裝成 redisObject 物件,並同時將鍵物件和值物件通過 dictEntry 物件進行封裝,如下就是一個 dictEntry 物件:

typedef struct dictEntry {
    void *key;//指向key,即sds
    union {
        void *val;//指向value
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;//指向下一個key-value鍵值對(雜湊值相同的鍵值對會形成一個連結串列,從而解決雜湊衝突問題)
} dictEntry;

redisObject 物件的定義為:

typedef struct redisObject {
    unsigned type:4;//物件型別(4位=0.5位元組)
    unsigned encoding:4;//編碼(4位=0.5位元組)
    unsigned lru:LRU_BITS;//記錄物件最後一次被應用程式訪問的時間(24位=3位元組)
    int refcount;//引用計數。等於0時表示可以被垃圾回收(32位=4位元組)
    void *ptr;//指向底層實際的資料儲存結構,如:sds等(8位元組)
} robj;

當我們在 Redis 客戶端中執行命令 set name lonely_wolf ,就會得到下圖所示的一個結構(省略了部分屬性):

看到這個圖想必大家會有疑問,這裡面的 typeencoding 到底是什麼呢?其實這兩個屬性非常關鍵,Redis 就是通過這兩個屬性來識別當前的 value 到底屬於哪一種基本資料型別,以及當前資料型別的底層採用了何種資料結構進行儲存。

type 屬性

type 屬性表示物件型別,其對應了 Redis 當中的 5 種基本資料型別:

型別屬性 描述 type 命令返回值
REDIS_STRING 字串物件 string
REDIS_LIST 列表物件 list
REDIS_HASH 雜湊物件 hash
REDIS_SET 集合物件 set
REDIS_ZSET 有序集合物件 zset

可以看到,這就是對應了我們 5 種常用的基本資料型別。

encoding 屬性

Redis 當中每種資料型別都是經過特別設計的,相信大家看完這個系列也會體會到 Redis 設計的精妙之處。字串在我們眼裡是非常簡單的一種資料結構了,但是 Redis 卻把它優化到了極致,為了節省空間,其通過編碼的方式定義了三種不同的儲存方式:

編碼屬性 描述 object encoding命令返回值
OBJ_ENCODING_INT 使用整數的字串物件 int
OBJ_ENCODING_EMBSTR 使用 embstr 編碼實現的字串物件 embstr
OBJ_ENCODING_RAW 使用 raw 編碼實現的字串物件 raw
  • int 編碼
    當我們用字串物件儲存的是整型,且能用 8 個位元組的 long 型別進行表示(即 263 次方減 1),則 Redis 會選擇使用 int 編碼來儲存,此時 redisObject 物件中的 ptr 指標直接替換為 long 型別。我們想想 8 個位元組如果用字串來儲存只能存 8 位,也就是千萬級別的數字,遠遠達不到 263 次方減 1 這個級別,所以如果都是數字,用 long 型別會更節省空間。
  • embstr 編碼
    當字串物件中儲存的是字串,且長度小於 44Redis 3.2 版本之前是 39)時,Redis 會選擇使用 embstr 編碼來儲存。
  • raw 編碼
    當字串物件中儲存的是字串,且長度大於 44 時,Redis 會選擇使用 raw 編碼來儲存。

講了半天理論,接下來讓我們一起來驗證下這些結論,依次輸入 set name lonely_wolftype nameobject encoding name 命令:

可以發現當前的資料型別就是 string,普通字串因為長度小於 44,所以採用的是 embstr 編碼。

再依次輸入:set num 1111111111set address aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(長度 44),set address aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa(長度 45),分別檢視型別和編碼:

可以發現,當輸入純數字的時候,採用的是 int 編碼,而字串小於等於 44 則為 embstr,大於 44 則為 raw 編碼。

字串物件中除了上面提到的純整數和字串,還可以儲存浮點型型別,所以字串物件可以儲存以下三種型別:

  • 字串
  • 整數
  • 浮點數

而當我們的 value 為整數時,還可以使用原子自增命令來實現 value 的自增,這個命令在實際開發過程中非常實用。

  • incr:自增 1
  • incrby:自增指定數值。

不過這兩個命令只能用在 value 為整數的場景,當 value 不是整數時則會報錯。

embstr 編碼為什麼從 39 位修改為 44 位

embstr 編碼中,redisObjectsds 是連續的一塊記憶體空間,這塊記憶體空間 Redis 限制為了 64 個位元組,而redisObject 固定佔了16位元組(上面定義中有標註),Redis 3.2 版本之前的 sds 佔了 8 個位元組,再加上字串末尾 \0 佔用了 1 個位元組,所以:64-16-8-1=39 位元組。

Redis 3.2 版本之後 sds 做了優化,對於 embstr 編碼會採用 sdshdr8 來儲存,而 sdshdr8 佔用的空間只有 24 位:3 位元組(len+alloc+flag)+ \0 字元(1位元組),所以最後就剩下了:64-16-3-1=44 位元組。

embstr 編碼和 raw 編碼的區別

embstr 編碼是一種優化的儲存方式,其在申請空間的時候因為 redisObjectsds 兩個物件是一個連續空間,所以只需要申請 1 次空間(同樣的,釋放記憶體也只需要 1 次),而 raw 編碼因為 redisObjectsds 兩個物件的空間是不連續的,所以使用的時候需要申請 2 次空間(同樣的,釋放記憶體也需要 2 次)。但是使用 embstr 編碼時,假如需要修改字串,那麼因為 redisObjectsds 是在一起的,所以兩個物件都需要重新申請空間,為了避免這種情況發生,embstr 編碼的字串是隻讀的,不允許修改

上圖中的示例我們看到,對一個 embstr 編碼的字串物件進行 append 操作時,長度還沒有達到 45,但是編碼已經被修改為 raw 了,這就是因為 embstr 編碼是隻讀的,如果需要對其修改,Redis 內部會將其修改為 raw 編碼之後再操作。同樣的,如果是操作 int 編碼的字串之後,導致 long 型別無法儲存時(int 型別不再是整數或者長度超過 263 次方減 1 時),也會將 int 編碼修改為 raw 編碼。

PS:需要注意的是,編碼一旦升級(int-->embstr-->raw),即使後期再把字串修改為符合原編碼能儲存的格式時,編碼也不會回退。

總結

本文主要講述了 Redis 當中最常用的字元創物件,通過二進位制安全字串的特別逐步分析了 sds 的底層儲存即編碼格式,並分別介紹了每種編碼格式的區別,最後通過示例來演示了編碼的轉換過程。

相關文章