Redis物件及底層資料結構
redis一共有五大常用的物件,用type命令即可檢視當前鍵對應的物件型別,分別是string(字串)、hash(雜湊)、list(列表)、set(集合)、zset(有序集合),但是這些只是對外的資料結構,實際上每一個物件都有兩到三種不同底層資料結構實現,可以通過object encoding命令檢視鍵值對應的底層資料結構實現,
下表即為每種物件所對應的底層資料結構實現。
型別 | 編碼 | 底層資料結構 |
---|---|---|
string | int | 整數值 |
string | raw | 簡單動態字串 |
string | embstr | 用embstr編碼的簡單動態字串 |
hash | ziplist | 壓縮列表 |
hash | hashtable | 字典 |
list | ziplist | 壓縮列表 |
list | linkedlist | 雙端列表 |
set | intset | 整數集合 |
set | hashtable | 字典 |
zset | ziplist | 壓縮列表 |
zset | skiplist | 跳錶和字典 |
簡單動態字串(SDS)
定義
redis並沒有使用C字串,而是使用了名為簡單動態字串(SDS)的結構,SDS的定義如下:
struct sdshdr {
// 記錄 buf 陣列中已使用位元組的數量
// 等於 SDS 所儲存字串的長度
int len;
// 記錄 buf 陣列中未使用位元組的數量
int free;
// 位元組陣列,用於儲存字串
char buf[];
};
複製程式碼
- len:記錄字串長度,大小為4個位元組
- free: 記錄buf[]中未被使用位元組數量,大小為4個位元組
- buf[]: 儲存字串,大小為字串大小+1,因為buf[]最後一個位元組儲存'\0' 所以sds的總大小為 = 4 + 4 + size(str) + 1
SDS的作用
那麼redis為什麼要使用看起來更佔空間的SDS結構呢?主要有以下幾個原因:
- O(1)複雜度獲得string的長度 相比於C字串需要遍歷string才能獲得長度(複雜度O(N)),SDS直接查詢len的數值即可。
- 防止緩衝區溢位 當修改C字串時,如果沒有分配夠足夠的記憶體,很容易造成緩衝區溢位。而使用SDS結構,當修改字串時,會自動檢測當前記憶體是否足夠,如果記憶體不夠,則會擴充套件SDS的空間,從而避免了緩衝區溢位。
- 減少修改字串帶來的頻繁的記憶體分配 每次增長或縮短C字串,都需要重新分配記憶體,而redis經常被用在資料修改頻繁的場合,所以SDS採用了兩種策略從而避免了頻繁的記憶體分配。 ①空間預分配 如上文所述,SDS會自動分配記憶體,如果修改後字串記憶體佔用小於1MB,則會分配同樣大小的未使用記憶體空間。(eg len: 20kb free: 10kb→ len: 40kb free 40kb),如果大於1MB,則分配1MB未使用記憶體空間。如此一來就可以避免因為字串增長帶來的頻繁空間分配。 ②惰性刪除 當縮短字串時,SDS並沒有釋放掉相應的記憶體,而是保留下來,用free記錄未使用的空間,為以後的增長字串做準備。
- 二進位制安全 SDS會以處理二進位制資料的形式存取buf中的內容,從而讓SDS不僅可以儲存任意編碼的文字資訊,還可以儲存諸如圖片、視訊、壓縮檔案等二進位制資料。
雙端列表
定義
雙端列表作為一種常用的資料結構,當一個list的長度超過512時,那麼redis將使用雙端列表作為底層資料結構。下面是一個列表節點的定義:
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;
複製程式碼
可以看到雙端列表是一個無環雙端帶表頭表尾節點的連結串列。
字典
定義
雜湊表(Hash table,也叫雜湊表),是根據鍵而直接訪問在記憶體儲存位置的資料結構。也就是說,它通過計算一個關於鍵值的函式,將所需查詢的資料對映到表中一個位置來訪問記錄,這加快了查詢速度。這個對映函式稱做雜湊函式,存放記錄的陣列稱做雜湊表。
當hashtable的型別無法滿足ziplist的條件時(元素型別小於512且所有值都小於64位元組時),redis會使用字典作為hashtable的底層資料結構實現。redis的字典(dict)中維護了兩個雜湊表(table),而每個雜湊表包含了多個雜湊表節點(entry)。下面分別來介紹這三個物件。
雜湊表節點
typedef struct dictEntry {
// 鍵
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下個雜湊表節點,形成連結串列
struct dictEntry *next;
} dictEntry;
複製程式碼
- key:鍵值對中的鍵。
- v: 鍵值對中的值,可以看到值可以為一個指標,或者是一個uint64整數或者int64整數。
- next:是為了用鏈地址法解決hash衝突。
雜湊表
typedef struct dictht {
// 雜湊表陣列
dictEntry **table;
// 雜湊表大小
unsigned long size;
// 雜湊表大小掩碼,用於計算索引值
// 總是等於 size - 1
unsigned long sizemask;
// 該雜湊表已有節點的數量
unsigned long used;
} dictht;
複製程式碼
- table:是一個儲存著指向所有節點指標的陣列。
- size: 記錄了table陣列的大小。
- sizemask: 用於和hash值一起計算索引值(index = hash & sizemask )
字典
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 屬性是針對不同型別的鍵值對, 為建立多型字典而設定的。
- 字典內部有兩個雜湊表,這樣做的目的是為rehash做準備。
hash演算法
當在雜湊表中存取資料時,首先需要用hash演算法算出鍵值對中的鍵所對應的hash值,然後再根據根據table陣列的大小取模,計算出對應的索引值,再繼續接下來的操作。redis使用了MurmurHash2 演算法來計算鍵的雜湊值,又使用了快速冪取模演算法降低了取模的複雜度。整個過程如下:
hash = dict->type->hashFunction(k0);
index = hash & dict->ht[0].sizemask;
複製程式碼
當hash衝突發生時則採用鏈地址法解決hash衝突。
rehash
當雜湊表儲存的鍵值對越來越多時,雜湊表的負載因子(load factor = used / size)越來越大, 原本O(1)複雜度的查詢也會漸漸趨向於O(N),為了保證雜湊表的負載因子在一定的範圍之內。redis需要動態的調整table陣列的大小,其中最重要的便是rehash過程。rehash分以下的幾個步驟:
- 為字典的 ht[1] 雜湊表分配空間,需要注意的是新的size必須是2^n,這主要是為了配合快速冪取模演算法。
- 將ht[0]上的鍵值對rehash到ht[1]上,即重新計算ht[0]上所有鍵值對的hash值和索引值,然後分配到ht[1]上,當原來的雜湊表資料量很大時可能會引起執行緒的阻塞,所以redis採用漸進式的rehash方式。
- ht[0]表釋放,原子性的替換ht[1]至ht[0],並建立一個空的雜湊表分配至ht[1]
漸進式rehash
redis的rehash過程並不是一次性集中rehash,而是分批間隔式的,在dict中的rehashidx便是為此服務。 相較於一次性的rehash,漸進式的rehash多了下面這些步驟:
- 開始rehash時,將rehashidx置為0。
- 當完成了一次rehash後,將rehashidx自增1,直到遍歷完所有的table陣列。
- 在rehash過程中,如果有對字典進行增加,則只增加ht[1],如果是查詢,則先查詢ht[0],如果找不到則去查詢ht[1],而如果是刪除和更新,則ht[0]和ht[1]同步操作。
- 完成所有rehash後,將rehashidx置為-1。
這是比較典型的分而治之的思想,將一次性集中作業分散,降低了系統的風險。
跳躍表
定義
跳錶的的查詢複雜度為平均O(logN)/最壞O(N)。在很多場合下作為替代平衡樹的資料結構,在redis中,如果有序集合的屬性不滿足ziplist的要求,則將跳錶作為有序集合的底層實現。
上圖即為一個完整的跳錶,其中有幾點比較重要,這個跳錶一共有三個節點再加上一個頭節點,最高有五層。一個跳躍表包含了兩種物件,一個是跳躍表節點,一個是跳躍表。跳躍表節點
typedef struct zskiplistNode {
// 後退指標
struct zskiplistNode *backward;
// 分值
double score;
// 成員物件
robj *obj;
// 層
struct zskiplistLevel {
// 前進指標
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
複製程式碼
- backward:後退指標,和雙端列表一樣,指向上一個節點。
- score:分值,有序列表的排序依據。
- obj:成員物件,實際上為一個SDS,在有序集合中分值可以重複,但成員物件不能重複。
- level:層,跳錶的關鍵所在,在條表中每一層包含了1到n個節點,在有序的情況下,可以快速遍歷陣列。
- forward:下一個節點的物件,這裡的下一個代表是第一個或者是第n個。
- span: 下一個節點和現在節點的距離。
跳躍表
typedef struct zskiplist {
// 表頭節點和表尾節點
struct zskiplistNode *header, *tail;
// 表中節點的數量
unsigned long length;
// 表中層數最大的節點的層數
int level;
} zskiplist;
複製程式碼
跳躍表中儲存了頭尾節點,方便遍歷,還儲存了節點的數量,可以在O(1) 複雜度內返回跳躍表的長度。
整數集合
定義
當集合的值全為整數且集合的長度不超過512時,redis採用整數集合作為集合的底層資料結構。
typedef struct intset {
// 編碼方式
uint32_t encoding;
// 集合包含的元素數量
uint32_t length;
// 儲存元素的陣列
int8_t contents[];
} intset;
複製程式碼
- encoding:整數集合中元素的編碼方式
INTSET_ENC_INT16 , contents 就是一個 int16_t 型別的陣列(最小值為 -32,768 ,最大值為 32,767 )。 INTSET_ENC_INT32 , contents 就是一個 int32_t 型別的陣列(最小值為 -2,147,483,648 ,最大值為 2,147,483,647 )。 INTSET_ENC_INT64 , contents 就是一個 int64_t 型別的陣列(最小值為 -9,223,372,036,854,775,808 ,最大值為 9,223,372,036,854,775,807 )。
- length:數量
- contents:集合元素 雖然contents看起來是int8_t,但是它的具體內容的存取還是按encoding的方式完成。
升級
redis採用多種編碼的方式,主要還是為了省記憶體。當集合中加入了不符合當前集合編碼的數字時,陣列集合會自動更新至能匹配到的編碼,值得注意的是,這種升級是不可逆的,只能由小往大,不能降級。如此一來,就能夠在存放小資料時,剩下很大的空間,而且也不必為編碼不匹配的事情而煩惱了。
壓縮列表
壓縮列表是redis又一個為了節省記憶體所做的優化,是list/hash/zset的底層資料結構之一,當資料值不大且數量較低時,redis都會使用壓縮列表。
- zlbytes:記錄整個壓縮列表佔用的記憶體位元組數:在對壓縮列表進行記憶體重分配, 或者計算 zlend 的位置時使用。
- zltail:記錄壓縮列表表尾節點距離壓縮列表的起始地址有多少位元組: 通過這個偏移量,程式無須遍歷整個壓縮列表就可以確定表尾節點的地址。
- zllen:記錄了壓縮列表包含的節點數量: 當這個屬性的值小於 UINT16_MAX (65535)時, 這個屬性的值就是壓縮列表包含節點的數量; 當這個值等於 UINT16_MAX 時, 節點的真實數量需要遍歷整個壓縮列表才能計算得出。
- entryX:壓縮列表包含的各個節點,節點的長度由節點儲存的內容決定。
- zlend:特殊值 0xFF (十進位制 255 ),用於標記。壓縮列表的末端。
壓縮列表和雙端列表有些類似,不過一個用指標銜接起來,一個則是用陣列和長度銜接起來。下面來看一看壓縮列表節點的定義:
- prevrawlen:前置節點的長度,相當於雙端列表中的前置指標,通過它可以計算出前置節點的地址。
- coding: 和正數集合類似,是為了表明content中是何種資料
- content: 資料
總結
本文對於redis常見的資料結構及其底層實現進行了分析和梳理,希望能夠理清這些底層資料結構對於redis高效能的作用和影響。