記得點贊+關注呦。
Redis 有五種基本數據類型,但是你們知道這五種數據類型的底層是咋實現嗎?接下就帶你們瞭解一下 String、List、Hash、Set、Sorted Set 底層是如何實現的,在這以前,先來看下下面的基本數據結構,分別有簡單動態字符串(SDS)、鏈表、字典、跳躍表、整數集合以及壓縮列表,它們是Redis數據結構的基本組成部分。redis
1. String算法
2. List數據庫
3. Hash數組
4. Set服務器
5. Sorted Set微信
接下來分別說說這些底層數據結構。數據結構
Redis 本身構建了一種名爲簡單動態字符串(simple dynamic string,SDS)的抽象類型, 並將 SDS 用做 Redis 的默認字符串表示。如:函數
set msg "hello world"
key 和 value 底層都是用 SDS 來實現的。
SDS 的結構:優化
struct sdshdr { // 記錄 buf 數組中已使用字節的數量 // 等於 SDS 所保存字符串的長度 int len; // 記錄 buf 數組中未使用字節的數量 int free; // 字節數組,用於保存字符串 char buf[]; };
SDS 與 C 語言字符串比較相近,但擁有更過的優點:ui
1. SDS 獲取字符串長度時間複雜度O(1):由於 SDS 經過 len 字段來存儲長度,使用時直接讀取就能夠;C 語言要想獲取字符串長度須要遍歷整個字符串,時間複雜度O(N)。 2. SDS 能杜絕緩衝區的溢出:由於當 SDS API 要對 SDS 進行修改時,會先檢查 SDS 的空間是否足夠,若是不夠的話 SDS 會自動擴容,So,不會形成緩衝區溢出。而 C 語言則不劇本這個功能。 3. SDS 能減小修改字符串時帶來的內存重分配次數: - 空間預分配:當SDS 擴容時不僅是會增長鬚要的空間大小,還會額外的分配一些未使用的空間。分配的規則是:若是分配後SDS的長度小於 1MB,那麼會分配等於分配後SDS 的大小的未使用空間,簡單說就是,SDS 動態分配後是 16KB,那麼就會多分配 16KB 的未使用空間;若是 小於 1MB,那麼久分配 1MB 的未使用空間。 - 惰性空間釋放: 惰性空間釋放用於優化 SDS 的字符串縮短操做:當 SDS 的 API 須要縮短 SDS 保存的字符串時,並不會當即內存重分配來回收多出來的字節,而是用 free 來記錄未使用空間。
鏈表提供了高效的節點重排能力,以及順序性的節點訪問方式,而且能夠經過增刪節點來靈活地調整鏈表的長度。鏈表在 Redis 中的應用很是普遍,好比 List 的底層實現之一鏈表,當一個 List 包含了數量比較多的元素,又或者列表中包含的元素都是比較長的字符串時,Redis 就會使用鏈表做爲 List 的底層實現。除了用做 List 的底層實現以外,發佈與訂閱、慢查詢、監視器等動能也用到了鏈表, Redis 服務器自己還使用鏈表來保存多個客戶端的狀態信息,以及使用鏈表來構建客戶端輸出緩衝區。
每一個鏈表節點使用一個 adlist.h/listNode 結構來表示:
typedef struct listNode { // 前置節點 struct listNode *prev; // 後置節點 struct listNode *next; // 節點的值 void *value; } listNode;
多個 listNode 能夠經過 prev 和 next 指針組成雙端鏈表, 以下圖所示。
雖然僅僅使用多個 listNode 結構就能夠組成鏈表, 但使用 adlist.h/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 結構爲鏈表提供了表頭指針 head 、表尾指針 tail , 以及鏈表長度計數器 len , 而 dup 、 free 和 match 成員則是用於實現多態鏈表所需的類型特定函數:
多態: 鏈表節點使用 void* 指針來保存節點值, 而且能夠經過 list 結構的 dup 、 free 、 match 三個屬性爲節點值設置類型特定函數, 因此鏈表能夠用於保存各類不一樣類型的值。
字典, 又稱符號表(symbol table)、關聯數組(associative array)或者映射(map), 是一種用於保存鍵值對(key-value pair)的抽象數據結構。其中 Key 是惟一的。相似 Java 的 Map。
字典在 Redis 中主要被應用與:
Redis 數據庫底層就是用字典實現的,對數據庫的增、刪、改、查操做都是構建在對字典的操做之上,好比:
> set msg "hello world" OK
這個就是建立一個 key 爲 "msg",value 爲 "hello world" 的鍵值對,保存在表明數據庫的字典中。
Redis 的字典使用哈希表做爲底層實現, 一個哈希表裏面能夠有多個哈希表節點, 而每一個哈希表節點就保存了字典中的一個鍵值對。
接下來的三個小節將分別介紹 Redis 的哈希表、哈希表節點、以及字典的實現。
Redis 字典所使用的哈希表由 dict.h/dictht 結構定義:
typedef struct dictht { // 哈希表數組 dictEntry **table; // 哈希表大小 unsigned long size; // 哈希表大小掩碼,用於計算索引值 // 老是等於 size - 1 unsigned long sizemask; // 該哈希表已有節點的數量 unsigned long used; } dictht;
table 屬性是一個數組, 數組中的每一個元素都是一個指向 dict.h/dictEntry 結構的指針, 每一個 dictEntry 結構保存着一個鍵值對。
size 屬性記錄了哈希表的大小, 也便是 table 數組的大小, 而 used 屬性則記錄了哈希表目前已有節點(鍵值對)的數量。
sizemask 屬性的值老是等於 size - 1 , 這個屬性和哈希值一塊兒決定一個鍵應該被放到 table 數組的哪一個索引上面。
下圖 展現了一個大小爲 4 的空哈希表 (沒有包含任何鍵值對)。
哈希表節點使用 dictEntry 結構表示, 每一個 dictEntry 結構都保存着一個鍵值對:
typedef struct dictEntry { // 鍵 void *key; // 值 union { void *val; uint64_t u64; int64_t s64; } v; // 指向下個哈希表節點,造成鏈表 struct dictEntry *next; } dictEntry;
key 屬性保存着鍵值對中的鍵, 而 v 屬性則保存着鍵值對中的值, 其中鍵值對的值能夠是一個指針, 或者是一個 uint64_t 整數, 又或者是一個 int64_t 整數。
next 屬性是指向另外一個哈希表節點的指針, 這個指針能夠將多個哈希值相同的鍵值對鏈接在一次, 以此來解決鍵衝突(collision)的問題。
舉個例子, 下圖就展現瞭如何經過 next 指針, 將兩個索引值相同的鍵 k1 和 k0 鏈接在一塊兒。
Redis 中的字典由 dict.h/dict 結構表示:
typedef struct dict { // 類型特定函數 dictType *type; // 私有數據 void *privdata; // 哈希表 dictht ht[2]; // rehash 索引 // 當 rehash 不在進行時,值爲 -1 int rehashidx; /* rehashing not in progress if rehashidx == -1 */ } dict;
type 屬性和 privdata 屬性是針對不一樣類型的鍵值對, 爲建立多態字典而設置的:
ht 屬性是一個包含兩個項的數組, 數組中的每一個項都是一個 dictht 哈希表, 通常狀況下, 字典只使用 ht[0] 哈希表, ht[1] 哈希表只會在對 ht[0] 哈希表進行 rehash 時使用。
除了 ht[1] 以外, 另外一個和 rehash 有關的屬性就是 rehashidx : 它記錄了 rehash 目前的進度, 若是目前沒有在進行 rehash , 那麼它的值爲 -1 。
下圖 展現了一個普通狀態下(沒有進行 rehash)的字典:
當將一個新的鍵值對插入到字典中,須要計算索引值,Redis 計算索引值的方法是:
# 使用字典設置的哈希函數,計算鍵 key 的哈希值 hash = dict->type->hashFunction(key); # 使用哈希表的 sizemask 屬性和哈希值,計算出索引值 # 根據狀況不一樣, ht[x] 能夠是 ht[0] 或者 ht[1] index = hash & dict->ht[x].sizemask;
相似 Java 的HashMap,計算 key 的 hash 值,而後 hash & (len - 1), 而 Redis 的 sizemask 就是 size - 1。
當出現 Hash 衝突時,Redis 使用的是 鏈地址法 來解決衝突,鏈地址法就是將衝突的節點構成一個鏈表放在該索引位置上,Redis 採用的是頭插法。解決hash衝突的還有三種方法,分別是:開放定址法(線性探測再散列,二次探測再散列,僞隨機探測再散列)、再哈希法以及創建一個公共溢出區,之後會單獨介紹一些解決hash衝突的四種方法。
隨着不斷的操做,hash表中的鍵值對可能會增多或減小,爲了讓哈希表的負載因子保持在一個範圍內,須要對 hash表進行擴容或收縮,收縮和擴容的過程就叫 rehash。rehash 過程以下:
爲字典的 ht[1] 哈希表分配空間, 這個哈希表的空間大小取決於要執行的操做, 以及 ht[0] 當前包含的鍵值對數量 (也便是 ht[0].used 屬性的值)(ht 是字典中的 hash 表,上文有介紹):
當如下條件中的任意一個被知足時, 程序會自動開始對哈希表執行擴展操做:
服務器目前沒有在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 1 ;
服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令, 而且哈希表的負載因子大於等於 5 ;
其中哈希表的負載因子能夠經過公式:
# 負載因子 = 哈希表已保存節點數量 / 哈希表大小 load_factor = ht[0].used / ht[0].size
計算得出。
好比說, 對於一個大小爲 4 , 包含 4 個鍵值對的哈希表來講, 這個哈希表的負載因子爲:
load_factor = 4 / 4 = 1
又好比說, 對於一個大小爲 512 , 包含 256 個鍵值對的哈希表來講, 這個哈希表的負載因子爲:
load_factor = 256 / 512 = 0.5
根據 BGSAVE 命令或 BGREWRITEAOF 命令是否正在執行, 服務器執行擴展操做所需的負載因子並不相同, 這是由於在執行 BGSAVE 命令或 BGREWRITEAOF 命令的過程當中, Redis 須要建立當前服務器進程的子進程, 而大多數操做系統都採用寫時複製(copy-on-write)技術來優化子進程的使用效率, 因此在子進程存在期間, 服務器會提升執行擴展操做所需的負載因子, 從而儘量地避免在子進程存在期間進行哈希表擴展操做, 這能夠避免沒必要要的內存寫入操做, 最大限度地節約內存。
另外一方面, 當哈希表的負載因子小於 0.1 時, 程序自動開始對哈希表執行收縮操做。
rehash 時會將 ht[0] 全部的鍵值對遷移到 ht[1] 中,但這個動做不是一次性的,而是分屢次、漸進式地完成。這樣的所得緣由時:當數據量大的時候一次性遷移會形成服務器在一段時間內定製服務。爲了不發生這樣的事就出現了 漸進式rehash。
如下是哈希表漸進式 rehash 的詳細步驟:
1) 爲 ht[1] 分配空間, 讓字典同時持有 ht[0] 和 ht[1] 兩個哈希表。
2) 在字典中維持一個索引計數器變量 rehashidx , 並將它的值設置爲 0 , 表示 rehash 工做正式開始。
3) 在 rehash 進行期間, 每次對字典執行添加、刪除、查找或者更新操做時, 程序除了執行指定的操做之外, 還會順帶將 ht[0] 哈希表在 rehashidx 索引上的全部鍵值對 rehash 到 ht[1] , 當 rehash 工做完成以後, 程序將 rehashidx 屬性的值增一。
4) 隨着字典操做的不斷執行, 最終在某個時間點上, ht[0] 的全部鍵值對都會被 rehash 至 ht[1] , 這時程序將 rehashidx 屬性的值設爲 -1 , 表示 rehash 操做已完成。
漸進式 rehash 的好處在於它採起分而治之的方式, 將 rehash 鍵值對所需的計算工做均灘到對字典的每一個添加、刪除、查找和更新操做上, 從而避免了集中式 rehash 而帶來的龐大計算量。在 rehash 期間,字典的刪改查操做都是同時做用在 ht[0] 以及 ht[1] 上的。如查找一個鍵,會如今 ht[0] 查找,找不到就去 ht[1] 查找,注意的是增長操做,新增的鍵值對只會保存到 ht[1]上,不會保存到 ht[0] 上,這一措施保證了 ht[0] 的鍵值只減不增,隨着 rehash 操做 ht[0] 最終會變成空表。
Redis 的字典實現的特性能夠總結以下:
跳躍表(skiplist)是一種有序數據結構, 它經過在每一個節點中維持多個指向其餘節點的指針, 從而達到快速訪問節點的目的。
跳躍表支持平均 O(\log N) 最壞 O(N) 複雜度的節點查找, 還能夠經過順序性操做來批量處理節點。
在大部分狀況下, 跳躍表的效率能夠和平衡樹相媲美, 而且由於跳躍表的實現比平衡樹要來得更爲簡單, 因此有很多程序都使用跳躍表來代替平衡樹。
Redis 使用跳躍表做爲有序集合鍵的底層實現之一: 若是一個有序集合包含的元素數量比較多,又或者有序集合中元素的成員(member)是比較長的字符串時,Redis 就會使用跳躍表來做爲有序集合鍵的底層實現。
Redis 只在兩個地方用到了跳躍表, 一個是實現有序集合鍵, 另外一個是在集羣節點中用做內部數據結構, 除此以外, 跳躍表在 Redis 裏面沒有其餘用途。
Redis 的跳躍表由 redis.h/zskiplistNode 和 redis.h/zskiplist 兩個結構定義, 其中 zskiplistNode 結構用於表示跳躍表節點, 而 zskiplist 結構則用於保存跳躍表節點的相關信息, 好比節點的數量, 以及指向表頭節點和表尾節點的指針, 等等。
上圖展現了一個跳躍表示例, 位於圖片最左邊的是 zskiplist 結構, 該結構包含如下屬性:
位於 zskiplist 結構右方的是四個 zskiplistNode 結構, 該結構包含如下屬性:
注意表頭節點和其餘節點的構造是同樣的: 表頭節點也有後退指針、分值和成員對象, 不過表頭節點的這些屬性都不會被用到, 因此圖中省略了這些部分, 只顯示了表頭節點的各個層。
本節接下來的內容將對 zskiplistNode 和 zskiplist 兩個結構進行更詳細的介紹。
跳躍表節點的實現由 redis.h/zskiplistNode 結構定義:
typedef struct zskiplistNode { // 後退指針 struct zskiplistNode *backward; // 分值 double score; // 成員對象 robj *obj; // 層 struct zskiplistLevel { // 前進指針 struct zskiplistNode *forward; // 跨度 unsigned int span; } level[]; } zskiplistNode;
層
跳躍表節點的 level 數組能夠包含多個元素, 每一個元素都包含一個指向其餘節點的指針, 程序能夠經過這些層來加快訪問其餘節點的速度, 通常來講, 層的數量越多, 訪問其餘節點的速度就越快。
每次建立一個新跳躍表節點的時候, 程序都根據冪次定律 (power law,越大的數出現的機率越小) 隨機生成一個介於 1 和 32 之間的值做爲 level 數組的大小, 這個大小就是層的「高度」。
下圖分別展現了三個高度爲 1 層、 3 層和 5 層的節點, 由於 C 語言的數組索引老是從 0 開始的, 因此節點的第一層是 level[0] , 而第二層是 level[1] ,以此類推。
跨度
層的跨度(level[i].span 屬性)用於記錄兩個節點之間的距離:
兩個節點之間的跨度越大, 它們相距得就越遠。
指向 NULL 的全部前進指針的跨度都爲 0 , 由於它們沒有連向任何節點。
初看上去, 很容易覺得跨度和遍歷操做有關, 但實際上並非這樣 —— 遍歷操做只使用前進指針就能夠完成了, 跨度其實是用來計算排位(rank)的: 在查找某個節點的過程當中, 將沿途訪問過的全部層的跨度累計起來, 獲得的結果就是目標節點在跳躍表中的排位。
舉個例子, 下圖用虛線標記了在跳躍表中查找分值爲 3.0 、 成員對象爲 o3 的節點時, 沿途經歷的層: 查找的過程只通過了一個層, 而且層的跨度爲 3 , 因此目標節點在跳躍表中的排位爲 3 。
後退指針
節點的後退指針(backward 屬性)用於從表尾向表頭方向訪問節點: 跟能夠一次跳過多個節點的前進指針不一樣, 由於每一個節點只有一個後退指針, 因此每次只能後退至前一個節點。
下圖用虛線展現了若是從表尾向表頭遍歷跳躍表中的全部節點: 程序首先經過跳躍表的 tail 指針訪問表尾節點, 而後經過後退指針訪問倒數第二個節點, 以後再沿着後退指針訪問倒數第三個節點, 再以後遇到指向 NULL 的後退指針, 因而訪問結束。
分值和成員
節點的分值(score 屬性)是一個 double 類型的浮點數, 跳躍表中的全部節點都按分值從小到大來排序。
節點的成員對象(obj 屬性)是一個指針, 它指向一個字符串對象, 而字符串對象則保存着一個 SDS 值。
在同一個跳躍表中, 各個節點保存的成員對象必須是惟一的, 可是多個節點保存的分值卻能夠是相同的: 分值相同的節點將按照成員對象在字典序中的大小來進行排序, 成員對象較小的節點會排在前面(靠近表頭的方向), 而成員對象較大的節點則會排在後面(靠近表尾的方向)。
舉個例子, 在下圖所示的跳躍表中, 三個跳躍表節點都保存了相同的分值 10086.0 , 但保存成員對象 o1 的節點卻排在保存成員對象 o2 和 o3 的節點以前, 而保存成員對象 o2 的節點又排在保存成員對象 o3 的節點以前, 因而可知, o1 、 o2 、 o3 三個成員對象在字典中的排序爲 o1 <= o2 <= o3 。
typedef struct zskiplist { // 表頭節點和表尾節點 struct zskiplistNode *header, *tail; // 表中節點的數量 unsigned long length; // 表中層數最大的節點的層數 int level; } zskiplist;
header 和 tail 指針分別指向跳躍表的表頭和表尾節點, 經過這兩個指針, 程序定位表頭節點和表尾節點的複雜度爲 O(1) 。
經過使用 length 屬性來記錄節點的數量, 程序能夠在 O(1) 複雜度內返回跳躍表的長度。
level 屬性則用於在 O(1) 複雜度內獲取跳躍表中層高最大的那個節點的層數量, 注意表頭節點的層高並不計算在內。
關於跳躍表的總結:
整數集合(intset)是集合鍵的底層實現之一: 當一個集合只包含整數值元素, 而且這個集合的元素數量很少時, Redis 就會使用整數集合做爲集合鍵的底層實現。
舉個例子, 若是咱們建立一個只包含五個元素的集合鍵, 而且集合中的全部元素都是整數值, 那麼這個集合鍵的底層實現就會是整數集合:
redis> SADD numbers 1 3 5 7 9 (integer) 5 redis> OBJECT ENCODING numbers "intset"
整數集合(intset)是 Redis 用於保存整數值的集合抽象數據結構, 它能夠保存類型爲 int16_t 、 int32_t 或者 int64_t 的整數值, 而且保證集合中不會出現重複元素。
每一個 intset.h/intset 結構表示一個整數集合:
typedef struct intset { // 編碼方式 uint32_t encoding; // 集合包含的元素數量 uint32_t length; // 保存元素的數組 int8_t contents[]; } intset;
contents 數組是整數集合的底層實現: 整數集合的每一個元素都是 contents 數組的一個數組項(item), 各個項在數組中按值的大小從小到大有序地排列, 而且數組中不包含任何重複項。
length 屬性記錄了整數集合包含的元素數量, 也便是 contents 數組的長度。
雖然 intset 結構將 contents 屬性聲明爲 int8_t 類型的數組, 但實際上 contents 數組並不保存任何 int8_t 類型的值 —— contents 數組的真正類型取決於 encoding 屬性的值:若是 encoding 屬性的值爲 INTSET_ENC_INT16 , 那麼 contents 就是一個 int16_t 類型的數組, 數組裏的每一個項都是一個 int16_t 類型的整數值 (最小值爲 -32,768 ,最大值爲 32,767 )。
下圖是一個包含五個int16_t類型整數值的整數集合。
每當咱們要將一個新元素添加到整數集合裏面, 而且新元素的類型比整數集合現有全部元素的類型都要長時, 整數集合須要先進行升級(upgrade), 而後才能將新元素添加到整數集合裏面。
升級整數集合並添加新元素共分爲三步進行:
整數集合不支持降級操做, 一旦對數組進行了升級, 編碼就會一直保持升級後的狀態。
關於整數集合的總結:
壓縮列表(ziplist)是列表鍵和哈希鍵的底層實現之一。
當一個列表鍵只包含少許列表項, 而且每一個列表項要麼就是小整數值, 要麼就是長度比較短的字符串, 那麼 Redis 就會使用壓縮列表來作列表鍵的底層實現。
好比說, 執行如下命令將建立一個壓縮列表實現的列表鍵:
redis> RPUSH lst 1 3 5 10086 "hello" "world" (integer) 6 redis> OBJECT ENCODING lst "ziplist"
由於列表鍵裏面包含的都是 1 、 3 、 5 、 10086 這樣的小整數值, 以及 "hello" 、 "world" 這樣的短字符串。
另外, 當一個哈希鍵只包含少許鍵值對, 而且每一個鍵值對的鍵和值要麼就是小整數值, 要麼就是長度比較短的字符串, 那麼 Redis 就會使用壓縮列表來作哈希鍵的底層實現。
舉個例子, 執行如下命令將建立一個壓縮列表實現的哈希鍵:
redis> HMSET profile "name" "Jack" "age" 28 "job" "Programmer" OK redis> OBJECT ENCODING profile "ziplist"
由於哈希鍵裏面包含的全部鍵和值都是小整數值或者短字符串。
壓縮列表的構成:
壓縮列表是 Redis 爲了節約內存而開發的, 由一系列特殊編碼的連續內存塊組成的順序型(sequential)數據結構。
一個壓縮列表能夠包含任意多個節點(entry), 每一個節點能夠保存一個字節數組或者一個整數值。
下圖展現了壓縮列表的各個組成部分。
下表則記錄了各個組成部分的類型、長度、以及用途。表 7-1 則記錄了各個組成部分的類型、長度、以及用途。
屬性 | 類型 | 長度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4 字節 | 記錄整個壓縮列表佔用的內存字節數:在對壓縮列表進行內存重分配, 或者計算 zlend 的位置時使用。 |
zltail | uint32_t | 4 字節 | 記錄壓縮列表表尾節點距離壓縮列表的起始地址有多少字節: 經過這個偏移量,程序無須遍歷整個壓縮列表就能夠肯定表尾節點的地址。 |
zllen | uint16_t | 2 字節 | 記錄了壓縮列表包含的節點數量: 當這個屬性的值小於 UINT16_MAX (65535)時, 這個屬性的值就是壓縮列表包含節點的數量; 當這個值等於 UINT16_MAX 時, 節點的真實數量須要遍歷整個壓縮列表才能計算得出。 |
entryX | 列表節點 | 不定 | 壓縮列表包含的各個節點,節點的長度由節點保存的內容決定。 |
zlend | uint8_t | 1 字節 | 特殊值 0xFF (十進制 255 ),用於標記壓縮列表的末端。 |
壓縮列表節點的構成:
每一個壓縮列表節點都由 previous_entry_length 、 encoding 、 content 三個部分組成, 以下圖所示。
若是以爲還不錯請點贊關注轉發,不勝感激。
更多精彩內容 微信搜素: 蘑菇睡不着