目錄
2、簡單動態字符串(simple dynamic string)SDS <=======> STRING
1.引言:
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 底層數據結構有一下數據類型:
2、簡單動態字符串(simple dynamic string)SDS <=======> STRING
2.1 概述
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
除了用來保存字符串以外,SDS還被用作緩衝區(buffer)AOF模塊中的AOF緩衝區。
2.2 SDS 的定義
Redis 中定義動態字符串的結構:
/*
* 保存字符串對象的結構
*/
struct sdshdr {
int len; // buf 中已佔用空間的長度
int free; // buf 中剩餘可用空間的長度
char buf[]; // 數據空間
};
- len 變量,用於記錄buf 中已經使用的空間長度(這裏指出Redis 的長度爲5)
- free 變量,用於記錄buf 中還空餘的空間(初次分配空間,一般沒有空餘,在對字符串修改的時候,會有剩餘空間出現)
- 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];// 哈希表
in trehashidx;// rehash 索引
}
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 帶來的龐大計算量。
我們已經講解了Redis 中的 動態字符串,鏈表,字典
在這裏我們簡單回顧一下他們的特點:
1、動態字符串SDS:區別於C語言字符串,具有良好的伸縮性,在獲取字符串長度,字符串修改,防止緩存區溢出等性能都比C語言字符串好
2、鏈表:順序存儲對象信息,有用於緩存鏈表長度的屬性,在插入刪除對象功能中有良好性能,避免環的產生
3、字典:key-value 存儲方式,通過hash值計算,判斷key的存儲,當容量過大,會通過rehash重新分配字典大小
5、跳躍表
5.1 概述
跳躍表(skiplist)是一種有序數據結構,它通過在每個節點中維持多個指向其他節點的指針,從而達到快速訪問節點的目的。跳躍表是一種隨機化的數據,跳躍表以有序的方式在層次化的鏈表中保存元素,效率和平衡樹媲美 ——查找、刪除、添加等操作都可以在對數期望時間下完成,並且比起平衡樹來說,跳躍表的實現要簡單直觀得多。
Redis 只在兩個地方用到了跳躍表,一個是實現有序集合鍵,另外一個是在集羣節點中用作內部數據結構。
5.2 跳躍表的定義
我們先來看一下一整個跳躍表的完整結構:
Redis 的跳躍表 主要由兩部分組成:zskiplist(鏈表)和zskiplistNode (節點)
5.2.1 zskiplistNode(節點) 數據結構:
typedef struct zskiplistNode{
//層
struct zskiplistLevel{
//前進指針
struct zskiplistNode *forward;
//跨度
unsigned int span;
} level[];
//後退指針
struct zskiplistNode *backward;
//分值
double score;
//成員對象
robj *obj;
}
1、層:level 數組可以包含多個元素,每個元素都包含一個指向其他節點的指針。
2、前進指針:用於指向表尾方向的前進指針
3、跨度:用於記錄兩個節點之間的距離
4、後退指針:用於從表尾向表頭方向訪問節點
5、分值和成員:跳躍表中的所有節點都按分值從小到大排序。成員對象指向一個字符串,這個字符串對象保存着一個SDS值
5.2. zskiplist 數據結構:
typedef struct zskiplist {
//表頭節點和表尾節點
structz skiplistNode *header,*tail;
//表中節點數量
unsigned long length;
//表中層數最大的節點的層數
int level;
}zskiplist;
從結構圖中我們可以清晰的看到,header,tail分別指向跳躍表的頭結點和尾節點。level 用於記錄最大的層數,length 用於記錄我們的節點數量。
5.3 總結
- 跳躍表是有序集合的底層實現之一
- 主要有zskiplist 和zskiplistNode兩個結構組成
- 每個跳躍表節點的層高都是1至32之間的隨機數
- 在同一個跳躍表中,多個節點可以包含相同的分值,但每個節點的對象必須是唯一的
- 節點按照分值的大小從大到小排序,如果分值相同,則按成員對象大小排序
6、整數集合(Intset)
6.1 概述
《Redis 設計與實現》 中這樣定義整數集合:“整數集合是集合建的底層實現之一,當一個集合中只包含整數,且這個集合中的元素數量不多時,redis就會使用整數集合intset作爲集合的底層實現。”
我們可以這樣理解整數集合,他其實就是一個特殊的集合,裏面存儲的數據只能夠是整數,並且數據量不能過大。
6.2 整數集合的實現
typedef struct intset{
uint32_t enconding;//編碼方式
uint32_t length;// 集合包含的元素數量
int8_t contents[];//保存元素的數組
}
我們觀察一下一個完成的整數集合結構圖:
1、encoding:用於定義整數集合的編碼方式
2、length:用於記錄整數集合中變量的數量
3、contents:用於保存元素的數組,雖然我們在數據結構圖中看到,intset將數組定義爲int8_t,但實際上數組保存的元素類型取決於encoding
6.3 整數集合的升級
在上述數據結構圖中我們可以看到,intset 在默認情況下會幫我們設定整數集合中的編碼方式,但是當我們存入的整數不符合整數集合中的編碼格式時,就需要使用到Redis 中的升級策略來解決
Intset 中升級整數集合並添加新元素共分爲三步進行:
1、根據新元素的類型,擴展整數集合底層數組的空間大小,併爲新元素分配空間
2、將底層數組現有的所有元素都轉換成新的編碼格式,重新分配空間
3、將新元素加入到底層數組中
比如,我們現在有如下的整數集合:
我們現在需要插入一個32位的整數,這顯然與整數集合不符合,我們將進行編碼格式的轉換,併爲新元素分配空間:
第二步,將原有數據他們的數據類型轉換爲與新數據相同的類型:(重新分配空間後的數據)
第三部,將新數據添加到數組中:
6.3.1 整數集合升級的好處
1、提升靈活性
2、節約內存
6.4 總結
整數集合是集合建的底層實現之一
整數集合的底層實現爲數組,這個數組以有序,無重複的範式保存集合元素,在有需要時,程序會根據新添加的元素類型改變這個數組的類型
升級操作爲整數集合帶來了操作上的靈活性,並且儘可能地節約了內存
整數集合只支持升級操作,不支持降級操作
7、壓縮列表
7.1 概述
壓縮列表是列表鍵和哈希鍵的底層實現之一。當一個列表鍵只把汗少量列表項,並且每個列表項要麼就是小整數,要麼就是長度比較短的字符串,那麼Redis 就會使用壓縮列表來做列表鍵的底層實現。
7.2 壓縮列表的構成
一個壓縮列表的組成如下:
1、zlbytes:用於記錄整個壓縮列表佔用的內存字節數
2、zltail:記錄要列表尾節點距離壓縮列表的起始地址有多少字節
3、zllen:記錄了壓縮列表包含的節點數量。
4、entryX:要說列表包含的各個節點
5、zlend:用於標記壓縮列表的末端
7.3 總結
壓縮列表是一種爲了節約內存而開發的順序型數據結構
壓縮列表被用作列表鍵和哈希鍵的底層實現之一
壓縮列表可以包含多個節點,每個節點可以保存一個字節數組或者整數值
添加新節點到壓縮列表,可能會引發連鎖更新操作。