Redis 構造了多種底層數據結構供使用,不同的數據類型有可能使用到多種底層數據結構存儲,因此,需要理解為何 Redis 會有這樣的設計,理解每個底層數據結構的概念之後,就能知曉在極端性能上如何做取捨。 ...
簡介
Redis 的底層數據結構主要以下幾種:
- SDS(Simple Dynamic String, 簡單動態字元串)
- ZipList(壓縮列表)
- QuickList(快表)
- Dict(字典)
- IntSet(整數集合)
- ZSkipList(跳躍表)
簡單動態字元串
在 Redis 中,並不會直接使用 C 語言自帶的字元串結構作為實際的存儲結構,而只是將字元串作為字面量使用,大多數情況使用自定義的 SDS 來表示字元串。
SDS 主要用於儲存 Redis 的預設字元串表示、AOF 模塊中的 AOF 緩衝區、客戶端狀態輸入緩衝區。它的定義如下:
struct sdshdr {
int len; // 記錄 buf 數組中已使用位元組的數量,等於 SDS 所保存的字元串的長度
int alloc; // 記錄 buf 數組中未使用位元組的數量
char buf[]; // 位元組數組,用於保存字元串
};
優點
相對於 C 語言的字元串實現,Redis 實現的 SDS 有以下優點:
- 通過記錄
len
屬性,實現常數級時間複雜度獲取字元串長度 - 通過檢查
len
屬性,避免字元串在修改時出現緩衝區溢出的情況 - 通過記錄
len
屬性和alloc
屬性,對於修改字元串實現了空間預分配和惰性空間釋放 - 實際存儲的
buf
是一個位元組數組,可以實現 SDS 安全操作二進位數據 - SDS 仍然以
\0
作為字元串結尾的標識,這樣可以重用 C 語言字元串的部分函數
空間預分配
當 SDS 修改時需要擴展空間大小,程式不僅會為 SDS 擴展修改所需的空間,還會為 SDS 分配額外的未使用空間。這額外空間一般是 len
的大小,最大不超過 1MB。
這樣可以減少連續執行字元串增長操作所需的記憶體重分配次數。
惰性空間釋放
當 SDS 修改時需要縮短空間大小,程式並不會立即將多出來的空間進行空間重分配,而是使用 alloc
屬性將這些空間大小記錄下來,以待後續使用。
而且 SDS 也提供手動釋放未使用空間的方法,這樣可以避免浪費記憶體。
壓縮列表
ZipList 實際是由一系列特殊編碼的連續記憶體塊組成的順序型數據結構,是 Hash 類型底層實現的一種方式。
結構
一個 ZipList 結構由 zlbytes
、zltail
、zllen
、entries
、zlend
這些屬性組成,這些屬性順序連接在一起組成了 ZipList:
zlbytes
用於記錄 ZipList 占用的記憶體位元組數,在對 ZipList 進行記憶體重分配或者計算 zlend
的位置時使用。
zltail
記錄了 ZipList 表尾結點距離 ZipList 的起始地址有多少個位元組,Redis 可以通過這個屬性快速確定表尾結點的地址。
zllen
記錄了 ZipList 包含的結點數量,當這個屬性小於 UINT16_MAX
(65535) 時,這個值就是 ZipList 包含的結點數量;這個屬性大於 UINT16_MAX
時,則需要遍歷整個 ZipList 才能計算得出結點數量。一個 ZipList 可以包含任意多個結點,每個結點可以保存一個位元組數組或者一個整數值。
zlend
定義了特殊值 OxFF
用於標記 ZipList 的末端。
優點
ZipList 是一種節省記憶體的列表結構,對於普通的數組來說,其中每個元素占用的空間大小取決於最大的元素,而 ZipList 解決了這個問題。
因此,ZipList 在設計的時候,儘量讓每個元素按照實際的內容大小存儲,所以增加了 encoding
屬性,使得程式可以根據不同的 encoding
屬性來細化存儲大小。
由於數組每個元素都占用相同的記憶體空間,在遍曆數組時非常方便。
而 ZipList 每個元素存儲的記憶體空間不一樣,為瞭解決倒序遍歷的問題,增加了 prevlen
屬性來定位上一個元素的起始位置。
缺點
ZipList 內部的數據存儲是一段連續的空間,並且沒有預留記憶體空間,在移除結點時也是立即縮容,這表示每次寫操作都會進行記憶體分配操作。
第二個缺點就是,在某種情況下,ZipList 會出現連鎖更新的問題。
連鎖更新
ZipList 存儲了 prevlen
屬性表示前一個元素的長度,如果前一個元素長度小於 254 個位元組,則 prevlen
使用 1 個位元組保存這個長度值,如果前一個結點大於 254 個位元組,則 prevlen
使用 5 個位元組保存這個長度值。
如果 ZipList 中正好存在連續多個長度介於 250~253 個位元組的結點,這時需要在 ZipList 前面插入一個大於等於 254 個位元組的新結點,後一個結點的 prevlen
需要從 1 個位元組轉換成 5 個位元組,則後一個結點也會大於等於 254 個位元組,後續的結點以此類推,將會造成這部分結點出現連續更新。
快表
Redis 在 3.2 版本之後新增了快表數據結構,它是一種以 ZipList 為結點的雙端鏈表結構,可以理解成分段的 ZipList 鏈接在一起。
在 3.2 版本之前,Redis 使用 ZipList 或 LinkedList 來實現 List 類型,並且有一個選擇的標準:
- 保存的所有字元串元素的長度都小於 64 位元組,且保存的元素數量小於 512 個,選擇使用 ZipList
- 否則使用 LinkedList 數據結構替代 ZipList
ZipList 要求有一段連續的記憶體空間,而使用 LinkedList 分配記憶體又會出現大量的記憶體碎片,因此 QuickList 對此做了優化,既避免出現大量的記憶體碎片,又避免一次性占用記憶體過大。
字典
字典在 Redis 中的應用非常廣泛,比如 Redis 的資料庫就是使用字典來作為底層實現的,對資料庫的增、刪、改、查操作都是構建在對字典的操作之上的。
哈希表結點
字典存儲數據的最小結構就是哈希表結點,Redis 中的哈希表結點使用 dictEntry
結構表示,每個 dictEntry
都保存著一個鍵值對:
typedef struct dictEntry {
void *key; // 鍵值對的鍵
union { // 鍵值對的值
void *val; // 可以是一個指針
uint64_t u64; // 可以是一個 uint64_t 整數
int64_t s64; // 可以是一個 int64_t 整數
} v;
struct dictEntry *next; // 指向下個哈希表節點,形成鏈表
} dictEntry;
這裡值得註意的就是,next
指針會指向下一個哈希表結點,而它的功能就是用於解決哈希衝突,由此可見 Redis 的哈希表解決哈希衝突的方法是鏈地址法。
哈希表
哈希表是由多個哈希表結點組成的,Redis 中自定義的哈希表結構如下:
typedef struct dictht {
dictEntry **table; // 哈希表數組
unsigned long size; // 哈希表大小
unsigned long sizemask; // 哈希表大小掩碼,用於計算索引值,總是等於 size - 1
unsigned long used; // 該哈希表已有節點的數量
} dictht;
一般的,哈希表的物理存儲結構都是數組,Redis 的哈希表結構也是如此,而這個結點數組中的每個元素都是一個指向 dictEntry
結構的指針。
字典結構
Redis 為了使哈希表結構更加具有通用性,最後是在自定義的 dictht
哈希表結構外層再包一層字典結構,即是 dict
結構:
typedef struct dict {
dictType *type; // 類型特定函數
void *privdata; // 私有數據
dictht ht[2]; // 哈希表
int rehashidx; // rehash 索引,當 rehash 不在進行時,值為 -1
} dict;
這裡展示了另一個 dictType
的結構,其實這個結構保存了一簇用於操作特定類型鍵值對的函數,Redis 會為用途不同的字典設置不同的類型特定函數。以下是 dictType
的結構定義:
typedof struct dictType {
unsigned int (*hashFunction)(const void *key); // 計算哈希值的函數
void *(*keyDup)(void *privData, const void *key); // 複製鍵的函數
void *(*valDup)(void *privData, const void *obj); // 複製值的函數
int (*keyCompare)(void *privData, const void *key1, const void *key2); // 對比鍵的函數
void *(*keyDestructor)(void *privData, const void *key); // 銷毀鍵的函數
void *(*keyDestructor)(void *privData, const void *obj); // 銷毀值的函數
} dictType;
其實 dict
結構的 type
屬性和 privdata
屬性是針對不同類型的鍵值對,為創建多態字典而設置的。其中 privData
屬性保存了需要傳給那些類型特定函數的可選參數。
需要註意一下,字典結構的 ht
屬性是一個長度為 2 的數組,也就是說,這個字典結構存儲了兩個 dictType
結構,其中一個用於存儲實際使用的哈希表,另一個用於存儲重新哈希的臨時哈希表。
這個重新哈希還涉及到了 rehashidx
屬性,表示的是重新哈希當前的進度。
哈希演算法
當要將一個新的鍵值對添加到字典裡面時,程式會先根據鍵值對的鍵計算出哈希值和索引值,然後再根據索引值,將包含新鍵值對的哈希表結點放到哈希表數組的指定索引上。
Redis 計算哈希值和索引值的流程是:通過 dict
中的 type
屬性找到計算哈希值的函數,然後通過函數計算出對應的哈希值;確定對應的 dictht
結構之後,再根據 sizemask
和哈希值計算出索引值。
Redis 使用 MurmurHash2 演算法計算鍵的哈希值,其優點就是對於有規律的輸入值也能給出很好的隨機分佈性,並且演算法的計算速度也非常快。
哈希衝突
相同的哈希值會計算出相同的索引值,這就會導致哈希衝突。
Redis 使用了鏈地址法解決哈希衝突,每一個哈希表結點都有一個 next
指針,多個衝突的哈希表結點就會使用這個 next
指針構成一個單向鏈表,這就解決了鍵衝突的問題。
這裡需要註意一點,由於哈希表結點不存儲鏈表的尾結點,為了速度考慮,哈希衝突時構建的單向鏈表使用頭插法插入一個鏈表結點。
重新哈希
隨著操作不斷執行,哈希表保存的數據會逐漸增多或減少,為了讓哈希表的負載因數維持在一個合理的範圍內,Redis 會在必要的時候進行重新哈希的操作。
重新哈希指的是重新計算哈希表結點的哈希值和索引值,然後將鍵值對放到 ht
數組的另一個哈希表中。
Redis 對哈希表進行擴展操作的兩個條件如下:
- 伺服器目前沒有正在執行
BGSAVE
命令或BGREWRITEAOF
命令,並且哈希表的負載因數大於等於 1。 - 伺服器目前正在執行
BGSAVE
命令或BGREWRITEAOF
命令,並且哈希表的負載因數大於等於 5。
其中負載因數 = 哈希表已保存結點數量 / 哈希表大小。
另一方面,當哈希表的負載因數小於 0.1 時,Redis 會自動開始對哈希表進行收縮操作。
Redis 做自動擴展的條件包含兩種情況的原因是,執行
BGSAVE
和BGREWRITEAOF
命令的是伺服器的子進程,而大多數操作系統都採用寫時複製技術以優化子進程的使用效率,所以在子進程存在期間,伺服器會提高執行擴展操作所需的負載因數。
漸進式重新哈希
為了避免因為重新哈希導致停止服務的情況,Redis 做重新哈希不是一次性完成的,而是分多次、漸進式地完成的。這也是 dict
結構中存在 ht
數組的原因。
漸進式重新哈希的好處在於它採取了分而治之的方式,將重新哈希所需的計算工作均攤到對字典的每個添加、刪除、查找和更新操作上,從而避免集中式重新哈希而帶來的龐大計算量。
整數集合
整數集合被 Redis 用於保存整數值的不重覆集合,以下是整數集合的實現:
typedef struct intset {
uint32_t encoding; // 編碼方式
uint32_t length; // 集合包含的元素數量
int8_t contents[]; // 保存元素的數組
} intset;
其中 contents
數組中存儲的是整數集合中的元素,各個項按照從小到大進行排列,且數組中不包含任何重覆值。
length
屬性記錄了整數集合包含的元素個數,也相當於 contents
的數組長度。
encoding
記錄著整數集合的編碼方式,雖然 contents
的定義是 int8_t
類型,但實際上 contents
數組存儲元素的真正類型取決於 encoding
的值。
升級
整數集合的 contents
屬性可以存儲 int16
、int32
或 int64
三種類型之一的數值,如果原本只存儲了 int16
類型的 contents
數組插入一個 int32
類型的數值,這時就涉及到整數集合的升級操作。
每當要將一個整數插入到整機集合中時,Redis 都會先判斷新元素的類型是否會比已存在的元素類型長,如果存在這種情況,整數集合需要先進行升級,才能將新元素添加到整數集合裡面。具體的步驟如下:
- 根據新元素的類型,擴展整數集合底層數組的空間大小,併為新元素分配空間;
- 將現有元素都轉換成與新元素的類型相同,並將轉換類型後的數值放置到正確的位上,並保持原數組的順序不變;
- 最後改變
encoding
的值,並將length
加1
。
整數集合的升級操作是不可逆的,一旦對數組進行了升級,編碼就會一直保持升級後的狀態。
升級的好處
整數集合的升級策略有兩個好處,一個是提升整數集合的靈活性,另一個是儘可能地節約記憶體。
因為 C 語言是靜態類型語言,不同類型的整數值需要用不同的數組存儲,而整數集合通過升級策略將有原本不同類型的整數添加到同一個數組中,減少了類型錯誤的情況。
同樣的,整數集合通過使用一個數組存儲了三種不同類型的整數,又確保升級操作只會在有需要的時候進行,這可以儘量節省記憶體。
跳錶
跳錶是一種有序的數據結構,它通過在每個結點中維持多個指向其他結點的指針,從而達到快速訪問結點的目的。
Redis 的跳錶包括了兩個結構,一個是跳錶結點的結構,另一個是存儲跳錶結點的外部結構。
跳錶結點
以下是跳錶結點的結構定義:
typedef struct zskiplistNode {
struct zskiplistLevel { // 索引層
struct zskiplistNode *forward; // 前進指針
unsigned int span; // 跨度
} level[];
robj *obj; // 成員對象
double score; // 分值
struct zskiplistNode *backward; // 後退指針
} zskiplistNode;
這裡只是一個跳錶結點的結構,概念比較多。
跳錶的 zskiplistLevel
是一個數組,數組的長度表示結點有多少層索引,其中每個元素都包含一個指向其他結點的指針,程式可以通過這些指針加快訪問其他結點的速度。每次創建一個新的跳錶結點的時候,程式都會根據冪次定律隨機生成一個介於 1 和 32 之間的值作為數組的大小。
forward
是指每個索引層都包含指向下一個具有相同高度索引層的結點。也可以將前進指針理解成鏈表的 next
指針,從相同層級的角度上看,每一個相同層級的結點都組成了類似於鏈表的結構。
span
記錄了兩個結點之間的距離,實際上是用來計算排位的:在查找某個結點的過程中,將沿途訪問過的所有層的跨度累積起來,得到的結果就是目標結點在跳躍表的排位。
backward
用於從表尾向表頭訪問結點,對於最底層的鏈表來說,前進指針和後退指針使得這個鏈表成為一個雙向鏈表。
結點的 score
即是 Redis 的有序集合中的分值。結點的成員是一個指向 SDS 對象的指針,這個 SDS 對象存儲當前結點的值。對於相同分值的成員,Redis 會按照成員對象在字典序中的大小來進行排序,成員對象較小的結點會排在前面,而成員對象較大的結點會排在後面。
跳錶
僅使用多個跳錶結點就可以實現跳錶,但是新增外部跳錶結構可以使得程式更方便處理跳錶。Redis 的跳錶結構如下:
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 頭節點,尾節點
unsigned long length; // 節點數量
int level; // 目前表內節點的最大層數
} zskiplist;
其中 head
指針和 tail
指針分別指向跳錶的表頭和表尾,通過這兩個指針,Redis 定位跳錶表頭結點和表尾結點的時間複雜度為 \(O(1)\)。
通過記錄 length
屬性,Redis 可以在 \(O(1)\) 的時間複雜度內返回跳錶的長度。
跳錶使用 level
屬性記錄了表內結點的最大層數,但這個是不包含表頭結點的層高的。