淺談Redis的六種底層數據結構和五種對象

前言:

Redis作爲一款優秀的中間件,是目前市場上最好的開源內存 NoSQL 數據庫之一,在緩存、計數和排名等實時分析、分佈式鎖、用戶會話數據管理等等場景中大放異彩,相信很多開發者都聽說過甚至頻繁使用過,從使用層面上,與我們直接接觸最多就是Redis的五種對象,分別是字符串、列表、集合、有序集合、哈希。這五種對象在Redis底層中實際上是使用六種數據結構實現的,分別是SDS(簡單動態字符串)、鏈表、字典、跳躍表、整數集合、壓縮列表,我們直接使用的對象至少都會使用到一種底層數據結構實現,在不同場景中會選擇合適的底層數據結構實現。Redis作爲內存數據庫,對內存的使用極爲敏感,一個對象由多種數據結構實現,可以優化不同場景的使用效率,使內存得到最大程度的利用,提高性能。下面主要是着重介紹六種底層數據結構,以及五種對象與底層數據結構的關聯.

六種數據結構介紹

一、SDS(簡單動態字符串)

說明:

Redis沒有直接使用C語言字符串來表示,而是自己實現一種名爲簡單動態字符串(simple dynamic string, SDS)來表示,將其用作Redis的默認字符串表示

SDS結構定義

 struct sdshdr {
 //記錄buf數組中已使用字節的數量
 //等於SDS所保存字符串的長度
 int len;
 //記錄buf數組中未使用字節的數量
 int free;
 //字節數組, 用於保存字符串
 //最後一個字節則保存了空字符'\0',遵循C字符串以空字符結尾的慣例 
 //不計算在SDS的len屬性裏面 
 char buf[];
};

SDS圖示說明

SDS圖示說明

SDS的優點

  1. 常數複雜度獲取字符串長度。因爲 C 字符串並不記錄自身的長度信息, 所以爲了獲取一個 C 字符串的長度, 程序必須遍歷整個字符串, 對遇到的每個字符進行計數, 直到遇到代表字符串結尾的空字符爲止, 這個操作的複雜度爲 O(N)
  2. 杜絕緩衝區溢出。C字符串不記錄字符串長度,有些不安全的API,比如char *strcat(char *dest, const char *src);,將一個src字符串拼接到dest後面可能會導致緩存區溢出。而SDS在執行拼接字符串操作時,會檢查dest字符串的長度是否足夠,如果不夠,會先擴大長度,再進行拼接操作
  3. 減少修改字符串時帶來的內存重分配次數。C字符串,比如長度爲N的字符串底層實現就是N+1長度的數組(末尾多了一個保存空字符的字符空間),字符串長度和數組的長度有對應的相關關係,每次修改時候,都需要重新分配數組的空間,會導致頻繁的內存重分配,效率低;SDS保存字符串的數組存在未使用的字節,SDS 實現了空間預分配(擴大數組時分配額外的空間)和惰性空間釋放(縮小數組時不釋放多餘的空間)兩種優化策略
  4. 二進制安全。C 字符串中的字符必須符合某種編碼(比如 ASCII), 並且除了字符串的末尾之外, 字符串裏面不能包含空字符, 否則最先被程序讀入的空字符將被誤認爲是字符串結尾 —— 這些限制使得 C 字符串只能保存文本數據, 而不能保存像圖片、音頻、視頻、壓縮文件這樣的二進制數據。而 SDS API 都會以處理二進制的方式來處理 SDS 存放在 buf 數組裏的數據, 程序不會對其中的數據做任何限制、過濾、或者假設 —— 數據在寫入時是什麼樣的,它被讀取時就是什麼樣。
  5. 兼容部分C字符串函數。雖然SDS有不少特性和C字符串不同,但是SDS一樣遵循C字符串以空字符結尾的慣例,這樣有些C字符串函數是可以兼容用的

二、鏈表

說明:

C語言是沒有實現鏈表數據結構的,Redis自然要自己實現,Redis的鏈表結構沒有很特殊的地方,可以從數據結構相關書籍查閱到相關定義。

鏈表結構定義:

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

鏈表圖示說明:

雙向鏈表

鏈表list結構定義:

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

list圖示說明:

list圖示說明
參數說明:
head:指向鏈表頭部
tail:指向鏈表尾部
len:指鏈表的長度,圖中有三個節點,len爲3
函數說明:
dup:函數用於複製鏈表節點所保存的值
free:函數用於釋放鏈表節點所保存的值
match:函數則用於對比鏈表節點所保存的值和另一個輸入值是否相等

特點:雙端(帶有prev、next指針)、無環(表頭表尾指針指向NULL)、帶表頭指針表尾指針、帶鏈表長度計數器(len字段保存長度)、多態(鏈表節點使用 void* 指針來保存節點值, 並且可以通過 list 結構的 dup 、 free 、 match 三個屬性爲節點值設置類型特定函數, 所以鏈表可以用於保存各種不同類型的值)

應用總結:

鏈表被廣泛用於實現Redis的各種功能,比如列表鍵(最基本、最常用)、發佈與訂閱、慢查詢、監視器等

三、字典

說明:

字典在高級語言中是一種很普遍的數據結構,比如Java的map實現,它是一種保存key-value(鍵值對)的抽象數據結構,C語言是沒有的,Redis構建了自己的字典實現。Redis字典所使用的哈希表由dict.h中的dictht定義

dictht結構定義:

 typedef struct dictht {
 //哈希表數組
 dictEntry **table;
 //哈希表大小
 unsigned long size;
//哈希表大小掩碼, 用於計算索引值
//總是等於size-1
 unsigned long sizemask;
//該哈希表已有節點的數量
unsigned long used;
} dictht;

哈希表圖示:

Redis哈希表圖示
圖中展示了一個大小爲 4 的空哈希表(沒有包含任何鍵值對)

哈希表節點定義:

typedef struct dictEntry {
     //鍵
     void *key;
     //值
     union {
      void *val;
      uint64_tu64;
      int64_ts64;
    } v;
 //指向下個哈希表節點, 形成鏈表
struct dictEntry *next;
} dictEntry;

既然是哈希表,那麼就會存在哈希鍵衝突的場景,解決哈希衝突有好幾種方法,這裏是採用鏈地址法,next屬性是指向另一個哈希表節點的指針,這個指針可以將多個哈希值相同的鍵值對連接起來在一起。
哈希衝突例子
在這裏插入圖片描述

字典定義:

typedef struct dict {
 //類型特定函數
 dictType *type;
 //私有數據
 void *privdata;
 //哈希表
 dictht ht[2];
 // rehash索引
 //當rehash不在進行時, 值爲-1
 in trehashidx; /* rehashing not in progress if rehashidx == -1 */
} dict;

屬性解釋:

  • type: type屬性是一個指向 dictType 結構的指針, 每個 dictType 結構保存了一簇用於操作特定類型鍵值對的函數,Redis 會爲用途不同的字典設置不同的類型特定函數
  • privdata: privdata 屬性保存了需要傳給那些類型特定函數的可選參數
  • ht: 這是包含了兩個項的數組,數組中的每一項都是一個dictht哈希表,爲什麼需要兩個項呢?熟悉Java hashmap的人都知道,在一定條件下(元素長度超過數組長度的75%),會觸發rehash的操作。隨着操作的不斷執行, 哈希表保存的鍵值對會逐漸地增多或者減少, 爲了讓哈希表的負載因子(load factor)維持在一個合理的範圍之內, 當哈希表保存的鍵值對數量太多或者太少時, 程序需要對哈希表的大小進行相應的擴展或者收縮。一般情況下,字典只使用ht[0]哈希表,ht[1]哈希表只會對ht[0]哈希表進行rehash使用。
  • trehashidx: trehashidx記錄了rehash的進度,如果目前沒有進行rehash,它的值爲-1

字典定義圖示:

在這裏插入圖片描述
這是普通狀態的字典的示意圖

字典重點特性:

  • 字典被廣泛用於實現 Redis 的各種功能, 其中包括數據庫和哈希鍵。
  • Redis 中的字典使用哈希表作爲底層實現, 每個字典帶有兩個哈希表, 一個用於平時使用, 另一個僅在進行 rehash 時使用。
  • 當字典被用作數據庫的底層實現, 或者哈希鍵的底層實現時, Redis 使用 MurmurHash2 算法來計算鍵的哈希值。
  • 哈希表使用鏈地址法來解決鍵衝突, 被分配到同一個索引上的多個鍵值對會連接成一個單向鏈表。
  • 在對哈希表進行擴展或者收縮操作時, 程序需要將現有哈希表包含的所有鍵值對 rehash 到新哈希表裏面, 並且這個 rehash 過程並不是一次性地完成的, 而是漸進式地完成的。

四、跳躍表

說明:

跳躍表(skiplist)是一種有序數據結構,它通過每個節點中維持多個指向其他節點的指針,從而達到快速訪問節點的目的。

跳躍表節點結構定義:

 typedef struct zskiplistNode {
   //層
 struct zskiplistLevel {
   //前進指針
   struct zskiplistNode *forward;
   //跨度
   unsigned int span;
 } level[];
 //後退指針
 struct zskiplistNode *backward;
 //分值
 double score;
 //成員對象
 robj *obj;
} zskiplistNode;

看了說明和節點定義的代碼,這裏解釋下各個參數的意義和作用

  • 層:層(level[])可以包含多個元素,這裏的多個元素的數值都是一樣的,相當於冗餘數據了,每個元素都包含一個指向其他節點的指針, 程序可以通過這些層來加快訪問其他節點的速度, 一般來說, 層的數量(數量是隨機的,介於1-32)越多層,訪問其他節點的速度就越快,是用空間換時間的做法。
  • 前進指針:就是指向其他節點的指針,包含在層裏面
  • 跨度: 用於記錄兩個節點的距離,和遍厲其實無關,是用於計算排位(rank)的,排位可以理解爲距離大小
  • 後退指針: 用於從表尾向表頭方向訪問節點: 跟可以一次跳過多個節點的前進指針不同, 因爲每個節點只有一個後退指針, 所以每次只能後退至前一個節點
  • 分值: 是一個 double 類型的浮點數, 跳躍表中的所有節點都按分值從小到大來排序
  • 成員對象: 是一個指針, 它指向一個字符串對象

跳躍表

雖然僅靠多個跳躍表節點就可以組成一個跳躍表,如圖5-8:
在這裏插入圖片描述
但通過使用一個 zskiplist 結構來持有這些節點, 程序可以更方便地對整個跳躍表進行處理, 比如快速訪問跳躍表的表頭節點和表尾節點, 又或者快速地獲取跳躍表節點的數量(也即是跳躍表的長度)等信息,如圖5-9:
在這裏插入圖片描述

zskiplist結構定義:

typedef struct zskiplist {

    // 表頭節點和表尾節點
    struct zskiplistNode *header, *tail;

    // 表中節點的數量
    unsigned long length;

    // 表中層數最大的節點的層數
    int level;

} zskiplist;

重點總結:

  • 跳躍表是有序集合的底層實現之一
  • Redis的跳躍表實現由zskiplist和zskiplistNode兩部分組成,其中zskiplist用於保存跳躍表的信息,如表頭、表尾節點信息,長度;zskiplistNode則表示跳躍表節點
  • 跳躍表的節點按照分值大小進行排序,當分值相同時,節點按照成員對象大小進行排序

五、整數集合

說明:

整數集合,整數代表的是int,集合代表的是set,連起來就是intset。整數集合是集合鍵(Redis五大對象中的set對象)的底層實現之一,爲什麼說底層實現之一呢?之前說了,每一個對象的底層實現至少用一種底層數據結構實現。可能讀者已經想到了,intset就是針對當一個集合只有整數值元素的場景的實現方式。Redis還有一個約束,就是這個集合的元素不是很多時(小於512吧)。

intset結構定義:

typedef struct intset {
//編碼方式
uint32_t encoding;
//集合包含的元素數量
uint32_t length;
//保存元素的數組
int8_t contents[];
} intset;

剛纔有說到整數元素小於512時候,會採用intset方式存儲,大於512自然要換方式實現了,這會就涉及到升級了。

升級

每當我們要將一個新元素添加到整數集合裏面, 並且新元素的類型比整數集合現有所有元素的類型都要長時, 整數集合需要先進行升級(upgrade),然後才能將新元素添加到整數集合裏面。
升級整數集合並添加新元素共分爲三步進行:

  • 根據新元素的類型, 擴展整數集合底層數組的空間大小, 併爲新元素分配空間。
  • 將底層數組現有的所有元素都轉換成與新元素相同的類型, 並將類型轉換後的元素放置到正確的位上, 而且在放置元素的過程中, 需要繼續維持底層數組的有序性質不變。
  • 將新元素添加到底層數組裏面。
    升級的動機是很明顯的,Redis作爲一個內存型數據庫,內存資源是很重要的,Redis的每一個設計都是想極大的提高內存利用效率,整數集合保存元素的數組的元素類型是根據存儲的元素大小所變化的,動態適應,提高內存利用效率

升級的好處

  • 提高靈活性。C 語言是靜態類型語言, 爲了避免類型錯誤, 我們通常不會將兩種不同類型的值放在同一個數據結構裏面。比如說, 我們一般只使用 int16_t 類型的數組來保存 int16_t 類型的值, 只使用 int32_t 類型的數組來保存 int32_t 類型的值, 諸如此類。但是, 因爲整數集合可以通過自動升級底層數組來適應新元素, 所以我們可以隨意地將 int16_t 、 int32_t 或者 int64_t 類型的整數添加到集合中, 而不必擔心出現類型錯誤, 這種做法非常靈活
  • 節約內存。

重點總結

  • 整數集合是集合鍵的底層實現之一
  • 整數集合的底層實現爲數組, 這個數組以有序、無重複的方式保存集合元素, 在有需要時, 程序會根據新添加元素的類型, 改變這個數組的類型
  • 升級操作爲整數集合帶來了操作上的靈活性, 並且儘可能地節約了內存
  • 整數集合只支持升級操作,不支持降級操作

六、壓縮列表

說明:

壓縮列表(ziplist)是列表鍵和哈希鍵的底層實現之一。當一個列表鍵只包含少量列表項,並且每個列表項要麼就是小整數值,要麼就是長度比較短的字符串,那麼Redis就會使用壓縮列表來做列表鍵的底層實現。壓縮列表也是Redis爲了節約內存而開發的,是由一系列特殊編碼的連續內存塊組成的順序型數據結構。一個壓縮列表可以包含任意多個節點(entry),一個節點可以保存一個字節數組一個整數值

壓縮列表構成圖:

壓縮列表構成圖

壓縮列表節點的構成

每個壓縮列表節點可以保存一個字節數組或者一個整數值, 其中, 字節數組可以是以下三種長度的其中一種:

  • 長度小於等於 63 (2^{6}-1)字節的字節數組;
  • 長度小於等於 16383 (2^{14}-1) 字節的字節數組;
  • 長度小於等於 4294967295 (2^{32}-1)字節的字節數組;
    而整數值則可以是以下六種長度的其中一種:
  • 4 位長,介於 0 至 12 之間的無符號整數;
  • 1 字節長的有符號整數;
  • 3 字節長的有符號整數;
  • int16_t 類型整數;
  • int32_t 類型整數;
  • int64_t 類型整數。
    每個壓縮列表節點都由 previous_entry_length 、 encoding 、 content 三個部分組成, 如圖 7-4 所示
    在這裏插入圖片描述
    壓縮列表屬性節點屬性:
  • previous_entry_length: 節點的 previous_entry_length 屬性以字節爲單位, 記錄了壓縮列表中前一個節點的長度。previous_entry_length 屬性的長度可以是 1 字節或者 5 字節;如果前一節點的長度小於 254 字節, 那麼 previous_entry_length 屬性的長度爲 1 字節: 前一節點的長度就保存在這一個字節裏面。如果前一節點的長度大於等於 254 字節, 那麼 previous_entry_length 屬性的長度爲 5 字節: 其中屬性的第一字節會被設置爲 0xFE (十進制值 254), 而之後的四個字節則用於保存前一節點的長度。

重點總結

  • 壓縮列表是一種爲了節約內存而開發的順序型數據結構。壓縮列表被用作列表鍵和哈希鍵的底層實現之一
  • 壓縮列表可以包含多個節點,每個節點可以保存一個字節數組或者整數值
  • 添加新節點到壓縮列表,或者從列表刪除節點,可能會引發連鎖的更新操作,但這種操作出現的機率並不高

到此,已經介紹完Redis的六種底層數據結構,可以看出,每一種數據結構都不是爲了設計而設計,都是圍繞如何節約內存、實現更佳的性能而考慮,下面主要是簡單談下我們使用Redis接觸到的五種對象,它們的應用場景,以及和Redis底層數據結構的關聯。

五種對象介紹

前面的內容大篇幅地介紹了Redis的六種底層數據結構,然而Redis並沒有直接使用這些數據結構,而是用對象系統的方式把底層數據結構封裝起來,成了五種常用的對象,分別是字符串對象、列表對象、集合對象、有序集合對象、哈希對象。一種對象至少使用一種底層數據結構

對象結構定義:

typedef struct redisObject {

    // 類型
    unsigned type:4;

    // 編碼
    unsigned encoding:4;

    // 指向底層實現數據結構的指針
    void *ptr;

    // ...

} robj;

類型(type):
在這裏插入圖片描述
編碼(encoding):
在這裏插入圖片描述
不同類型和編碼對象:
在這裏插入圖片描述

一、字符串對象

字符串是我們使用最多的也是Redis最基本的對象類型,一個key對應一個value,value不單單是string類型,也可以是int類型,Redis的string類型是二進制安全的,意思是可以存儲任意類型的數據,比如圖片(jpg格式等等),整型,字符串等等,string類型的值最大能存儲512MB。

二、列表對象

列表對象的編碼可以是 ziplist 或者 linkedlist,ziplist 編碼的列表對象使用壓縮列表作爲底層實現,每個壓縮列表節點(entry)保存了一個列表元素。linkedlist 編碼的列表對象使用雙端鏈表作爲底層實現, 每個雙端鏈表節點(node)都保存了一個字符串對象, 而每個字符串對象都保存了一個列表元素。

三、哈希對象

哈希對象的編碼可以是 ziplist 或者 hashtable。
用ziplist編碼要滿足兩個條件:1.哈希對象保存的所有鍵值對的字符串長度都小於64字節 2.哈希對象保存的所有鍵值對數量都小於512個;不能滿足這兩個條件的哈希對象需要使用hashtable編碼(字典底層實現)

四、集合對象

集合對象的編碼可以是 intset或者hashtable
使用intset編碼兩個條件:1.集合元素保存的都是int類型 2.集合保存的元素不超過512個
不滿足使用instset編碼條件需要使用hashtable。需要注意的是第二個條件的元素數量上限值是可以修改的,具體可以看Redis配置文件中set-max-intset-entries

五、有序集合對象

有序集合的編碼可以是 ziplist 或者 skiplist。
使用ziplist編碼的兩個條件:1. 有序集合保存的元素數量小於128個 2. 有序集合保存的所有元素成員長度都小於64個字節;
不能滿足以上兩個條件的有序集合對象將使用skiplist編碼,需要注意的是以上兩個條件的上限值都是可以修改的,具體可以看Redis配置文件zset-max-ziplist-entries選項和zet-max-ziplist-value選項的說明。
對於使用ziplist編碼的有序集合對象來說,當使用ziplist編碼所需的兩個條件有任意一個不滿足時候,就會執行對象的編碼轉換操作,原本保存在壓縮列表裏的所有集合元素都會被轉移並保存到zset結構裏面,對象的編碼也會從ziplist變更爲skiplist。

最後小總結

  • Redis的鍵和值都是一個對象
  • Redis一共有五種對象類型,分別是字符串、列表、哈希、集合、有序集合,每種類型的對象至少有兩種或以上的編碼方式,可以在不同的場景上節約對象存儲內存,優化對象使用效率
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章