上一篇說了Redis有五種數據類型,今天就來聊一下Redis底層的數據結構是什么樣的。是這一周看了《redis設計與實現》一書,現來總結一下。(看書總是非常煩躁的!)
Redis是由C語言所寫,所以以下會有c語言的片段,不過都是一些定義,很好理解。
Redis底層數據結構有六種:
1、簡單動態字符串
2、鏈表
3、字典
4、跳躍表
5、整數集合
6、壓縮列表
7、快速列表
接下來看一下每種數據結構到底是啥?
一、簡單動態字符串
(1)Redis默認字符串底層存儲結構,比如set k1 v1,鍵k1是一個字符串,底層實現是保存着字符串k1的SDS,值v1也是一個字符串,底層實現是保存着字符串v1的SDS
(2)每個sds.h/sdshdr表示一個SDS,結構如下
struct sdshdr {
//記錄buf數組中已使用字節的數量,相當於保存的字符串的長度
int len;
//記錄buf數組中未使用的字節數量
int free;
//字節數組,用於保存字符串
char buf[]
};
結構圖如下:

buf數組的最后一個空字符‘’是因為其遵循了C字符串以空字符結尾的慣例。
(3)優點:
1>獲取字符串長度的復雜度為O(1)。
2>杜絕緩存區溢出。因為其API會進行空間擴展,擴展之后未使用字節數量free和已使用字節數量len一樣
3>減少字符串修改時的內存重分配次數,因為有free(預分配),所有在最壞的情況下就是修改n次,重分配n次。
二、鏈表
(1)redis的list數據類型的底層實現之一,類似於java集合類LinkedArrayList。
(2)每個鏈表節點用一個adlist.h/listNode結構來表示
struct listNode{
//前置節點
struct listNode *prev;
//后置節點
struct listNode *next;
//節點的值
void *value;
}listNode;
多個listNode可以通過prev和next指針組層雙端鏈表

(3)鏈表通過結構adlist.h/list來構建
struct list{
//表頭節點
listNode *head;
//表尾節點
listNode *tail;
//鏈表節點數量
unsigned long len;
//節點值復制函數
void *(*dup)(void *ptr);
//節點值釋放函數
void (*free)(void *ptr);
//節點值對比函數
int (*match)(void *ptr,void *key);
}list;
結構如下:

三、字典
(1)字典又稱為符號表、關聯數組或映射,是一種用於保存鍵值對的抽象數據結構,如果了解java7的HashMap的底層實現,那么這個自然就懂了。
(2)使用哈希表由dict.h/dictht結構定義
struct dictht{
//哈希表數組
dictEntry **table;
//哈希表大小
unsigned long size;
//大小掩碼,用於計算索引值,總是等於size-1
unsigned long sizemask;
//已有節點的數量
unsigned long used;
}dictht;
table是一個數組,類型是指向dict.h/dictEntry結構的指針。每個dictEntry保存着一個鍵對值,結構如下
struct dictEntry{
//鍵
void *key;
//值,下面三個的其中一個
union{
//指針
void *val;
//uint64_t整數
uint64_tu64;
//int64_t整數
int64_ts64;
}v;
//指向下一個節點的指針
struct dictEntry *next;
}dictEntry;
如果此時表中有一個entry的鍵為k0,插入鍵為k1的entry的時候,k1、k0它倆的hash值都一樣,這時候就發生了哈希沖突,那么此時k1就會放在table中對應的索引下,k1的next就會指向k0,這個就是解決hash沖突的實現。

四、跳躍表()
(1)跳躍表是一種有序的數據結構,通過每個節點中維持多個指向其他節點的指針,從而達到快速訪問節點的目的。這種數據結構可以在小於等於O(n)的情況下找到相應的數據。
1>由很多層組成
2>每一層都是一個有序的鏈表
3>最底層的鏈表包含了所有的元素;
4>如果一個元素出現在某一層的鏈表中,那么在該層之下的鏈表也全都會出現(上一層的元素是當前層的元素的子集);
5>鏈表中的每個節點都包含兩個指針,一個指向同一層的下一個鏈表節點,另一個指向下一層的同一個鏈表節點;

簡單的理解就是當前層節點之間的間隔都比下一層更大,但是每一層都必須是有頭結點和尾節點。
(2)redis中跳躍表定義由zskiplistNode和zskiplist兩個結構定義zskiplistNode表示節點,zskiplist表示整個跳躍表信息。
struct zskiplistNode{
//層級信息
struct zskiplistLevel{
//前進指針
struct zskiplistNode *forward;
//跨度
unsigned int span;
}level[];
//后退指針
struct zskiplistNode *backward;
//分值
double score;
//成員對象
robj *obj;
}zskiplistNode;
struct zskiplist{
//表頭節點和表尾節點
struct zskiplistNode *header,*tail;
//表中節點的數量
unsigned long length;
//表中最大的層數
int level;
}zskiplist;
redis中的結構如下圖:

上圖中用BW字樣表示節點的后腿指針,指向當前節點的前一個節點,可用於從后往前遍歷。
(3)增刪改節點操作
①、搜索:從最高層的鏈表節點開始,如果比當前節點要大和比當前層的下一個節點要小,那么則往下找,也就是和當前層的下一層的節點的下一個節點進行比較,以此類推,一直找到最底層的最后一個節點,如果找到則返回,反之則返回空。
②、插入:首先確定插入的層數,有一種方法是假設拋一枚硬幣,如果是正面就累加,直到遇見反面為止,最后記錄正面的次數作為插入的層數。當確定插入的層數k后,則需要將新元素插入到從底層到k層。
③、刪除:在各個層中找到包含指定值的節點,然后將節點從鏈表中刪除即可,如果刪除以后只剩下頭尾兩個節點,則刪除這一層。
五、整數集合
(1)整數集合(intset)是Redis用於保存整數值的集合抽象數據類型,它可以保存類型為int16_t、int32_t 或者int64_t 的整數值,並且保證集合中不會出現重復元素。
(2)用intset結構實現:
struct intset{
//編碼方式
uint32_t encoding;
//元素數量
uint32_t length;
//保存元素額數組
int8_t contents[];
}intset;
整數集合的每個元素都是 contents 數組的一個數據項,它們按照從小到大的順序排列,並且不包含任何重復項。
length 屬性記錄了 contents 數組的大小。
需要注意的是雖然 contents 數組聲明為 int8_t 類型,但是實際上contents 數組並不保存任何 int8_t 類型的值,其真正類型有 encoding 來決定。
(3)升級
當我們新增的元素類型比原集合元素類型的長度要大時,需要對整數集合進行升級,才能將新元素放入整數集合中。具體步驟:
1、根據新元素類型,擴展整數集合底層數組的大小,並為新元素分配空間。
2、將底層數組現有的所有元素都轉成與新元素相同類型的元素,並將轉換后的元素放到正確的位置,放置過程中,維持整個元素順序都是有序的。
3、將新元素添加到整數集合中(保證有序)。
升級能極大地節省內存,因為如果需要保存不同長度的值的話,需要將集合置為64位的。
(4)整數集合不支持降級操作
六、壓縮列表
(1)壓縮列表(ziplist)是Redis為了節省內存而開發的,是由一系列特殊編碼的連續內存塊組成的順序型數據結構,一個壓縮列表可以包含任意多個節點(entry),每個節點可以保存一個字節數組或者一個整數值。
(2)結構如下:



①、previous_entry_ength:記錄壓縮列表前一個字節的長度。previous_entry_ength的長度可能是1個字節或者是5個字節,如果上一個節點的長度小於254,則該節點只需要一個字節就可以表示前一個節點的長度了,如果前一個節點的長度大於等於254,則previous length的第一個字節為254,后面用四個字節表示當前節點前一個節點的長度。利用此原理即當前節點位置減去上一個節點的長度即得到上一個節點的起始位置,壓縮列表可以從尾部向頭部遍歷。這么做很有效地減少了內存的浪費。
②、encoding:節點的encoding保存的是節點的content的內容類型以及長度,encoding類型一共有兩種,一種字節數組一種是整數,encoding區域長度為1字節、2字節或者5字節長。
③、content:content區域用於保存節點的內容,節點內容類型和長度由encoding決定。
七、快速列表
(1)由於使用鏈表的附加空間相對太高以及內存碎片化等缺點,Redis后續版本對列表數據結構進行改造,使用quicklist代替了ziplist和linkedlist。
(2)快速列表有quicklistNode和quicklist結構組成
// 快速列表節點
struct quicklistNode {
quicklistNode *prev;
quicklistNode *next;
ziplist *zl; // 指向壓縮列表
int32 size; // ziplist字節總數
int16 count; // ziplist中元素數量
int2 encoding; // 存儲形式,表示原生字節數組還是LZF壓縮存儲
...
} quicklistNode;
// 快速列表
struct quicklist {
quicklistNode *head;
quicklistNode *next;
long count; // 元素總數
int nodes; // ziplist節點個數
int compressDepth; // LZF算法壓縮深度
}
quicklist;
從代碼可以看出,quicklist實際上是ziplist和linkedlist的混合體,它將linkedlist按段進行切分,每一段使用ziplist進行緊湊存儲,多個ziplist之間使用雙向指針進行串接。

以上就是Redis七種數據結構的介紹。下面看一下Redis五種數據類型的底層數據結構分別是什么?
Redis中的每一個對象都是由redisObject結構表示,三個屬性分別是type,encoding,ptr
struct redisObject{
//類型
unsigned type:4;
//編碼
unsigned encoding:4;
//指向底層實現數據結構的指針
void *ptr;
}robj;
type記錄里對象的類型,是如下幾個,可以在redis中用“type key”獲取類型

對象的ptr指針指向對象的底層實現數據結構,而數據結構是由encoding屬性決定的。
Encoding屬性記錄了對象所使用的編碼,也即是說使用了何種數據結構 。


使用object encoding key可以查看數據庫的鍵的值對象所使用的編碼。
一、字符串對象
字符串對象的編碼可以是int,raw或者是embstr。
如果一個字符串對象保存的是整數值,此時使用的int編碼


如果一個字符串對象保存的字符串長度大於32字節,使用的raw編碼


如果一個字符串對象保存的字符串長度小於32字節,使用的是embstr編碼,此編碼與raw並無不同,只是底層結構不一樣,如下圖,其空間是連續的,而raw的redisObject和SDS是分開的。


二、列表對象
在redis的早期版本中,列表對象使用的編碼是ziplist或linkedlist。
但是現在使用的是快速列表(quicklist)

三、哈希對象
哈希對象的底層編碼是ziplist或者hashtable(字典)
當哈希對象保存的所有鍵對值的鍵和值的長度都是小於64字節並且鍵對值數量小於512個的時候,使用ziplist。
保存鍵對值的時候,現將鍵壓至棧底,再將值壓至棧底。


當不滿足用ziplist的條件的時候,使用hashtable


上述兩個條件的上限值是可以修改的,具體是配置文件中的hash-max-ziplist-value和hash-max-ziplist-entried屬性。
四、集合對象
集合對象可使用的編碼是intset或hashtable
當集合中所有元素都是整數並且元素數量小於512個,intset底層是使用整數集合實現的。


當不滿足用intset的條件的時候,使用hashtable
//使用eval命令執行lua腳本,往集合set_k添加514個數據
eval "for i=4,516 do redis.call('sadd',KEYS[1],i) end" 1 set_k


上述intset的上限值是可以修改的,具體的配置項是set-max-intset-entries屬性。
五、有序集合對象
有序結合對象使用的是ziplist或者是skiplist
當有序集合中元素小於128個並且所有元素的長度都小於64字節,使用ziplist,ziplist保存的方式也是先保存鍵,再保存值,鍵和值是挨着的,元素是按照值由小變大排序的。

當不滿足ziplist的兩個條件的時候,使用的是skiplist,skiplist底層是zset結構,包含一個字典和一個跳躍表。

struct zset{
//跳躍表
zskiplist *zkl;
//字典
dict *dict;
}zset;
zsl屬性是一個跳躍表,按分值從小到大保存所有集合元素,每個節點保存一個元素,節點的object屬相保存元素的成員,scope屬性保存元素的分值,通過跳躍表,可以實現范圍的操作,例如ZRANK,ZRANGE等。
dict是一個字典,字典的每一個鍵對值保存着一個集合元素,鍵是元素,值是對應的分值。可以支持復雜度為O(1)的元素分值查找。


從上述可以各種數據類型的底層實現數據結構可以看到,redis支持在不同的場景下使用不同的編碼用來優化對象的使用效率。
服務器在執行某個命令的時候,會先根據redisObject里的type屬性判斷是否可以執行指定的命令。
Redis使用引用計數實現內存回收機制(在JVM垃圾回收的時候說過,此機制不能解決循環引用的問題,所以JVM不用此機制)。
這篇文章比較長,普通redis使用者其實沒必要了解的那么詳細,簡單知道有這么一回事就行了,真正應該關注的是redis在何種場景的用法是什么,這一點需要使用者慢慢去摸索。

=======================================================
我是Liusy,一個喜歡健身的程序員。
歡迎關注微信公眾號【Liusy01】,一起交流Java技術及健身,獲取更多干貨。
