深入淺出Redis-redis底層資料結構(上)
1、概述
相信使用過Redis 的各位同學都很清楚,Redis 是一個基於鍵值對(key-value)的分散式儲存系統,與Memcached類似,卻優於Memcached的一個高效能的key-value資料庫。
在《Redis設計與實現》這樣描述:
Redis 資料庫裡面的每個鍵值對(key-value) 都是由物件(object)組成的:
資料庫鍵總是一個字串物件(string object);
資料庫的值則可以是字串物件、列表物件(list)、雜湊物件(hash)、集合物件(set)、有序集合(sort set)物件這五種物件中的其中一種。
我們為什麼會說Redis 優於Memcached 呢,因為Redis 的出現,豐富了memcached 中key-value的儲存不足,在部分場合可以對關係資料庫起到很好的補充作用,而且這些資料型別都支援push/pop、add/remove及取交集並集和差集及更豐富的操作,而且這些操作都是原子性的。
我們今天探討的並不是Redis 中value 的資料型別,而是他們的具體實現——底層資料型別。
Redis 底層資料結構有一下資料型別:
- 簡單動態字串
- 連結串列
- 字典
- 跳躍表
- 整數集合
- 壓縮列表
- 物件
我們接下來會一步一步的探討這些資料結構有什麼特點,已經他們是如何構成我們所使用的value 資料型別。
2、簡單動態字串(simple dynamic string)SDS
2.1 概述
Redis 是一個開源的使用ANSI C語言編寫的key-value 資料庫,我們可能會較為主觀的認為 Redis 中的字串就是採用了C語言中的傳統字串表示,但其實不然,Redis 沒有直接使用C語言傳統的字串表示,而是自己構建了一種名為簡單動態字串(simple dynamic string SDS)的抽象型別,並將SDS用作Redis 的預設字串表示:
redis>SET msg "hello world"
OK
設定一個key= msg,value = hello world 的新鍵值對,他們底層是資料結構將會是:
鍵(key)是一個字串物件,物件的底層實現是一個儲存著字串“msg” 的SDS;
值(value)也是一個字串物件,物件的底層實現是一個儲存著字串“hello world” 的SDS
從上述例子,我們可以很直觀的看到我們在平常使用redis 的時候,建立的字串到底是一個什麼樣子的資料型別。除了用來儲存字串以外,SDS還被用作緩衝區(buffer)AOF模組中的AOF緩衝區。
2.2 SDS 的定義
Redis 中定義動態字串的結構:
/*
* 儲存字串物件的結構
*/
struct sdshdr {
// buf 中已佔用空間的長度
int len;
// buf 中剩餘可用空間的長度
int free;
// 資料空間
char buf[];
};
1、len 變數,用於記錄buf 中已經使用的空間長度(這裡指出Redis 的長度為5)
2、free 變數,用於記錄buf 中還空餘的空間(初次分配空間,一般沒有空餘,在對字串修改的時候,會有剩餘空間出現)
3、buf 字元陣列,用於記錄我們的字串(記錄Redis)
2.3 SDS 與 C 字串的區別
傳統的C 字串 使用長度為N+1 的字串陣列來表示長度為N 的字串,這樣做在獲取字串長度,字串擴充套件等操作的時候效率低下。C 語言使用這種簡單的字串表示方式,並不能滿足Redis 對字串在安全性、效率以及功能方面的要求
2.3.1 獲取字串長度(SDS O(1)/C 字串 O(n))
傳統的C 字串 使用長度為N+1 的字串陣列來表示長度為N 的字串,所以為了獲取一個長度為C字串的長度,必須遍歷整個字串。
和C 字串不同,SDS 的資料結構中,有專門用於儲存字串長度的變數,我們可以通過獲取len 屬性的值,直接知道字串長度。
2.3.2 杜絕緩衝區溢位
C 字串 不記錄字串長度,除了獲取的時候複雜度高以外,還容易導致緩衝區溢位。
假設程式中有兩個在記憶體中緊鄰著的 字串 s1 和 s2,其中s1 儲存了字串“redis”,二s2 則儲存了字串“MongoDb”:
如果我們現在將s1 的內容修改為redis cluster,但是又忘了重新為s1 分配足夠的空間,這時候就會出現以下問題:
我們可以看到,原本s2 中的內容已經被S1的內容給佔領了,s2 現在為 cluster,而不是“Mongodb”。
Redis 中SDS 的空間分配策略完全杜絕了發生緩衝區溢位的可能性:
當我們需要對一個SDS 進行修改的時候,redis 會在執行拼接操作之前,預先檢查給定SDS 空間是否足夠,如果不夠,會先拓展SDS 的空間,然後再執行拼接操作
2.3.3 減少修改字串時帶來的記憶體重分配次數
C語言字串在進行字串的擴充和收縮的時候,都會面臨著記憶體空間的重新分配問題。
1. 字串拼接會產生字串的記憶體空間的擴充,在拼接的過程中,原來的字串的大小很可能小於拼接後的字串的大小,那麼這樣的話,就會導致一旦忘記申請分配空間,就會導致記憶體的溢位。
2. 字串在進行收縮的時候,記憶體空間會相應的收縮,而如果在進行字串的切割的時候,沒有對記憶體的空間進行一個重新分配,那麼這部分多出來的空間就成為了記憶體洩露。
舉個例子:我們需要對下面的SDS進行拓展,則需要進行空間的拓展,這時候redis 會將SDS的長度修改為13位元組,並且將未使用空間同樣修改為1位元組
因為在上一次修改字串的時候已經拓展了空間,再次進行修改字串的時候會發現空間足夠使用,因此無須進行空間拓展
通過這種預分配策略,SDS將連續增長N次字串所需的記憶體重分配次數從必定N次降低為最多N次
2.3.4 惰性空間釋放
我們在觀察SDS 的結構的時候可以看到裡面的free 屬性,是用於記錄空餘空間的。我們除了在拓展字串的時候會使用到free 來進行記錄空餘空間以外,在對字串進行收縮的時候,我們也可以使用free 屬性來進行記錄剩餘空間,這樣做的好處就是避免下次對字串進行再次修改的時候,需要對字串的空間進行拓展。
然而,我們並不是說不能釋放SDS 中空餘的空間,SDS 提供了相應的API,讓我們可以在有需要的時候,自行釋放SDS 的空餘空間。
通過惰性空間釋放,SDS 避免了縮短字串時所需的記憶體重分配操作,並未將來可能有的增長操作提供了優化
2.3.5 二進位制安全
C 字串中的字元必須符合某種編碼,並且除了字串的末尾之外,字串裡面不能包含空字元,否則最先被程式讀入的空字元將被誤認為是字串結尾,這些限制使得C字串只能儲存文字資料,而不能儲存想圖片,音訊,視訊,壓縮檔案這樣的二進位制資料。
但是在Redis中,不是靠空字元來判斷字串的結束的,而是通過len這個屬性。那麼,即便是中間出現了空字元對於SDS來說,讀取該字元仍然是可以的。
例如:
2.3.6 相容部分C字串函式
雖然SDS 的API 都是二進位制安全的,但他們一樣遵循C字串以空字串結尾的慣例。
2.3.7 總結
C 字串 |
SDS |
---|---|
獲取字串長度的複雜度為O(N) |
獲取字串長度的複雜度為O(1) |
API 是不安全的,可能會造成緩衝區溢位 |
API 是安全的,不會造成緩衝區溢位 |
修改字串長度N次必然需要執行N次記憶體重分配 |
修改字串長度N次最多執行N次記憶體重分配 |
只能儲存文字資料 |
可以儲存二進位制資料和文字文資料 |
可以使用所有<String.h>庫中的函式 |
可以使用一部分<string.h>庫中的函式 |
3、連結串列
3.1 概述
連結串列提供了高效的節點重排能力,以及順序性的節點訪問方式,並且可以通過增刪節點來靈活地調整連結串列的長度。
連結串列在Redis 中的應用非常廣泛,比如列表鍵的底層實現之一就是連結串列。當一個列表鍵包含了數量較多的元素,又或者列表中包含的元素都是比較長的字串時,Redis 就會使用連結串列作為列表鍵的底層實現。
3.2 連結串列的資料結構
每個連結串列節點使用一個 listNode結構表示(adlist.h/listNode):
typedef struct listNode{
struct listNode *prev;
struct listNode * next;
void * value;
}
多個連結串列節點組成的雙端連結串列:
我們可以通過直接操作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 組成的結構圖:
3.3 連結串列的特性
- 雙端:連結串列節點帶有prev 和next 指標,獲取某個節點的前置節點和後置節點的時間複雜度都是O(N)
- 無環:表頭節點的 prev 指標和表尾節點的next 都指向NULL,對立案表的訪問時以NULL為截止
- 表頭和表尾:因為連結串列帶有head指標和tail 指標,程式獲取連結串列頭結點和尾節點的時間複雜度為O(1)
- 長度計數器:連結串列中存有記錄連結串列長度的屬性 len
- 多型:連結串列節點使用 void* 指標來儲存節點值,並且可以通過list 結構的dup 、 free、 match三個屬性為節點值設定型別特定函式。
4、字典
4.1 概述
字典,又稱為符號表(symbol table)、關聯陣列(associative array)或對映(map),是一種用於儲存鍵值對的抽象資料結構。
在字典中,一個鍵(key)可以和一個值(value)進行關聯,字典中的每個鍵都是獨一無二的。在C語言中,並沒有這種資料結構,但是Redis 中構建了自己的字典實現。
舉個簡單的例子:
redis > SET msg "hello world"
OK
建立這樣的鍵值對(“msg”,“hello world”)在資料庫中就是以字典的形式儲存
4.2 字典的定義
4.2.1 雜湊表
Redis 字典所使用的雜湊表由 dict.h/dictht 結構定義:
typedef struct dictht {
//雜湊表陣列
dictEntry **table;
//雜湊表大小
unsigned long size;
//雜湊表大小掩碼,用於計算索引值
unsigned long sizemask;
//該雜湊表已有節點的數量
unsigned long used;
}
一個空的字典的結構圖如下:
我們可以看到,在結構中存有指向dictEntry 陣列的指標,而我們用來儲存資料的空間既是dictEntry
4.2.2 雜湊表節點( dictEntry )
dictEntry 結構定義:
typeof struct dictEntry{
//鍵
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}
struct dictEntry *next;
}
在資料結構中,我們清楚key 是唯一的,但是我們存入裡面的key 並不是直接的字串,而是一個hash 值,通過hash 演算法,將字串轉換成對應的hash 值,然後在dictEntry 中找到對應的位置。
這時候我們會發現一個問題,如果出現hash 值相同的情況怎麼辦?Redis 採用了鏈地址法:
當k1 和k0 的hash 值相同時,將k1中的next 指向k0 想成一個連結串列。
4.2.3 字典
typedef struct dict {
// 型別特定函式
dictType *type;
// 私有資料
void *privedata;
// 雜湊表
dictht ht[2];
// rehash 索引
in trehashidx;
}
type 屬性 和privdata 屬性是針對不同型別的鍵值對,為建立多型字典而設定的。
ht 屬性是一個包含兩個項(兩個雜湊表)的陣列
普通狀態下的字典:
4.3 解決雜湊衝突
在上述分析雜湊節點的時候我們有講到:在插入一條新的資料時,會進行雜湊值的計算,如果出現了hash值相同的情況,Redis 中採用了連地址法(separate chaining)來解決鍵衝突。每個雜湊表節點都有一個next 指標,多個雜湊表節點可以使用next 構成一個單向連結串列,被分配到同一個索引上的多個節點可以使用這個單向連結串列連線起來解決hash值衝突的問題。
舉個例子:
現在雜湊表中有以下的資料:k0 和k1
我們現在要插入k2,通過hash 演算法計算到k2 的hash 值為2,即我們需要將k2 插入到dictEntry[2]中:
在插入後我們可以看到,dictEntry指向了k2,k2的next 指向了k1,從而完成了一次插入操作(這裡選擇表頭插入是因為雜湊表節點中沒有記錄連結串列尾節點位置)
4.4 Rehash
隨著對雜湊表的不斷操作,雜湊表儲存的鍵值對會逐漸的發生改變,為了讓雜湊表的負載因子維持在一個合理的範圍之內,我們需要對雜湊表的大小進行相應的擴充套件或者壓縮,這時候,我們可以通過 rehash(重新雜湊)操作來完成。
4.4.1 目前的雜湊表狀態:
我們可以看到,雜湊表中的每個節點都已經使用到了,這時候我們需要對雜湊表進行拓展。
4.4.2 為雜湊表分配空間
雜湊表空間分配規則:
如果執行的是拓展操作,那麼ht[1] 的大小為第一個大於等於ht[0] 的2的n次冪
如果執行的是收縮操作,那麼ht[1] 的大小為第一個大於等於ht[0] 的2的n次冪
因此這裡我們為ht[1] 分配 空間為8,
4.4.3 資料轉移
將ht[0]中的資料轉移到ht[1]中,在轉移的過程中,需要對雜湊表節點的資料重新進行雜湊值計算
資料轉移後的結果:
4.4.4 釋放ht[0]
將ht[0]釋放,然後將ht[1]設定成ht[0],最後為ht[1]分配一個空白雜湊表:
4.4.5 漸進式 rehash
上面我們說到,在進行拓展或者壓縮的時候,可以直接將所有的鍵值對rehash 到ht[1]中,這是因為資料量比較小。在實際開發過程中,這個rehash 操作並不是一次性、集中式完成的,而是分多次、漸進式地完成的。
漸進式rehash 的詳細步驟:
1、為ht[1] 分配空間,讓字典同時持有ht[0]和ht[1]兩個雜湊表
2、在幾點鐘維持一個索引計數器變數rehashidx,並將它的值設定為0,表示rehash 開始
3、在rehash 進行期間,每次對字典執行CRUD操作時,程式除了執行指定的操作以外,還會將ht[0]中的資料rehash 到ht[1]表中,並且將rehashidx加一
4、當ht[0]中所有資料轉移到ht[1]中時,將rehashidx 設定成-1,表示rehash 結束
採用漸進式rehash 的好處在於它採取分而治之的方式,避免了集中式rehash 帶來的龐大計算量。