楔子
我們知道 Redis 是一款 QPS 能達到 10w 級別的內存數據庫,具有如此高性能的原因有很多,除了所有的操作都在內存中進行之外,其數據類型的底層設計也起到了很大的作用,這也是我們接下來的重點。
我們知道 Redis 中有 5 種基礎數據類型,分別是:String(字符串)、List(列表)、Hash(哈希)、Set(集合)和 ZSet(有序集合),這些數據類型底層都使用了哪些數據結構呢,這些數據結構都有哪些特點呢,為什么能夠這么快呢,下面我們就來聊一聊。
不同版本的 Redis,底層的數據結構也會有所差異,比如在 3.0 的時候 List 對象的底層數據結構是由「雙向鏈表」或「壓縮列表」實現,在 3.0 之后則換成了 quicklist。並且在最新版的 Redis 中,壓縮列表(ziplist)已經廢棄了,改成了 listpack 數據結構。
下面我們就來介紹一下這幾種數據結構。
Redis 的 key、value 存在什么地方?
首先在 Redis 中,String、List、Hash、Set、ZSet 都有自己的 key,key 不可以重名,我們舉個栗子:
127.0.0.1:6379> SET name satori
OK
127.0.0.1:6379> LPUSH scores 90 95 93
(integer) 3
127.0.0.1:6379> HSET students satori 99
(integer) 1
127.0.0.1:6379>
這里的 name、scores、students 都屬於 key,它們的 value 是不同的類型,那么問題來了,這些 key、value 存在什么地方呢?其實不難得出,既然 Redis 是鍵值對數據庫,查詢的效率又這么高,那么肯定是存在哈希表當中的,事實上也確實如此。
以上代碼位於 Redis 源碼的 src/server.c 文件中,里面的 server 就相當於 Redis 的服務端。另外我們知道 Redis 默認有 16 個庫,而 server.db[j] 就是獲取 j 號庫,所以此時相信你應該知道上面的代碼所做的事情是什么了,就是為每一個庫創建多個哈希表,用於存儲相關的 key、value。而我們使用命令新創建的 key、value 都會存儲在 server.db[j].dict 里面,就是綠色框框里面創建的哈希表。
所以結論很清晰了,每一個庫都有一個全局的哈希表 server.db[j].dict,專門負責存儲設置 key、value,其中 key 永遠為 String 類型,value 可以是任意類型。這也側面說明了我們設置的 key 不會重復,因為哈希表里面的 key 是不重復的。
# 當 name 不存在時,會自動往全局的哈希表中加入一個鍵值對,key 為 name,value 為 List
# 但此時 name 已經存在了,並且值為 String 類型,那么執行 LPUSH 就會報錯
127.0.0.1:6379> LPUSH name 1 2 3
(error) WRONGTYPE Operation against a key holding the wrong kind of value
現在我們知道 key、value 是存在哈希表里面了,但具體是怎么存儲的呢?我們來看一張圖:
一個哈希表可以是一個數組,數組里面的每一個存儲單元都叫做哈希桶(Bucket),比如數組第一個位置(索引為 0)被編為哈希桶 0,第二個位置(索引為 1)被編為哈希桶 1,以此類推。當我們寫入一個鍵值對的時候,會根據 key 和 value 的指針構建一個 dictEntry 結構體實例,然后通過對 key 進行哈希運算來計算出桶的位置,最后將 dictEntry 結構體實例的指針寫入哈希表中。
關於 Redis 哈希表的具體細節一會再說,目前只需要知道 Redis 中的 key、value 是存在哈希表中的即可。當然啦,更准確地說應該是 key、value 的指針所構建的 dictEntry 結構體實例的指針是存在哈希表當中的,當我們通過 key 進行查找的時候,會先對 key 進行哈希運算,找到對應的哈希桶中存儲的 dictEntry *,然后再根據 value 獲取對應的值。整個過程可以先這么理解,至於如何將 key 映射成索引、以及哈希沖突相關的話題,后面再詳細說。
最后還需要特別補充一下,Redis 中所有的對象其實都是一個 redisObject 結構體實例,其結構如下:
不管是 String 對象、還是 List 對象、Hash 對象等等,它們其實都是一個 redisObject 對象,里面有一個 type 字段來標識這個對象的所屬類型。
127.0.0.1:6379> TYPE name
string
127.0.0.1:6379> TYPE students
hash
127.0.0.1:6379> TYPE scores
list
我們可以用 TYPE 命令查看對象的類型,雖然顯示的類型不同,但你要知道它們都是 redisObject 這個結構體的實例對象。內部的 encoding 表示該對象所使用的底層數據結構,ptr 表示指向底層數據結構的指針,而根據 encoding 和 ptr 的不同,type 可以是 string、list、hash、set 等等。
比如我們執行 sadd box 1 2 3 1,我們會說 box 是一個 Set 對象,這種說法是正確的。但是我們應該知道它其實是一個 redisObject,里面的 type 字段等於 "set",這背后的細節要搞清楚。但是后續我們還是會按照 String、List、Hash 之類的來稱呼,就不用 redisObject 了,只要明白背后的關系就行。
SDS
下面我們就來分析底層數據結構了,首先是字符串,字符串在 Redis 中是很常用的,鍵值對中的鍵是字符串類型,值有時也是字符串類型。
我們知道 Redis 是用 C 語言實現的,但是它沒有直接使用 C 語言的字符數組(char *)來實現字符串,而是自己封裝了一個名為簡單動態字符串(simple dynamic string,SDS) 的數據結構來表示字符串,也就是說 Redis 的 String 數據類型的底層數據結構是 SDS。既然 Redis 設計了 SDS 結構來表示字符串,肯定是 C 語言的字符數組存在一些缺陷。
要了解這一點,得先來看看字符數組的結構。
C 語言字符串的缺陷
C 語言的字符串其實就是一個字符數組,即數組中每個元素是字符串中的一個字符,比如下圖就是字符串 "koishi" 的字符數組結構:
我們看到 s 只是一個指針,它指向了字符數組的起始位置,那么問題來了,C 語言要如何得知一個字符數組的長度呢?於是 C 語言會默認在每一個字符數組后面加上一個 \0,來表示字符串的結束。因此 C 語言標准庫中的字符串操作函數就是通過判斷字符是不是 \0 來決定要不要停止操作,如果當前字符不是 \0 ,說明字符串還沒結束,可以繼續操作,如果當前字符是 \0 是則說明字符串結束了,就要停止操作。
舉個例子,C 語言獲取字符串長度的函數 strlen,就是通過字符數組中的每一個字符,並進行計數,當遇到字符 \0 時停止遍歷,然后返回已經統計到的字符個數,即為字符串長度。下圖顯示了 strlen 函數的執行流程:
如果用代碼實現的話:
#include <stdio.h>
size_t strlen(const char *s) {
size_t count = 0;
while (*s != '\0') count++;
return count;
}
int main() {
printf("%lu\n", strlen("koishi")); // 6
}
很明顯,C 語言獲取字符串長度的時間復雜度是 O(N),並且使用 \0 作為字符串結尾標記有一個缺陷。但如果某個字符串中間恰好有一個 \0,那么這個字符串就會提前結束,舉個栗子:
#include <stdio.h>
#include <string.h>
int main() {
// 字符串相關操作函數位於標准庫 string.h 中
printf("%lu\n", strlen("abcdefg")); // 7
printf("%lu\n", strlen("abc\0efg")); // 3
}
所以在 C 中 \0 是字符串是否結束的標准,因此如果使用 C 的字符數組,只能讓 C 在字符串結尾自動幫你加上 \0,我們創建的字符串內部是不可以包含 \0 的,否則就會出問題,因為字符串會提前結束。這個限制使得 C 語言的字符串只能保存文本數據,不能保存像圖片、音頻、視頻之類的二進制數據。
另外 C 語言標准庫中字符串的操作函數是很不安全的,對程序員很不友好,稍微一不注意,就會導致緩沖區溢出。舉個例子,strcat 函數是可以將兩個字符串拼接在一起。
#include <stdio.h>
#include <string.h>
//將 src 字符串拼接到 dest 字符串后面
char *strcat(char *dest, const char* src);
int main() {
char buf[12] = "hello ";
printf("%s\n", buf); // hello
strcat(buf, "world");
printf("%s\n", buf); // hello world
}
"hello world" 占 11 個字符,加上 \0 一共 12 個,buf 的長度為 12,剛好能容納的下。但如果我們將 buf 的長度改成 11,就會發生緩沖區溢出,可能造成程序終止。因此 C 語言的字符串不會記錄自身的緩沖區大小,它假定我們在執行這個函數時,已經為 dest 分配了足夠多的內存。而且 strcat 函數和 strlen 函數類似,時間復雜度也是 O(N) 級別,也是需要先通過遍歷字符串才能得到目標字符串的末尾。然后對於 strcat 函數來說,還要再遍歷源字符串才能完成追加,所以整個字符串的操作效率是不高的。
我們還是手動實現一下 strcat,看一看整個過程:
#include <stdio.h>
char *strcat(char *dest, const char *src) {
char *head = dest;
// 遍歷字符串,直到結尾
while (*head != '\0') head++;
// 循環結束之后,head 停在了 \0 的位置,然后將 src 對應的字符數組中的字符逐個拷貝過去
// 將 src 的最后一個字符 \0 拷貝過去之后循環結束
while ((*head++ = *src++) != '\0');
// 最后返回 dest
return dest;
}
int main() {
char buf[12] = "hello ";
printf("%s\n", buf); // hello
strcat(buf, "world");
printf("%s\n", buf); // hello world
}
好了, 通過以上的分析,我們可以得知 C 語言的字符串不足之處以及可以改進的地方:
獲取字符串長度的時間復雜度為 O(N)
字符串的結尾是以 \0 作為字符標識,使得字符串里面不能含有 \0 字符,因此不能保存二進制數據
字符串操作函數不高效且不安全,比如有緩沖區溢出的風險,有可能會造成程序運行終止
而 Redis 實現的 SDS 結構體就把上面這些問題解決了,接下來我們一起看看 Redis 是如何解決的。
SDS 結構設計
我們先來看一看 SDS 長什么樣子?
結構中的每個成員變量分別介紹下:
len:記錄了字符串的長度,這樣后續在獲取的時候只需返回這個成員變量的值即可,時間復雜度為 O(1)。
alloc:分配給字符數組的空間長度,這樣后續對字符串進行修改時(比如追加一個字符串),可以通過 alloc 減去 len 計算出剩余空間大小,來判斷空間是否滿足修改需求。如果不滿足,就會自動將 SDS 內的 buf 進行擴容(所謂擴容就是申請一個更大的 buf,然后將原來 buf 的內容拷貝到新的 buf 中,再將原來的 buf 給釋放掉),然后執行修改操作。通過這種方式就避免了緩沖區溢出的問題,而且事先可以申請一個較大的 buf,避免每次追加的時候都進行擴容。
flags:用來表示不同類型的 SDS,SDS 總共有 5 種,分別是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面說明它們之間的區別。所以 SDS 只是一個概念,它並不是真實存在的結構體,sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64 才是底層定義好的結構體,相當於 SDS 的具體實現,當然它們都可以叫做 SDS。
buf[]:字符數組,用來保存實際數據,不僅可以保存字符串,也可以保存二進制數據。之所以可以保存二進制數據是因為在計算字符串長度的時候不再以 \0 為依據,因為 SDS 中的 len 字段在時刻維護字符串的長度。
總的來說,Redis 的 SDS 結構在原本字符數組之上,增加了三個元數據:len、alloc、flags,用來解決 C 語言字符串的缺陷。所以 SDS 在獲取長度時的時間復雜度為 O(1)、並且是二進制安全的、不會發生緩沖區溢出、以及節省內存空間,至於原因,上面已經解釋過了。不過為了加深記憶,我們再啰嗦一遍。
O(1) 時間復雜度獲取字符串長度
C 語言的字符串長度獲取 strlen 函數,需要通過遍歷的方式來統計字符串長度,時間復雜度是 O(N)。而 Redis 的 SDS 結構因為加入了 len 成員變量,會時刻維護字符串的長度,所以獲取字符串長度的時候,直接返回這個成員變量的值就行,所以復雜度只有 O(1)。
二進制安全
因為 SDS 不需要用 \0 字符來標識字符串結尾,而是有個專門的 len 成員變量來記錄長度,所以可存儲包含 \0 的數據。但是 SDS 為了兼容部分 C 語言標准庫的函數, SDS 字符串結尾還是會加上 \0 字符。
因此, SDS 的 API 都是以處理二進制的方式來處理 SDS 存放在 buf[] 里的數據,程序不會對其中的數據做任何限制,數據寫入的時候時什么樣的,它被讀取時就是什么樣的。通過使用二進制安全的 SDS,而不是 C 字符串,使得 Redis 不僅可以保存文本數據,也可以保存任意格式的二進制數據。
不會發生緩沖區溢出
C 語言的字符串標准庫提供的字符串操作函數,大多數(比如 strcat 追加字符串函數)都是不安全的,因為這些函數把緩沖區大小是否滿足操作需求的工作交由開發者來保證,程序內部並不會判斷緩沖區大小是否足夠用,當發生了緩沖區溢出就有可能造成程序異常結束。所以 Redis 的 SDS 結構里引入了 alloc 和 len 成員變量,這樣 SDS API 通過 alloc 減去 len 可以計算出剩余可用的空間大小,這樣在對字符串做修改操作的時候,就可以由程序內部判斷緩沖區大小是否足夠用。
當判斷出緩沖區大小不夠用時,Redis 會自動將擴大 SDS 的空間大小,以滿足修改所需的大小。當然准確的說,擴容的是 SDS 內部的 buf 數組,擴容規則是:當小於 1MB 翻倍擴容,大於 1MB 按 1MB 擴容。並且在擴展 SDS 空間的時候,API 不僅會為 SDS 分配修改所必須要的空間,還會給 SDS 分配額外的「未使用空間」。這樣的好處是,下次在操作 SDS 時,如果 SDS 空間夠的話,API 就會直接使用「未使用空間」,而無須執行內存分配,從而有效地減少內存分配次數。
所以在使用 SDS 即不需要手動修改 SDS 的空間大小,也不會出現緩沖區溢出的問題。
節省內存空間
SDS 結構中有個 flags 成員變量,表示的是 SDS 類型,Redis 一共設計了 5 種類型,分別是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。而這 5 種類型的主要區別就在於,它們數據結構中的 len 和 alloc 成員變量的數據類型不同。
比如 sdshdr16 和 sdshdr32 這兩個類型,它們的定義分別如下:
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[];
};
可以看到 sdshdr16 類型的 len 和 alloc 的數據類型都是 uint16_t,表示字符數組長度和分配空間大小不能超過 2 的 16 次方;sdshdr32 則都是 uint32_t,表示表示字符數組長度和分配空間大小不能超過 2 的 32 次方。
之所以 SDS 設計不同類型的結構體,是為了能靈活保存不同大小的字符串,從而有效節省內存空間。比如在保存小字符串時,len、alloc 這些元數據的占用空間也會比較少。
除了設計不同類型的結構體,Redis 在編程上還使用了專門的編譯優化來節省內存空間,即在 struct 聲明了 __attribute__ ((packed)),它的作用是:告訴編譯器取消結構體在編譯過程中的優化對齊,按照實際占用字節數進行對齊。
內存對齊是為了減少數據存儲和讀取的工作量,現在的 64 位處理器默認按照 8 字節進行對齊。所以相同的結構體,如果字段順序不同,那么占用的大小也不一樣,我們舉個栗子:
#include <stdio.h>
typedef struct {
int a;
long b;
char c;
} S1;
typedef struct {
long a;
int b;
char c;
} S2;
int main() {
printf("%lu %lu\n", sizeof(S1), sizeof(S2)); // 24 16
}
兩個結構體的內部都是 3 個成員,類型為 int、long、char,但因為順序不同導致整個結構體的大小不同,這就是內存對齊導致的。
關於內存對齊的具體細節這里不再贅述,總之它的核心就是:雖然現代計算機的內存空間都是按照 byte 划分的,從理論上講似乎對任何類型的變量的訪問可以從任何地址開始,但是實際的計算機系統對基本類型數據在內存中存放的位置有限制,它們默認會要求這些數據的首地址的值是 8 的倍數(64 位機器),這就是所謂的內存對齊。
我們在 C 中可以通過 #pragma pack(4) 來指定對齊的字節數,這里表示按照 4 字節對齊。當然啦,還可以像 Redis 那樣在聲明結構體的時候指定 __attribute__ ((packed)) 來禁止內存對齊,此時會結構體中的字段都是緊密排列的,不會出現空洞。
#include <stdio.h>
typedef struct {
int a;
long b;
char c;
} S1;
typedef struct {
long a;
int b;
char c;
} S2;
typedef struct __attribute__ ((packed)) {
long a;
int b;
char c;
} S3;
int main() {
printf("%lu %lu %lu\n", sizeof(S1), sizeof(S2), sizeof(S3)); // 24 16 13
}
我們看到在禁止內存對齊之后,結構體占 13 個字節,就是每個成員的大小相加。
鏈表
相信各位最熟悉的數據結構除了數組之外,應該就是鏈表了,Redis 的 List 對象的底層實現之一就是鏈表。但是 C 語言本身沒有鏈表這個數據結構的,所以 Redis 自己設計了一個鏈表數據結構。
注:如果只能通過前一個節點找到后一個節點,那么該鏈表被稱為單向鏈表;如果不僅能通過前一個節點找到后一個節點,還能通過后一個節點找到前一個節點,那么該鏈表被稱為雙向鏈表。
那么我們來看一下 Redis 中的鏈表是如何設計的,首先鏈表是由一個個節點組成的,我們先來看看節點在 Redis 中是如何定義的。
typedef struct listNode {
// 前繼節點
struct listNode *prev;
// 后繼節點
struct listNode *next;
// 節點的值
void *value;
} listNode;
有前繼節點和后繼節點,可以看出節點之間會形成一個雙向鏈表。
有了 ListNode 之后,Redis 在其基礎之上又進行了封裝,這樣操作起來會更加方便。
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;
Redis 封裝了一個數據結構叫 list ,該結構提供了鏈表頭節點 head、鏈表尾節點 tail、鏈表節點數量 len、以及可以自定義實現的 dup、free、match 函數。
舉個栗子,下圖是由 list 和 3 個 ListNode 組成的雙向鏈表。
結構還是比較簡單和清晰的,畢竟鏈表算是最常見的數據結構之一了。那么問題來了,雙向鏈表它的優缺點是什么呢?
優點
- listNode 鏈表節點的結構里帶有 prev 和 next 指針,獲取某個節點的前置節點或后置節點的時間復雜度只需 O(1),而且這兩個指針都可以指向 NULL,所以鏈表是無環鏈表。
- list 結構因為提供了鏈表頭節點 head 和鏈表尾節點 tail,所以獲取鏈表的表頭節點和表尾節點的時間復雜度只需O(1)。
- list 結構因為提供了鏈表節點數量 len,所以獲取鏈表中的節點數量的時間復雜度只需O(1)。
- listNode 鏈表節使用 void * 指針保存節點值,並且可以通過 list 結構的 dup、free、match 函數指針為節點設置該節點類型特定的函數,因此鏈表節點可以保存各種不同類型的值(指針)。
缺點
- 和數組不同,鏈表每個節點之間的內存都是不連續的,這意味着鏈表無法像數組那樣很好地利用 CPU 緩存來加速訪問。
- 還有一點,每一個鏈表節點除了保存值之外,還包含了 prev 和 next 兩個指針,因此會有額外的內存開銷。
在 Redis 3.0 中,List 會使用雙向鏈表作為底層數據結構的實現,但如果 List 對象的數據量比較少,那么會采用「壓縮列表」來實現,它的優勢是節省內存空間,並且是內存緊湊型的數據結構。不過壓縮列表存在性能問題(具體什么問題,下面馬上會說),總之「壓縮列表」和「雙向鏈表」都不夠好,所以 Redis 在 3.2 版本設計了新的數據結構 quicklist,並將 List 對象的底層數據結構改由 quicklist 實現,「雙向鏈表」就廢棄了。
不過「壓縮列表」還保留着,因為它除了可以作為 List 對象的底層數據結構,還可以作為 Hash 對象和 ZSet 對象的底層數據結構。不過我們說它雖然省內存,但性能不夠好也是無法忍受的,於是在 Redis 在 5.0 的時候又設計了新的數據結構 listpack,它不僅沿用了壓縮列表緊湊型的內存布局,還提升了性能。最終在最新的 Redis 版本,將 Hash 對象和 Zset 對象的底層數據結構實現之一的壓縮列表,替換成了 listpack。
雖然「壓縮列表」被替代了,但我們該說還是要說的,下面就來看一下「壓縮列表」。
壓縮列表
壓縮列表的最大特點,就是它被設計成一種內存緊湊型的數據結構,占用一塊連續的內存空間,不僅可以利用 CPU 緩存,而且會針對不同長度的數據,進行相應編碼,這種方法可以有效地節省內存開銷。但缺陷也很明顯:
不能保存過多的元素,否則查詢效率就會降低;
新增或修改某個元素時,壓縮列表占用的內存空間需要重新分配,甚至可能引發連鎖更新的問題;
因此在 Redis 3.0 的時候,只有當 Redis 對象(List 對象、Hash 對象、Zset 對象)包含的元素數量較少,或者元素值不大的情況才會使用壓縮列表作為底層數據結構。
那么接下來,我們就來看一下壓縮列表是如何設計的?
壓縮列表的結構設計
壓縮列表是 Redis 為了節約內存而開發的,它是由連續內存塊組成的順序型數據結構,有點類似於數組。
- zlbytes:記錄整個壓縮列表占用的內存字節數,該字段占 4 個字節;
- zltail:記錄壓縮列表「尾部」節點距離起始地址由多少字節,也就是列表尾的偏移量;
- zllen:記錄壓縮列表包含的節點數量;
- zlend:標記壓縮列表的結束點,固定值 0xFF(十進制255);
在壓縮列表中,如果我們要查找定位第一個元素和最后一個元素,可以通過表頭三個字段的長度直接定位,復雜度是 O(1)。而查找其他元素時,就沒有這么高效了,只能逐個查找,此時的復雜度就是 O(N) 級別,因此壓縮列表不適合保存過多的元素。
然后壓縮列表的每一個節點叫做一個 entry,是一個結構體,其內部字段如下:
壓縮列表節點包含三部分內容:
prevlen:記錄了「前一個節點」的長度;
encoding:記錄了當前節點實際數據的類型以及長度;
data:記錄了當前節點的實際數據;
當我們往壓縮列表中插入數據時,壓縮列表就會根據數據是字符串還是整數,以及數據的大小,會使用不同空間大小的 prevlen 和 encoding 這兩個元素里保存的信息,這種根據數據大小和類型進行不同的空間大小分配的設計思想,正是 Redis 為了節省內存而采用的。
分別說下,prevlen 和 encoding 是如何根據數據的大小和類型來進行不同的空間大小分配。首先壓縮列表里的每個節點中的 prevlen 字段都記錄了「前一個節點的長度」,而且 prevlen 字段的空間大小跟前一個節點長度值有關,比如:
- 如果前一個節點的長度小於 254 字節,那么 prevlen 字段需要用 1 字節的空間來保存這個長度值;
- 如果前一個節點的長度大於等於 254 字節,那么 prevlen 字段需要用 5 字節的空間來保存這個長度值;
encoding 字段的空間大小跟數據是字符串還是整數,以及字符串的長度有關:
- 如果當前節點的數據是整數,encoding 會使用 1 字節的空間進行編碼;
- 如果當前節點的數據是字符串,根據字符串的長度大小,encoding 會使用 1 字節 / 2 字節 / 5 字節的空間進行編碼;
連鎖更新
壓縮列表除了查詢時的時間復雜度高之外,還有一個問題。
壓縮列表新增某個元素或修改某個元素時,如果空間不不夠,壓縮列表占用的內存空間就需要重新分配。而當新插入的元素較大時,可能會導致后續元素的 prevlen 占用空間都發生變化,從而引起「連鎖更新」問題,導致每個元素的空間都要重新分配,造成訪問壓縮列表性能的下降。
前面提到,壓縮列表節點的 prevlen 屬性會根據前一個節點的長度進行不同的空間大小分配:
- 如果前一個節點的長度小於 254 字節,那么 prevlen 字段需要用 1 字節的空間來保存這個長度值;
- 如果前一個節點的長度大於等於 254 字節,那么 prevlen 字段需要用 5 字節的空間來保存這個長度值;
現在假設一個壓縮列表中有多個連續的、長度在 250~253 之間的節點,如下圖:
因為這些節點長度值小於 254 字節,所以 prevlen 字段需要用 1 字節的空間來保存這個長度值。這時,如果將一個長度大於等於 254 字節的新節點加入到壓縮列表的表頭節點,即新節點將成為 entry1 的前繼節點,如下圖所示:
因為 entry1 節點的 prevlen 字段只有 1 個字節大小,無法保存新節點的長度,此時就需要對壓縮列表的空間重分配操作,並將 entry1 節點的 prevlen 字段從原來的 1 字節大小擴展為 5 字節大小,因此 entry1 節點的大小相比之前會增加 4 字節。而一旦增加,那么 entry1 也大於等於 254 字節,所以此時就要擴展 entry2 的 prevlen 字段。而一旦擴展 entry2 的 prevlen 字段,那么會有什么結果相信你已經猜到了,就像多米諾骨牌一樣,連鎖效應一發不可收拾。
空間擴展就意味着重新分配內存,所以一旦出現「連鎖更新」,就會導致壓縮列表占用的內存空間被多次重新分配,這回直接影響到壓縮列表的訪問性能。
所以說,雖然壓縮列表緊湊型的內存布局能節省內存開銷,但是如果保存的元素數量增加了,或是元素變大了,會導致內存重新分配,最糟糕的是會有「連鎖更新」的問題。因此壓縮列表只會用於保存節點數量不多的場景,只要節點數量足夠小,即使發生連鎖更新也是能接受的。
不過雖說如此,但壓縮列表畢竟存在較大缺陷,所以 Redis 針對壓縮列表在設計上的不足,在后來的版本中,新增設計了兩種數據結構:quicklist(Redis 3.2 引入) 和 listpack(Redis 5.0 引入)。這兩種數據結構的設計目標,就是盡可能地保持壓縮列表節省內存的優勢,同時解決壓縮列表的「連鎖更新」的問題。
哈希表
哈希表是一種保存鍵值對(key-value)的數據結構,當中的每一個 key 都是獨一無二的,程序可以根據 key 查找到與之關聯的 value,或者通過 key 來更新 value,又或者根據 key 來刪除整個 key-value等等。
上面在說壓縮列表的時候,提到過 Redis 的 Hash 對象的底層實現之一是壓縮列表(最新 Redis 代碼已將壓縮列表替換成 listpack),而 Hash 對象的另外一個底層實現就是哈希表。
哈希表優點在於,它能以 O(1) 的復雜度快速查詢數據。至於原因我們已經解釋過了,哈希表可以理解為一個數組,在存儲的時候會通過 Hash 函數對 key 進行運算,計算出桶的編號(也可以理解為索引),然后將元素存進去。至於在根據 key 獲取元素的時候,也是同樣的道理,也是先對 key 進行哈希運算找到桶的位置,然后將里面的元素取出來。
當然上面說的是理想情況,因為我們知道哈希運算是隨機的,有可能不同的 key 被映射到同一個桶,此時我們就說出現了哈希沖突。而常見的解決哈希沖突的方式有兩種,分別是「分離鏈接法(separate chaining)」和「開放尋址法(open addressing)」。
- 「分離鏈接法」是為每一個哈希桶維護一個鏈表,出現沖突時,所有哈希到同一個桶的元素通過一個鏈表連接起來。
- 「開放尋址法」是當哈希到某一個桶的時候,發現這個桶里面已經有其它元素了(出現沖突),那么會執行探測函數進行二次探查,重新找一個桶。而探測函數也有多種,比如線性探測、平方探迭代探測等等。
那么 Redis 采用的是哪一種做法呢?答案是「分離鏈接法」,在不擴容哈希表的前提下,將具有相同哈希值的數據串起來,形成鏈表,以便這些數據在表中仍然可以被查詢到。好了,那么我們接下來就來看看 Redis 中的哈希表的結構設計。
哈希表的結構設計
Redis 的哈希表結構如下:
typedef struct dictht {
// 數組的首地址,我們說哈希表是通過數組實現的
// 而數組中的每個元素都是一個 dictEntry *,所以這里 table 的類型是 dictEntry **
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩碼,用於計算索引值
unsigned long sizemask;
// 該哈希表已有的節點數量
unsigned long used;
} dictht;
我們說哈希函數在映射的時候是隨機的,因此當元素越多就越可能出現哈希沖突,雖然映射到同一個桶的元素可以通過鏈表組織起來,但是這個鏈表不可能無限長,否則就失去了哈希表的意義。為避免這一點,當元素過多的時候,就需要對哈希表進行擴容,申請一個新的哈希表,並將老哈希表中的元素拷貝過去。並且在申請新的哈希表的時候,可以適當將空間申請的大一些,目的就是為了減少哈希沖突的頻率。所以上面的 size 成員指的就是哈希表的空間大小,而 used 成員指的是哈希表中已存儲的節點數量,當 used 快超過 size 的時候就意味着哈希表要擴容了。
Python 的字典在底層也是通過哈希表實現的,不過 Python 的哈希表在出現沖突時使用的是「開放尋址法」,並且當 used 數量達到哈希表空間大小的三分之二的時候,就會發生擴容。
我們再用一張圖來描述一下 Redis Dict 的結構:
整個結構還是很好理解的,這里需要注意 dictEntry,里面還有一個 next 字段,用於指向下一個 dictEntry。因為 Redis 要通過「分離鏈接法」解決哈希沖突的問題,所以需要維護一個鏈表,也就是所謂的「鏈式哈希」。
然后我們再來看一下 dictEntry,我們知道它是一個結構體,但是 value 字段和我們想象的有些不一樣。
typedef struct dictEntry {
// 指向鍵值對中的鍵
void *key;
// 指向鍵值對中的值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一個哈希表節點,形成鏈表
struct dictEntry *next;
} dictEntry;
我們看到 value 不單純是一個指針,而是一個共同體,因此 value 可以是一個指向實際值的指針,也可以是一個無符號的 64 位整數、有符號的 64 位整數或 double 類的浮點數。這么做的好處就是可以節省內存,當「值」是整數或浮點數時,就可以將值的數據內嵌在 dictEntry 結構體里,無需再用一個指針指向實際的值,從而節省了內存空間。
哈希沖突與鏈式哈希
這里再來聊一聊哈希沖突,最開始的時候說過,哈希表實際上就是一個數組,數組中的每一個存儲單元就是一個哈希桶(也可以稱之為哈希槽),桶的編號和索引保持一致。寫入一個鍵值對時,會對 key 進行哈希映射得到桶的編號,然后將鍵值對(dictEntry *)寫入對應的桶中。
哈希映射,可以簡單理解為先對 key 進行哈希運算,得到一個很大的數,然后再對數組的長度進行取模運算,即可得到一個合法的索引,整個過程就稱為哈希映射。因此在實現哈希表的時候,如何設計一個好的哈希函數是非常關鍵的,它能直接影響哈希表的效率。
而哈希沖突則是兩個不同的 key 最終被映射到同一個桶中,舉個栗子,有一個可以存放 5 個桶的哈希表,key1 和 key3 都被映射到了 2 號哈希桶。
此時 key1 和 key3 對應到了相同的哈希桶中,而當有兩個及以上的 key 被分配到了哈希表中同一個哈希桶時,這些 key 就發生了沖突。而解決哈希沖突,我們說 Redis 采用了「鏈式哈希」,也即是「分離鏈接法」。
實現的方式就是每個哈希表節點都有一個 next 指針,用於指向下一個哈希表節點,因此多個哈希表節點可以用 next 指針構成一個單項鏈表,被分配到同一個哈希桶上的多個節點可以用這個單項鏈表連接起來,這樣就解決了哈希沖突。
還是以上面的哈希沖突為例,key1 和 key3 經過哈希計算后,都落在同一個哈希桶,鏈式哈希的話,key1 就會通過 next 指針指向 key3,形成一個單向鏈表。
不過鏈式哈希局限性也很明顯,隨着鏈表長度的增加,在查詢這一位置上的數據的耗時就會增加,畢竟鏈表的查詢的時間復雜度是 O(n)。而要想解決這一問題,就需要進行 rehash,也就是對哈希表的大小進行擴展,接下來就看看 Redis 是如何實現 rehash 的。
rehash
哈希表結構設計的這一小節,我們說 Redis 使用 dictht 結構體表示哈希表。不過在實際使用哈希表時,Redis 定義一個 dict 結構體,這個結構體里定義了兩個哈希表。
typedef struct dict {
…
// 兩個Hash表,交替使用,用於 rehash 操作
dictht ht[2];
…
} dict;
之所以定義了 2 個哈希表,是因為進行 rehash 的時候,需要用上 2 個哈希表了。
在正常服務請求階段,插入的數據,都會寫入到「哈希表 1」,此時的「哈希表 2 」 並沒有被分配空間。但隨着數據逐步增多,觸發了 rehash 操作,「哈希表 2」就閃亮登場了,整個過程分為三步:
給「哈希表 2」 分配空間,一般會比「哈希表 1」 大 2 倍;
將「哈希表 1 」的數據遷移到「哈希表 2」 中;
遷移完成后,「哈希表 1 」的空間會被釋放,並把「哈希表 2」 設置為「哈希表 1」,然后在「哈希表 2」 新創建一個空白的哈希表,為下次 rehash 做准備;
我們用一張圖展示一下整個過程:
過程不難理解,就是哈希表 1 滿了之后,為哈希表 2 申請更大的空間,然后將哈希表 1 的元素拷貝過去,再釋放哈希表 1,最后將哈希表 2 和哈希表 1 交換位置。之后容量再滿了的話,則繼續重復此過程,周而復始。
不過這個過程看起來簡單,但是其實第二步很有問題,如果「哈希表 1 」的數據量非常大,那么在遷移至「哈希表 2 」的時候,因為會涉及大量的數據拷貝,此時可能會對 Redis 造成阻塞,無法服務其他請求。那么要怎么解決呢?於是 Redis 采用了漸進式 rehash。
漸進式 rehash
為了避免 rehash 在數據遷移過程中,因拷貝數據的耗時,影響 Redis 性能的情況,所以 Redis 采用了漸進式 rehash,核心思想就是數據遷移的工作不再是一次性完成,而是分多次遷移。
漸進式 rehash 步驟如下:
給「哈希表 2」 分配空間;
在 rehash 進行期間,每次哈希表元素進行新增、刪除、查找或者更新操作時,Redis 除了會執行對應的操作之外,還會順序將「哈希表 1 」中索引位置上的 key-value 遷移到「哈希表 2」 上。當然這是分批次完成的,客戶端對哈希表每發起一次請求,就遷移一部分;
隨着處理客戶端發起的哈希表操作請求數量越多,最終在某個時間點,會把「哈希表 1 」的所有 key-value 遷移到「哈希表 2」,從而完成 rehash 操作
這樣就巧妙地把一次性大量數據遷移工作的開銷,分攤到了多次處理請求的過程中,避免了一次性 rehash 的耗時操作。但是在進行漸進式 rehash 的過程中,會有兩個哈希表,所以在漸進式 rehash 進行期間,哈希表元素的刪除、查找、更新等操作都會在這兩個哈希表進行。
比如查找一個 key 的值的話,先會在「哈希表 1」 里面進行查找,如果沒找到,就會繼續到哈希表 2 里面進行找到。另外,在漸進式 rehash 進行期間,新增一個 key-value 時,會被保存到「哈希表 2 」里面,而「哈希表 1」 則不再進行任何添加操作,這樣保證了「哈希表 1 」的 key-value 數量只會減少,隨着 rehash 操作的完成,最終「哈希表 1 」就會變成空表。
rehash 觸發條件
介紹了這么多 rehash ,但是我們還沒說什么時情況下會觸發 rehash 操作呢。首先 rehash 操作的觸發條件和負載因子(load factor)有關,其計算方式如下:
觸發 rehash 操作的條件,主要有兩個:
當負載因子大於等於 1 ,並且 Redis 沒有在執行 bgsave 命令或者 bgrewiteaof 命令,也就是沒有執行 RDB 快照或沒有進行 AOF 重寫的時候,就會進行 rehash 操作;
當負載因子大於等於 5 時,此時說明哈希沖突非常嚴重了,不管有沒有有在執行 RDB 快照或 AOF 重寫,都會強制進行 rehash 操作;
整數集合
整數集合是 Set 對象的底層實現之一,當一個 Set 對象只包含整數值元素,並且元素數量不多時,就會使用整數集這個數據結構作為底層實現。
整數集合結構設計
整數集合本質上是一塊連續內存空間,它的結構定義如下:
typedef struct intset {
// 編碼方式
uint32_t encoding;
// 集合包含的元素數量
uint32_t length;
// 保存元素的數組
int8_t contents[];
} intset;
可以看到保存元素的容器是一個 contents 數組,雖然 contents 被聲明為 int8_t 類型的數組,但是實際上 contents 數組並不保存任何 int8_t 類型的元素,contents 數組的真正類型取決於 intset 結構體里的 encoding 字段的值。比如:
如果 encoding 字段為 INTSET_ENC_INT16,那么 contents 就是一個 int16_t 類型的數組,數組中每一個元素的類型都是 int16_t;
如果 encoding 字段為 INTSET_ENC_INT32,那么 contents 就是一個 int32_t 類型的數組,數組中每一個元素的類型都是 int32_t;
如果 encoding 字段為 INTSET_ENC_INT64,那么 contents 就是一個 int64_t 類型的數組,數組中每一個元素的類型都是 int64_t;
不同類型的 contents 數組,意味着數組的大小也會不同。
整數集合的升級操作
整數集合有一個升級規則,就是當我們將一個新元素加入到整數集合里面,如果新元素的類型(比如是 int32_t)比整數集合現有所有元素的類型(比如 int16_t)都要長時,整數集合需要先進行升級。因為 C 中的數組只能保存相同類型的元素,所以為了能夠存儲 int32_t,那么整個集合中已存在的所有元素都要變成 int32_t 類型,也就是按新元素的類型(int32_t)擴展 contents 數組的空間大小,然后才能將新元素加入到整數集合里,當然升級的過程中,也要維持整數集合的有序性。
注意:整數集合升級的過程不會重新分配一個新類型的數組,而是在原本的數組上擴展空間,然后再將每個元素轉成指定類型。如果 encoding 屬性值為 INTSET_ENC_INT32,則每個元素的大小就是 32 位(4 字節)。
舉個例子,假設有一個整數集合里有 3 個類型為 int16_t 的元素。
現在需要往這個整數集合中加入一個新元素 65535,這個新元素需要用 int32_t 類型來保存,所以整數集合要進行升級操作,首先需要為 contents 數組擴容,在原本空間的大小之上再擴容多 80 個位(10 字節)。因為新來的元素占 4 字節,之前的 3 個元素本來是 int16_t(2 字節),變成 int32_t 之后每個元素大小要增加 2 字節,所以總共要擴容 10( 4 + 3 * 2 )字節,這樣才能保存 4 個 int32_t 類型的數據。
擴容完 contents 數組空間大小后,需要將之前的三個元素轉換為 int32_t 類型,並將轉換后的元素放置到正確的位上面,並且需要維持底層數組的有序性不變,整個轉換過程如下:
邏輯不難理解,重點是整數集合升級的具體實現,也是非常考驗編程功底的地方,可以自己嘗試一下。那么問題來了,整數集合升級有什么好處呢?不用想絕對是省內存。
如果要讓一個數組同時保存 int16_t、int32_t、int64_t 類型的元素,最簡單做法就是直接使用 int64_t 類型的數組。不過這樣的話,當如果元素都是 int16_t 類型的,就會造成內存浪費的情況。整數集合升級就能避免這種情況,如果一直向整數集合添加 int16_t 類型的元素,那么整數集合的底層實現就一直是用 int16_t 類型的數組,只有在我們要將 int32_t 類型或 int64_t 類型的元素添加到集合時,才會對數組進行升級操作。
因此整數集合升級的好處就是是節省內存資源。
另外,整數集合不支持降級操作,一旦對數組進行了升級,就會一直保持升級后的狀態。比如前面的升級操作的例子,如果刪除了 65535 元素,整數集合的數組還是 int32_t 類型的,並不會因此降級為 int16_t 類型。
跳表
Redis 只有在 Zset 對象的底層實現用到了跳表,跳表的優勢是能支持平均 O(logN) 復雜度的節點查找。另外 ZSet 對象比較特殊,它是唯一一個同時使用了兩個數據結構來實現的對象,這兩個數據結構分別是哈希表和跳表。這樣的好處是既能進行高效的范圍查詢,也能進行高效單點查詢。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
ZSet 對象能支持范圍查詢(如 ZRANGEBYSCORE 操作),這是因為它的數據結構設計采用了跳表,而又能以常數復雜度獲取元素權重(如 ZSCORE 操作),這是因為它同時采用了哈希表進行索引。
當元素不多的時候,ZSet 對象采用壓縮列表實現,但如果有序集合保存的元素的長度大於 64 字節、或者元素個數超過128個時,就會改成跳表+哈希表。可以通過配置文件中的 zset-max-ziplist-entries(默認 128)和 zset-max-ziplist-value(默認 64)來設置有序集合使用 ziplist 存儲的臨界值。
接下來我們就來詳細地說一下跳表。
跳表結構設計
我們知道鏈表在查找元素的時候,因為需要逐一查找,所以查詢效率非常低,時間復雜度是 O(N),於是就出現了跳表。跳表是在鏈表基礎上改進過來的,實現了一種「多層級」的有序鏈表,這樣的好處是能快讀定位數據。
那么我們下面就來用一張圖,展示一下跳表的結構。
當我們在跳躍表中查詢值 62 時,執行流程如下:
從最上層開始找,首先 1 比 62 小,那么在當前層向后移動一個節點、也就是 27,然后再進行比較;
比較時發現 27 仍比 62 小,那么在當前層繼續向后移動一個節點,也就是 100,繼續比較;
比較時發現 100 大於 62,所以要以 27 為目標,移動到下一層(圖中的第二層)繼續向后比較;
50 小於 62,繼續向后移動查找,對比 100 大於 62,因此以 50 為目標,移動到下一層(圖中的最后一層)繼續向后比較;
對比 62 等於 62,值被順利找到。
從上面的流程可以看出,跳躍表會先從最上層開始找起,依次向后查找,如果本層的節點大於要找的值,或者本層的節點為 NULL 時,以該節點的上一個節點為目標,往下移一層繼續向后查找並循環此流程,直到找到滿足條件的節點並返回,如果對比到最后一層仍未找到,則返回 NULL。
所以如果回顧整個過程,我們會發現跳表有點類似於二分查找,而跳表在查找元素時的時間復雜度也是 O(logN)。實際上 ZSet 還可以使用 AVL 樹或紅黑樹實現,但由於性能相近,並且跳表更加的好實現,於是 Redis 選擇了跳表。
那么問題來了,我們說跳表是多層級的有序鏈表,比如上圖中的跳表總共有 3 層,但其實圖中畫的還不夠完全。因為當前層找不到的時候,會跳到下一層去找,但問題是怎么跳到下一層呢?所以每一層的有序鏈表之間應該還需要一個指針建立連接,但連接是單向的,我們只需要從上一次指向下一層即可。我們將上面的圖補充完整:
接下來我們就來看看 Redis 中的跳表節點是如何定義的,因為跳表也是由一個個的節點組成的,只不過這些節點分散在多層級的鏈表上,並且部分節點之間可以在不同層級之間跳躍,因此叫跳表。
補充一點:我們上圖畫的跳表結構和 Redis 實際實現的跳表還是有點區別的,我們后面會說明,但總之跳表的核心思想就是圖中展示的那樣。
typedef struct zskiplistNode {
// Zset 對象的元素值
sds ele;
// 元素的權重值,或者就按照單詞的意思理解為分數也行,誰分高誰 NB,(#^.^#),開個玩笑
double score;
// 比如我們執行命令 ZADD test_zset 1 satori 2 koishi 3 sakuya,那么就會產生 3 個節點
// 第一個節點的 ele 就是 "satori",score 就是 1
// 第二個節點的 ele 就是 "koishi",score 就是 2
// 第三個節點的 ele 就是 "sakuya",score 就是 13
// 后向指針,指向前一個節點
struct zskiplistNode *backward;
// 節點的 level 數組,保存每層上的前向指針和跨度
struct zskiplistLevel {
// 前向指針,指向后一個節點
struct zskiplistNode *forward;
// 跨度
unsigned long span;
} level[];
} zskiplistNode;
ZSet 對象要同時保存元素和元素的權重,對應到跳表節點結構里就是 sds 類型的 ele 變量和 double 類型的 score 變量。每個跳表節點都有一個后向指針,指向前一個節點,目的是為了方便從跳表的尾節點開始訪問節點,這樣倒序查找時很方便。
另外我們說跳表是一個帶有層級關系的鏈表,而且每一層級可以包含多個節點,每一個節點通過指針連接起來,實現這一特性就是靠跳表節點結構體中的zskiplistLevel 結構體類型的 level 數組。level 數組中的每一個元素代表跳表的一層,也就是由 zskiplistLevel 結構體表示,比如 leve[0] 就表示第一層,leve[1] 就表示第二層,所以查找的時候雖然是從上往下,但是計算層數的時候其實是從下往上計算的,也就是最下層才是第一層。可以想象一下走樓梯,越往上層數越高。然后 zskiplistLevel 結構體里定義了「指向下一個跳表節點的指針」和「跨度」,跨度時用來記錄兩個節點之間的距離。
光說的話可能不好理解,我們畫一張圖就清晰了,並且接下來畫的才是 Redis 中的跳表節點對應的結構。
這里我們單獨解釋一下跨度,它存在的意義就在於計算這個節點在跳表中的排位,那么具體是怎么計算的呢?首先跳表中的節點都是按照順序排列的,那么計算某個節點排位的時候,把從頭結點到該節點的查詢路徑中沿途訪問過的所有層的跨度加起來,得到的結果就是該節點在跳表中的排位。
比如查找 score 為 27 的節點在跳表中的排位,我們會從 1 開始查找,經過一個層即可找到 27,並且層的跨度是 3,所以節點 27 在跳表中的排位就是 3。
上面顯示的就是跳表中的每一個節點,那么下面再來看看跳表在底層的定義:
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
跳表結構體里包含了:
跳表的頭尾節點,便於在 O(1) 時間復雜度內訪問跳表的頭節點和尾節點;
跳表的長度,便於在 O(1) 時間復雜度獲取跳表節點的數量;
跳表的最大層數,也即是所有節點的 span 的最大值,便於在 O(1) 時間復雜度獲取跳表中層數最高的節點的層數量;
跳表節點的查詢過程
查找一個跳表節點時,跳表會從頭節點的最高層開始,逐一遍歷每一層。在遍歷某一層的跳表節點時,會用跳表節點中的 SDS 類型的元素和元素的權重來進行判斷,共有兩個判斷條件:
如果當前節點的權重「小於」要查找的權重時,跳表就會訪問該層上的下一個節點;
如果當前節點的權重「等於」要查找的權重時,並且當前節點的 SDS 類型數據「小於」要查找的數據時,跳表就會訪問該層上的下一個節點;
如果上面兩個條件都不滿足,或者下一個節點為空時,跳表就會使用目前遍歷到的節點的 level 數組里的下一層指針,然后沿着下一層指針繼續向后查找,這就相當於跳到了下一層接着查找。這里第一個條件很好理解,但第二個條件估計會有人困惑,我們解釋一下。
首先對於 ZSet 而言,它節點內部的元素是不可以重復的,比如有一個內部元素(ele)叫 "xxx" 的節點,那么跳表中就不可能有第二個內部元素也叫 "xxx" 的節點。不過雖然元素不可以重復,但是權重可以重復,也就是不同的元素可以具有相同的 score 值。在跳表中的節點會按照內部 score 值從小到大的順序進行組織,如果 score 相同,那么會按照內部元素(ele)的字典序進行組織。
我們舉個栗子,我們上面的圖中只畫了權重,然后我們起個名字吧。
127.0.0.1:6379> ZADD zset_test 1 n1 5 n2 11 n3 20 n4 27 n5 33 n6 50 n7 62 n8 100 n9
(integer) 9
127.0.0.1:6379>
我們按照 n1、n2、n3······ 的順序和每個權重進行了組合,接下來我們進行查找:
# ZSCORE key value:獲取元素對應的 score,這個查詢和跳表沒有多大關系,是通過哈希表實現的
127.0.0.1:6379> ZSCORE zset_test n2
"5"
# ZRANGE key start end [WITHSCORES]:獲取指定范圍的元素,遞增排列,這個就相當於遍歷跳表的第一層
# 加上 WITHSCORES 會同時返回 score,同理還有 ZREVRANGE,輸出結果遞減排列
127.0.0.1:6379> ZRANGE zset_test 0 2
1) "n1"
2) "n2"
3) "n3"
# ZRANGEBYSCORE key 開始score 結束score:獲取 >=開始score 並 <=結束score 的元素,遞增排列
# 也可以加上 WITHSCORES 同時返回 score,也有 ZREVRANGEBYSCORE,輸出結果遞減排列
127.0.0.1:6379> ZRANGEBYSCORE zset_test 5 60
1) "n2"
2) "n3"
3) "n4"
4) "n5"
5) "n6"
6) "n7"
127.0.0.1:6379>
顯然 ZRANGEBYSCORE 是用到了跳表的結構,那么它是怎么做的呢?首先從最高層的 1 開始查找,發現 1 小於 5 ,於是移動到下一個節點,但發現 27 大於 5,於是回到 score 為 1 的節點中的下一層,也就是第 2 層。然而第 2 層的下一個節點為 11,也大於 5,於是回到 score 為 1 的節點中再下一層,也就是第 1 層。此時發現下一個節點的 score 為 5,正好匹配,所以左邊界對應的節點就找到了。如果沒有 score 等於 5 的節點,那么就去找第一個 score 大於 5 的節點作為邊界。
左邊界找到了之后是不是該去找右邊界了呢?答案是不需要,只需要從左邊界對應的節點的第一層開始不斷向后遍歷即可,當出現第一個 score 大於右邊界的節點時停止遍歷即可。因為跳表不像數組,即使你找到了右邊界,也依舊需要從左邊界開始遍歷一遍。
跳表節點層數設置
實際使用跳表的時候需要維護相鄰兩層的節點數量,讓它們保持一個合理的比例關系,因為跳表的相鄰兩層的節點數量的比例會影響跳表的查詢性能。
注意:這里我們說相鄰兩層的節點其實不太嚴謹,因為給人一種感覺,好像是不同層的節點之間彼此獨立一樣。但我們知道,在 Redis 的跳表中多層共用一個節點,每個節點內部通過數組維護多個指針來實現多層級。當然了,這里我們為了方便描述,就使用這種不嚴謹的說法了,心中理解就好。
比如第二層的節點只有 1 個,但第一層有 100 個,這顯然不太合理,因為此時就類似於鏈表了。最終需要在第一層的節點中依次順序查找,復雜度就變成了 O(N),因此為了避免跳表退化成鏈表,我們需要維持相鄰層之間的節點數量關系。
跳表的相鄰兩層的節點數量最理想的比例是 2: 1,這樣查找復雜度可以降低到 O(logN)。
不過話雖如此,即使將相鄰兩層的節點數量維持在了 2:1,但還是有一點需要注意,我們舉個栗子:
這里相鄰兩層的節點數量大概也在 2:1,但很明顯它起不到任何加速查詢的效果,或者說這種結構不具備任何跳表的性質,查詢速度甚至還不如鏈表。因此跳表的層數越高,節點不僅越少,而且節點之間也要越稀疏,應該像二分查找一樣,每次都能過濾掉一部分元素。如果相鄰兩層的節點數量控制在 2:1,並且跳表的最高層只有 3 個節點(首、尾、中間),那么此時就可以認為其等價於二分查找。
那怎樣才能維持相鄰兩層的節點數量的比例為 2: 1 呢?
如果采用新增節點或者刪除節點的方式,來調整不同層級比例的方法的話,會帶來額外的開銷。所以 Redis 采用了一種巧妙的方法,跳表在創建節點的時候,隨機生成每個節點的層數,並沒有嚴格維持相鄰兩層的節點數量比例為 2: 1 的情況。
具體的做法是,跳表在創建節點時候,會生成范圍為 [0-1] 的一個隨機數,如果這個隨機數小於 0.25(相當於概率 25%),那么層數就增加 1 層,然后繼續生成下一個隨機數,直到隨機數的結果大於 0.25 結束,最終確定該節點的層數。這樣的做法,相當於每增加一層的概率不超過 25%,層數越高,概率越低,層高最大限制是 64。
quicklist
在 Redis 3.0 之前,List 對象的底層數據結構是雙向鏈表或者壓縮列表,然后在 Redis 3.2 的時候,List 對象的底層改由 quicklist 數據結構實現。而 quicklist 其實就是「雙向鏈表 + 壓縮列表」組合,因為一個 quicklist 就是一個鏈表,而鏈表中的每個元素又是一個壓縮列表。
在前面介紹壓縮列表的時候,我們也提到了壓縮列表的不足,雖然壓縮列表是通過緊湊型的內存布局節省了內存開銷,但是因為它的結構設計,如果保存的元素數量增加,或者元素變大了,壓縮列表會有「連鎖更新」的風險,一旦發生,會造成性能下降。
而 quicklist 的解決辦法是通過控制每個鏈表節點中的壓縮列表的大小或者元素個數,來規避連鎖更新的問題。因為壓縮列表元素越少或越小,連鎖更新帶來的影響就越小,從而提供了更好的訪問性能。
quicklist 結構設計
quicklist 的結構體跟鏈表的結構體類似,都包含了表頭和表尾,區別在於 quicklist 的節點是 quicklistNode。
typedef struct quicklist {
// quicklist 的鏈表頭
quicklistNode *head;
// quicklist 的鏈表尾
quicklistNode *tail;
// 所有壓縮列表中的總元素個數
unsigned long count;
// quicklistNodes 的個數
unsigned long len;
...
} quicklist;
接下來看看,quicklistNode 的結構定義:
typedef struct quicklistNode {
//前一個 quicklistNode
struct quicklistNode *prev;
//下一個 quicklistNode
struct quicklistNode *next;
// quicklistNode 指向的壓縮列表
unsigned char *zl;
// 壓縮列表的的字節大小
unsigned int sz;
// 壓縮列表的元素個數
unsigned int count : 16;
....
} quicklistNode;
可以看到,quicklistNode 結構體里包含了前一個節點和下一個節點指針,這樣每個 quicklistNode 形成了一個雙向鏈表。但是鏈表節點的元素不再是單純保存元素值,而是保存了一個壓縮列表,所以 quicklistNode 結構體里有個指向壓縮列表的指針 *zl。
在向 quicklist 添加一個元素的時候,不會像普通的鏈表那樣,直接新建一個鏈表節點。而是會檢查插入位置的壓縮列表是否能容納該元素,如果能容納就直接保存到 quicklistNode 結構里的壓縮列表,如果不能容納,才會新建一個新的 quicklistNode 結構。
所以 quicklist 會控制 quicklistNode 結構里的壓縮列表的大小或者元素個數,來規避潛在的連鎖更新的風險,但是這並沒有完全解決連鎖更新的問題。
listpack
我們 quicklist 雖然通過控制 quicklistNode 結構里的壓縮列表的大小或者元素個數,來減少連鎖更新帶來的性能影響,但是並沒有完全解決連鎖更新的問題。因為 quicklistNode 還是用了壓縮列表來保存元素,壓縮列表連鎖更新的問題,來源於它的結構設計,所以要想徹底解決這個問題,需要設計一個新的數據結構。
於是 Redis 在 5.0 的時候新設計了一個數據結構叫 listpack,目的是替代壓縮列表,它最大特點是 listpack 中每個節點不再包含前一個節點的長度了,壓縮列表每個節點正因為需要保存前一個節點的長度字段,就會有連鎖更新的隱患。
雖然 listpack 是在 5.0 的時候設計的,但卻並沒有馬上應用,在 6.2 之后才將用到壓縮列表的 Redis 對象的底層數據結構替換成 listpack。
那么我們就來 listpack 是怎么設計的?
listpack 采用了壓縮列表的很多優秀的設計,比如還是用一塊連續的內存空間來緊湊地保存數據,並且為了節省內存的開銷,listpack 節點會采用不同的編碼方式保存不同大小的數據。
listpack 頭包含兩個屬性,分別記錄了 listpack 總字節數和元素數量,然后 listpack 末尾也有個結尾標識。圖中的 listpack entry 就是 listpack 的節點了。每個 listpack 節點結構如下:
主要包含三個字段:
encoding,定義該元素的編碼類型,會對不同長度的整數和字符串進行編碼;
data,實際存放的數據;
len,encoding+data的總長度;
可以看到 listpack 的結構和壓縮列表(ziplist)還是很相似的,只是沒有記錄前一個節點長度的字段了,listpack 只記錄當前節點的長度。當我們向 listpack 加入一個新元素的時候,不會影響其它節點的長度字段的變化,從而避免了壓縮列表的連鎖更新問題。
總結
以上就是 Redis 基礎數據類型的底層實現,作為一款高性能的內存鍵值對數據庫,高效的數據結構是必不可少的,了解這些數據結構之后,在工作中我們也可以參考並應用在自己的項目中。關於數據結構,其實是一個比較大的話題,也是面試中經常問題的點。
當然啦,Redis 之所以高效,數據結構只是原因之一,至於其它方面的因素我們改天再聊吧。