Redis技術知識總結之一——Redis 的數據結構

一. Redis 的數據結構

參考地址:
《【Redis】redis各類型數據存儲分析》
《一文深入瞭解 Redis 內存模型,Redis 的快是有原因的!》

1.1 底層數據結構

Redis 常用的數據類型主要有:String, List, Hash, Set, ZSet 五種,它們分別對應的底層數據結構有:

  • String: sds
  • List: quicklist (linkedlist + ziplist)
  • Hash: ziplist 或 hashtable
  • Set: intset 或 hashtable
  • ZSet: ziplist 或 skiplist

1.2 redisObject

redisObject 對象非常重要,Redis 對象的類型、內部編碼、內存回收、共享對象等功能,都需要 redisObject 支持。這樣設計的好處是,可以針對不同的使用場景,對五種常用類型設置多種不同的數據結構實現,從而優化對象在不同場景下的使用效率。

例如當我們執行set hello world命令時,會有以下數據模型:

img

dictEntry:Redis 給每個 key-value 鍵值對分配一個 dictEntry,裏面有着 key 和 val 的指針,next 指向下一個 dictEntry 形成鏈表,這個指針可以將多個哈希值相同的鍵值對鏈接在一起,由此來解決哈希衝突問題(鏈地址法)。

sds:鍵 key “hello” 是以 SDS(簡單動態字符串)存儲,後面詳細介紹。

redisObject:值val “world” 存儲在 redisObject 的 ptr 中。實際上,redis 常用五種類型都是以 redisObject 來存儲的;而 redisObject 中的 type 字段指明瞭 Value 對象的類型,ptr 字段則指向對象所在的地址。

注:無論是 dictEntry 對象,還是 redisObject、SDS 對象,都需要內存分配器(如jemalloc)分配內存進行存儲。jemalloc作爲Redis的默認內存分配器,在減小內存碎片方面做的相對比較好。比如jemalloc在64位系統中,將內存空間劃分爲小、大、巨大三個範圍;每個範圍內又劃分了許多小的內存塊單位;當Redis存儲數據時,會選擇大小最合適的內存塊進行存儲。

前面說過,Redis 每個對象由一個 redisObject 結構表示,它的 ptr 指針指向底層實現的數據結構,而數據結構由 encoding 屬性決定。比如我們執行以下命令得到存儲“hello”對應的編碼:

img

redis所有的數據結構類型如下:

img

1.3 sds

struct sdshdr {
    // buf 中已佔用空間的長度
    int len;
    // buf 中剩餘可用空間的長度
    int free;
    // 數據空間
    char buf[]; // ’\0’空字符結尾
};

1.3.1 sds 編碼

字符串對象的底層實現可以是int、raw、embstr(上面的表對應有名稱介紹)。

embstr編碼是通過調用一次內存分配函數來分配一塊連續的空間,而raw需要調用兩次。

img

int 編碼字符串和 embstr 編碼字符串在一定條件下會轉化爲 raw 編碼字符串。

  • embstr:<= 39 字節;
  • int:8個字節的長整型;
  • raw:> 39 個字節的字符串

1.3.2 空間分配

如果對一個SDS進行修改,分爲一下兩種情況:

  1. 長度小於1MB:程序將分配和 len 屬性同樣大小的未使用空間,這時free和len屬性值相同。
    • 舉個例子,SDS的len將變成15字節,則程序也會分配15字節的未使用空間,SDS的buf數組的實際長度變成15+15+1=31字節(額外一個字節用戶保存空字符)
  2. 長度大於等於1MB:程序會分配 1MB 的未使用空間;
    • 比如進行修改之後,SDS的len變成30MB,那麼它的實際長度是30MB+1MB+1byte。

1.4 hashtable

hashtable 又名字典,是 Redis 中應用十分廣泛的數據結構。除了基礎數據結構 Hash, Set 之外,Redis 的全局字典,過期時間的 Key 集合,ZSet 中 value 與 score 的映射,都是基於 hashtable 完成的。

1.4.1 Hashtable 源碼

Hashtable 可以簡化成如下結構:

img可以看出,HashTable 與 Java 1.7 中的 HashMap 實現原理基本相同。代碼如下:

typedef struct dict {
    // 類型特定函數
    dictType *type;
     // 私有數據
    void *privdata;
     // 哈希表
    dictht ht[2];
    // rehash 索引
    // 當 rehash 不在進行時,值爲 -1
    int rehashidx; /* rehashing not in progress if rehashidx == -1 */
     // 目前正在運行的安全迭代器的數量
    int iterators; /* number of iterators currently running */
 } dict;
typedef struct dictht {
    // 哈希表數組
    dictEntry **table;
     // 哈希表大小
    unsigned long size;
    // 哈希表大小掩碼,用於計算索引值
    // 總是等於 size - 1
    unsigned long sizemask;
    // 該哈希表已有節點的數量
    unsigned long used;
} dictht;
typedef struct dictEntry {
    void *key;
    union {void *val;uint64_t u64;int64_t s64;} v;
    // 指向下個哈希表節點,形成鏈表
    struct dictEntry *next;
 } dictEntry;

dict 的定義中,可以看出有兩個 dictht 字典對象。每個字典會帶有兩個哈希表,一個平時使用,另一個僅在rehash(重新散列)時使用。隨着對哈希表的操作,鍵會逐漸增多或減少。爲了讓哈希表的負載因子維持在一個合理範圍內,Redis會對哈希表的大小進行擴展或收縮(rehash)。只有在擴展與收縮時,ht[0] 裏面所有的鍵值對會多次、漸進式的 rehash 到 ht[1] 裏。

1.4.2 Hash 的 Hashtable

Hash 可以使用 Hashtable 或者 ziplist 結構來實現。Hash對象只有同時滿足下面兩個條件時,纔會使用ziplist(壓縮列表):

  1. Hash 中元素數量小於 512 個;
  2. Hash 中所有鍵值對的鍵和值字符串長度都小於 64 字節。

1.4.3 Set 的 Hashtable

較大數量的 Set 同樣也是 HashTable ,但實現的時候 value 全部置爲 NULL。

注:

  1. Hash 爲壓縮鏈表的條件如下,如果其中一個不滿足,則會轉換爲 Hashtable 格式;
  • 元素數量少於 512 個;
  • 每個元素大小都不足 64bytes;
  1. Set 爲 Intset 的條件如下,如果其中一個不滿足,則會轉換爲 Hashtable 格式;
  • 元素數量少於 512 個;
  • 每個元素都是整數類型;

1.5 壓縮列表 ziplist

當 hash 與 zset 數據很少時,爲了節省空間,Redis 就使用 ziplist(壓縮列表)做列表鍵的底層實現。
ziplist 是 Redis 爲了節約內存而開發的,是由一系列特殊編碼的連續內存塊(而不是像雙端鏈表一樣每個節點是指針)組成的順序型數據結構,是一個
可以雙向遍歷的壓縮鏈表
。ziplist 空間壓縮的非常緊湊,所以只適合小數據量的情況

img

ziplist 的數據結構如下所示:

  • ziplist
    • size:ziplist 的容量;
    • tail:尾部節點,與 entry 的 prevlen 字段配合,可以實現雙向遍歷的後續遍歷;
    • entry[]:列表內容;
    • end:ziplist 的結束標誌;
  • entry
    • int prevlen:前一個 entry 佔用空間大小,用於 ziplist 的後續遍歷;
    • int encoding:編碼,決定 entry 的數據類型;
    • byte[] content:entry 的數據內容

每一個 entry 的數據內容是由 encoding 字段決定的,內容十分複雜,根據不同的 encoding 值,可以決定 entry 的 content 是哪種長度的 int,哪種長度的字符串。

ziplist 的空間壓縮十分緊密,所以佔用空間很小。但相應的,增刪改時代價較大。插入數據時,都需要用 realloc 重新申請內存,申請內存可能是重新分配整個新 ziplist 的內存,也可能是在 ziplist 尾部申請空間。更新數據時,由於每個 entry 都有前一個 entry 佔用空間大小的信息(prevlen 字段),所以更新數據時會觸發前向數據的級聯更新。綜上所述,ziplist 只適合小數據集。

注:

  1. List 滿足以下條件纔會使用 ziplist,如果其中之一不滿足,則轉換爲雙端鏈表
  • 元素數量少於 512 個;
  • 每個元素大小都不足 64bytes;
  1. ZSet 滿足以下條件纔會使用 ziplist,如果其中之一不滿足,則轉換爲跳躍鏈表
  • 元素數量小於 128 個;
  • 有序集合中所有成員長度都不足 64 字節。

1.6 雙端鏈表 linkedlist

Redis 的 List 結構就是 linkedList 與 ziplist 結合而成的。LinkedList 結構比較像 Java 的 LinkedList,源碼如下:

typedef struct listNode {
     // 前置節點
    struct listNode *prev;
    // 後置節點
    struct listNode *next;
    // 節點的值
    void *value;
 } listNode;

 typedef struct list {
     // 表頭節點
    listNode *head;
    // 表尾節點
    listNode *tail;
    // 節點值複製函數
    void *(*dup)(void *ptr);
    // 節點值釋放函數
    void (*free)(void *ptr);
     // 節點值對比函數
    int (*match)(void *ptr, void *key);
     // 鏈表所包含的節點數量
    unsigned long len;
 } list;

img

從圖中可以看出 Redis 的 linkedlist 雙端鏈表有以下特性:

  • 節點 (ListNode) 帶有 prev, next 指針;
  • 列表 (List) 有 head 指針和 tail 指針;

所以獲取前置節點、後置節點、表頭節點和表尾節點的複雜度都是 O(1)。len屬性獲取節點數量也爲O(1)。

與雙端鏈表相比,壓縮列表可以節省內存空間,但是進行修改或增刪操作時,複雜度較高;因此當節點數量較少時,可以使用壓縮列表;但是節點數量多時,還是使用雙端鏈表划算。

注:

  1. 雙端鏈表轉換爲壓縮鏈表的條件:
  • 元素數量少於 512 個;
  • 每個元素大小都不足 64bytes;

1.7 快速列表 quicklist

img

List 對象的底層實現是 quicklist(快速列表,是 ziplist 壓縮列表 和 linkedlist 雙端鏈表的組合)。Redis 中的列表支持兩端插入和彈出,並可以獲得指定位置(或範圍)的元素,可以充當數組、隊列、棧等。

quicklist 將 linkedList 按段切分,每一段使用 zipList 來緊湊存儲,多個 zipList 之間使用雙向指針串接起來。因爲鏈表的附加空間相對太高,prev 和 next 指針就要佔去 16 個字節 (64bit 系統的指針是 8 個字節),另外每個節點的內存都是單獨分配,會加劇內存的碎片化,影響內存管理效率。

quicklist 默認的壓縮深度是 0,也就是不壓縮。爲了支持快速的 push/pop 操作,quicklist 的首尾兩個 ziplist 不壓縮,此時深度就是 1。爲了進一步節約空間,Redis 還會對 ziplist 進行壓縮存儲,使用 LZF 算法壓縮。

注:通常每個 ziplist 的長度爲 8KB,該長度可以通過配置文件進行配置。

1.8 跳躍列表 zskiplist

參考地址:

《漫畫:什麼是跳躍表?》

《Redis內部數據結構詳解之跳躍表(skiplist)》

1.8.1 跳躍列表基礎說明

跳躍表是一種隨機化數據結構,基於並聯的鏈表,其效率可以比擬平衡二叉樹,查找、刪除、插入等操作都可以在對數期望時間內完成,對比平衡樹,跳躍表的實現要簡單直觀很多。

以下是一個跳躍表的例圖(來自維基百科):
跳躍鏈表
從圖中可以看出跳躍表主要有以下幾個部分構成:

  1. 表頭 head:負責維護跳躍表的節點指針;
  2. 節點 node:實際保存元素值,每個節點有一層或多層;
  3. 層 level:保存着指向該層下一個節點的指針;
  4. 表尾 tail:全部由 null 組成;

跳躍表的遍歷總是從高層開始,然後隨着元素值範圍的縮小,慢慢降低到低層。

1.8.2 跳躍列表的基本操作

  • 查詢O(logN):在跳躍列表上的操作,就是從高層向低層的逐層比較、定位,然後進行查詢、插入、刪除的過程。

  • 插入O(logN)

    1. 用查詢的方法找到待插入位置;O(logN)
    2. 然後在最底層鏈表上執行鏈表的插入操作;O(1)
    3. 概率升級:在最底層有 50% 的概率進行升級;如果升級成功後,倒數第二層插入該節點,同時又有了 50% 概率插入到上一層節點…… 如此每次向上升級都有 50% 的概率,直到觸發 50% 不升級概率;O(logN)
  • 刪除O(logN)

    1. 用查詢的方法找到待插入位置;自上而下,查找第一次出現節點的索引,並逐層找到每一層對應的節點;O(logN)
    2. 除每一層查找到的節點,如果該層只剩下1個節點,刪除整個一層(原鏈表除外);O(1)

跳躍表保持平衡使用的是【隨機拋硬幣】的方法。因爲跳躍表刪除和添加的節點是不可預測的,很難用一種有效算法保證跳錶索引分佈始終是均勻的。隨機拋硬幣的方法雖然不能保證所以的絕對均勻分佈,但是隨着數據量的增大,該算法可以使跳跳結構大體趨於均勻。

1.8.3 Redis 跳躍表的修改

Redis 作者爲了適合自己功能的需要,對原來的跳躍表進行了一下修改:

  1. 允許重複的 score 值:多個不同的元素 (member) 的 score 值可以相同;
  2. 進行元素對比的時候,不僅要檢查 score 值,還需要檢查 member:當 score 值相等時,需要比較 member 域進行比較;
  3. 結構保存一個 tail 指針:跳躍表的表尾指針;
  4. 每個節點都有一個高度爲 1 層的前驅指針,用於從底層表尾向表頭方向遍歷;
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章