1. 程式人生 > >深度歷險:Redis 記憶體模型詳解

深度歷險:Redis 記憶體模型詳解

Redis 是目前最火爆的記憶體資料庫之一,通過在記憶體中讀寫資料,大大提高了讀寫速度,可以說 Redis 是實現網站高併發不可或缺的一部分。

我們使用 Redis 時,會接觸 Redis 的 5 種物件型別(字串、雜湊、列表、集合、有序集合),豐富的型別是 Redis 相對於 Memcached 等的一大優勢。

在瞭解 Redis 的 5 種物件型別的用法和特點的基礎上,進一步瞭解 Redis 的記憶體模型,對 Redis 的使用有很大幫助,例如:

  • 估算 Redis 記憶體使用量。目前為止,記憶體的使用成本仍然相對較高,使用記憶體不能無所顧忌;根據需求合理的評估 Redis 的記憶體使用量,選擇合適的機器配置,可以在滿足需求的情況下節約成本。

  • 優化記憶體佔用。瞭解 Redis 記憶體模型可以選擇更合適的資料型別和編碼,更好的利用 Redis 記憶體。

  • 分析解決問題。當 Redis 出現阻塞、記憶體佔用等問題時,儘快發現導致問題的原因,便於分析解決問題。

這篇文章主要介紹 Redis 的記憶體模型(以 3.0 為例),包括 Redis 佔用記憶體的情況及如何查詢、不同的物件型別在記憶體中的編碼方式、記憶體分配器(jemalloc)、簡單動態字串(SDS)、RedisObject 等;然後在此基礎上介紹幾個 Redis 記憶體模型的應用。點選這裡檢視Redis面試題彙總。

Redis 記憶體統計

工欲善其事必先利其器,在說明 Redis 記憶體之前首先說明如何統計 Redis 使用記憶體的情況。

在客戶端通過 redis-cli 連線伺服器後(後面如無特殊說明,客戶端一律使用redis-cli),通過 info 命令可以檢視記憶體使用情況:info memory。

其中,info 命令可以顯示 Redis 伺服器的許多資訊,包括伺服器基本資訊、CPU、記憶體、持久化、客戶端連線資訊等等;Memory 是引數,表示只顯示記憶體相關的資訊。

返回結果中比較重要的幾個說明如下:

used_memory

Redis 分配器分配的記憶體總量(單位是位元組),包括使用的虛擬記憶體(即 swap);Redis 分配器後面會介紹。used_memory_human 只是顯示更友好。

used_memory_rss

Redis 程序佔據作業系統的記憶體(單位是位元組),與 top 及 ps 命令看到的值是一致的。

除了分配器分配的記憶體之外,used_memory_rss 還包括程序執行本身需要的記憶體、記憶體碎片等,但是不包括虛擬記憶體。

因此,used_memory 和 used_memory_rss,前者是從 Redis 角度得到的量,後者是從作業系統角度得到的量。

二者之所以有所不同,一方面是因為記憶體碎片和 Redis 程序執行需要佔用記憶體,使得前者可能比後者小,另一方面虛擬記憶體的存在,使得前者可能比後者大。

由於在實際應用中,Redis 的資料量會比較大,此時程序執行佔用的記憶體與 Redis 資料量和記憶體碎片相比,都會小得多。

因此 used_memory_rss 和 used_memory 的比例,便成了衡量 Redis 記憶體碎片率的引數;這個引數就是 mem_fragmentation_ratio。

mem_fragmentation_ratio

記憶體碎片比率,該值是 used_memory_rss / used_memory 的比值。

mem_fragmentation_ratio 一般大於 1,且該值越大,記憶體碎片比例越大;mem_fragmentation_ratio<1,說明 Redis 使用了虛擬記憶體,由於虛擬記憶體的媒介是磁碟,比記憶體速度要慢很多。

當這種情況出現時,應該及時排查,如果記憶體不足應該及時處理,如增加 Redis 節點、增加 Redis 伺服器的記憶體、優化應用等。

一般來說,mem_fragmentation_ratio 在 1.03 左右是比較健康的狀態(對於 jemalloc 來說)。

上面截圖中的 mem_fragmentation_ratio 值很大,是因為還沒有向 Redis 中存入資料,Redis 程序本身執行的記憶體使得 used_memory_rss 比 used_memory 大得多。

mem_allocator

Redis 使用的記憶體分配器,在編譯時指定;可以是 libc 、jemalloc 或者 tcmalloc,預設是 jemalloc;截圖中使用的便是預設的 jemalloc。

Redis 記憶體劃分

Redis 作為記憶體資料庫,在記憶體中儲存的內容主要是資料(鍵值對);通過前面的敘述可以知道,除了資料以外,Redis 的其他部分也會佔用記憶體。點選這裡檢視Redis面試題彙總。

Redis 的記憶體佔用主要可以劃分為以下幾個部分:

資料

作為資料庫,資料是最主要的部分;這部分佔用的記憶體會統計在 used_memory 中。

Redis 使用鍵值對儲存資料,其中的值(物件)包括 5 種類型,即字串、雜湊、列表、集合、有序集合。

這 5 種類型是 Redis 對外提供的,實際上,在 Redis 內部,每種型別可能有 2 種或更多的內部編碼實現。

此外,Redis 在儲存物件時,並不是直接將資料扔進記憶體,而是會對物件進行各種包裝:如 RedisObject、SDS 等;這篇文章後面將重點介紹 Redis 中資料儲存的細節。

程序本身執行需要的記憶體

Redis 主程序本身執行肯定需要佔用記憶體,如程式碼、常量池等等;這部分記憶體大約幾兆,在大多數生產環境中與 Redis 資料佔用的記憶體相比可以忽略。

這部分記憶體不是由 jemalloc 分配,因此不會統計在 used_memory 中。

補充說明:除了主程序外,Redis 建立的子程序執行也會佔用記憶體,如 Redis 執行 AOF、RDB 重寫時建立的子程序。

當然,這部分記憶體不屬於 Redis 程序,也不會統計在 used_memory 和 used_memory_rss 中。

緩衝記憶體

緩衝記憶體包括客戶端緩衝區、複製積壓緩衝區、AOF 緩衝區等;其中,客戶端緩衝區儲存客戶端連線的輸入輸出緩衝;複製積壓緩衝區用於部分複製功能;AOF 緩衝區用於在進行 AOF 重寫時,儲存最近的寫入命令。

在瞭解相應功能之前,不需要知道這些緩衝的細節;這部分記憶體由 jemalloc 分配,因此會統計在 used_memory 中。點選這裡檢視Redis面試題彙總。

記憶體碎片

記憶體碎片是 Redis 在分配、回收物理記憶體過程中產生的。例如,如果對資料的更改頻繁,而且資料之間的大小相差很大,可能導致 Redis 釋放的空間在實體記憶體中並沒有釋放。

但 Redis 又無法有效利用,這就形成了記憶體碎片,記憶體碎片不會統計在 used_memory 中。

記憶體碎片的產生與對資料進行的操作、資料的特點等都有關;此外,與使用的記憶體分配器也有關係:如果記憶體分配器設計合理,可以儘可能的減少記憶體碎片的產生。後面將要說到的 jemalloc 便在控制記憶體碎片方面做的很好。

如果 Redis 伺服器中的記憶體碎片已經很大,可以通過安全重啟的方式減小記憶體碎片:因為重啟之後,Redis 重新從備份檔案中讀取資料,在記憶體中進行重排,為每個資料重新選擇合適的記憶體單元,減小記憶體碎片。

Redis 資料儲存的細節

關於 Redis 資料儲存的細節,涉及到記憶體分配器(如 jemalloc)、簡單動態字串(SDS)、5 種物件型別及內部編碼、RedisObject。在講述具體內容之前,先說明一下這幾個概念之間的關係。

下圖是執行 set hello world 時,所涉及到的資料模型:

 

dictEntry:Redis 是 Key-Value 資料庫,因此對每個鍵值對都會有一個 dictEntry,裡面儲存了指向 Key 和 Value 的指標;next 指向下一個 dictEntry,與本 Key-Value 無關。

Key:圖中右上角可見,Key(”hello”)並不是直接以字串儲存,而是儲存在 SDS 結構中。

RedisObject:Value(“world”)既不是直接以字串儲存,也不是像 Key 一樣直接儲存在 SDS 中,而是儲存在 RedisObject 中。

實際上,不論 Value 是 5 種類型的哪一種,都是通過 RedisObject 來儲存的;而 RedisObject 中的 type 欄位指明瞭 Value 物件的型別,ptr 欄位則指向物件所在的地址。

不過可以看出,字串物件雖然經過了 RedisObject 的包裝,但仍然需要通過 SDS 儲存。

實際上,RedisObject 除了 type 和 ptr 欄位以外,還有其他欄位圖中沒有給出,如用於指定物件內部編碼的欄位。

jemalloc:無論是 DictEntry 物件,還是 RedisObject、SDS 物件,都需要記憶體分配器(如 jemalloc)分配記憶體進行儲存。

以 DictEntry 物件為例,有 3 個指標組成,在 64 位機器下佔 24 個位元組,jemalloc 會為它分配 32 位元組大小的記憶體單元。

下面來分別介紹 jemalloc、RedisObject、SDS、物件型別及內部編碼。

jemalloc

Redis 在編譯時便會指定記憶體分配器;記憶體分配器可以是 libc 、jemalloc 或者 tcmalloc,預設是 jemalloc。

jemalloc 作為 Redis 的預設記憶體分配器,在減小記憶體碎片方面做的相對比較好。

jemalloc 在 64 位系統中,將記憶體空間劃分為小、大、巨大三個範圍;每個範圍內又劃分了許多小的記憶體塊單位;當 Redis 儲存資料時,會選擇大小最合適的記憶體塊進行儲存。

jemalloc 劃分的記憶體單元如下圖所示:

例如,如果需要儲存大小為 130 位元組的物件,jemalloc 會將其放入 160 位元組的記憶體單元中。

RedisObject

前面說到,Redis 物件有 5 種類型;無論是哪種型別,Redis 都不會直接儲存,而是通過 RedisObject 物件進行儲存。

RedisObject 物件非常重要,Redis 物件的型別、內部編碼、記憶體回收、共享物件等功能,都需要 RedisObject 支援,下面將通過 RedisObject 的結構來說明它是如何起作用的。

RedisObject 的定義如下(不同版本的 Redis 可能稍稍有所不同):

RedisObject 的每個欄位的含義和作用如下:

type

type 欄位表示物件的型別,佔 4 個位元;目前包括 REDIS_STRING(字串)、REDIS_LIST (列表)、REDIS_HASH(雜湊)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。

當我們執行 type 命令時,便是通過讀取 RedisObject 的 type 欄位獲得物件的型別;如下圖所示:

encoding

encoding 表示物件的內部編碼,佔 4 個位元。對於 Redis 支援的每種型別,都有至少兩種內部編碼,例如對於字串,有 int、embstr、raw 三種編碼。

通過 encoding 屬性,Redis 可以根據不同的使用場景來為物件設定不同的編碼,大大提高了 Redis 的靈活性和效率。

以列表物件為例,有壓縮列表和雙端連結串列兩種編碼方式;如果列表中的元素較少,Redis 傾向於使用壓縮列表進行儲存,因為壓縮列表佔用記憶體更少,而且比雙端連結串列可以更快載入。

當列表物件元素較多時,壓縮列表就會轉化為更適合儲存大量元素的雙端連結串列。

通過 object encoding 命令,可以檢視物件採用的編碼方式,如下圖所示:

5 種物件型別對應的編碼方式以及使用條件,將在後面介紹。

lru

lru 記錄的是物件最後一次被命令程式訪問的時間,佔據的位元數不同的版本有所不同(如 4.0 版本佔 24 位元,2.6 版本佔 22 位元)。

通過對比 lru 時間與當前時間,可以計算某個物件的空轉時間;object idletime 命令可以顯示該空轉時間(單位是秒)。object idletime 命令的一個特殊之處在於它不改變物件的 lru 值。

lru 值除了通過 object idletime 命令列印之外,還與 Redis 的記憶體回收有關係。

如果 Redis 打開了 maxmemory 選項,且記憶體回收演算法選擇的是 volatile-lru 或 allkeys—lru,那麼當 Redis 記憶體佔用超過 maxmemory 指定的值時,Redis 會優先選擇空轉時間最長的物件進行釋放。

refcount

refcount 與共享物件:refcount 記錄的是該物件被引用的次數,型別為整型。refcount 的作用,主要在於物件的引用計數和記憶體回收。

當建立新物件時,refcount 初始化為 1;當有新程式使用該物件時,refcount 加 1;當物件不再被一個新程式使用時,refcount 減 1;當 refcount 變為 0 時,物件佔用的記憶體會被釋放。

Redis 中被多次使用的物件(refcount>1),稱為共享物件。Redis 為了節省記憶體,當有一些物件重複出現時,新的程式不會建立新的物件,而是仍然使用原來的物件。

這個被重複使用的物件,就是共享物件。目前共享物件僅支援整數值的字串物件。

共享物件的具體實現:Redis 的共享物件目前只支援整數值的字串物件。之所以如此,實際上是對記憶體和 CPU(時間)的平衡:共享物件雖然會降低記憶體消耗,但是判斷兩個物件是否相等卻需要消耗額外的時間。

對於整數值,判斷操作複雜度為 O(1);對於普通字串,判斷複雜度為 O(n);而對於雜湊、列表、集合和有序集合,判斷的複雜度為 O(n^2)。

雖然共享物件只能是整數值的字串物件,但是5種類型都可能使用共享物件(如雜湊、列表等的元素可以使用)。

就目前的實現來說,Redis 伺服器在初始化時,會建立 10000 個字串物件,值分別是 0~9999 的整數值;當 Redis 需要使用值為 0~9999 的字串物件時,可以直接使用這些共享物件。

10000 這個數字可以通過調整引數 REDIS_SHARED_INTEGERS(4.0 中是 OBJ_SHARED_INTEGERS)的值進行改變。

共享物件的引用次數可以通過 object refcount 命令檢視,如下圖所示。命令執行的結果頁佐證了只有 0~9999 之間的整數會作為共享物件。

ptr

ptr 指標指向具體的資料,如前面的例子中,set hello world,ptr 指向包含字串 world 的 SDS。

綜上所述,RedisObject 的結構與物件型別、編碼、記憶體回收、共享物件都有關係。

一個 RedisObject 物件的大小為 16 位元組:4bit+4bit+24bit+4Byte+8Byte=16Byte。

SDS

Redis 沒有直接使用 C 字串(即以空字元’\0’結尾的字元陣列)作為預設的字串表示,而是使用了 SDS。SDS 是簡單動態字串(Simple Dynamic String)的縮寫。

SDS 結構

SDS 的結構如下:

其中,buf 表示位元組陣列,用來儲存字串;len 表示 buf 已使用的長度;free 表示 buf 未使用的長度。

下面是兩個例子:

通過 SDS 的結構可以看出,buf 陣列的長度=free+len+1(其中 1 表示字串結尾的空字元)。

所以,一個 SDS 結構佔據的空間為:free 所佔長度+len 所佔長度+ buf 陣列的長度=4+4+free+len+1=free+len+9。

SDS 與 C 字串的比較

SDS 在 C 字串的基礎上加入了 free 和 len 欄位,帶來了很多好處:

獲取字串長度:SDS 是 O(1),C 字串是 O(n)。

緩衝區溢位:使用 C 字串的 API 時,如果字串長度增加(如 strcat 操作)而忘記重新分配記憶體,很容易造成緩衝區的溢位。

而 SDS 由於記錄了長度,相應的 API 在可能造成緩衝區溢位時會自動重新分配記憶體,杜絕了緩衝區溢位。

修改字串時記憶體的重分配:對於 C 字串,如果要修改字串,必須要重新分配記憶體(先釋放再申請),因為如果沒有重新分配,字串長度增大時會造成記憶體緩衝區溢位,字串長度減小時會造成記憶體洩露。

而對於 SDS,由於可以記錄 len 和 free,因此解除了字串長度和空間陣列長度之間的關聯,可以在此基礎上進行優化。

空間預分配策略(即分配記憶體時比實際需要的多)使得字串長度增大時重新分配記憶體的概率大大減小;惰性空間釋放策略使得字串長度減小時重新分配記憶體的概率大大減小。

存取二進位制資料:SDS 可以,C 字串不可以。因為 C 字串以空字元作為字串結束的標識,而對於一些二進位制檔案(如圖片等)。

內容可能包括空字串,因此 C 字串無法正確存取;而 SDS 以字串長度 len 來作為字串結束標識,因此沒有這個問題。

此外,由於 SDS 中的 buf 仍然使用了 C 字串(即以’\0’結尾),因此 SDS 可以使用 C 字串庫中的部分函式。

但是需要注意的是,只有當 SDS 用來儲存文字資料時才可以這樣使用,在儲存二進位制資料時則不行(’\0’不一定是結尾)。

SDS 與 C 字串的應用

Redis 在儲存物件時,一律使用 SDS 代替 C 字串。例如 set hello world 命令,hello 和 world 都是以 SDS 的形式儲存的。

而 sadd myset member1 member2 member3 命令,不論是鍵(“myset”),還是集合中的元素(“member1”、“member2”和“member3”),都是以 SDS 的形式儲存。

除了儲存物件,SDS 還用於儲存各種緩衝區。只有在字串不會改變的情況下,如列印日誌時,才會使用 C 字串。

Redis 的物件型別與內部編碼

前面已經說過,Redis 支援 5 種物件型別,而每種結構都有至少兩種編碼。

這樣做的好處在於:一方面介面與實現分離,當需要增加或改變內部編碼時,使用者使用不受影響,另一方面可以根據不同的應用場景切換內部編碼,提高效率。

Redis 各種物件型別支援的內部編碼如下圖所示(圖中版本是 Redis3.0,Redis 後面版本中又增加了內部編碼,略過不提;本章所介紹的內部編碼都是基於 3.0 的):

關於 Redis 內部編碼的轉換,都符合以下規律:編碼轉換在 Redis 寫入資料時完成,且轉換過程不可逆,只能從小記憶體編碼向大記憶體編碼轉換。

字串

字串是最基礎的型別,因為所有的鍵都是字串型別,且字串之外的其他幾種複雜型別的元素也是字串,字串長度不能超過 512MB。

內部編碼

字串型別的內部編碼有 3 種,它們的應用場景如下:

int:8 個位元組的長整型。字串值是整型時,這個值使用 long 整型表示。

embstr:<=39 位元組的字串。embstr 與 raw 都使用 RedisObject 和 sds 儲存資料。

區別在於:embstr 的使用只分配一次記憶體空間(因此 RedisObject 和 sds 是連續的),而 raw 需要分配兩次記憶體空間(分別為 RedisObject 和 sds 分配空間)。

因此與 raw 相比,embstr 的好處在於建立時少分配一次空間,刪除時少釋放一次空間,以及物件的所有資料連在一起,尋找方便。

而 embstr 的壞處也很明顯,如果字串的長度增加需要重新分配記憶體時,整個 RedisObject 和 sds 都需要重新分配空間,因此 Redis 中的 embstr 實現為只讀。

raw:大於 39 個位元組的字串。

示例如下圖所示:

embstr 和 raw 進行區分的長度,是 39;是因為 RedisObject 的長度是 16 位元組,sds 的長度是 9+ 字串長度。

因此當字串長度是 39 時,embstr 的長度正好是 16+9+39=64,jemalloc 正好可以分配 64 位元組的記憶體單元。

編碼轉換

當 int 資料不再是整數,或大小超過了 long 的範圍時,自動轉化為 raw。

而對於 embstr,由於其實現是隻讀的,因此在對 embstr 物件進行修改時,都會先轉化為 raw 再進行修改。

因此,只要是修改 embstr 物件,修改後的物件一定是 raw 的,無論是否達到了 39 個位元組。

示例如下圖所示:

列表

列表(list)用來儲存多個有序的字串,每個字串稱為元素;一個列表可以儲存 2^32-1 個元素。

Redis 中的列表支援兩端插入和彈出,並可以獲得指定位置(或範圍)的元素,可以充當陣列、佇列、棧等。

內部編碼

列表的內部編碼可以是壓縮列表(ziplist)或雙端連結串列(linkedlist)。

雙端連結串列:由一個 list 結構和多個 listNode 結構組成;典型結構如下圖所示:

通過圖中可以看出,雙端連結串列同時儲存了表頭指標和表尾指標,並且每個節點都有指向前和指向後的指標。

連結串列中儲存了列表的長度;dup、free 和 match 為節點值設定型別特定函式。

所以連結串列可以用於儲存各種不同型別的值,而連結串列中每個節點指向的是type為字串的 RedisObject。

壓縮列表:壓縮列表是 Redis 為了節約記憶體而開發的,是由一系列特殊編碼的連續記憶體塊(而不是像雙端連結串列一樣每個節點是指標)組成的順序型資料結構,具體結構相對比較複雜。

與雙端連結串列相比,壓縮列表可以節省記憶體空間,但是進行修改或增刪操作時,複雜度較高。

因此當節點數量較少時,可以使用壓縮列表;但是節點數量多時,還是使用雙端連結串列划算。

壓縮列表不僅用於實現列表,也用於實現雜湊、有序列表;使用非常廣泛。

編碼轉換

只有同時滿足下面兩個條件時,才會使用壓縮列表:列表中元素數量小於 512 個;列表中所有字串物件都不足 64 位元組。

如果有一個條件不滿足,則使用雙端列表;且編碼只可能由壓縮列表轉化為雙端連結串列,反方向則不可能。

下圖展示了列表編碼轉換的特點:

其中,單個字串不能超過 64 位元組,是為了便於統一分配每個節點的長度。

這裡的 64 位元組是指字串的長度,不包括 SDS 結構,因為壓縮列表使用連續、定長記憶體塊儲存字串,不需要 SDS 結構指明長度。

後面提到壓縮列表,也會強調長度不超過 64 位元組,原理與這裡類似。

雜湊

雜湊(作為一種資料結構),不僅是 Redis 對外提供的 5 種物件型別的一種(與字串、列表、集合、有序結合並列),也是 Redis 作為 Key-Value 資料庫所使用的資料結構。

為了說明的方便,在本文後面當使用“內層的雜湊”時,代表的是 Redis 對外提供的 5 種物件型別的一種;使用“外層的雜湊”代指 Redis 作為 Key-Value 資料庫所使用的資料結構。

內部編碼

內層的雜湊使用的內部編碼可以是壓縮列表(ziplist)和雜湊表(hashtable)2 種;Redis 的外層的雜湊則只使用了 hashtable。

壓縮列表前面已介紹,與雜湊表相比,壓縮列表用於元素個數少、元素長度小的場景;其優勢在於集中儲存,節省空間。

同時,雖然對於元素的操作複雜度也由 O(n)變為了 O(1),但由於雜湊中元素數量較少,因此操作的時間並沒有明顯劣勢。

hashtable:一個 hashtable 由 1 個 dict 結構、2 個 dictht 結構、1 個 dictEntry 指標陣列(稱為 bucket)和多個 dictEntry 結構組成。

正常情況下(即 hashtable 沒有進行 rehash 時),各部分關係如下圖所示:

下面從底層向上依次介紹各個部分:

dictEntry:dictEntry 結構用於儲存鍵值對,結構定義如下。

其中,各個屬性的功能如下:

  • key:鍵值對中的鍵。

  • val:鍵值對中的值,使用 union(即共用體)實現,儲存的內容既可能是一個指向值的指標,也可能是 64 位整型,或無符號 64 位整型。

  • next:指向下一個 dictEntry,用於解決雜湊衝突問題。

在 64 位系統中,一個 dictEntry 物件佔 24 位元組(key/val/next 各佔 8 位元組)。

bucket:bucket 是一個數組,陣列的每個元素都是指向 dictEntry 結構的指標。

Redis 中 bucket 陣列的大小計算規則如下:大於 dictEntry 的、最小的 2^n。

例如,如果有 1000 個 dictEntry,那麼 bucket 大小為 1024;如果有 1500 個 dictEntry,則 bucket 大小為 2048。

dictht:dictht 結構如下。

其中,各個屬性的功能說明如下:

  • table 屬性是一個指標,指向 bucket。

  • size 屬性記錄了雜湊表的大小,即 bucket 的大小。

  • used 記錄了已使用的 dictEntry 的數量。

  • sizemask 屬性的值總是為 size-1,這個屬性和雜湊值一起決定一個鍵在 table 中儲存的位置。

dict:一般來說,通過使用 dictht 和 dictEntry 結構,便可以實現普通雜湊表的功能。

但是 Redis 的實現中,在 dictht 結構的上層,還有一個 dict 結構。下面說明 dict 結構的定義及作用。

dict 結構如下:

其中,type 屬性和 privdata 屬性是為了適應不同型別的鍵值對,用於建立多型字典。

ht 屬性和 trehashidx 屬性則用於 rehash,即當雜湊表需要擴充套件或收縮時使用。

ht 是一個包含兩個項的陣列,每項都指向一個 dictht 結構,這也是 Redis 的雜湊會有 1 個 dict、2 個 dictht 結構的原因。

通常情況下,所有的資料都是存在放 dict 的 ht[0] 中,ht[1] 只在 rehash 的時候使用。

dict 進行 rehash 操作的時候,將 ht[0] 中的所有資料 rehash 到 ht[1] 中。然後將 ht[1] 賦值給 ht[0],並清空 ht[1]。

因此,Redis 中的雜湊之所以在 dictht 和 dictEntry 結構之外還有一個 dict 結構,一方面是為了適應不同型別的鍵值對,另一方面是為了 rehash。

編碼轉換

如前所述,Redis 中內層的雜湊既可能使用雜湊表,也可能使用壓縮列表。

只有同時滿足下面兩個條件時,才會使用壓縮列表:雜湊中元素數量小於 512 個;雜湊中所有鍵值對的鍵和值字串長度都小於 64 位元組。

如果有一個條件不滿足,則使用雜湊表;且編碼只可能由壓縮列表轉化為雜湊表,反方向則不可能。

下圖展示了 Redis 內層的雜湊編碼轉換的特點:

集合

集合(set)與列表類似,都是用來儲存多個字串,但集合與列表有兩點不同:集合中的元素是無序的,因此不能通過索引來操作元素;集合中的元素不能有重複。

一個集合中最多可以儲存 2^32-1 個元素;除了支援常規的增刪改查,Redis 還支援多個集合取交集、並集、差集。

內部編碼

集合的內部編碼可以是整數集合(intset)或雜湊表(hashtable)。

雜湊表前面已經講過,這裡略過不提;需要注意的是,集合在使用雜湊表時,值全部被置為 null。

整數集合的結構定義如下:

其中,encoding 代表 contents 中儲存內容的型別,雖然 contents(儲存集合中的元素)是 int8_t 型別。

但實際上其儲存的值是 int16_t、int32_t 或 int64_t,具體的型別便是由 encoding 決定的,length 表示元素個數。

整數集合適用於集合所有元素都是整數且集合元素數量較小的時候,與雜湊表相比,整數集合的優勢在於集中儲存,節省空間。

同時,雖然對於元素的操作複雜度也由 O(n) 變為了 O(1),但由於集合數量較少,因此操作的時間並沒有明顯劣勢。

編碼轉換

只有同時滿足下面兩個條件時,集合才會使用整數集合:集合中元素數量小於 512 個,集合中所有元素都是整數值。

如果有一個條件不滿足,則使用雜湊表;且編碼只可能由整數集合轉化為雜湊表,反方向則不可能。

下圖展示了集合編碼轉換的特點:

有序集合

有序集合與集合一樣,元素都不能重複;但與集合不同的是,有序集合中的元素是有順序的。

與列表使用索引下標作為排序依據不同,有序集合為每個元素設定一個分數(score)作為排序依據。

內部編碼

有序集合的內部編碼可以是壓縮列表(ziplist)或跳躍表(skiplist)。ziplist 在列表和雜湊中都有使用,前面已經講過,這裡略過不提。

跳躍表是一種有序資料結構,通過在每個節點中維持多個指向其他節點的指標,從而達到快速訪問節點的目的。

除了跳躍表,實現有序資料結構的另一種典型實現是平衡樹;大多數情況下,跳躍表的效率可以和平衡樹媲美,且跳躍表實現比平衡樹簡單很多,因此 Redis 中選用跳躍表代替平衡樹。

跳躍表支援平均 O(logN)、最壞 O(N) 的複雜點進行節點查詢,並支援順序操作。

Redis 的跳躍表實現由 zskiplist 和 zskiplistNode 兩個結構組成:前者用於儲存跳躍表資訊(如頭結點、尾節點、長度等),後者用於表示跳躍表節點,具體結構相對比較複雜。

編碼轉換

只有同時滿足下面兩個條件時,才會使用壓縮列表:有序集合中元素數量小於 128 個;有序集合中所有成員長度都不足 64 位元組。

如果有一個條件不滿足,則使用跳躍表;且編碼只可能由壓縮列表轉化為跳躍表,反方向則不可能。

下圖展示了有序集合編碼轉換的特點:

應用舉例

瞭解 Redis 的記憶體模型之後,下面通過幾個例子說明它的應用。

估算 Redis 記憶體使用量

要估算 Redis 中的資料佔據的記憶體大小,需要對 Redis 的記憶體模型有比較全面的瞭解,包括前面介紹的 hashtable、sds、redisobject、各種物件型別的編碼方式等。

下面以最簡單的字串型別來進行說明。

假設有 90000 個鍵值對,每個 key 的長度是 7 個位元組,每個 value 的長度也是 7 個位元組(且 key 和 value 都不是整數),下面來估算這 90000 個鍵值對所佔用的空間。

在估算佔據空間之前,首先可以判定字串型別使用的編碼方式:embstr。

90000 個鍵值對佔據的記憶體空間主要可以分為兩部分:

  • 90000 個 dictEntry 佔據的空間。

  • 鍵值對所需要的 bucket 空間。

每個 dictEntry 佔據的空間包括:

  • 一個 dictEntry,24 位元組,jemalloc 會分配 32 位元組的記憶體塊。

  • 一個 key,7 位元組,所以 SDS(key)需要 7+9=16 個位元組,jemalloc 會分配 16 位元組的記憶體塊。

  • 一個 RedisObject,16 位元組,jemalloc 會分配 16 位元組的記憶體塊。

  • 一個 value,7 位元組,所以 SDS(value)需要 7+9=16 個位元組,jemalloc 會分配 16 位元組的記憶體塊。

  • 綜上,一個 dictEntry 需要 32+16+16+16=80 個位元組。

bucket 空間:bucket 陣列的大小為大於 90000 的最小的 2^n,是 131072;每個 bucket 元素為 8 位元組(因為 64 位系統中指標大小為 8 位元組)。

因此,可以估算出這 90000 個鍵值對佔據的記憶體大小為:90000*80 + 131072*8 = 8248576。

下面寫個程式在 Redis 中驗證一下:

執行結果:8247552。

理論值與結果值誤差在萬分之 1.2,對於計算需要多少記憶體來說,這個精度已經足夠了。

之所以會存在誤差,是因為在我們插入 90000 條資料之前 Redis 已分配了一定的 bucket 空間,而這些 bucket 空間尚未使用。

作為對比將 key 和 value 的長度由 7 位元組增加到 8 位元組,則對應的 SDS 變為 17 個位元組,jemalloc 會分配 32 個位元組,因此每個 dictEntry 佔用的位元組數也由 80 位元組變為 112 位元組。

此時估算這 90000 個鍵值對佔據記憶體大小為:90000*112 + 131072*8 = 11128576。

在Redis 中驗證程式碼如下(只修改插入資料的程式碼):

執行結果:11128576,估算準確。

對於字串型別之外的其他型別,對記憶體佔用的估算方法是類似的,需要結合具體型別的編碼方式來確定。

優化記憶體佔用

瞭解 Redis 的記憶體模型,對優化 Redis 記憶體佔用有很大幫助。下面介紹幾種優化場景。

利用 jemalloc 特性進行優化

上一小節所講述的 90000 個鍵值便是一個例子。由於 jemalloc 分配記憶體時數值是不連續的,因此 key/value 字串變化一個位元組,可能會引起佔用記憶體很大的變動,在設計時可以利用這一點。

例如,如果 key 的長度是 8 個位元組,則 SDS 為 17 位元組,jemalloc 分配 32 位元組。

此時將 key 長度縮減為 7 個位元組,則 SDS 為 16 位元組,jemalloc 分配 16 位元組;則每個 key 所佔用的空間都可以縮小一半。

使用整型/長整型

如果是整型/長整型,Redis 會使用 int 型別(8 位元組)儲存來代替字串,可以節省更多空間。

因此在可以使用長整型/整型代替字串的場景下,儘量使用長整型/整型。

共享物件

利用共享物件,可以減少物件的建立(同時減少了 RedisObject 的建立),節省記憶體空間。

目前 Redis 中的共享物件只包括 10000 個整數(0-9999);可以通過調整 REDIS_SHARED_INTEGERS 引數提高共享物件的個數。

例如將 REDIS_SHARED_INTEGERS 調整到 20000,則 0-19999 之間的物件都可以共享。

考慮這樣一種場景:論壇網站在 Redis 中儲存了每個帖子的瀏覽數,而這些瀏覽數絕大多數分佈在 0-20000 之間。

這時候通過適當增大 REDIS_SHARED_INTEGERS 引數,便可以利用共享物件節省記憶體空間。

避免過度設計

然而需要注意的是,不論是哪種優化場景,都要考慮記憶體空間與設計複雜度的權衡;而設計複雜度會影響到程式碼的複雜度、可維護性。

如果資料量較小,那麼為了節省記憶體而使得程式碼的開發、維護變得更加困難並不划算;還是以前面講到的 90000 個鍵值對為例,實際上節省的記憶體空間只有幾 MB。

但是如果資料量有幾千萬甚至上億,考慮記憶體的優化就比較必要了。

關注記憶體碎片率

記憶體碎片率是一個重要的引數,對 Redis 記憶體的優化有重要意義。

如果記憶體碎片率過高(jemalloc 在 1.03 左右比較正常),說明記憶體碎片多,記憶體浪費嚴重。

這時便可以考慮重啟 Redis 服務,在記憶體中對資料進行重排,減少記憶體碎片。

如果記憶體碎片率小於 1,說明 Redis 記憶體不足,部分資料使用了虛擬記憶體(即 swap)。

由於虛擬記憶體的存取速度比實體記憶體差很多(2-3 個數量級),此時 Redis 的訪問速度可能會變得很慢。

因此必須設法增大實體記憶體(可以增加伺服器節點數量,或提高單機記憶體),或減少 Redis 中的資料。

要減少 Redis 中的資料,除了選用合適的資料型別、利用共享物件等,還有一點是要設定合理的資料回收策略(maxmemory-policy),當記憶體達到一定量後,根據不同的優先順序對記憶體進行回收。