先看個對照關係: redis
Redis數據結構 | 實現一 | 實現二 |
string | 整數(若是value可以表示爲整數) | 字符串 |
hash | 壓縮列表(只包含少許鍵值對, 而且每一個鍵值對的鍵和值要麼就是小整數值, 要麼就是長度比較短的字符串) | 字典 |
list | 壓縮列表(只包含少許列表項, 而且每一個列表項要麼就是小整數值, 要麼就是長度比較短的字符串) | 雙端鏈表 |
set | 整數集合(當一個集合只包含整數值元素, 而且這個集合的元素數量很少時) | 字典 |
sorted set | 壓縮列表 | 跳錶 |
再討論每種數據結構的實現原理:數組
實現以下:服務器
typedef struct listNode { // 前置節點 struct listNode *prev; // 後置節點 struct listNode *next; // 節點的值 void *value; } listNode; 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;
Redis 的字典使用哈希表做爲底層實現, 數據結構
哈希表的實現以下:函數
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;
字典的實現:性能
typedef struct dict { // 類型特定函數 dictType *type; // 私有數據 void *privdata; // 哈希表,通常狀況下只使用ht[0],rehash才使用ht[1] dictht ht[2]; // rehash 索引 // 當 rehash 不在進行時,值爲 -1 int rehashidx; /* rehashing not in progress if rehashidx == -1 */ } dict;
隨着操做的不斷執行, 哈希表保存的鍵值對會逐漸地增多或者減小, 爲了讓哈希表的負載因子(load factor)維持在一個合理的範圍以內, 當哈希表保存的鍵值對數量太多或者太少時, 程序須要對哈希表的大小進行相應的擴展或者收縮。優化
rehash步驟:ui
ht[1]
哈希表分配空間, 這個哈希表的空間大小取決於要執行的操做, 以及 ht[0]
當前包含的鍵值對數量 (也便是 ht[0].used
屬性的值):
ht[1]
的大小爲第一個大於等於 ht[0].used * 2
的 2^n (2
的 n
次方冪);ht[1]
的大小爲第一個大於等於 ht[0].used
的 2^n 。ht[0]
中的全部鍵值對 rehash 到 ht[1]
上面: rehash 指的是從新計算鍵的哈希值和索引值, 而後將鍵值對放置到 ht[1]
哈希表的指定位置上。ht[0]
包含的全部鍵值對都遷移到了 ht[1]
以後 (ht[0]
變爲空表), 釋放 ht[0]
, 將 ht[1]
設置爲 ht[0]
, 並在 ht[1]
新建立一個空白哈希表, 爲下一次 rehash 作準備。當如下條件中的任意一個被知足時, 程序會自動開始對哈希表執行擴展操做:編碼
1
;5
;其中哈希表的負載因子能夠經過公式:spa
# 負載因子 = 哈希表已保存節點數量 / 哈希表大小 load_factor = ht[0].used / ht[0].size
根據 BGSAVE 命令或 BGREWRITEAOF 命令是否正在執行, 服務器執行擴展操做所需的負載因子並不相同, 這是由於在執行 BGSAVE命令或 BGREWRITEAOF 命令的過程當中, Redis 須要建立當前服務器進程的子進程, 而大多數操做系統都採用寫時複製(copy-on-write)技術來優化子進程的使用效率, 因此在子進程存在期間, 服務器會提升執行擴展操做所需的負載因子, 從而儘量地避免在子進程存在期間進行哈希表擴展操做, 這能夠避免沒必要要的內存寫入操做, 最大限度地節約內存。
另外一方面, 當哈希表的負載因子小於 0.1
時, 程序自動開始對哈希表執行收縮操做。
爲了不 rehash 對服務器性能形成影響, 服務器不是一次性將 ht[0]
裏面的全部鍵值對所有 rehash 到 ht[1]
, 而是分屢次、漸進式地將 ht[0]
裏面的鍵值對慢慢地 rehash到 ht[1]
。如下是哈希表漸進式 rehash 的詳細步驟:
ht[1]
分配空間, 讓字典同時持有 ht[0]
和 ht[1]
兩個哈希表。rehashidx
, 並將它的值設置爲 0
, 表示 rehash 工做正式開始。ht[0]
哈希表在 rehashidx
索引上的全部鍵值對 rehash 到 ht[1]
, 當 rehash 工做完成以後, 程序將 rehashidx
屬性的值增一。ht[0]
的全部鍵值對都會被 rehash 至 ht[1]
, 這時程序將 rehashidx
屬性的值設爲 -1
, 表示 rehash 操做已完成。漸進式 rehash 的好處在於它採起分而治之的方式, 將 rehash 鍵值對所需的計算工做均灘到對字典的每一個添加、刪除、查找和更新操做上, 從而避免了集中式 rehash 而帶來的龐大計算量。
由於在進行漸進式 rehash 的過程當中, 字典會同時使用 ht[0]
和 ht[1]
兩個哈希表, 因此在漸進式 rehash 進行期間, 字典的刪除(delete)、查找(find)、更新(update)等操做會在兩個哈希表上進行: 好比說, 要在字典裏面查找一個鍵的話, 程序會先在 ht[0]
裏面進行查找, 若是沒找到的話, 就會繼續到 ht[1]
裏面進行查找, 諸如此類。
另外, 在漸進式 rehash 執行期間, 新添加到字典的鍵值對一概會被保存到 ht[1]
裏面, 而 ht[0]
則再也不進行任何添加操做: 這一措施保證了 ht[0]
包含的鍵值對數量會只減不增, 並隨着 rehash 操做的執行而最終變成空表。
整數集合是用一個有序數組實現的,
typedef struct intset { // 編碼方式 uint32_t encoding; // 集合包含的元素數量 uint32_t length; // 保存元素的數組 int8_t contents[]; } intset;
雖然 intset
結構將 contents
屬性聲明爲 int8_t
類型的數組, 但實際上 contents
數組的真正類型取決於 encoding
屬性的值:
encoding
屬性的值爲 INTSET_ENC_INT16
, 那麼 contents
就是一個 int16_t
類型的數組, 數組裏的每一個項都是一個 int16_t
類型的整數值 (最小值爲 -32,768
,最大值爲 32,767
)。encoding
屬性的值爲 INTSET_ENC_INT32
, 那麼 contents
就是一個 int32_t
類型的數組, 數組裏的每一個項都是一個 int32_t
類型的整數值 (最小值爲 -2,147,483,648
,最大值爲 2,147,483,647
)。encoding
屬性的值爲 INTSET_ENC_INT64
, 那麼 contents
就是一個 int64_t
類型的數組, 數組裏的每一個項都是一個 int64_t
類型的整數值 (最小值爲 -9,223,372,036,854,775,808
,最大值爲 9,223,372,036,854,775,807
)。
intset 的升級操做:例如當向一個底層爲 int16_t
數組的整數集合添加一個 int64_t
類型的整數值時, intset 已有的全部元素都會被轉換成 int64_t
類型。
升級整數集合並添加新元素共分爲三步進行:
升級以後新元素的擺放位置
由於引起升級的新元素的長度老是比整數集合現有全部元素的長度都大, 因此這個新元素的值要麼就大於全部現有元素, 要麼就小於全部現有元素:
0
);length-1
)。注意:intset不支持降級!
數據結構定義以下:
typedef struct zskiplistNode { // 後退指針 struct zskiplistNode *backward; // 分值 double score; // 成員對象 robj *obj; // 層 struct zskiplistLevel { // 前進指針 struct zskiplistNode *forward; // 跨度 unsigned int span; } level[]; } zskiplistNode; typedef struct zskiplist { // 表頭節點和表尾節點 struct zskiplistNode *header, *tail; // 表中節點的數量 unsigned long length; // 表中層數最大的節點的層數 int level; } zskiplist;
上圖展現了一個跳躍表示例, 位於圖片最左邊的是 zskiplist
結構, 該結構包含如下屬性:
header
:指向跳躍表的表頭節點。tail
:指向跳躍表的表尾節點。level
:記錄目前跳躍表內,層數最大的那個節點的層數(表頭節點的層數不計算在內)。length
:記錄跳躍表的長度,也便是,跳躍表目前包含節點的數量(表頭節點不計算在內)。位於 zskiplist
結構右方的是四個 zskiplistNode
結構, 該結構包含如下屬性:
L1
、 L2
、 L3
等字樣標記節點的各個層, L1
表明第一層, L2
表明第二層,以此類推。每一個層都帶有兩個屬性:前進指針和跨度。前進指針用於訪問位於表尾方向的其餘節點,而跨度則記錄了前進指針所指向節點和當前節點的距離。在上面的圖片中,連線上帶有數字的箭頭就表明前進指針,而那個數字就是跨度。當程序從表頭向表尾進行遍歷時,訪問會沿着層的前進指針進行。BW
字樣標記節點的後退指針,它指向位於當前節點的前一個節點。後退指針在程序從表尾向表頭遍歷時使用。1.0
、 2.0
和 3.0
是節點所保存的分值。在跳躍表中,節點按各自所保存的分值從小到大排列。o1
、 o2
和 o3
是節點所保存的成員對象。
ziplist是由若干個entry 組成,每一個entry能夠保存一個字節數組或者一個整數值。
屬性 | 類型 | 長度 | 用途 |
---|---|---|---|
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 ),用於標記壓縮列表的末端。 |
entry能夠保存一個字節數組或者一個整數值, 其中, 字節數組能夠是如下三種長度的其中一種:
63
(2^{6}-1)字節的字節數組;16383
(2^{14}-1) 字節的字節數組;4294967295
(2^{32}-1)字節的字節數組;而整數值則能夠是如下六種長度的其中一種:
4
位長,介於 0
至 12
之間的無符號整數;1
字節長的有符號整數;3
字節長的有符號整數;int16_t
類型整數;int32_t
類型整數;int64_t
類型整數。每一個壓縮列表entry都由 previous_entry_length
、 encoding
、 content
三個部分組成,
屬性 | 類型 | 長度 | 用途 |
previous_entry_length | 整型 | 1(小於254)或5(大於或等於254) | 前一個entry的長度 |
encoding | 整型 | 記錄content的類型和長度 | |
content | 整數或字節數組 | entry的值 |
如今, 考慮這樣一種狀況: 在一個壓縮列表中, 有多個連續的、長度介於 250
字節到 253
字節之間的節點 e1
至 eN
,以下圖:
由於 e1
至 eN
的全部節點的長度都小於 254
字節, 因此記錄這些節點的長度只須要 1
字節長的 previous_entry_length
屬性, 換句話說, e1
至 eN
的全部節點的 previous_entry_length
屬性都是 1
字節長的。
這時, 若是咱們將一個長度大於等於 254
字節的新節點 new
設置爲壓縮列表的表頭節點, 那麼 new
將成爲 e1
的前置節點, 如圖
由於 e1
的 previous_entry_length
屬性僅長 1
字節, 它沒辦法保存新節點 new
的長度, 因此程序將對壓縮列表執行空間重分配操做, 並將 e1
節點的 previous_entry_length
屬性從原來的 1
字節長擴展爲 5
字節長。
如今, 麻煩的事情來了 —— e1
本來的長度介於 250
字節至 253
字節之間, 在爲 previous_entry_length
屬性新增四個字節的空間以後,e1
的長度就變成了介於 254
字節至 257
字節之間, 而這種長度使用 1
字節長的 previous_entry_length
屬性是沒辦法保存的。
所以, 爲了讓 e2
的 previous_entry_length
屬性能夠記錄下 e1
的長度, 程序須要再次對壓縮列表執行空間重分配操做, 並將 e2
節點的 previous_entry_length
屬性從原來的 1
字節長擴展爲 5
字節長。
正如擴展 e1
引起了對 e2
的擴展同樣, 擴展 e2
也會引起對 e3
的擴展, 而擴展 e3
又會引起對 e4
的擴展……爲了讓每一個節點的 previous_entry_length
屬性都符合壓縮列表對節點的要求, 程序須要不斷地對壓縮列表執行空間重分配操做, 直到 eN
爲止。
Redis 將這種在特殊狀況下產生的連續屢次空間擴展操做稱之爲「連鎖更新」。
除了添加新節點可能會引起連鎖更新以外, 刪除節點也可能會引起連鎖更新。
由於連鎖更新在最壞狀況下須要對壓縮列表執行 N
次空間重分配操做, 而每次空間重分配的最壞複雜度爲 O(N) , 因此連鎖更新的最壞複雜度爲 O(N^2) 。
參考文檔:
http://redisbook.com/