為了拿捏後端打工人都要會的 Redis 資料結構,我畫了 20 張圖

語言: CN / TW / HK

Redis 為什麼那麼快?

除了它是記憶體資料庫,使得所有的操作都在記憶體上進行之外,還有一個重要因素,它實現的資料結構,使得我們對資料進行增刪查改操作時,Redis 能高效地處理。

因此,這次我們就來好好聊一下 Redis 資料結構,這個在面試中太常見了。

注意,Redis 資料結構並不是指 tring(字串)、List(列表)、Hash(雜湊)、Set(集合)和 Zset(有序集合),因為這些是 Redis 鍵值對中值的資料型別,並不是資料結構。這些資料型別的底層實現的方式,才是資料結構。

Redis 底層的資料結構一共有 6 種,如下圖右邊部分,它和資料型別對應關係也如下圖:

可以看到,有些資料型別可以由兩種組成 資料結構實現,比如:

  • List 資料型別底層資料結構由「雙向連結串列」或「壓縮表列表」實現;
  • Hash 資料型別底層資料結構由「壓縮列表」或「雜湊表」實現;
  • Set 資料型別底層資料結構由「雜湊表」或「整數集合」實現;
  • Zset 資料型別底層資料結構由「壓縮列表」或「跳錶」實現;

好了,不多 BB 了,直接發車!

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”,如果不是說明字串還沒結束,可以繼續操作,如果是則說明字串結束了,停止操作。

舉個例子,C 語言獲取字串長度的函式 strlen,就是通過字元陣列中的每一個字元,並進行計數,等遇到字元為“\0”後,就會停止遍歷,然後返回已經統計到的字元個數,即為字串長度。下圖顯示了 strlen 函式的執行流程:

很明顯,C 語言獲取字串長度操作的時間複雜度是 O(N)( 這是一個可以改進的地方

C 語言的字串用 “\0” 字元作為結尾標記有個缺陷。假設有個字串中有個 “\0” 字元,這時在操作這個字串時就會提早結束,比如 “xiao\0lin” 字串,計算字串長度的時候則會是 4,如下圖:

還有,除了字串中不能 “\0” 字元外,用 char* 字串中的字元必須符合某種編碼(比如ASCII)。

這些限制使得 C 語言的字串只能儲存文字資料,不能儲存像圖片、音訊、影片文化這樣的二進位制資料( 這也是一個可以改進的地方

C 語言標準庫中字串的操作函式是很不安全的,對程式設計師很不友好,稍微一不注意,就會導致緩衝區溢位。

舉個例子,strcat 函式是可以將兩個字串拼接在一起的。

c //將 src 字串拼接到 dest 字串後面 char strcat(char dest, const char* src);

C 語言的字串是不會記錄自身的緩衝區大小的,所以 strcat 函式假定程式設計師在執行這個函式時,已經為 dest 分配了足夠多的記憶體,可以容納 src 字串中的所有內容,而一旦這個假定不成立,就會發生緩衝區溢位將可能會造成程式執行終止,( 這是一個可以改進的地方)。

而且,strcat 函式和 strlen 函式類似,時間複雜度也很高,也都需要先通過遍歷字串才能得到目標字串的末尾。然後對於 strcat 函式來說,還要再遍歷源字串才能完成追加,對字串的操作效率不高。

好了, 通過以上的分析,我們可以得知 C 語言的字串 不足之處以及可以改進的地方:

  • 獲取字串長度的時間複雜度為 O(N);
  • 字串的結尾是以 “\0” 字元標識,而且字元必須符合某種編碼(比如ASCII),只能儲存文字資料,不能儲存二進位制資料;
  • 字串操作函式不高效且不安全,比如可能會發生緩衝區溢位,從而造成程式執行終止;

Redis 實現的 SDS 的結構就把上面這些問題解決了,接下來我們一起看看 Redis 是如何解決的。

SDS 結構設計

下圖就是 Redis 5.0 的 SDS 的資料結構:

結構中的每個成員變數分別介紹下:

  • len,SDS 所儲存的字串長度。這樣獲取字串長度的時候,只需要返回這個變數值就行,時間複雜度只需要 O(1)。
  • alloc,分配給字元陣列的空間長度。這樣在修改字串的時候,可以通過 alloc - len 計算 出剩餘的空間大小,然後用來判斷空間是否滿足修改需求,如果不滿足的話,就會自動將 SDS 的空間擴充套件至執行修改所需的大小,然後才執行實際的修改操作,所以使用 SDS 既不需要手動修改 SDS 的空間大小,也不會出現前面所說的緩衝區益處的問題。
  • flags,SDS 型別,用來表示不同型別的 SDS。一共設計了 5 種類型,分別是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,後面在說明區別之處。
  • buf[],位元組陣列,用來儲存實際資料。不需要用 “\0” 字元來標識字串結尾了,而是直接將其作為二進位制資料處理,可以用來儲存圖片等二進位制資料。它即可以儲存文字資料,也可以儲存二進位制資料,所以叫位元組陣列會更好點。

總的來說,Redis 的 SDS 結構在原本字元陣列之上,增加了三個元資料:len、alloc、flags,用來解決 C 語言字串的缺陷。

O(1)複雜度獲取字串長度

C 語言的字串長度獲取 strlen 函式,需要通過遍歷的方式來統計字串長度,時間複雜度是 O(N)。

而 Redis 的 SDS 結構因為加入了 len 成員變數,那麼獲取字串長度的時候,直接返回這個變數的值就行,所以複雜度只有 O(1)。

二進位制安全

因為 SDS 不需要用 “\0” 字元來標識字串結尾了,而且 SDS 的 API 都是以處理二進位制的方式來處理 SDS 存放在 buf[] 裡的資料,程式不會對其中的資料做任何限制,資料寫入的時候時什麼樣的,它被讀取時就是什麼樣的。

通過使用二進位制安全的 SDS,而不是 C 字串,使得 Redis 不僅 可以儲存文字資料,也可以儲存任意格式的二進位制資料。

不會發生緩衝區溢位

C 語言的字串標準庫提供的字串操作函式,大多數(比如 strcat 追加字串函式)都是不安全的,因為這些函式把緩衝區大小是否滿足操作的工作交由開發者來保證,程式內部並不會判斷緩衝區大小是否足夠用,當發生了緩衝區溢位就有可能造成程式異常結束。

所以,Redis 的 SDS 結構裡引入了 alloc 和 leb 成員變數,這樣 SDS API 通過alloc - len 計算,可以算出剩餘可用的空間大小,這樣在對字串做修改操作的時候,就可以由程式內部判斷緩衝區大小是否足夠用。

而且,當判斷出緩衝區大小不夠用時,Redis 會自動將擴大 SDS 的空間大小,以滿足修改所需的大小。

在擴充套件 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 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 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 的 list 資料型別在資料量比較少的情況下,會採用「壓縮列表」作為底層資料結構的實現,壓縮列表就是由陣列實現的,下面我們會細說壓縮列表。


壓縮列表

壓縮列表是 Redis 資料型別為 list 和 hash 的底層實現之一。

  • 當一個列表鍵(list)只包含少量的列表項,並且每個列表項都是小整數值,或者長度比較短的字串,那麼 Redis 就會使用壓縮列表作為列表鍵(list)的底層實現。
  • 當一個雜湊鍵(hash)只包含少量鍵值對,並且每個鍵值對的鍵和值都是小整數值,或者長度比較短的字串,那麼 Redis 就會使用壓縮列表作為雜湊鍵(hash)的底層實現。

壓縮列表結構設計

壓縮列表是 Redis 為了節約記憶體而開發的,它是由連續記憶體塊組成的順序型資料結構,有點類似於陣列。

壓縮列表在表頭有三個欄位:

  • zlbytes,記錄整個壓縮列表佔用對記憶體位元組數;
  • zltail,記錄壓縮列表「尾部」節點距離起始地址由多少位元組,也就是列表尾的偏移量;
  • zllen,記錄壓縮列表包含的節點數量;
  • zlend,標記壓縮列表的結束點,特殊值 OxFF(十進位制255)。

在壓縮列表中,如果我們要查詢定位第一個元素和最後一個元素,可以通過表頭三個欄位的長度直接定位,複雜度是 O(1)。而查詢其他元素時,就沒有這麼高效了,只能逐個查詢,此時的複雜度就是 O(N) 了。

另外,壓縮列表節點(entry)的構成如下:

壓縮列表節點包含三部分內容:

  • prevlen,記錄了前一個節點的長度;
  • encoding,記錄了當前節點實際資料的型別以及長度;
  • data,記錄了當前節點的實際資料;

當我們往壓縮列表中插入資料時,壓縮列表 就會根據資料是字串還是整數,以及它們的大小會在 prevlen 和 encoding 這兩個元素裡儲存不同的資訊,這種根據資料大小進行對應資訊儲存的設計思想,正是 Redis 為了節省記憶體而採用的。

連鎖更新

壓縮列表除了查詢複雜度高的問題,壓縮列表在插入元素時,如果記憶體空間不夠了,壓縮列表還需要重新分配一塊連續的記憶體空間,而這可能會引發連鎖更新的問題。

壓縮列表裡的每個節點中的 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 的擴充套件…. 一直持續到結尾。

這種在特殊情況下產生的連續多次空間擴充套件操作就叫做「連鎖更新」,就像多米諾牌的效應一樣,第一張牌倒下了,推動了第二張牌倒下;第二張牌倒下,又推動了第三張牌倒下….

連鎖更新一旦發生,就會導致壓縮列表 佔用的記憶體空間要多次重新分配,這就會直接影響到壓縮列表的訪問效能。

所以說,雖然壓縮列表緊湊型的記憶體佈局能節省記憶體開銷,但是如果儲存的元素數量增加了,或是元素變大了,壓縮列表就會面臨「連鎖更新」的風險。

因此,壓縮列表只會用於儲存的節點數量不多的場景,只要節點數量足夠小,即使發生連鎖更新,也是能接受的。

雜湊表

雜湊表是一種儲存鍵值對(key-value)的資料結構。

雜湊表中的每一個 key 都是獨一無二的,程式可以根據 key 查詢到與之關聯的 value,或者通過 key 來更新 value,又或者根據 key 來刪除整個 key-value等等。

在講壓縮列表的時候,提到過 Redis 的 hash 資料型別的底層實現之一是壓縮列表。hash 資料型別的另外一個底層實現就是雜湊表。

那 hash 資料型別什麼時候會選用雜湊表作為底層實現呢?

當一個雜湊鍵包含的 key-value 比較多,或者 key-value 中元素都是比較長多字串時,Redis 就會使用雜湊表作為雜湊鍵的底層實現。

Hash 表優點在於,它能以 O(1) 的複雜度快速查詢資料。主要是通過 Hash 函式的計算,就能定位資料在表中的位置,緊接著可以對資料進行操作,這就使得資料操作非常快。

但是存在的風險也是有,在雜湊表大小固定的情況下,隨著資料不斷增多,那麼雜湊衝突的可能性也會越高。

解決雜湊衝突的方式,有很多種。Redis 採用了鏈式雜湊,在不擴容雜湊表的前提下,將具有相同雜湊值的資料鏈接起來,以便這些資料在表中仍然可以被查詢到。

接下來,詳細說說雜湊衝突以及鏈式雜湊。

雜湊衝突

雜湊表實際上是一個數組,數組裡多每一個元素就是一個雜湊桶。

當一個鍵值對的鍵經過 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 會使用了兩個全域性雜湊表進行 rehash。

在正常服務請求階段,插入的資料,都會寫入到「雜湊表 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 操作。

End