Redis資料結構(一)-Redis的資料儲存及String型別的實現

京東雲發表於2022-10-25


1 引言

Redis作為基於記憶體的非關係型的K-V資料庫。因讀寫響應快速、原子操作、提供了多種資料型別String、List、Hash、Set、Sorted Set、在專案中有著廣泛的使用,今天我們來探討下下Redis的資料結構是如何實現的。

2 資料儲存

2.1 RedisDB

Redis將資料儲存在redisDb中,預設0~15共16個db。每個庫都是獨立的空間,不必擔心key衝突問題,可透過select命令切換db。叢集模式使用db0

typedef struct redisDb {    dict *dict;                 /* The keyspace for this DB */    dict *expires;              /* Timeout of keys with a timeout set */    ...} redisDb;
  • dict:資料庫鍵空間,儲存著資料庫中的所有鍵值對
  • expires:鍵的過期時間,字典的鍵為鍵,字典的值為過期事件UNIX時間戳

2.2 Redis雜湊表實現

2.2.1 雜湊字典dict

K-V儲存我們最先想到的就是map,在Redis中透過dict實現,資料結構如下:

typedef struct dict {    dictType *type;    void *privdata;    dictht ht[2];    long rehashidx; /* rehashing not in progress if rehashidx == -1 */    unsigned long iterators; /* number of iterators currently running */} dict;
  • type:型別特定函式是一個指向dictType結構的指標,每個dictType結構儲存了一簇用於操作特定型別鍵值對的函式,Redis會為用途不同的字典設定不同的型別特定函式。
  • privdata:私有資料儲存了需要傳給那些型別特定函式的可選引數
  • ht[2]:雜湊表一個包含兩個項的陣列,陣列中的每個項都是一個dictht雜湊表,一般情況下,字典只使用ht[0] 雜湊表,ht[1]雜湊表只會在對ht[0]雜湊表進行rehash時使用
  • rehashidx:rehash 索引,當rehash不在進行時,值為 -1

hash資料存在兩個特點:

  • 任意相同的輸入一定能得到相同的資料
  • 不同的輸入,有可能得到相同的輸出

針對hash資料的特點,存在hash碰撞的問題,dict透過dictType中的函式能夠解決這個問題

typedef struct dictType {    uint64_t (*hashFunction)(const void *key);    int (*keyCompare)(void *privdata, const void *key1, const void *key2); ...} dictType;
  • hashFunction:用於計算key的hash值的方法
  • keyCompare:key的值比較方法
2.2.2 雜湊表 dictht

dict.h/dictht表示一個雜湊表,具體結構如下:

typedef struct dictht {    dictEntry **table;    unsigned long size;    unsigned long sizemask;    unsigned long used;} dictht;
  • table:陣列指標,陣列中的每個元素都是一個指向dict.h/dictEntry結構的指標,每個dictEntry結構儲存著一個鍵值對。
  • size:記錄了雜湊表的大小,也就是table陣列的大小,大小總是2^n
  • sizemask:總是等於size - 1,這個屬性和雜湊值一起決定一個鍵應該被放到table陣列的哪個索引上面。
  • used:記錄了雜湊表目前已有節點(鍵值對)的數量。

鍵值對dict.h/dictEntry

typedef struct dictEntry {    void *key;    union {        void *val;        uint64_t u64;        int64_t s64;        double d;    } v;    struct dictEntry *next;} dictEntry;
  • key:儲存著鍵值對中的鍵(SDS型別物件)
  • val:儲存著鍵值對中的值,可以是一個uint64_t整數,或者是一個int64_t整數,又或者是一個指標指向一個被redisObject包裝的值
  • next:指向下個雜湊表節點,形成連結串列指向另一個雜湊表節點的指標,這個指標可以將多個雜湊值相同的鍵值對連線在一次,以此來解決鍵衝突(collision)的問題

使用hash表就一定會存在hash碰撞的問題,hash碰撞後在當前陣列節點形成一個連結串列,在資料量超過hash表長度的情況下,就會存在大量節點稱為連結串列,極端情況下時間複雜度會從O(1)變為O(n);如果hash表的資料再不斷減少,會造成空間浪費的情況。Redis會針對這兩種情況根據負載因子做擴充套件與收縮操作:

  • 負載因子:雜湊表已儲存節點數量/雜湊表大小,load_factor = ht[0].used/ht[0].size
  • 擴充套件操作:
  • 伺服器目前沒有在執行BGSAVE命令或者BGREWRITEAOF命令,並且雜湊表的負載因子大於等於 1;
  • 伺服器目前正在執行BGSAVE命令或者BGREWRITEAOF命令,並且雜湊表的負載因子大於等於5;

收縮操作:

  • 當雜湊表的負載因子小於 0.1 時, 程式自動開始對雜湊表執行收縮操作。

Redis在擴容時如果全量擴容會因為資料量問題導致客戶端操作短時間內無法處理,所以採用漸進式 rehash進行擴容,步驟如下:

  1. 同時持有2個雜湊表
  2. 將rehashidx的值設定為0,表示rehash工作正式開始
  3. 在rehash進行期間, 每次對字典執行新增、刪除、查詢或者更新操作時,程式除了執行指定的操作以外,還會順帶將ht[0]雜湊表在rehashidx索引上的所有鍵值對rehash到ht[1] ,當rehash工作完成之後,程式將rehashidx屬性的值增一
  4. 某個時間點上,ht[0]的所有鍵值對都會被rehash至ht[1] ,這時程式將rehashidx屬性的值設為-1, 表示rehash操作已完成

在漸進式 rehash 進行期間,字典的刪除(delete)、查詢(find)、更新(update)等操作會在兩個雜湊表上進行;在字典裡面查詢一個鍵的話, 程式會先在 ht[0] 裡面進行查詢,如果沒找到的話,就會繼續到ht[1]裡面進行查詢;新新增到字典的鍵值對一律會被儲存到 ht[1] 裡面,而ht[0]則不再進行任何新增操作:這一措施保證了ht[0]包含的鍵值對數量會只減不增(如果長時間不進行操作時,事件輪詢進行這種操作),並隨著rehash操作的執行而最終變成空表。

dict.h/redisObject

Typedef struct redisObject {        unsigned type:4;        unsigned encoding:4;        unsigned lru:LRU_BITS;        int refcount;        void *ptr;}
  • type:4:約束客戶端操作時儲存的資料型別,已存在的資料無法修改型別,4bit
  • encoding:4:值在redis底層的編碼模式,4bit
  • lru:LRU_BITS:記憶體淘汰策略
  • refcount:透過引用計數法管理記憶體,4byte
  • ptr:指向真實儲存值的地址,8byte

完整結構圖如下:

3 String型別

3.1 String型別使用場景

String 字串存在有三種型別:字串,整數,浮點。主要有以下使用場景

1)頁面動態快取
比如生成一個動態頁面,首次可以將後臺資料生成頁面,並且儲存到redis字串中。再次訪問,不再進行資料庫請求,直接從redis中讀取該頁面。特點是:首次訪問比較慢,後續訪問快速。

2)資料快取
在前後分離式開發中,有些資料雖然儲存在資料庫,但是更改特別少。比如有個全國地區表。當前端發起請求後,後臺如果每次都從關係型資料庫讀取,會影響網站整體效能。
我們可以在第一次訪問的時候,將所有地區資訊儲存到redis字串中,再次請求,直接從資料庫中讀取地區的json字串,返回給前端。

3)資料統計
redis整型可以用來記錄網站訪問量,某個檔案的下載量。(原子自增自減)

4)時間內限制請求次數
比如已登入使用者請求簡訊驗證碼,驗證碼在5分鐘內有效的場景。當使用者首次請求了簡訊介面,將使用者id儲存到redis 已經傳送簡訊的字串中,並且設定過期時間為5分鐘。當該使用者再次請求簡訊介面,發現已經存在該使用者傳送簡訊記錄,則不再傳送簡訊。

5)分散式session
當我們用nginx做負載均衡的時候,如果我們每個從伺服器上都各自儲存自己的session,那麼當切換了伺服器後,session資訊會由於不共享而會丟失,我們不得不考慮第三應用來儲存session。透過我們用關係型資料庫或者redis等非關係型資料庫。關係型資料庫儲存和讀取效能遠遠無法跟redis等非關係型資料庫。

3.2 String型別的實現——SDS結構

Redis並沒有直接使用C字串實現String型別,在Redis3.2版本之前透過SDS實現

Typedef struct sdshdr {    int len;    int free;    char buf[];};
  • len:分配記憶體空間
  • free:剩餘可用分配空間
  • char[]:value值實際資料

3.3 SDS與C字串之間的區別

3.3.1 查詢時間複雜度

C獲取字串長度的複雜度為O(N)。而SDS透過len記錄長度,從C的O(n)變為O(1)。

3.3.2 緩衝區溢位

C字串不記錄自身長度容易造成緩衝區溢位(buffer overflow)。SDS的空間分配策略完全杜絕了發生緩衝區溢位的可能性,當需要對SDS進行修改時,會先檢查SDS的空間是否滿足修改所需的要求,如果不滿足的話SDS的空間擴充套件至執行修改所需的大小,然後才執行實際的修改操作,所以使用SDS既不需要手動修改SDS的空間大小,也不會出現緩衝區溢位問題。

在SDS中,buf陣列的長度不一定就是字元數量加一,陣列裡面可以包含未使用的位元組,而這些位元組的數量就由SDS的free屬性記錄。透過未使用空間,SDS實現了空間預分配和惰性空間釋放兩種最佳化策略:

  • 空間預分配:當對一個SDS進行修改,並且需要對SDS進行空間擴充套件的時候,程式不僅會為SDS分配修改所必須要的空間,還會為SDS分配額外的未使用空間。擴充套件SDS 空間之前,會先檢查未使用空間是否足夠, 如果足夠的話,就會直接使用未使用空間,而無須執行記憶體重分配。如果不夠根據(len + addlen(新增位元組)) * 2的方式進行擴容,大於1M時,每次只會增加1M大小。透過這種預分配策略,SDS將連續增長N次字串所需的記憶體重分配次數從必定N次降低為最多N次。
  • 惰性空間釋放:惰性空間釋放用於最佳化SDS的字串縮短操作:當需要縮短SDS儲存的字串時,程式並不立即使用記憶體重分配來回收縮短後多出來的位元組,而是使用free屬性將這些位元組的數量記錄起來,並等待將來使用。
3.3.3 二進位制安全

C字串中的字元必須符合某種編碼(比如 ASCII,並且除了字串的末尾之外,字串裡面不能包含空字元, 否則最先被程式讀入的空字元將被誤認為是字串結尾。

SDS的API都是二進位制安全的(binary-safe):都會以處理二進位制的方式來處理SDS存放在buf陣列裡的資料,程式不會對其中的資料做任何限制、過濾、或者假設 —— 資料在寫入時是什麼樣的,它被讀取時就是什麼樣。redis不是用這個陣列來儲存字元,而是用它來儲存一系列二進位制資料。

3.4 SDS結構最佳化

String型別所儲存的資料可能會幾byte存在大量這種型別資料,但len、free屬性的int型別會佔用4byte共8byte儲存,3.2之後會根據字串大小使用sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64資料結構儲存,具體結構如下:

struct __attribute__ ((__packed__)) sdshdr5 {    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */    char buf[];};struct __attribute__ ((__packed__)) sdshdr8 {    uint8_t len; /* used */    uint8_t alloc; /* excluding the header and null terminator */    unsigned char flags; /* 3 lsb of type, 5 unused bits */    char buf[];};struct __attribute__ ((__packed__)) sdshdr16 {    uint16_t len; /* used */    uint16_t alloc; /* excluding the header and null terminator */    unsigned char flags; /* 3 lsb of type, 5 unused bits */    char buf[];};struct __attribute__ ((__packed__)) sdshdr32 {    uint32_t len; /* used */    uint32_t alloc; /* excluding the header and null terminator */    unsigned char flags; /* 3 lsb of type, 5 unused bits */    char buf[];};struct __attribute__ ((__packed__)) sdshdr64 {    uint64_t len; /* used */    uint64_t alloc; /* excluding the header and null terminator */    unsigned char flags; /* 3 lsb of type, 5 unused bits */    char buf[];};
  • unsign char flags:3bit表示型別,5bit表示未使用長度
  • len:表示已使用長度
  • alloc:表示分配空間大小,剩餘空間大小可以使用alloc - len獲得

3.5 字符集編碼

redisObject包裝儲存的value值,透過字符集編碼對資料儲存進行最佳化,string型別的編碼方式有如下三種:

  • embstr:
    CPU每次按Cache Line 64byte讀取資料,一個redisObject物件為16byte,為填充64byte大小,會向後再讀取48 byte資料。但獲取實際資料時還需要再透過*ptr指標讀取對應記憶體地址的資料。而一個sdshdr8屬性的資訊佔用4byte,其餘44byte可以用來儲存資料。如果value值小於44,byte可以透過一次讀取快取行獲取資料。
  • int:
    如果SDS小於20位,並且能夠轉換成整型數字,redisObject的*ptr指標會直接進行儲存。
  • raw:
    SDS

4 總結

redis作為k-v資料儲存,因查詢和操作的時間複雜度都是O(1)和豐富的資料型別及資料結構的最佳化,瞭解了這些資料型別和結構更有利於我們平時對於redis的使用。下一期將對其它常用資料型別List、Hash、Set、Sorted Set所使用的ZipList、QuickList、SkipList做進一步介紹,對於文章中不清晰不準確的地方歡迎大家一起討論交流。



作者:盛旭

相關文章