大家好,我是小林。
Redis 為什麼那麼快?
除了它是記憶體資料庫,使得所有的操作都在記憶體上進行之外,還有一個重要因素,它實現的資料結構,使得我們對資料進行增刪查改操作時,Redis 能高效的處理。
因此,這次我們就來好好聊一下 Redis 資料結構,這個在面試中太常問了。
注意,Redis 資料結構並不是指 String(字串)物件、List(列表)物件、Hash(雜湊)物件、Set(集合)物件和 Zset(有序集合)物件,因為這些是 Redis 鍵值對中值的資料型別,也就是資料的儲存形式,這些物件的底層實現的方式就用到了資料結構。
我畫了一張 Redis 資料型別(也叫 Redis 物件)和底層資料結構的對應關圖,左邊是 Redis 3.0版本的,也就是《Redis 設計與實現》這本書講解的版本,現在看還是有點過時了,右邊是現在 Github 最新的 Redis 程式碼的(還未釋出正式版本)。
可以看到,Redis 資料型別的底層資料結構隨著版本的更新也有所不同,比如:
- 在 Redis 3.0 版本中 List 物件的底層資料結構由「雙向連結串列」或「壓縮表列表」實現,但是在 3.2 版本之後,List 資料型別底層資料結構是由 quicklist 實現的;
- 在最新的 Redis 程式碼(還未釋出正式版本)中,壓縮列表資料結構已經廢棄了,交由 listpack 資料結構來實現了。
這次,小林把新舊版本的資料結構說圖解一遍,共有 9 種資料結構:SDS、雙向連結串列、壓縮列表、雜湊表、跳錶、整數集合、quicklist、listpack。
不多 BB 了,直接發車!
鍵值對資料庫是怎麼實現的?
在開始講資料結構之前,先給介紹下 Redis 是怎樣實現鍵值對(key-value)資料庫的。
Redis 的鍵值對中的 key 就是字串物件,而 value 可以是字串物件,也可以是集合資料型別的物件,比如 List 物件、Hash 物件、Set 物件和 Zset 物件。
舉個例子,我這裡列出幾種 Redis 新增鍵值對的命令:
> SET name "xiaolincoding"
OK
> HSET person name "xiaolincoding" age 18
0
> RPUSH stu "xiaolin" "xiaomei"
(integer) 4
這些命令代表著:
- 第一條命令:name 是一個字串鍵,因為鍵的值是一個字串物件;
- 第二條命令:person 是一個雜湊表鍵,因為鍵的值是一個包含兩個鍵值對的雜湊表物件;
- 第三條命令:stu 是一個列表鍵,因為鍵的值是一個包含兩個元素的列表物件;
這些鍵值對是如何儲存在 Redis 中的呢?
Redis 是使用了一個「雜湊表」儲存所有鍵值對,雜湊表的最大好處就是讓我們可以用 O(1) 的時間複雜度來快速查詢到鍵值對。雜湊表其實就是一個陣列,陣列中的元素叫做雜湊桶。
Redis 的雜湊桶是怎麼儲存鍵值對資料的呢?
雜湊桶存放的是指向鍵值對資料的指標(dictEntry*),這樣通過指標就能找到鍵值對資料,然後因為鍵值對的值可以儲存字串物件和集合資料型別的物件,所以鍵值對的資料結構中並不是直接儲存值本身,而是儲存了 void * key 和 void * value 指標,分別指向了實際的鍵物件和值物件,這樣一來,即使值是集合資料,也可以通過 void * value 指標找到。
我這裡畫了一張 Redis 儲存鍵值對所涉及到的資料結構。
這些資料結構的內部細節,我先不展開講,後面在講雜湊表資料結構的時候,在詳細的說說,因為用到的資料結構是一樣的。這裡先大概說下圖中涉及到的資料結構的名字和用途:
- redisDb 結構,表示 Redis 資料庫的結構,結構體裡存放了指向了 dict 結構的指標;
- dict 結構,結構體裡存放了 2 個雜湊表,正常情況下都是用「雜湊表1」,「雜湊表2」只有在 rehash 的時候才用,具體什麼是 rehash,我在本文的雜湊表資料結構會講;
- ditctht 結構,表示雜湊表的結構,結構裡存放了雜湊表陣列,陣列中的每個元素都是指向一個雜湊表節點結構(dictEntry)的指標;
- dictEntry 結構,表示雜湊表節點的結構,結構裡存放了 void * key 和 void * value 指標, *key 指向的是 String 物件,而 *value 則可以指向 String 物件,也可以指向集合型別的物件,比如 List 物件、Hash 物件、Set 物件和 Zset 物件。
特別說明下,void * key 和 void * value 指標指向的是 Redis 物件,Redis 中的每個物件都由 redisObject 結構表示,如下圖:
物件結構裡包含的成員變數:
- type,標識該物件是什麼型別的物件(String 物件、 List 物件、Hash 物件、Set 物件和 Zset 物件);
- encoding,標識該物件使用了哪種底層的資料結構;
- ptr,指向底層資料結構的指標。
我畫了一張 Redis 鍵值對資料庫的全景圖,你就能清晰知道 Redis 物件和資料結構的關係了:
接下里,就好好聊一下底層資料結構!
SDS
字串在 Redis 中是很常用的,鍵值對中的鍵是字串型別,值有時也是字串型別。
Redis 是用 C 語言實現的,但是它沒有直接使用 C 語言的 char* 字元陣列來實現字串,而是自己封裝了一個名為簡單動態字串(simple dynamic string,SDS) 的資料結構來表示字串,也就是 Redis 的 String 資料型別的底層資料結構是 SDS。
既然 Redis 設計了 SDS 結構來表示字串,肯定是 C 語言的 char* 字元陣列存在一些缺陷。
要了解這一點,得先來看看 char* 字元陣列的結構。
C 語言字串的缺陷
C 語言的字串其實就是一個字元陣列,即陣列中每個元素是字串中的一個字元。
比如,下圖就是字串“xiaolin”的 char* 字元陣列的結構:
沒學過 C 語言的同學,可能會好奇為什麼最後一個字元是“\0”?
在 C 語言裡,對字串操作時,char * 指標只是指向字元陣列的起始位置,而字元陣列的結尾位置就用“\0”表示,意思是指字串的結束。
因此,C 語言標準庫中的字串操作函式就通過判斷字元是不是 “\0” 來決定要不要停止操作,如果當前字元不是 “\0” ,說明字串還沒結束,可以繼續操作,如果當前字元是 “\0” 是則說明字串結束了,就要停止操作。
舉個例子,C 語言獲取字串長度的函式 strlen
,就是通過字元陣列中的每一個字元,並進行計數,等遇到字元為 “\0” 後,就會停止遍歷,然後返回已經統計到的字元個數,即為字串長度。下圖顯示了 strlen 函式的執行流程:
很明顯,C 語言獲取字串長度的時間複雜度是 O(N)(這是一個可以改進的地方)
C 語言字串用 “\0” 字元作為結尾標記有個缺陷。假設有個字串中有個 “\0” 字元,這時在操作這個字串時就會提早結束,比如 “xiao\0lin” 字串,計算字串長度的時候則會是 4,如下圖:
因此,除了字串的末尾之外,字串裡面不能含有 “\0” 字元,否則最先被程式讀入的 “\0” 字元將被誤認為是字串結尾,這個限制使得 C 語言的字串只能儲存文字資料,不能儲存像圖片、音訊、視訊文化這樣的二進位制資料(這也是一個可以改進的地方)
另外, C 語言標準庫中字串的操作函式是很不安全的,對程式設計師很不友好,稍微一不注意,就會導致緩衝區溢位。
舉個例子,strcat 函式是可以將兩個字串拼接在一起。
c //將 src 字串拼接到 dest 字串後面 char *strcat(char *dest, const char* src);
C 語言的字串是不會記錄自身的緩衝區大小的,所以 strcat 函式假定程式設計師在執行這個函式時,已經為 dest 分配了足夠多的記憶體,可以容納 src 字串中的所有內容,而一旦這個假定不成立,就會發生緩衝區溢位將可能會造成程式執行終止,(這是一個可以改進的地方)。
而且,strcat 函式和 strlen 函式類似,時間複雜度也很高,也都需要先通過遍歷字串才能得到目標字串的末尾。然後對於 strcat 函式來說,還要再遍歷源字串才能完成追加,對字串的操作效率不高。
好了, 通過以上的分析,我們可以得知 C 語言的字串不足之處以及可以改進的地方:
- 獲取字串長度的時間複雜度為 O(N);
- 字串的結尾是以 “\0” 字元標識,字串裡面不能包含有 “\0” 字元,因此不能儲存二進位制資料;
- 字串操作函式不高效且不安全,比如有緩衝區溢位的風險,有可能會造成程式執行終止;
Redis 實現的 SDS 的結構就把上面這些問題解決了,接下來我們一起看看 Redis 是如何解決的。
SDS 結構設計
下圖就是 Redis 5.0 的 SDS 的資料結構:
結構中的每個成員變數分別介紹下:
- len,記錄了字串長度。這樣獲取字串長度的時候,只需要返回這個成員變數值就行,時間複雜度只需要 O(1)。
- alloc,分配給字元陣列的空間長度。這樣在修改字串的時候,可以通過
alloc - len
計算出剩餘的空間大小,可以用來判斷空間是否滿足修改需求,如果不滿足的話,就會自動將 SDS 的空間擴充套件至執行修改所需的大小,然後才執行實際的修改操作,所以使用 SDS 既不需要手動修改 SDS 的空間大小,也不會出現前面所說的緩衝區溢位的問題。 - flags,用來表示不同型別的 SDS。一共設計了 5 種型別,分別是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,後面在說明區別之處。
- buf[],字元陣列,用來儲存實際資料。不僅可以儲存字串,也可以儲存二進位制資料。
總的來說,Redis 的 SDS 結構在原本字元陣列之上,增加了三個後設資料:len、alloc、flags,用來解決 C 語言字串的缺陷。
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 的空間大小(小於 1MB 翻倍擴容,大於 1MB 按 1MB 擴容),以滿足修改所需的大小。
在擴充套件 SDS 空間之前,SDS API 會優先檢查未使用空間是否足夠,如果不夠的話,API 不僅會為 SDS 分配修改所必須要的空間,還會給 SDS 分配額外的「未使用空間」。
這樣的好處是,下次在操作 SDS 時,如果 SDS 空間夠的話,API 就會直接使用「未使用空間」,而無須執行記憶體分配,有效的減少記憶體分配次數。
所以,使用 SDS 即不需要手動修改 SDS 的空間大小,也不會出現緩衝區溢位的問題。
節省記憶體空間
SDS 結構中有個 flags 成員變數,表示的是 SDS 型別。
Redos 一共設計了 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 設計不同型別的結構體,是為了能靈活儲存不同大小的字串,從而有效節省記憶體空間。比如,在儲存小字串時,結構頭佔用空間也比較少。
除了設計不同型別的結構體,Redis 在程式設計上還使用了專門的編譯優化來節省記憶體空間,即在 struct 宣告瞭 __attribute__ ((packed))
,它的作用是:告訴編譯器取消結構體在編譯過程中的優化對齊,按照實際佔用位元組數進行對齊。
比如,sdshdr16 型別的 SDS,預設情況下,編譯器會按照 16 位元組對齊的方式給變數分配記憶體,這意味著,即使一個變數的大小不到 16 個位元組,編譯器也會給它分配 16 個位元組。
舉個例子,假設下面這個結構體,它有兩個成員變數,型別分別是 char 和 int,如下所示:
#include <stdio.h>
struct test1 {
char a;
int b;
} test1;
int main() {
printf("%lu\n", sizeof(test1));
return 0;
}
大家猜猜這個結構體大小是多少?我先直接說答案,這個結構體大小計算出來是 8。
這是因為預設情況下,編譯器是使用「位元組對齊」的方式分配記憶體,雖然 char 型別只佔一個位元組,但是由於成員變數裡有 int 型別,它佔用了 4 個位元組,所以在成員變數為 char 型別分配記憶體時,會分配 4 個位元組,其中這多餘的 3 個位元組是為了位元組對齊而分配的,相當於有 3 個位元組被浪費掉了。
如果不想編譯器使用位元組對齊的方式進行分配記憶體,可以採用了 __attribute__ ((packed))
屬性定義結構體,這樣一來,結構體實際佔用多少記憶體空間,編譯器就分配多少空間。
比如,我用 __attribute__ ((packed))
屬性定義下面的結構體 ,同樣包含 char 和 int 兩個型別的成員變數,程式碼如下所示:
#include <stdio.h>
struct __attribute__((packed)) test2 {
char a;
int b;
} test2;
int main() {
printf("%lu\n", sizeof(test2));
return 0;
}
這時列印的結果是 5(1 個位元組 char + 4 位元組 int)。
可以看得出,這是按照實際佔用位元組數進行分配記憶體的,這樣可以節省記憶體空間。
連結串列
大家最熟悉的資料結構除了陣列之外,我相信就是連結串列了。
Redis 的 List 物件的底層實現之一就是連結串列。C 語言本身沒有連結串列這個資料結構的,所以 Redis 自己設計了一個連結串列資料結構。
連結串列節點結構設計
先來看看「連結串列節點」結構的樣子:
typedef struct listNode {
//前置節點
struct listNode *prev;
//後置節點
struct listNode *next;
//節點的值
void *value;
} listNode;
有前置節點和後置節點,可以看的出,這個是一個雙向連結串列。
連結串列結構設計
不過,Redis 在 listNode 結構體基礎上又封裝了 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;
list 結構為連結串列提供了連結串列頭指標 head、連結串列尾節點 tail、連結串列節點數量 len、以及可以自定義實現的 dup、free、match 函式。
舉個例子,下面是由 list 結構和 3 個 listNode 結構組成的連結串列。
連結串列的優勢與缺陷
Redis 的連結串列實現優點如下:
- listNode 連結串列節點的結構裡帶有 prev 和 next 指標,獲取某個節點的前置節點或後置節點的時間複雜度只需O(1),而且這兩個指標都可以指向 NULL,所以連結串列是無環連結串列;
- list 結構因為提供了表頭指標 head 和表尾節點 tail,所以獲取連結串列的表頭節點和表尾節點的時間複雜度只需O(1);
- list 結構因為提供了連結串列節點數量 len,所以獲取連結串列中的節點數量的時間複雜度只需O(1);
- listNode 連結串列節使用 void* 指標儲存節點值,並且可以通過 list 結構的 dup、free、match 函式指標為節點設定該節點型別特定的函式,因此連結串列節點可以儲存各種不同型別的值;
連結串列的缺陷也是有的:
-
連結串列每個節點之間的記憶體都是不連續的,意味著無法很好利用 CPU 快取。能很好利用 CPU 快取的資料結構就是陣列,因為陣列的記憶體是連續的,這樣就可以充分利用 CPU 快取來加速訪問。
-
還有一點,儲存一個連結串列節點的值都需要一個連結串列節點結構頭的分配,記憶體開銷較大。
因此,Redis 3.0 的 List 物件在資料量比較少的情況下,會採用「壓縮列表」作為底層資料結構的實現,它的優勢是節省記憶體空間,並且是記憶體緊湊型的資料結構。
不過,壓縮列表存在效能問題(具體什麼問題,下面會說),所以 Redis 在 3.2 版本設計了新的資料結構 quicklist,並將 List 物件的底層資料結構改由 quicklist 實現。
然後在 Redis 5.0 設計了新的資料結構 listpack,沿用了壓縮列表緊湊型的記憶體佈局,最終在最新的 Redis 版本,將 Hash 物件和 Zset 物件的底層資料結構實現之一的壓縮列表,替換成由 listpack 實現。
壓縮列表
壓縮列表的最大特點,就是它被設計成一種記憶體緊湊型的資料結構,佔用一塊連續的記憶體空間,不僅可以利用 CPU 快取,而且會針對不同長度的資料,進行相應編碼,這種方法可以有效地節省記憶體開銷。
但是,壓縮列表的缺陷也是有的:
- 不能儲存過多的元素,否則查詢效率就會降低;
- 新增或修改某個元素時,壓縮列表佔用的記憶體空間需要重新分配,甚至可能引發連鎖更新的問題。
因此,Redis 物件(List 物件、Hash 物件、Zset 物件)包含的元素數量較少,或者元素值不大的情況才會使用壓縮列表作為底層資料結構。
接下來,就跟大家詳細聊下壓縮列表。
壓縮列表結構設計
壓縮列表是 Redis 為了節約記憶體而開發的,它是由連續記憶體塊組成的順序型資料結構,有點類似於陣列。
壓縮列表在表頭有三個欄位:
- zlbytes,記錄整個壓縮列表佔用對記憶體位元組數;
- 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 位元組的新節點加入到壓縮列表的表頭節點,即新節點將成為 e1 的前置節點,如下圖:
因為 e1 節點的 prevlen 屬性只有 1 個位元組大小,無法儲存新節點的長度,此時就需要對壓縮列表的空間重分配操作,並將 e1 節點的 prevlen 屬性從原來的 1 位元組大小擴充套件為 5 位元組大小。
多米諾牌的效應就此開始。
e1 原本的長度在 250~253 之間,因為剛才的擴充套件空間,此時 e1 的長度就大於等於 254 了,因此原本 e2 儲存 e1 的 prevlen 屬性也必須從 1 位元組擴充套件至 5 位元組大小。
正如擴充套件 e1 引發了對 e2 擴充套件一樣,擴充套件 e2 也會引發對 e3 的擴充套件,而擴充套件 e3 又會引發對 e4 的擴充套件…. 一直持續到結尾。
這種在特殊情況下產生的連續多次空間擴充套件操作就叫做「連鎖更新」,就像多米諾牌的效應一樣,第一張牌倒下了,推動了第二張牌倒下;第二張牌倒下,又推動了第三張牌倒下….,
壓縮列表的缺陷
空間擴充套件操作也就是重新分配記憶體,因此連鎖更新一旦發生,就會導致壓縮列表佔用的記憶體空間要多次重新分配,這就會直接影響到壓縮列表的訪問效能。
所以說,雖然壓縮列表緊湊型的記憶體佈局能節省記憶體開銷,但是如果儲存的元素數量增加了,或是元素變大了,會導致記憶體重新分配,最糟糕的是會有「連鎖更新」的問題。
因此,壓縮列表只會用於儲存的節點數量不多的場景,只要節點數量足夠小,即使發生連鎖更新,也是能接受的。
雖說如此,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) 的複雜度快速查詢資料。怎麼做到的呢?將 key 通過 Hash 函式的計算,就能定位資料在表中的位置,因為雜湊表實際上是陣列,所以可以通過索引值快速查詢到資料。
但是存在的風險也是有,在雜湊表大小固定的情況下,隨著資料不斷增多,那麼雜湊衝突的可能性也會越高。
解決雜湊衝突的方式,有很多種。
Redis 採用了「鏈式雜湊」來解決雜湊衝突,在不擴容雜湊表的前提下,將具有相同雜湊值的資料串起來,形成連結起,以便這些資料在表中仍然可以被查詢到。
接下來,詳細說說雜湊表。
雜湊表結構設計
Redis 的雜湊表結構如下:
typedef struct dictht {
//雜湊表陣列
dictEntry **table;
//雜湊表大小
unsigned long size;
//雜湊表大小掩碼,用於計算索引值
unsigned long sizemask;
//該雜湊表已有的節點數量
unsigned long used;
} dictht;
可以看到,雜湊表是一個陣列(dictEntry **table),陣列的每個元素是一個指向「雜湊表節點(dictEntry)」的指標。
雜湊表節點的結構如下:
typedef struct dictEntry {
//鍵值對中的鍵
void *key;
//鍵值對中的值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//指向下一個雜湊表節點,形成連結串列
struct dictEntry *next;
} dictEntry;
dictEntry 結構裡不僅包含指向鍵和值的指標,還包含了指向下一個雜湊表節點的指標,這個指標可以將多個雜湊值相同的鍵值對連結起來,以此來解決雜湊衝突的問題,這就是鏈式雜湊。
另外,這裡還跟你提一下,dictEntry 結構裡鍵值對中的值是一個「聯合體 v」定義的,因此,鍵值對中的值可以是一個指向實際值的指標,或者是一個無符號的 64 位整數或有符號的 64 位整數或double 類的值。這麼做的好處是可以節省記憶體空間,因為當「值」是整數或浮點數時,就可以將值的資料內嵌在 dictEntry 結構裡,無需再用一個指標指向實際的值,從而節省了記憶體空間。
雜湊衝突
雜湊表實際上是一個陣列,陣列裡多每一個元素就是一個雜湊桶。
當一個鍵值對的鍵經過 Hash 函式計算後得到雜湊值,再將(雜湊值 % 雜湊表大小)取模計算,得到的結果值就是該 key-value 對應的陣列元素位置,也就是第幾個雜湊桶。
什麼是雜湊衝突呢?
舉個例子,有一個可以存放 8 個雜湊桶的雜湊表。key1 經過雜湊函式計算後,再將「雜湊值 % 8 」進行取模計算,結果值為 1,那麼就對應雜湊桶 1,類似的,key9 和 key10 分別對應雜湊桶 1 和桶 6。
此時,key1 和 key9 對應到了相同的雜湊桶中,這就發生了雜湊衝突。
因此,當有兩個以上數量的 kay 被分配到了雜湊表中同一個雜湊桶上時,此時稱這些 key 發生了衝突。
鏈式雜湊
Redis 採用了「鏈式雜湊」的方法來解決雜湊衝突。
鏈式雜湊是怎麼實現的?
實現的方式就是每個雜湊表節點都有一個 next 指標,用於指向下一個雜湊表節點,因此多個雜湊表節點可以用 next 指標構成一個單項鍊表,被分配到同一個雜湊桶上的多個節點可以用這個單項鍊表連線起來,這樣就解決了雜湊衝突。
還是用前面的雜湊衝突例子,key1 和 key9 經過雜湊計算後,都落在同一個雜湊桶,鏈式雜湊的話,key1 就會通過 next 指標指向 key9,形成一個單向連結串列。
不過,鏈式雜湊侷限性也很明顯,隨著連結串列長度的增加,在查詢這一位置上的資料的耗時就會增加,畢竟連結串列的查詢的時間複雜度是 O(n)。
要想解決這一問題,就需要進行 rehash,也就是對雜湊表的大小進行擴充套件。
接下來,看看 Redis 是如何實現的 rehash 的。
rehash
雜湊表結構設計的這一小節,我給大家介紹了 Redis 使用 dictht 結構體表示雜湊表。不過,在實際使用雜湊表時,Redis 定義一個 dict 結構體,這個結構體裡定義了兩個雜湊表(ht[2])。
typedef struct dict {
…
//兩個Hash表,交替使用,用於rehash操作
dictht ht[2];
…
} dict;
之所以定義了 2 個雜湊表,是因為進行 rehash 的時候,需要用上 2 個雜湊表了。
在正常服務請求階段,插入的資料,都會寫入到「雜湊表 1」,此時的「雜湊表 2 」 並沒有被分配空間。
隨著資料逐步增多,觸發了 rehash 操作,這個過程分為三步:
- 給「雜湊表 2」 分配空間,一般會比「雜湊表 1」 大 2 倍;
- 將「雜湊表 1 」的資料遷移到「雜湊表 2」 中;
- 遷移完成後,「雜湊表 1 」的空間會被釋放,並把「雜湊表 2」 設定為「雜湊表 1」,然後在「雜湊表 2」 新建立一個空白的雜湊表,為下次 rehash 做準備。
為了方便你理解,我把 rehash 這三個過程畫在了下面這張圖:
這個過程看起來簡單,但是其實第二步很有問題,如果「雜湊表 1 」的資料量非常大,那麼在遷移至「雜湊表 2 」的時候,因為會涉及大量的資料拷貝,此時可能會對 Redis 造成阻塞,無法服務其他請求。
漸進式 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)都要長時,整數集合需要先進行升級,也就是按新元素的型別(int32_t)擴充套件 contents 陣列的空間大小,然後才能將新元素加入到整數集合裡,當然升級的過程中,也要維持整數集合的有序性。
整數集合升級的過程不會重新分配一個新型別的陣列,而是在原本的陣列上擴充套件空間,然後在將每個元素按間隔型別大小分割,如果 encoding 屬性值為 INTSET_ENC_INT16,則每個元素的間隔就是 16 位。
舉個例子,假設有一個整數集合裡有 3 個型別為 int16_t 的元素。
現在,往這個整數集合中加入一個新元素 65535,這個新元素需要用 int32_t 型別來儲存,所以整數集合要進行升級操作,首先需要為 contents 陣列擴容,在原本空間的大小之上再擴容多 80 位(4x32-3x16=80),這樣就能儲存下 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 物件是唯一一個同時使用了兩個資料結構來實現的 Redis 物件,這兩個資料結構一個是跳錶,一個是雜湊表。這樣的好處是既能進行高效的範圍查詢,也能進行高效單點查詢。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
Zset 物件能支援範圍查詢(如 ZRANGEBYSCORE 操作),這是因為它的資料結構設計採用了跳錶,而又能以常數複雜度獲取元素權重(如 ZSCORE 操作),這是因為它同時採用了雜湊表進行索引。
接下來,詳細的說下跳錶。
跳錶結構設計
連結串列在查詢元素的時候,因為需要逐一查詢,所以查詢效率非常低,時間複雜度是O(N),於是就出現了跳錶。跳錶是在連結串列基礎上改進過來的,實現了一種「多層」的有序連結串列,這樣的好處是能快讀定位資料。
那跳錶長什麼樣呢?我這裡舉個例子,下圖展示了一個層級為 3 的跳錶。
圖中頭節點有 L0~L2 三個頭指標,分別指向了不同層級的節點,然後每個層級的節點都通過指標連線起來:
- L0 層級共有 5 個節點,分別是節點1、2、3、4、5;
- L1 層級共有 3 個節點,分別是節點 2、3、5;
- L2 層級只有 1 個節點,也就是節點 3 。
如果我們要在連結串列中查詢節點 4 這個元素,只能從頭開始遍歷連結串列,需要查詢 4 次,而使用了跳錶後,只需要查詢 2 次就能定位到節點 4,因為可以在頭節點直接從 L2 層級跳到節點 3,然後再往前遍歷找到節點 4。
可以看到,這個查詢過程就是在多個層級上跳來跳去,最後定位到元素。當資料量很大時,跳錶的查詢複雜度就是 O(logN)。
那跳錶節點是怎麼實現多層級的呢?這就需要看「跳錶節點」的資料結構了,如下:
typedef struct zskiplistNode {
//Zset 物件的元素值
sds ele;
//元素權重值
double score;
//後向指標
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 結構體裡定義了「指向下一個跳錶節點的指標」和「跨度」,跨度時用來記錄兩個節點之間的距離。
比如,下面這張圖,展示了各個節點的跨度。
第一眼看到跨度的時候,以為是遍歷操作有關,實際上並沒有任何關係,遍歷操作只需要用前向指標就可以完成了。
跨度實際上是為了計算這個節點在跳錶中的排位。具體怎麼做的呢?因為跳錶中的節點都是按序排列的,那麼計算某個節點排位的時候,從頭節點點到該結點的查詢路徑上,將沿途訪問過的所有層的跨度累加起來,得到的結果就是目標節點在跳錶中的排位。
舉個例子,查詢圖中節點 3 在跳錶中的排位,從頭節點開始查詢節點 3,查詢的過程只經過了一個層(L3),並且層的跨度是 3,所以節點 3 在跳錶中的排位是 3。
另外,圖中的頭節點其實也是 zskiplistNode 跳錶節點,只不過頭節點的後向指標、權重、元素值都會被用到,所以圖中省略了這部分。
問題來了,由誰定義哪個跳錶節點是頭節點呢?這就介紹「跳錶」結構體了,如下所示:
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
跳錶結構裡包含了:
- 跳錶的頭尾節點,便於在O(1)時間複雜度內訪問跳錶的頭節點和尾節點;
- 跳錶的長度,便於在O(1)時間複雜度獲取跳錶節點的數量;
- 跳錶的最大層數,便於在O(1)時間複雜度獲取跳錶中層高最大的那個節點的層數量;
跳錶節點查詢過程
查詢一個跳錶節點的過程時,跳錶會從頭節點的最高層開始,逐一遍歷每一層。在遍歷某一層的跳錶節點時,會用跳錶節點中的 SDS 型別的元素和元素的權重來進行判斷,共有兩個判斷條件:
- 如果當前節點的權重「小於」要查詢的權重時,跳錶就會訪問該層上的下一個節點。
- 如果當前節點的權重「等於」要查詢的權重時,並且當前節點的 SDS 型別資料「小於」要查詢的資料時,跳錶就會訪問該層上的下一個節點。
如果上面兩個條件都不滿足,或者下一個節點為空時,跳錶就會使用目前遍歷到的節點的 level 陣列裡的下一層指標,然後沿著下一層指標繼續查詢,這就相當於跳到了下一層接著查詢。
舉個例子,下圖有個 3 層級的跳錶。
如果要查詢「元素:abcd,權重:4」的節點,查詢的過程是這樣的:
- 先從頭節點的最高層開始,L2 指向了「元素:abc,權重:3」節點,這個節點的權重比要查詢節點的小,所以要訪問該層上的下一個節點;
- 但是該層上的下一個節點是空節點,於是就會跳到「元素:abc,權重:3」節點的下一層去找,也就是 leve[1];
- 「元素:abc,權重:3」節點的 leve[1] 的下一個指標指向了「元素:abcde,權重:4」的節點,然後將其和要查詢的節點比較。雖然「元素:abcde,權重:4」的節點的權重和要查詢的權重相同,但是當前節點的 SDS 型別資料「大於」要查詢的資料,所以會繼續跳到「元素:abc,權重:3」節點的下一層去找,也就是 leve[0];
- 「元素:abc,權重:3」節點的 leve[0] 的下一個指標指向了「元素:abcd,權重:4」的節點,該節點正是要查詢的節點,查詢結束。
跳錶節點層數設定
跳錶的相鄰兩層的節點數量的比例會影響跳錶的查詢效能。
舉個例子,下圖的跳錶,第二層的節點數量只有 1 個,而第一層的節點數量有 6 個。
這時,如果想要查詢節點 6,那基本就跟連結串列的查詢複雜度一樣,就需要在第一層的節點中依次順序查詢,複雜度就是 O(N) 了。所以,為了降低查詢複雜度,我們就需要維持相鄰層結點數間的關係。
跳錶的相鄰兩層的節點數量最理想的比例是 2:1,查詢複雜度可以降低到 O(logN)。
下圖的跳錶就是,相鄰兩層的節點數量的比例是 2 : 1。
那怎樣才能維持相鄰兩層的節點數量的比例為 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的連結串列頭
//quicklist的連結串列頭
quicklistNode *tail;
//所有壓縮列表中的總元素個數
unsigned long count;
//quicklistNodes的個數
unsigned long len;
...
} quicklist;
接下來看看,quicklistNode 的結構定義:
typedef struct quicklistNode {
//前一個quicklistNode
struct quicklistNode *prev; //前一個quicklistNode
//下一個quicklistNode
struct quicklistNode *next; //後一個quicklistNode
//quicklistNode指向的壓縮列表
unsigned char *zl;
//壓縮列表的的位元組大小
unsigned int sz;
//壓縮列表的元素個數
unsigned int count : 16; //ziplist中的元素個數
....
} quicklistNode;
可以看到,quicklistNode 結構體裡包含了前一個節點和下一個節點指標,這樣每個 quicklistNode 形成了一個雙向連結串列。但是連結串列節點的元素不再是單純儲存元素值,而是儲存了一個壓縮列表,所以 quicklistNode 結構體裡有個指向壓縮列表的指標 *zl。
我畫了一張圖,方便你理解 quicklist 資料結構。
在向 quicklist 新增一個元素的時候,不會像普通的連結串列那樣,直接新建一個連結串列節點。而是會檢查插入位置的壓縮列表是否能容納該元素,如果能容納就直接儲存到 quicklistNode 結構裡的壓縮列表,如果不能容納,才會新建一個新的 quicklistNode 結構。
quicklist 會控制 quicklistNode 結構裡的壓縮列表的大小或者元素個數,來規避潛在的連鎖更新的風險,但是這並沒有完全解決連鎖更新的問題。
listpack
quicklist 雖然通過控制 quicklistNode 結構裡的壓縮列表的大小或者元素個數,來減少連鎖更新帶來的效能影響,但是並沒有完全解決連鎖更新的問題。
因為 quicklistNode 還是用了壓縮列表來儲存元素,壓縮列表連鎖更新的問題,來源於它的結構設計,所以要想徹底解決這個問題,需要設計一個新的資料結構。
於是,Redis 在 5.0 新設計一個資料結構叫 listpack,目的是替代壓縮列表,它最大特點是 listpack 中每個節點不再包含前一個節點的長度了,壓縮列表每個節點正因為需要儲存前一個節點的長度欄位,就會有連鎖更新的隱患。
我看了 Redis 的 Github,在最新 6.2 發行版本中,Redis Hash 物件、Set 物件的底層資料結構的壓縮列表還未被替換成 listpack,而 Redis 的最新程式碼(還未釋出版本)已經將所有用到壓縮列表底層資料結構的 Redis 物件替換成 listpack 資料結構來實現,估計不久將來,Redis 就會釋出一個將壓縮列表為 listpack 的發行版本。
listpack 結構設計
listpack 採用了壓縮列表的很多優秀的設計,比如還是用一塊連續的記憶體空間來緊湊地儲存資料,並且為了節省記憶體的開銷,listpack 節點會採用不同的編碼方式儲存不同大小的資料。
我們先看看 listpack 結構:
listpack 頭包含兩個屬性,分別記錄了 listpack 總位元組數和元素數量,然後 listpack 末尾也有個結尾標識。圖中的 listpack entry 就是 listpack 的節點了。
每個 listpack 節點結構如下:
主要包含三個方面內容:
- encoding,定義該元素的編碼型別,會對不同長度的整數和字串進行編碼;
- data,實際存放的資料;
- len,encoding+data的總長度;
可以看到,listpack 沒有壓縮列表中記錄前一個節點長度的欄位了,listpack 只記錄當前節點的長度,當我們向 listpack 加入一個新元素的時候,不會影響其他節點的長度欄位的變化,從而避免了壓縮列表的連鎖更新問題。
參考資料:
- 《Redis設計與實現》
- 《Redis 原始碼剖析與實戰》
總結
終於完工了,鬆一口氣。
好久沒寫那麼長的圖解技術文啦,這次瀟瀟灑灑寫了 1.5 萬字 + 畫了 40 多張圖,花費了不少時間,又是看書,又是看原始碼。
希望這篇文章,能幫你破除 Redis 資料結構的迷霧!
我是小林,我們下次再見~
等等等,滑到底的朋友,給個「三連」在走呀!