Redis 数据结构的实现
?
?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;?
?
?
字典(dictionay)?
Redis 的字典使用哈希表作為底層實現,?
哈希表的實現如下:
typedef struct dictht {// 哈希表數組dictEntry **table;// 哈希表大小unsigned long size;// 哈希表大小掩碼,用于計算索引值// 總是等于 size - 1unsigned 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 不在進行時,值為 -1int rehashidx; /* rehashing not in progress if rehashidx == -1 */} dict;?
?
擴張和收縮(rehash)
隨著操作的不斷執行, 哈希表保存的鍵值對會逐漸地增多或者減少, 為了讓哈希表的負載因子(load factor)維持在一個合理的范圍之內, 當哈希表保存的鍵值對數量太多或者太少時, 程序需要對哈希表的大小進行相應的擴展或者收縮。
rehash步驟:
- 如果執行的是擴展操作, 那么?ht[1]?的大小為第一個大于等于?ht[0].used?*?2?的?2^n?(2?的?n?次方冪);
- 如果執行的是收縮操作, 那么?ht[1]?的大小為第一個大于等于?ht[0].used?的?2^n?。
當以下條件中的任意一個被滿足時, 程序會自動開始對哈希表執行擴展操作:
其中哈希表的負載因子可以通過公式:
# 負載因子 = 哈希表已保存節點數量 / 哈希表大小 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 的詳細步驟:
漸進式 rehash 的好處在于它采取分而治之的方式, 將 rehash 鍵值對所需的計算工作均灘到對字典的每個添加、刪除、查找和更新操作上, 從而避免了集中式 rehash 而帶來的龐大計算量。
因為在進行漸進式 rehash 的過程中, 字典會同時使用?ht[0]?和?ht[1]?兩個哈希表, 所以在漸進式 rehash 進行期間, 字典的刪除(delete)、查找(find)、更新(update)等操作會在兩個哈希表上進行: 比如說, 要在字典里面查找一個鍵的話, 程序會先在?ht[0]里面進行查找, 如果沒找到的話, 就會繼續到?ht[1]?里面進行查找, 諸如此類。
另外, 在漸進式 rehash 執行期間, 新添加到字典的鍵值對一律會被保存到?ht[1]?里面, 而?ht[0]?則不再進行任何添加操作: 這一措施保證了?ht[0]?包含的鍵值對數量會只減不增, 并隨著 rehash 操作的執行而最終變成空表。
?
?
?
整數集合(intset)
?整數集合是用一個有序數組實現的,
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?)。
?
升級(upgrade)
intset 的升級操作:例如當向一個底層為?int16_t?數組的整數集合添加一個?int64_t?類型的整數值時, intset 已有的所有元素都會被轉換成?int64_t?類型。
升級整數集合并添加新元素共分為三步進行:
?升級之后新元素的擺放位置
因為引發升級的新元素的長度總是比整數集合現有所有元素的長度都大, 所以這個新元素的值要么就大于所有現有元素, 要么就小于所有現有元素:
- 在新元素小于所有現有元素的情況下, 新元素會被放置在底層數組的最開頭(索引?0?);
- 在新元素大于所有現有元素的情況下, 新元素會被放置在底層數組的最末尾(索引?length-1?)。
?注意:intset不支持降級!
?
?
跳表(skiplist)
數據結構定義如下:
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?結構, 該結構包含以下屬性:
- 層(level):節點中用?L1?、?L2?、?L3?等字樣標記節點的各個層,?L1?代表第一層,?L2?代表第二層,以此類推。每個層都帶有兩個屬性:前進指針和跨度。前進指針用于訪問位于表尾方向的其他節點,而跨度則記錄了前進指針所指向節點和當前節點的距離。在上面的圖片中,連線上帶有數字的箭頭就代表前進指針,而那個數字就是跨度。當程序從表頭向表尾進行遍歷時,訪問會沿著層的前進指針進行。
- 后退(backward)指針:節點中用?BW?字樣標記節點的后退指針,它指向位于當前節點的前一個節點。后退指針在程序從表尾向表頭遍歷時使用。
- 分值(score):各個節點中的?1.0?、?2.0?和?3.0?是節點所保存的分值。在跳躍表中,節點按各自所保存的分值從小到大排列。
- 成員對象(obj):各個節點中的?o1?、?o2?和?o3?是節點所保存的成員對象。
?
?
?
?
壓縮列表(ziplist)
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
entry可以保存一個字節數組或者一個整數值, 其中, 字節數組可以是以下三種長度的其中一種:
而整數值則可以是以下六種長度的其中一種:
每個壓縮列表entry都由?previous_entry_length?、?encoding?、?content?三個部分組成,
?
| 屬性 | 類型 | 長度 | 用途 |
| previous_entry_length | 整型 | 1(小于254)或5(大于或等于254) | 前一個entry的長度 |
| encoding | 整型 | ? | 記錄content的類型和長度 |
| content | 整數或字節數組 | ? | ?entry的值 |
?
?
?
?
?
連鎖更新(cascade update)
現在, 考慮這樣一種情況: 在一個壓縮列表中, 有多個連續的、長度介于?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/
?
總結
以上是生活随笔為你收集整理的Redis 数据结构的实现的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 我对if(!this.IsPostBac
- 下一篇: 一篇文章带你了解Cloud Native