說明
說到Redis的資料結構,我們大概會很快想到Redis的5種常見資料結構:字串(String)、列表(List)、雜湊(Hash)、集合(Set)、有序集合(Sorted Set),以及他們的特點和運用場景。不過它們是Redis對外暴露的資料結構,用於API的操作,而組成它們的底層基礎資料結構又是什麼呢
- 簡單動態字串(SDS)
- 連結串列
- 字典
- 跳躍表
- 整數集合
- 壓縮列表
Redis的GitHub地址github.com/antirez/red…
簡單動態字串(SDS)
Redis是用C語言寫的,但是Redis並沒有使用C的字串表示(C是字串是以\0
空字元結尾的字元陣列),而是自己構建了一種簡單動態字串(simple dynamic string,SDS)的抽象型別,並作為Redis的預設字串表示
在Redis中,包含字串值的鍵值對底層都是用SDS實現的
SDS的定義
SDS的結構定義在sds.h
檔案中,SDS的定義在Redis 3.2版本之後有一些改變,由一種資料結構變成了5種資料結構,會根據SDS儲存的內容長度來選擇不同的結構,以達到節省記憶體的效果,具體的結構定義,我們看以下程式碼
// 3.0
struct sdshdr {
// 記錄buf陣列中已使用位元組的數量,即SDS所儲存字串的長度
unsigned int len;
// 記錄buf資料中未使用的位元組數量
unsigned int free;
// 位元組陣列,用於儲存字串
char buf[];
};
// 3.2
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
複製程式碼
3.2版本之後,會根據字串的長度來選擇對應的資料結構
static inline char sdsReqType(size_t string_size) {
if (string_size < 1<<5) // 32
return SDS_TYPE_5;
if (string_size < 1<<8) // 256
return SDS_TYPE_8;
if (string_size < 1<<16) // 65536 64k
return SDS_TYPE_16;
if (string_size < 1ll<<32) // 4294967296 4G
return SDS_TYPE_32;
return SDS_TYPE_64;
}
複製程式碼
下面以3.2版本的sdshdr8
看一個示例
len
:記錄當前已使用的位元組數(不包括'\0'
),獲取SDS長度的複雜度為O(1)alloc
:記錄當前位元組陣列總共分配的位元組數量(不包括'\0'
)flags
:標記當前位元組陣列的屬性,是sdshdr8
還是sdshdr16
等,flags值的定義可以看下面程式碼buf
:位元組陣列,用於儲存字串,包括結尾空白字元'\0'
// flags值定義
#define SDS_TYPE_5 0
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
複製程式碼
上面的位元組陣列的空白處表示未使用空間,是Redis優化的空間策略,給字串的操作留有餘地,保證安全提高效率
SDS與C字串的區別
C語言使用長度為N+1的字元陣列來表示長度為N的字串,字元陣列的最後一個元素為空字元'\0'
,但是這種簡單的字串表示方法並不能滿足Redis對於字串在安全性、效率以及功能方面的要求,那麼使用SDS,會有哪些好處呢
參考於《Redis設計與實現》
常數複雜度獲取字串長度
C字串不記錄字串長度,獲取長度必須遍歷整個字串,複雜度為O(N);而SDS結構中本身就有記錄字串長度的len
屬性,所有複雜度為O(1)。Redis將獲取字串長度所需的複雜度從O(N)降到了O(1),確保獲取字串長度的工作不會成為Redis的效能瓶頸
杜絕緩衝區溢位,減少修改字串時帶來的記憶體重分配次數
C字串不記錄自身的長度,每次增長或縮短一個字串,都要對底層的字元陣列進行一次記憶體重分配操作。如果是拼接append操作之前沒有通過記憶體重分配來擴充套件底層資料的空間大小,就會產生快取區溢位;如果是截斷trim操作之後沒有通過記憶體重分配來釋放不再使用的空間,就會產生記憶體洩漏
而SDS通過未使用空間解除了字串長度和底層資料長度的關聯,3.0版本是用free
屬性記錄未使用空間,3.2版本則是alloc
屬性記錄總的分配位元組數量。通過未使用空間,SDS實現了空間預分配和惰性空間釋放兩種優化的空間分配策略,解決了字串拼接和擷取的空間問題
二進位制安全
C字串中的字元必須符合某種編碼,除了字串的末尾,字串裡面是不能包含空字元的,否則會被認為是字串結尾,這些限制了C字串只能儲存文字資料,而不能儲存像圖片這樣的二進位制資料
而SDS的API都會以處理二進位制的方式來處理存放在buf
陣列裡的資料,不會對裡面的資料做任何的限制。SDS使用len
屬性的值來判斷字串是否結束,而不是空字元
相容部分C字串函式
雖然SDS的API是二進位制安全的,但還是像C字串一樣以空字元結尾,目的是為了讓儲存文字資料的SDS可以重用一部分C字串的函式
C字串與SDS對比
C字串 | SDS |
---|---|
獲取字串長度複雜度為O(N) | 獲取字串長度複雜度為O(1) |
API是不安全的,可能會造成緩衝區溢位 | API是安全的,不會造成緩衝區溢位 |
修改字串長度必然會需要執行記憶體重分配 | 修改字串長度N次最多會需要執行N次記憶體重分配 |
只能儲存文字資料 | 可以儲存文字或二進位制資料 |
可以使用所有<string.h> 庫中的函式 |
可以使用一部分<string.h> 庫中的函式 |
連結串列
連結串列是一種比較常見的資料結構了,特點是易於插入和刪除、記憶體利用率高、且可以靈活調整連結串列長度,但隨機訪問困難。許多高階程式語言都內建了連結串列的實現,但是C語言並沒有實現連結串列,所以Redis實現了自己的連結串列資料結構
連結串列在Redis中應用的非常廣,列表(List)的底層實現就是連結串列。此外,Redis的釋出與訂閱、慢查詢、監視器等功能也用到了連結串列
連結串列節點和連結串列的定義
連結串列上的節點定義如下,adlist.h/listNode
typedef struct listNode {
// 前置節點
struct listNode *prev;
// 後置節點
struct listNode *next;
// 節點值
void *value;
} listNode;
複製程式碼
連結串列的定義如下,adlist.h/list
typedef struct list {
// 連結串列頭節點
listNode *head;
// 連結串列尾節點
listNode *tail;
// 節點值複製函式
void *(*dup)(void *ptr);
// 節點值釋放函式
void (*free)(void *ptr);
// 節點值對比函式
int (*match)(void *ptr, void *key);
// 連結串列所包含的節點數量
unsigned long len;
} list;
複製程式碼
每個節點listNode
可以通過prev
和next
指標分佈指向前一個節點和後一個節點組成雙端連結串列,同時每個連結串列還會有一個list
結構為連結串列提供表頭指標head
、表尾指標tail
、以及連結串列長度計數器len
,還有三個用於實現多型連結串列的型別特定函式
dup
:用於複製連結串列節點所儲存的值free
:用於釋放連結串列節點所儲存的值match
:用於對比連結串列節點所儲存的值和另一個輸入值是否相等
連結串列結構圖
連結串列特性
- 雙端連結串列:帶有指向前置節點和後置節點的指標,獲取這兩個節點的複雜度為O(1)
- 無環:表頭節點的
prev
和表尾節點的next
都指向NULL,對連結串列的訪問以NULL結束 - 連結串列長度計數器:帶有
len
屬性,獲取連結串列長度的複雜度為O(1) - 多型:連結串列節點使用
void*
指標儲存節點值,可以儲存不同型別的值
字典
字典,又稱為符號表(symbol table)、關聯陣列(associative array)或對映(map),是一種用於儲存鍵值對(key-value pair)的抽象資料結構。字典中的每一個鍵都是唯一的,可以通過鍵查詢與之關聯的值,並對其修改或刪除
Redis的鍵值對儲存就是用字典實現的,雜湊(Hash)的底層實現之一也是字典
我們直接來看一下字典是如何定義和實現的吧
字典的定義實現
Redis的字典底層是使用雜湊表實現的,一個雜湊表裡面可以有多個雜湊表節點,每個雜湊表節點中儲存了字典中的一個鍵值對
雜湊表結構定義,dict.h/dictht
typedef struct dictht {
// 雜湊表陣列
dictEntry **table;
// 雜湊表大小
unsigned long size;
// 雜湊表大小掩碼,用於計算索引值,等於size-1
unsigned long sizemask;
// 雜湊表已有節點的數量
unsigned long used;
} dictht;
複製程式碼
雜湊表是由陣列table
組成,table
中每個元素都是指向dict.h/dictEntry
結構的指標,雜湊表節點的定義如下
typedef struct dictEntry {
// 鍵
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一個雜湊表節點,形成連結串列
struct dictEntry *next;
} dictEntry;
複製程式碼
其中key
是我們的鍵;v
是鍵值,可以是一個指標,也可以是整數或浮點數;next
屬性是指向下一個雜湊表節點的指標,可以讓多個雜湊值相同的鍵值對形成連結串列,解決鍵衝突問題
最後就是我們的字典結構,dict.h/dict
typedef struct dict {
// 和型別相關的處理函式
dictType *type;
// 私有資料
void *privdata;
// 雜湊表
dictht ht[2];
// rehash 索引,當rehash不再進行時,值為-1
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 迭代器數量
unsigned long iterators; /* number of iterators currently running */
} dict;
複製程式碼
type
屬性和privdata
屬性是針對不同型別的鍵值對,用於建立多型別的字典,type
是指向dictType
結構的指標,privdata
則儲存需要傳給型別特定函式的可選引數,關於dictType
結構和型別特定函式可以看下面程式碼
typedef struct dictType {
// 計算雜湊值的行數
uint64_t (*hashFunction)(const void *key);
// 複製鍵的函式
void *(*keyDup)(void *privdata, const void *key);
// 複製值的函式
void *(*valDup)(void *privdata, const void *obj);
// 對比鍵的函式
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
// 銷燬鍵的函式
void (*keyDestructor)(void *privdata, void *key);
// 銷燬值的函式
void (*valDestructor)(void *privdata, void *obj);
} dictType;
複製程式碼
dict
的ht
屬性是兩個元素的陣列,包含兩個dictht
雜湊表,一般字典只使用ht[0]
雜湊表,ht[1]
雜湊表會在對ht[0]
雜湊表進行rehash
(重雜湊)的時候使用,即當雜湊表的鍵值對數量超過負載數量過多的時候,會將鍵值對遷移到ht[1]
上
rehashidx
也是跟rehash相關的,rehash的操作不是瞬間完成的,rehashidx
記錄著rehash的進度,如果目前沒有在進行rehash,它的值為-1
結合上面的幾個結構,我們來看一下字典的結構圖(沒有在進行rehash)
在這裡,雜湊演算法和rehash(重新雜湊)的操作不再詳細說明,有機會以後單獨介紹
當一個新的鍵值對要新增到字典中時,會根據鍵值對的鍵計算出雜湊值和索引值,根據索引值放到對應的雜湊表上,即如果索引值為0,則放到
ht[0]
雜湊表上。當有兩個或多個的鍵分配到了雜湊表陣列上的同一個索引時,就發生了鍵衝突的問題,雜湊表使用鏈地址法來解決,即使用雜湊表節點的next
指標,將同一個索引上的多個節點連線起來。當雜湊表的鍵值對太多或太少,就需要對雜湊表進行擴充套件和收縮,通過rehash
(重新雜湊)來執行
跳躍表
一個普通的單連結串列查詢一個元素的時間複雜度為O(N),即便該單連結串列是有序的。使用跳躍表(SkipList)是來解決查詢問題的,它是一種有序的資料結構,不屬於平衡樹結構,也不屬於Hash結構,它通過在每個節點維持多個指向其他節點的指標,而達到快速訪問節點的目的
跳躍表是有序集合(Sorted Set)的底層實現之一,如果有序集合包含的元素比較多,或者元素的成員是比較長的字串時,Redis會使用跳躍表做有序集合的底層實現
跳躍表的定義
跳躍表其實可以把它理解為多層的連結串列,它有如下的性質
- 多層的結構組成,每層是一個有序的連結串列
- 最底層(level 1)的連結串列包含所有的元素
- 跳躍表的查詢次數近似於層數,時間複雜度為O(logn),插入、刪除也為 O(logn)
- 跳躍表是一種隨機化的資料結構(通過拋硬幣來決定層數)
那麼如何來理解跳躍表呢,我們從最底層的包含所有元素的連結串列開始,給定如下的連結串列
然後我們每隔一個元素,把它放到上一層的連結串列當中,這裡我把它叫做上浮(注意,科學的辦法是拋硬幣的方式,來決定元素是否上浮到上一層連結串列,我這裡先簡單每隔一個元素上浮到上一層連結串列,便於理解),操作完成之後的結構如下
查詢元素的方法是這樣,從上層開始查詢,大數向右找到頭,小數向左找到頭,例如我要查詢17
,查詢的順序是:13 -> 46 -> 22 -> 17;如果是查詢35
,則是 13 -> 46 -> 22 -> 46 -> 35;如果是54
,則是 13 -> 46 -> 54
上面是查詢元素,如果是新增元素,是通過拋硬幣的方式來決定該元素會出現到多少層,也就是說它會有 1/2的概率出現第二層、1/4 的概率出現在第三層......
跳躍表節點的刪除和新增都是不可預測的,很難保證跳錶的索引是始終均勻的,拋硬幣的方式可以讓大體上是趨於均勻的
假設我們已經有了上述例子的一個跳躍表了,現在往裡面新增一個元素18
,通過拋硬幣的方式來決定它會出現的層數,是正面就繼續,反面就停止,假如我拋了2次硬幣,第一次為正面,第二次為反面
跳躍表的刪除很簡單,只要先找到要刪除的節點,然後順藤摸瓜刪除每一層相同的節點就好了
跳躍表維持結構平衡的成本是比較低的,完全是依靠隨機,相比二叉查詢樹,在多次插入刪除後,需要Rebalance來重新調整結構平衡
跳躍表的實現
Redis的跳躍表實現是由redis.h/zskiplistNode
和redis.h/zskiplist
(3.2版本之後redis.h改為了server.h)兩個結構定義,zskiplistNode
定義跳躍表的節點,zskiplist
儲存跳躍表節點的相關資訊
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
// 成員物件 (robj *obj;)
sds ele;
// 分值
double score;
// 後退指標
struct zskiplistNode *backward;
// 層
struct zskiplistLevel {
// 前進指標
struct zskiplistNode *forward;
// 跨度
// 跨度實際上是用來計算元素排名(rank)的,在查詢某個節點的過程中,將沿途訪過的所有層的跨度累積起來,得到的結果就是目標節點在跳躍表中的排位
unsigned long span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
// 表頭節點和表尾節點
struct zskiplistNode *header, *tail;
// 表中節點的數量
unsigned long length;
// 表中層數最大的節點的層數
int level;
} zskiplist;
複製程式碼
zskiplistNode
結構
level
陣列(層):每次建立一個新的跳錶節點都會根據冪次定律計算出level陣列的大小,也就是次層的高度,每一層帶有兩個屬性-前進指標和跨度,前進指標用於訪問表尾方向的其他指標;跨度用於記錄當前節點與前進指標所指節點的距離(指向的為NULL,闊度為0)backward
(後退指標):指向當前節點的前一個節點score
(分值):用來排序,如果分值相同看成員變數在字典序大小排序obj
或ele
:成員物件是一個指標,指向一個字串物件,裡面儲存著一個sds;在跳錶中各個節點的成員物件必須唯一,分值可以相同
zskiplist
結構
header
、tail
表頭節點和表尾節點length
表中節點的數量level
表中層數最大的節點的層數
假設我們現在展示一個跳躍表,有四個節點,節點的高度分別是2、1、4、3
zskiplist
的頭結點不是一個有效的節點,它有ZSKIPLIST_MAXLEVEL層(32層),每層的forward
指向該層跳躍表的第一個節點,若沒有則為NULL,在Redis中,上面的跳躍表結構如下
- 每個跳躍表節點的層數在1-32之間
- 一個跳躍表中,節點按照分值大小排序,多個節點的分值是可以相同的,相同時,節點按成員物件大小排序
- 每個節點的成員變數必須是唯一的
整數集合
整數集合(intset)是Redis用於儲存整數值的集合抽象資料結構,可以儲存型別為int16_t、int32_t、int64_t的整數值,並且保證集合中不會出現重複元素
整數集合是集合(Set)的底層實現之一,如果一個集合只包含整數值元素,且元素數量不多時,會使用整數集合作為底層實現
整數集合的定義實現
整數集合的定義為inset.h/inset
typedef struct intset {
// 編碼方式
uint32_t encoding;
// 集合包含的元素數量
uint32_t length;
// 儲存元素的陣列
int8_t contents[];
} intset;
複製程式碼
contents
陣列:整數集合的每個元素在陣列中按值的大小從小到大排序,且不包含重複項length
記錄整數集合的元素數量,即contents陣列長度encoding
決定contents陣列的真正型別,如INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64
整數集合的升級
當想要新增一個新元素到整數集合中時,並且新元素的型別比整數集合現有的所有元素的型別都要長,整數集合需要先進行升級(upgrade),才能將新元素新增到整數集合裡面。每次想整數集合中新增新元素都有可能會引起升級,每次升級都需要對底層陣列已有的所有元素進行型別轉換
升級新增新元素:
- 根據新元素型別,擴充套件整數集合底層陣列的空間大小,併為新元素分配空間
- 把陣列現有的元素都轉換成新元素的型別,並將轉換後的元素放到正確的位置,且要保持陣列的有序性
- 新增新元素到底層陣列
整數集合的升級策略可以提升整數集合的靈活性,並儘可能的節約記憶體
另外,整數集合不支援降級,一旦升級,編碼就會一直保持升級後的狀態
壓縮列表
壓縮列表(ziplist)是為了節約記憶體而設計的,是由一系列特殊編碼的連續記憶體塊組成的順序性(sequential)資料結構,一個壓縮列表可以包含多個節點,每個節點可以儲存一個位元組陣列或者一個整數值
壓縮列表是列表(List)和雜湊(Hash)的底層實現之一,一個列表只包含少量列表項,並且每個列表項是小整數值或比較短的字串,會使用壓縮列表作為底層實現(在3.2版本之後是使用quicklist
實現)
壓縮列表的構成
一個壓縮列表可以包含多個節點(entry),每個節點可以儲存一個位元組陣列或者一個整數值
各部分組成說明如下
zlbytes
:記錄整個壓縮列表佔用的記憶體位元組數,在壓縮列表記憶體重分配,或者計算zlend
的位置時使用zltail
:記錄壓縮列表表尾節點距離壓縮列表的起始地址有多少位元組,通過該偏移量,可以不用遍歷整個壓縮列表就可以確定表尾節點的地址zllen
:記錄壓縮列表包含的節點數量,但該屬性值小於UINT16_MAX(65535)時,該值就是壓縮列表的節點數量,否則需要遍歷整個壓縮列表才能計算出真實的節點數量entryX
:壓縮列表的節點zlend
:特殊值0xFF(十進位制255),用於標記壓縮列表的末端
壓縮列表節點的構成
每個壓縮列表節點可以儲存一個位元組數字或者一個整數值,結構如下
previous_entry_ength
:記錄壓縮列表前一個位元組的長度encoding
:節點的encoding儲存的是節點的content的內容型別content
:content區域用於儲存節點的內容,節點內容型別和長度由encoding決定
物件
上面介紹了Redis的主要底層資料結構,包括簡單動態字串(SDS)、連結串列、字典、跳躍表、整數集合、壓縮列表。但是Redis並沒有直接使用這些資料結構來構建鍵值對資料庫,而是基於這些資料結構建立了一個物件系統,也就是我們所熟知的可API操作的Redis那些資料型別,如字串(String)、列表(List)、雜湊(Hash)、集合(Set)、有序集合(Sorted Set)
根據物件的型別可以判斷一個物件是否可以執行給定的命令,也可針對不同的使用場景,物件設定有多種不同的資料結構實現,從而優化物件在不同場景下的使用效率
型別 | 編碼 | BOJECT ENCODING 命令輸出 | 物件 |
---|---|---|---|
REDIS_STRING | REDIS_ENCODING_INT | "int" | 使用整數值實現的字串物件 |
REDIS_STRING | REDIS_ENCODING_EMBSTR | "embstr" | 使用embstr編碼的簡單動態字串實現的字串物件 |
REDIS_STRING | REDIS_ENCODING_RAW | "raw" | 使用簡單動態字串實現的字串物件 |
REDIS_LIST | REDIS_ENCODING_ZIPLIST | "ziplist" | 使用壓縮列表實現的列表物件 |
REDIS_LIST | REDIS_ENCODING_LINKEDLIST | '"linkedlist' | 使用雙端連結串列實現的列表物件 |
REDIS_HASH | REDIS_ENCODING_ZIPLIST | "ziplist" | 使用壓縮列表實現的雜湊物件 |
REDIS_HASH | REDIS_ENCODING_HT | "hashtable" | 使用字典實現的雜湊物件 |
REDIS_SET | REDIS_ENCODING_INTSET | "intset" | 使用整數集合實現的集合物件 |
REDIS_SET | REDIS_ENCODING_HT | "hashtable" | 使用字典實現的集合物件 |
REDIS_ZSET | REDIS_ENCODING_ZIPLIST | "ziplist" | 使用壓縮列表實現的有序集合物件 |
REDIS_ZSET | REDIS_ENCODING_SKIPLIST | "skiplist" | 使用跳躍表表實現的有序集合物件 |
參考:《Redis設計與實現》