1. 演示數據類型的實現
????????上篇博客我們在介紹 key 相關命令的時候,介紹了如下命令:
```
OBJECT ENCODING? ? key
```
????????該命令是用來顯示那五大數據類型的底層數據結構。
????????比如對于 string 數據類型:
(1).string
????????在使用string存儲字符串的時候返回的是"embstr",存儲數字的時候返回的就是“int”。
(2).hash
????????在使用hash的時候無論是存儲字符串還是數字返回的都是"ziplist"。
(3).list
????????使用list存儲字符串還是數字返回的都是“quicklist”。
(4).set
????????set存儲純數字的時候返回的是"intset",存儲包含字符串的時候返回的是"hashtable"。
(5).zset
????????使用zset存儲數字或者字符串都是返回的"ziplist"。
2.簡單動態字符串
????????Redis 是用 C 語言寫的,但是對于Redis的字符串,卻不是 C 語言中的字符串(即以空字符’\0’結尾的字符數組),它是自己構建了一種名為 簡單動態字符串(simple dynamic string,SDS)的抽象類型,并將 SDS 作為 Redis的默認字符串表示。
SDS 定義:
```
struct?sdshdr{
?????//記錄buf數組中已使用字節的數量
?????//等于 SDS 保存字符串的長度
?????int?len;
?????//記錄 buf 數組中未使用字節的數量
?????int?free;
?????//字節數組,用于保存字符串
?????char?buf[];
}
```
用SDS保存字符串 “Redis”具體圖示如下:
圖片來源:《Redis設計與實現》
? 我們看上面對于 SDS 數據類型的定義:
1、len 保存了SDS保存字符串的長度
2、buf[] 數組用來保存字符串的每個元素
3、free j記錄了 buf 數組中未使用的字節數量
上面的定義相對于 C 語言對于字符串的定義,多出了 len 屬性以及 free 屬性。為什么不使用C語言字符串實現,而是使用 SDS呢?這樣實現有什么好處?
①、常數復雜度獲取字符串長度
由于 len 屬性的存在,我們獲取 SDS 字符串的長度只需要讀取 len 屬性,時間復雜度為 O(1)。而對于 C 語言,獲取字符串的長度通常是經過遍歷計數來實現的,時間復雜度為 O(n)。通過 strlen key 命令可以獲取 key 的字符串長度。
②、杜絕緩沖區溢出
我們知道在 C 語言中使用 strcat? 函數來進行兩個字符串的拼接,一旦沒有分配足夠長度的內存空間,就會造成緩沖區溢出。而對于 SDS 數據類型,在進行字符修改的時候,會首先根據記錄的 len 屬性檢查內存空間是否滿足需求,如果不滿足,會進行相應的空間擴展,然后在進行修改操作,所以不會出現緩沖區溢出。
③、減少修改字符串的內存重新分配次數
C語言由于不記錄字符串的長度,所以如果要修改字符串,必須要重新分配內存(先釋放再申請),因為如果沒有重新分配,字符串長度增大時會造成內存緩沖區溢出,字符串長度減小時會造成內存泄露。
而對于SDS,由于len屬性和free屬性的存在,對于修改字符串SDS實現了空間預分配和惰性空間釋放兩種策略:
1、空間預分配:對字符串進行空間擴展的時候,擴展的內存比實際需要的多,這樣可以減少連續執行字符串增長操作所需的內存重分配次數。
2、惰性空間釋放:對字符串進行縮短操作時,程序不立即使用內存重新分配來回收縮短后多余的字節,而是使用 free 屬性將這些字節的數量記錄下來,等待后續使用。(當然SDS也提供了相應的API,當我們有需要時,也可以手動釋放這些未使用的空間。)
④、二進制安全
因為C字符串以空字符作為字符串結束的標識,而對于一些二進制文件(如圖片等),內容可能包括空字符串,因此C字符串無法正確存取;而所有 SDS 的API 都是以處理二進制的方式來處理 buf 里面的元素,并且 SDS 不是以空字符串來判斷是否結束,而是以 len 屬性表示的長度來判斷字符串是否結束。
⑤、兼容部分 C 字符串函數
雖然 SDS 是二進制安全的,但是一樣遵從每個字符串都是以空字符串結尾的慣例,這樣可以重用 C 語言庫<string.h> 中的一部分函數。
⑥、總結
一般來說,SDS 除了保存數據庫中的字符串值以外,SDS 還可以作為緩沖區(buffer):包括 AOF 模塊中的AOF緩沖區以及客戶端狀態中的輸入緩沖區。后面在介紹Redis的持久化時會進行介紹。
3.鏈表
????????鏈表是一種常用的數據結構,C 語言內部是沒有內置這種數據結構的實現,所以Redis自己構建了鏈表的實現。關于鏈表的詳細介紹可以參考網上的資料。
鏈表定義:
```
typedef?struct?listNode{
???????//前置節點
???????struct?listNode *prev;
???????//后置節點
???????struct?listNode *next;
???????//節點的值
???????void?*value;??
}listNode
```
????????通過多個 listNode 結構就可以組成鏈表,這是一個雙端鏈表,Redis還提供了操作鏈表的數據結構:
```
typedef?struct?list{
?????//表頭節點
?????listNode *head;
?????//表尾節點
?????listNode *tail;
?????//鏈表所包含的節點數量
?????unsigned?long?len;
?????//節點值復制函數
?????void?(*free) (void?*ptr);
?????//節點值釋放函數
?????void?(*free) (void?*ptr);
?????//節點值對比函數
?????int?(*match) (void?*ptr,void?*key);
}list;
```
Redis鏈表特性:
①、雙端:鏈表具有前置節點和后置節點的引用,獲取這兩個節點時間復雜度都為O(1)。
②、無環:表頭節點的 prev 指針和表尾節點的 next 指針都指向 NULL,對鏈表的訪問都是以 NULL 結束。
③、帶鏈表長度計數器:通過 len 屬性獲取鏈表長度的時間復雜度為 O(1)。
④、多態:鏈表節點使用 void* 指針來保存節點值,可以保存各種不同類型的值。
4.字典
????????字典又稱為符號表或者關聯數組、或映射(map),是一種用于保存鍵值對的抽象數據結構。字典中的每一個鍵 key 都是唯一的,通過 key 可以對值來進行查找或修改。C 語言中沒有內置這種數據結構的實現,所以字典依然是 Redis自己構建的。
????????Redis 的字典使用哈希表作為底層實現,關于哈希表的詳細講解可以參考我這篇博客。
哈希表結構定義:
```
typedef?struct?dictht{
?????//哈希表數組
?????dictEntry **table;
?????//哈希表大小
?????unsigned?long?size;
?????//哈希表大小掩碼,用于計算索引值
?????//總是等于 size-1
?????unsigned?long?sizemask;
?????//該哈希表已有節點的數量
?????unsigned?long?used;
}dictht
```
哈希表是由數組 table 組成,table 中每個元素都是指向 dict.h/dictEntry 結構,dictEntry 結構定義如下:
```
typedef?struct?dictEntry{
?????//鍵
?????void?*key;
?????//值
?????union{
??????????void?*val;
??????????uint64_tu64;
??????????int64_ts64;
?????}v;
?????//指向下一個哈希表節點,形成鏈表
?????struct?dictEntry *next;
}dictEntry
```
key 用來保存鍵,val 屬性用來保存值,值可以是一個指針,也可以是uint64_t整數,也可以是int64_t整數。
注意這里還有一個指向下一個哈希表節點的指針,我們知道哈希表最大的問題是存在哈希沖突,如何解決哈希沖突,有開放地址法和鏈地址法。這里采用的便是鏈地址法,通過next這個指針可以將多個哈希值相同的鍵值對連接在一起,用來解決哈希沖突。
①、哈希算法:Redis計算哈希值和索引值方法如下:
```
#1、使用字典設置的哈希函數,計算鍵 key 的哈希值
hash = dict->type->hashFunction(key);
#2、使用哈希表的sizemask屬性和第一步得到的哈希值,計算索引值
index = hash & dict->ht[x].sizemask;
```
②、解決哈希沖突:這個問題上面我們介紹了,方法是鏈地址法。通過字典里面的 *next 指針指向下一個具有相同索引值的哈希表節點。
③、擴容和收縮:當哈希表保存的鍵值對太多或者太少時,就要通過 rerehash(重新散列)來對哈希表進行相應的擴展或者收縮。具體步驟:
1、如果執行擴展操作,會基于原哈希表創建一個大小等于 ht[0].used*2n 的哈希表(也就是每次擴展都是根據原哈希表已使用的空間擴大一倍創建另一個哈希表)。相反如果執行的是收縮操作,每次收縮是根據已使用空間縮小一倍創建一個新的哈希表。
2、重新利用上面的哈希算法,計算索引值,然后將鍵值對放到新的哈希表位置上。
3、所有鍵值對都遷徙完畢后,釋放原哈希表的內存空間。
④、觸發擴容的條件:
1、服務器目前沒有執行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且負載因子大于等于1。
2、服務器目前正在執行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且負載因子大于等于5。
ps:負載因子 = 哈希表已保存節點數量 / 哈希表大小。
⑤、漸近式 rehash
什么叫漸進式 rehash?也就是說擴容和收縮操作不是一次性、集中式完成的,而是分多次、漸進式完成的。如果保存在Redis中的鍵值對只有幾個幾十個,那么 rehash 操作可以瞬間完成,但是如果鍵值對有幾百萬,幾千萬甚至幾億,那么要一次性的進行 rehash,勢必會造成Redis一段時間內不能進行別的操作。所以Redis采用漸進式 rehash,這樣在進行漸進式rehash期間,字典的刪除查找更新等操作可能會在兩個哈希表上進行,第一個哈希表沒有找到,就會去第二個哈希表上進行查找。但是進行 增加操作,一定是在新的哈希表上進行的。
5.跳躍表
跳躍表(skiplist)是一種有序數據結構,它通過在每個節點中維持多個指向其它節點的指針,從而達到快速訪問節點的目的。具有如下性質:
1、由很多層結構組成;
2、每一層都是一個有序的鏈表,排列順序為由高層到底層,都至少包含兩個鏈表節點,分別是前面的head節點和后面的nil節點;
3、最底層的鏈表包含了所有的元素;
4、如果一個元素出現在某一層的鏈表中,那么在該層之下的鏈表也全都會出現(上一層的元素是當前層的元素的子集);
5、鏈表中的每個節點都包含兩個指針,一個指向同一層的下一個鏈表節點,另一個指向下一層的同一個鏈表節點;
Redis中跳躍表節點定義如下:
```
typedef?struct?zskiplistNode {
?????//層
?????struct?zskiplistLevel{
???????????//前進指針
???????????struct?zskiplistNode *forward;
???????????//跨度
???????????unsigned?int?span;
?????}level[];
?????//后退指針
?????struct?zskiplistNode *backward;
?????//分值
?????double?score;
?????//成員對象
?????robj *obj;
} zskiplistNode
```
多個跳躍表節點構成一個跳躍表:
```
typedef?struct?zskiplist{
?????//表頭節點和表尾節點
?????structz skiplistNode *header, *tail;
?????//表中節點的數量
?????unsigned?long?length;
?????//表中層數最大的節點的層數
?????int?level;
}zskiplist;
```
①、搜索:從最高層的鏈表節點開始,如果比當前節點要大和比當前層的下一個節點要小,那么則往下找,也就是和當前層的下一層的節點的下一個節點進行比較,以此類推,一直找到最底層的最后一個節點,如果找到則返回,反之則返回空。
②、插入:首先確定插入的層數,有一種方法是假設拋一枚硬幣,如果是正面就累加,直到遇見反面為止,最后記錄正面的次數作為插入的層數。當確定插入的層數k后,則需要將新元素插入到從底層到k層。
③、刪除:在各個層中找到包含指定值的節點,然后將節點從鏈表中刪除即可,如果刪除以后只剩下頭尾兩個節點,則刪除這一層。
6.整數集合
????????整數集合(intset)是Redis用于保存整數值的集合抽象數據類型,它可以保存類型為int16_t、int32_t 或者int64_t 的整數值,并且保證集合中不會出現重復元素。
定義如下:
```
typedef?struct?intset{
?????//編碼方式
?????uint32_t encoding;
?????//集合包含的元素數量
?????uint32_t length;
?????//保存元素的數組
?????int8_t contents[];
}intset;
```
????????整數集合的每個元素都是 contents 數組的一個數據項,它們按照從小到大的順序排列,并且不包含任何重復項。
length 屬性記錄了 contents 數組的大小。
需要注意的是雖然 contents 數組聲明為 int8_t 類型,但是實際上contents 數組并不保存任何 int8_t 類型的值,其真正類型有 encoding 來決定。
①、升級
當我們新增的元素類型比原集合元素類型的長度要大時,需要對整數集合進行升級,才能將新元素放入整數集合中。具體步驟:
1、根據新元素類型,擴展整數集合底層數組的大小,并為新元素分配空間。
2、將底層數組現有的所有元素都轉成與新元素相同類型的元素,并將轉換后的元素放到正確的位置,放置過程中,維持整個元素順序都是有序的。
3、將新元素添加到整數集合中(保證有序)。
升級能極大地節省內存。
②、降級
整數集合不支持降級操作,一旦對數組進行了升級,編碼就會一直保持升級后的狀態。
7、壓縮列表
????????壓縮列表(ziplist)是Redis為了節省內存而開發的,是由一系列特殊編碼的連續內存塊組成的順序型數據結構,一個壓縮列表可以包含任意多個節點(entry),每個節點可以保存一個字節數組或者一個整數值。
? ??????壓縮列表的原理:壓縮列表并不是對數據利用某種算法進行壓縮,而是將數據按照一定規則編碼在一塊連續的內存區域,目的是節省內存。
壓縮列表的每個節點構成如下:
①、previous_entry_ength:記錄壓縮列表前一個字節的長度。previous_entry_ength的長度可能是1個字節或者是5個字節,如果上一個節點的長度小于254,則該節點只需要一個字節就可以表示前一個節點的長度了,如果前一個節點的長度大于等于254,則previous length的第一個字節為254,后面用四個字節表示當前節點前一個節點的長度。利用此原理即當前節點位置減去上一個節點的長度即得到上一個節點的起始位置,壓縮列表可以從尾部向頭部遍歷。這么做很有效地減少了內存的浪費。
②、encoding:節點的encoding保存的是節點的content的內容類型以及長度,encoding類型一共有兩種,一種字節數組一種是整數,encoding區域長度為1字節、2字節或者5字節長。
③、content:content區域用于保存節點的內容,節點內容類型和長度由encoding決定。
8、總結
大多數情況下,Redis使用簡單字符串SDS作為字符串的表示,相對于C語言字符串,SDS具有常數復雜度獲取字符串長度,杜絕了緩存區的溢出,減少了修改字符串長度時所需的內存重分配次數,以及二進制安全能存儲各種類型的文件,并且還兼容部分C函數。
通過為鏈表設置不同類型的特定函數,Redis鏈表可以保存各種不同類型的值,除了用作列表鍵,還在發布與訂閱、慢查詢、監視器等方面發揮作用(后面會介紹)。
Redis的字典底層使用哈希表實現,每個字典通常有兩個哈希表,一個平時使用,另一個用于rehash時使用,使用鏈地址法解決哈希沖突。
跳躍表通常是有序集合的底層實現之一,表中的節點按照分值大小進行排序。
整數集合是集合鍵的底層實現之一,底層由數組構成,升級特性能盡可能的節省內存。
壓縮列表是Redis為節省內存而開發的順序型數據結構,通常作為列表鍵和哈希鍵的底層實現之一。
以上介紹的簡單字符串、鏈表、字典、跳躍表、整數集合、壓縮列表等數據結構就是Redis底層的一些數據結構,用來實現上一篇博客介紹的Redis五大數據類型,那么每種數據類型是由哪些數據結構實現的呢?下一篇博客進行介紹。
參考文檔:《Redis設計與實現》