Redis-記憶體優化(一)

妮蔻發表於2021-06-08

一、正確使用redis 資料型別

  我們先了解下 String 型別的記憶體空間消耗問題,以及選擇節省記憶體開銷的資料型別的解決方案。例如一個圖片儲存系統,要求這個系統能快速地記錄圖片 ID 和圖片在儲存系統中儲存時的 ID(可以直接叫作圖片儲存物件 ID)。同時,還要能夠根據圖片 ID 快速查詢到圖片儲存物件 ID。因為圖片數量巨大,所以我們就用 10 位數來表示圖片 ID 和圖片儲存物件 ID,例如,圖片 ID 為 1101000051,它在儲存系統中對應的 ID 號是 3301000051。

photo_id: 1101000051
photo_obj_id: 3301000051

如果我們的第一個方案就是用 String 儲存資料。我們把圖片 ID 和圖片儲存物件 ID 分別作為鍵值對的 key 和 value 來儲存,其中,圖片儲存物件 ID 用了 String 型別。剛開始,我們儲存了 1 億張圖片,大約用了 6.4GB 的記憶體。但是,隨著圖片資料量的不斷增加,我們的 Redis 記憶體使用量也在增加,結果就遇到了大記憶體 Redis 例項因為生成 RDB 而響應變慢的問題。很顯然,String 型別並不是一種好的選擇,我們還需要進一步尋找能節省記憶體開銷的資料型別方案。在這個過程中,發現String型別的記憶體開銷巨大,對“萬金油”的 String 型別有了全新的認知:String 型別並不是適用於所有場合的,它有一個明顯的短板,就是它儲存資料時所消耗的記憶體空間較多。

為什麼String型別記憶體開銷大呢?

在剛才的案例中,我們儲存了 1 億張圖片的資訊,用了約 6.4GB 的記憶體,一個圖片 ID 和圖片儲存物件 ID 的記錄平均用了 64 位元組。但問題是,一組圖片 ID 及其儲存物件 ID 的記錄,實際只需要 16 位元組就可以了。

我們來分析一下。圖片 ID 和圖片儲存物件 ID 都是 10 位數,我們可以用兩個 8 位元組的 Long 型別表示這兩個 ID。因為 8 位元組的 Long 型別最大可以表示 2 的 64 次方的數值,所以肯定可以表示 10 位數。但是,為什麼 String 型別卻用了 64 位元組呢?

其實,除了記錄實際資料,String 型別還需要額外的記憶體空間記錄資料長度、空間使用等資訊,這些資訊也叫作後設資料。當實際儲存的資料較小時,後設資料的空間開銷就顯得比較大了。那麼,String 型別具體是怎麼儲存資料的呢?

因為當你儲存 64 位有符號整數時,String 型別會把它儲存為一個 8 位元組的 Long 型別整數,這種儲存方式通常也叫作 int 編碼方式。但是,當你儲存的資料中包含字元時,String 型別就會用簡單動態字串(Simple Dynamic String,SDS)結構體來儲存,如下圖所示:

 

 

 

 

 

buf:位元組陣列,儲存實際資料。為了表示位元組陣列的結束,Redis 會自動在陣列最後加一個“\0”,這就會額外佔用 1 個位元組的開銷。

len:佔 4 個位元組,表示 buf 的已用長度。

alloc:也佔個 4 位元組,表示 buf 的實際分配長度,一般大於 len。

可以看到,在 SDS 中,buf 儲存實際資料,而 len 和 alloc 本身其實是 SDS 結構體的額外開銷。另外,對於 String 型別來說,除了 SDS 的額外開銷,還有一個來自於 RedisObject 結構體的開銷。因為 Redis 的資料型別有很多,而且,不同資料型別都有些相同的後設資料要記錄(比如最後一次訪問的時間、被引用的次數等),所以,Redis 會用一個 RedisObject 結構體來統一記錄這些後設資料,同時指向實際資料。一個 RedisObject 包含了 8 位元組的後設資料和一個 8 位元組指標,這個指標再進一步指向具體資料型別的實際資料所在,例如指向 String 型別的 SDS 結構所在的記憶體地址,可以看一下下面的示意圖。

 

 

 

 

 為了節省記憶體空間,Redis 還對 Long 型別整數和 SDS 的記憶體佈局做了專門的設計。一方面,當儲存的是 Long 型別整數時,RedisObject 中的指標就直接賦值為整數資料了,這樣就不用額外的指標再指向整數了,節省了指標的空間開銷。另一方面,當儲存的是字串資料,並且字串小於等於 44 位元組時,RedisObject 中的後設資料、指標和 SDS 是一塊連續的記憶體區域,這樣就可以避免記憶體碎片。這種佈局方式也被稱為 embstr 編碼方式。當然,當字串大於 44 位元組時,SDS 的資料量就開始變多了,Redis 就不再把 SDS 和 RedisObject 佈局在一起了,而是會給 SDS 分配獨立的空間,並用指標指向 SDS 結構。這種佈局方式被稱為 raw 編碼模式。int、embstr 和 raw 這三種編碼模式示意圖如下:

 好了,知道了 RedisObject 所包含的額外後設資料開銷,借來下來計算 String 型別的記憶體使用量了。

因為 10 位數的圖片 ID 和圖片儲存物件 ID 是 Long 型別整數,所以可以直接用 int 編碼的 RedisObject 儲存。每個 int 編碼的 RedisObject 後設資料部分佔 8 位元組,指標部分被直接賦值為 8 位元組的整數了。此時,每個 ID 會使用 16 位元組,加起來一共是 32 位元組。但是,另外的 32 位元組去哪兒了呢?

如果大家有了解過redis的底層資料結構的話,Redis 會使用一個全域性雜湊表儲存所有鍵值對,雜湊表的每一項是一個 dictEntry 的結構體,用來指向一個鍵值對。dictEntry 結構中有三個 8 位元組的指標,分別指向 key、value 以及下一個 dictEntry,三個指標共 24 位元組,如下圖所示:

 

 

 但是,這三個指標只有 24 位元組,為什麼會佔用了 32 位元組呢?這就要提到 Redis 使用的記憶體分配庫 jemalloc 了。jemalloc 在分配記憶體時,會根據我們申請的位元組數 N,找一個比 N 大,但是最接近 N 的 2 的冪次數作為分配的空間,這樣可以減少頻繁分配的次數。

舉個例子。如果你申請 6 位元組空間,jemalloc 實際會分配 8 位元組空間;如果你申請 24 位元組空間,jemalloc 則會分配 32 位元組。所以,在我們剛剛說的場景裡,dictEntry 結構就佔用了 32 位元組。

那麼我們該選擇redis那種資料結構呢?

Redis 有一種底層資料結構,叫壓縮列表(ziplist),這是一種非常節省記憶體的結構。壓縮列表的構成由三個欄位 zlbytes、zltail 和 zllen,分別表示列表長度、列表尾的偏移量,以及列表中的 entry 個數。壓縮列表尾還有一個 zlend,表示列表結束。

 

 

 壓縮列表之所以能節省記憶體,就在於它是用一系列連續的 entry 儲存資料。每個 entry 的後設資料包括下面幾部分:

prev_len,表示前一個 entry 的長度。prev_len 有兩種取值情況:1 位元組或 5 位元組。取值 1 位元組時,表示上一個 entry 的長度小於 254 位元組。雖然 1 位元組的值能表示的數值範圍是 0 到 255,但是壓縮列表中 zlend 的取值預設是 255,因此,就預設用 255 表示整個壓縮列表的結束,其他表示長度的地方就不能再用 255 這個值了。所以,當上一個 entry 長度小於 254 位元組時,prev_len 取值為 1 位元組,否則,就取值為 5 位元組。

len:表示自身長度,4 位元組;

encoding:表示編碼方式,1 位元組;

content:儲存實際資料。

 

這些 entry 會挨個兒放置在記憶體中,不需要再用額外的指標進行連線,這樣就可以節省指標所佔用的空間。我們以儲存圖片儲存物件 ID 為例,來分析一下壓縮列表是如何節省記憶體空間的。每個 entry 儲存一個圖片儲存物件 ID(8 位元組),此時,每個 entry 的 prev_len 只需要 1 個位元組就行,因為每個 entry 的前一個 entry 長度都只有 8 位元組,小於 254 位元組。這樣一來,一個圖片的儲存物件 ID 所佔用的記憶體大小是 14 位元組(1+4+1+8=14),實際分配 16 位元組。

Redis 基於壓縮列表實現了 List、Hash 和 Sorted Set 這樣的集合型別,這樣做的最大好處就是節省了 dictEntry 的開銷。當你用 String 型別時,一個鍵值對就有一個 dictEntry,要用 32 位元組空間。但採用集合型別時,一個 key 就對應一個集合的資料,能儲存的資料多了很多,但也只用了一個 dictEntry,這樣就節省了記憶體。這個方案聽起來很好,但還存在一個問題:在用集合型別儲存鍵值對時,一個鍵對應了一個集合的資料,但是在我們的場景中,一個圖片 ID 只對應一個圖片的儲存物件 ID,我們該怎麼用集合型別呢?換句話說,在一個鍵對應一個值(也就是單值鍵值對)的情況下,我們該怎麼用集合型別來儲存這種單值鍵值對呢?

在儲存單值的鍵值對時,可以採用基於 Hash 型別的二級編碼方法。這裡說的二級編碼,就是把一個單值的資料拆分成兩部分,前一部分作為 Hash 集合的 key,後一部分作為 Hash 集合的 value,這樣一來,我們就可以把單值資料儲存到 Hash 集合中了。以圖片 ID 1101000060 和圖片儲存物件 ID 3302000080 為例,我們可以把圖片 ID 的前 7 位(1101000)作為 Hash 型別的鍵,把圖片 ID 的最後 3 位(060)和圖片儲存物件 ID 分別作為 Hash 型別值中的 key 和 value。按照這種設計方法,我在 Redis 中插入了一組圖片 ID 及其儲存物件 ID 的記錄,並且用 info 命令檢視了記憶體開銷,我發現,增加一條記錄後,記憶體佔用只增加了 16 位元組,如下所示:

127.0.0.1:6379> info memory
#Memory
used_memory:1039120
127.0.0.1:6379> hset 1101000 060 3302000080
(integer) 1
127.0.0.1:6379> info memory
# Memory
used_memory:1039136

在使用 String 型別時,每個記錄需要消耗 64 位元組,這種方式卻只用了 16 位元組,所使用的記憶體空間是原來的 1/4,滿足了我們節省記憶體空間的需求。不過,你可能也會有疑惑:“二級編碼一定要把圖片 ID 的前 7 位作為 Hash 型別的鍵,把最後 3 位作為 Hash 型別值中的 key 嗎?”其實,二級編碼方法中採用的 ID 長度是有講究的。

Redis Hash 型別的兩種底層實現結構,分別是壓縮列表和雜湊表。那麼,Hash 型別底層結構什麼時候使用壓縮列表,什麼時候使用雜湊表呢?

其實,Hash 型別設定了用壓縮列表儲存資料時的兩個閾值,一旦超過了閾值,Hash 型別就會用雜湊表來儲存資料了。這兩個閾值分別對應以下兩個配置項:

hash-max-ziplist-entries:表示用壓縮列表儲存時雜湊集合中的最大元素個數。

hash-max-ziplist-value:表示用壓縮列表儲存時雜湊集合中單個元素的最大長度。

如果我們往 Hash 集合中寫入的元素個數超過了 hash-max-ziplist-entries,或者寫入的單個元素大小超過了 hash-max-ziplist-value,Redis 就會自動把 Hash 型別的實現結構由壓縮列表轉為雜湊表。一旦從壓縮列表轉為了雜湊表,Hash 型別就會一直用雜湊表進行儲存,而不會再轉回壓縮列表了。在節省記憶體空間方面,雜湊表就沒有壓縮列表那麼高效了。

為了能充分使用壓縮列表的精簡記憶體佈局,我們一般要控制儲存在 Hash 集合中的元素個數。所以,在剛才的二級編碼中,我們只用圖片 ID 最後 3 位作為 Hash 集合的 key,也就保證了 Hash 集合的元素個數不超過 1000,同時,我們把 hash-max-ziplist-entries 設定為 1000,這樣一來,Hash 集合就可以一直使用壓縮列表來節省記憶體空間了。

 

二、記憶體碎片化

 在使用 Redis 時,我們經常會遇到這樣一個問題:明明做了資料刪除,資料量已經不大了,為什麼使用 top 命令檢視時,還會發現 Redis 佔用了很多記憶體呢?實際上,這是因為,當資料刪除後,Redis 釋放的記憶體空間會由記憶體分配器管理,並不會立即返回給作業系統。所以,作業系統仍然會記錄著給 Redis 分配了大量記憶體。但是,這往往會伴隨一個潛在的風險點:Redis 釋放的記憶體空間可能並不是連續的,那麼,這些不連續的記憶體空間很有可能處於一種閒置的狀態。這就會導致一個問題:雖然有空閒空間,Redis 卻無法用來儲存資料,不僅會減少 Redis 能夠實際儲存的資料量,還會降低 Redis 執行機器的成本回報率。

Redis 中的記憶體碎片是什麼原因導致的呢?接下來,我帶你來具體看一看。我們只有瞭解了記憶體碎片的成因,才能對症下藥,把 Redis 佔用的記憶體空間充分利用起來,增加儲存的資料量。

其實,記憶體碎片的形成有內因和外因兩個層面的原因。簡單來說,內因是作業系統的記憶體分配機制,外因是 Redis 的負載特徵。

內因:記憶體分配器的分配策略

記憶體分配器的分配策略就決定了作業系統無法做到“按需分配”。這是因為,記憶體分配器一般是按固定大小來分配記憶體,而不是完全按照應用程式申請的記憶體空間大小給程式分配。

Redis 可以使用 libc、jemalloc、tcmalloc 多種記憶體分配器來分配記憶體,預設使用 jemalloc。接下來,我就以 jemalloc 為例,來具體解釋一下。其他分配器也存在類似的問題。jemalloc 的分配策略之一,是按照一系列固定的大小劃分記憶體空間,例如 8 位元組、16 位元組、32 位元組、48 位元組,…, 2KB、4KB、8KB 等。當程式申請的記憶體最接近某個固定值時,jemalloc 會給它分配相應大小的空間。

這樣的分配方式本身是為了減少分配次數。例如,Redis 申請一個 20 位元組的空間儲存資料,jemalloc 就會分配 32 位元組,此時,如果應用還要寫入 10 位元組的資料,Redis 就不用再向作業系統申請空間了,因為剛才分配的 32 位元組已經夠用了,這就避免了一次分配操作。但是,如果 Redis 每次向分配器申請的記憶體空間大小不一樣,這種分配方式就會有形成碎片的風險,而這正好來源於 Redis 的外因了。

外因:鍵值對大小不一樣和刪改操作

Redis 通常作為共用的快取系統或鍵值資料庫對外提供服務,所以,不同業務應用的資料都可能儲存在 Redis 中,這就會帶來不同大小的鍵值對。這樣一來,Redis 申請記憶體空間分配時,本身就會有大小不一的空間需求。這是第一個外因。

從上面,我們知道記憶體分配器只能按固定大小分配記憶體,所以,分配的記憶體空間一般都會比申請的空間大一些,不會完全一致,這本身就會造成一定的碎片,降低記憶體空間儲存效率。

比如說,應用 A 儲存 6 位元組資料,jemalloc 按分配策略分配 8 位元組。如果應用 A 不再儲存新資料,那麼,這裡多出來的 2 位元組空間就是記憶體碎片了,如下圖所示:

 

 

 第二個外因是,這些鍵值對會被修改和刪除,這會導致空間的擴容和釋放。具體來說,一方面,如果修改後的鍵值對變大或變小了,就需要佔用額外的空間或者釋放不用的空間。另一方面,刪除的鍵值對就不再需要記憶體空間了,此時,就會把空間釋放出來,形成空閒空間。

如下圖:

 

 

 一開始,應用 A、B、C、D 分別儲存了 3、1、2、4 位元組的資料,並佔據了相應的記憶體空間。然後,應用 D 刪除了 1 個位元組,這個 1 位元組的記憶體空間就空出來了。緊接著,應用 A 修改了資料,從 3 位元組變成了 4 位元組。為了保持 A 資料的空間連續性,作業系統就需要把 B 的資料拷貝到別的空間,比如拷貝到 D 剛剛釋放的空間中。此時,應用 C 和 D 也分別刪除了 2 位元組和 1 位元組的資料,整個記憶體空間上就分別出現了 2 位元組和 1 位元組的空閒碎片。如果應用 E 想要一個 3 位元組的連續空間,顯然是不能得到滿足的。因為,雖然空間總量夠,但卻是碎片空間,並不是連續的。

好了,到這裡,我們就知道了造成記憶體碎片的內外因素,其中,記憶體分配器策略是內因,而 Redis 的負載屬於外因,包括了大小不一的鍵值對和鍵值對修改刪除帶來的記憶體空間變化。大量記憶體碎片的存在,會造成 Redis 的記憶體實際利用率變低,接下來,我們就要來解決這個問題了。不過,在解決問題前,我們要先判斷 Redis 執行過程中是否存在記憶體碎片。

如何判斷是否有記憶體碎片?

Redis 是記憶體資料庫,記憶體利用率的高低直接關係到 Redis 執行效率的高低。為了讓使用者能監控到實時的記憶體使用情況,Redis 自身提供了 INFO 命令,可以用來查詢記憶體使用的詳細資訊,命令如下:

INFO memory
# Memory
used_memory:1073741736
used_memory_human:1024.00M
used_memory_rss:1997159792
used_memory_rss_human:1.86G
…
mem_fragmentation_ratio:1.86

這裡有一個 mem_fragmentation_ratio 的指標,它表示的就是 Redis 當前的記憶體碎片率。那麼,這個碎片率是怎麼計算的呢?其實,就是上面的命令中的兩個指標 used_memory_rss 和 used_memory 相除的結果。

mem_fragmentation_ratio = used_memory_rss/ used_memory

used_memory_rss 是作業系統實際分配給 Redis 的實體記憶體空間,裡面就包含了碎片;而 used_memory 是 Redis 為了儲存資料實際申請使用的空間。我簡單舉個例子。例如,Redis 申請使用了 100 位元組(used_memory),作業系統實際分配了 128 位元組(used_memory_rss),此時,mem_fragmentation_ratio 就是 1.28。

那麼,知道了這個指標,我們該如何使用呢?

在這兒,我提供一些經驗閾值:

    mem_fragmentation_ratio 大於 1 但小於 1.5。這種情況是合理的。這是因為,剛才我介紹的那些因素是難以避免的。畢竟,內因的記憶體分配器是一定要使用的,分配策略都是通用的,不會輕易修改;而外因由 Redis 負載決定,也無法限制。所以,存在記憶體碎片也是正常的。

    mem_fragmentation_ratio 大於 1.5 。這表明記憶體碎片率已經超過了 50%。一般情況下,這個時候,我們就需要採取一些措施來降低記憶體碎片率了。

如何清理記憶體碎片?

當 Redis 發生記憶體碎片後,一個“簡單粗暴”的方法就是重啟 Redis 例項。當然,這並不是一個“優雅”的方法,畢竟,重啟 Redis 會帶來兩個後果:

    如果 Redis 中的資料沒有持久化,那麼,資料就會丟失;

    即使 Redis 資料持久化了,我們還需要通過 AOF 或 RDB 進行恢復,恢復時長取決於 AOF 或 RDB 的大小,如果只有一個 Redis 例項,恢復階段無法提供服務。

幸運的是,從 4.0-RC3 版本以後,Redis 自身提供了一種記憶體碎片自動清理的方法,我們先來看這個方法的基本機制。記憶體碎片清理,簡單來說,就是“搬家讓位,合併空間”。

不過,需要注意的是:

碎片清理是有代價的,作業系統需要把多份資料拷貝到新位置,把原有空間釋放出來,這會帶來時間開銷。因為 Redis 是單執行緒,在資料拷貝時,Redis 只能等著,這就導致 Redis 無法及時處理請求,效能就會降低。而且,有的時候,資料拷貝還需要注意順序,就像剛剛說的清理記憶體碎片的例子,作業系統需要先拷貝 D,並釋放 D 的空間後,才能拷貝 B。這種對順序性的要求,會進一步增加 Redis 的等待時間,導致效能降低。

那麼,有什麼辦法可以儘量緩解這個問題嗎?

Redis 專門為自動記憶體碎片清理功機制設定的引數了。我們可以通過設定引數,來控制碎片清理的開始和結束時機,以及佔用的 CPU 比例,從而減少碎片清理對 Redis 本身請求處理的效能影響。首先,Redis 需要啟用自動記憶體碎片清理,可以把 activedefrag 配置項設定為 yes,命令如下:

config set activedefrag yes

這個命令只是啟用了自動清理功能,但是,具體什麼時候清理,會受到下面這兩個引數的控制。這兩個引數分別設定了觸發記憶體清理的一個條件,如果同時滿足這兩個條件,就開始清理。在清理的過程中,只要有一個條件不滿足了,就停止自動清理。

     active-defrag-ignore-bytes 100mb:表示記憶體碎片的位元組數達到 100MB 時,開始清理;

     active-defrag-threshold-lower 10:表示記憶體碎片空間佔作業系統分配給 Redis 的總空間比例達到 10% 時,開始清理。

為了儘可能減少碎片清理對 Redis 正常請求處理的影響,自動記憶體碎片清理功能在執行時,還會監控清理操作佔用的 CPU 時間,而且還設定了兩個引數,分別用於控制清理操作佔用的 CPU 時間比例的上、下限,既保證清理工作能正常進行,又避免了降低 Redis 效能。這兩個引數具體如下:

       active-defrag-cycle-min 25: 表示自動清理過程所用 CPU 時間的比例不低於 25%,保證清理能正常開展;

       active-defrag-cycle-max 75:表示自動清理過程所用 CPU 時間的比例不高於 75%,一旦超過,就停止清理,從而避免在清理時,大量的記憶體拷貝阻塞 Redis,導致響應延遲升高。

自動記憶體碎片清理機制在控制碎片清理啟停的時機上,既考慮了碎片的空間佔比、對 Redis 記憶體使用效率的影響,還考慮了清理機制本身的 CPU 時間佔比、對 Redis 效能的影響。而且,清理機制還提供了 4 個引數,讓我們可以根據實際應用中的資料量需求和效能要求靈活使用,建議你在實踐中好好地把這個機制用起來。

 

合理使用Redis 快取有淘汰策略

Redis 4.0 之前一共實現了 6 種記憶體淘汰策略,在 4.0 之後,又增加了 2 種策略。我們可以按照是否會進行資料淘汰把它們分成兩類:

       不進行資料淘汰的策略,只有 noeviction 這一種。

       會進行淘汰的 7 種其他策略。

 

會進行淘汰的 7 種策略,我們可以再進一步根據淘汰候選資料集的範圍把它們分成兩類:

       在設定了過期時間的資料中進行淘汰,包括 volatile-random、volatile-ttl、volatile-lru、volatile-lfu(Redis 4.0 後新增)四種。

       在所有資料範圍內進行淘汰,包括 allkeys-lru、allkeys-random、allkeys-lfu(Redis 4.0 後新增)三種。

 

 

 預設情況下,Redis 在使用的記憶體空間超過 maxmemory 值時,並不會淘汰資料,也就是設定的 noeviction 策略。對應到 Redis 快取,也就是指,一旦快取被寫滿了,再有寫請求來時,Redis 不再提供服務,而是直接返回錯誤。Redis 用作快取時,實際的資料集通常都是大於快取容量的,總會有新的資料要寫入快取,這個策略本身不淘汰資料,也就不會騰出新的快取空間,我們不把它用在 Redis 快取中。

volatile-random、volatile-ttl、volatile-lru 和 volatile-lfu 這四種淘汰策略。它們篩選的候選資料範圍,被限制在已經設定了過期時間的鍵值對上。也正因為此,即使快取沒有寫滿,這些資料如果過期了,也會被刪除。

例如,我們使用 EXPIRE 命令對一批鍵值對設定了過期時間後,無論是這些鍵值對的過期時間是快到了,還是 Redis 的記憶體使用量達到了 maxmemory 閾值,Redis 都會進一步按照 volatile-ttl、volatile-random、volatile-lru、volatile-lfu 這四種策略的具體篩選規則進行淘汰。

    volatile-ttl 在篩選時,會針對設定了過期時間的鍵值對,根據過期時間的先後進行刪除,越早過期的越先被刪除。

    volatile-random 就像它的名稱一樣,在設定了過期時間的鍵值對中,進行隨機刪除。

    volatile-lru 會使用 LRU 演算法篩選設定了過期時間的鍵值對。

    volatile-lfu 會使用 LFU 演算法選擇設定了過期時間的鍵值對。

 

相對於 volatile-ttl、volatile-random、volatile-lru、volatile-lfu 這四種策略淘汰的是設定了過期時間的資料,allkeys-lru、allkeys-random、allkeys-lfu 這三種淘汰策略的備選淘汰資料範圍,就擴大到了所有鍵值對,無論這些鍵值對是否設定了過期時間。它們篩選資料進行淘汰的規則是:

  allkeys-random 策略,從所有鍵值對中隨機選擇並刪除資料;

  allkeys-lru 策略,使用 LRU 演算法在所有資料中進行篩選。

  allkeys-lfu 策略,使用 LFU 演算法在所有資料中進行篩選。

這也就是說,如果一個鍵值對被刪除策略選中了,即使它的過期時間還沒到,也需要被刪除。當然,如果它的過期時間到了但未被策略選中,同樣也會被刪除。接下來,我們就看看 volatile-lru 和 allkeys-lru 策略都用到的 LRU 演算法吧。LRU 演算法工作機制並不複雜,我們一起學習下。LRU 演算法的全稱是 Least Recently Used,從名字上就可以看出,這是按照最近最少使用的原則來篩選資料,最不常用的資料會被篩選出來,而最近頻繁使用的資料會留在快取中。那具體是怎麼篩選的呢?LRU 會把所有的資料組織成一個連結串列,連結串列的頭和尾分別表示 MRU 端和 LRU 端,分別代表最近最常使用的資料和最近最不常用的資料。我們看一個例子。

 

 

 我們現在有資料 6、3、9、20、5。如果資料 20 和 3 被先後訪問,它們都會從現有的連結串列位置移到 MRU 端,而連結串列中在它們之前的資料則相應地往後移一位。因為,LRU 演算法選擇刪除資料時,都是從 LRU 端開始,所以把剛剛被訪問的資料移到 MRU 端,就可以讓它們儘可能地留在快取中。

如果有一個新資料 15 要被寫入快取,但此時已經沒有快取空間了,也就是連結串列沒有空餘位置了,那麼,LRU 演算法做兩件事:

  資料 15 是剛被訪問的,所以它會被放到 MRU 端;

  演算法把 LRU 端的資料 5 從快取中刪除,相應的連結串列中就沒有資料 5 的記錄了。

LRU 演算法在實際實現時,需要用連結串列管理所有的快取資料,這會帶來額外的空間開銷。而且,當有資料被訪問時,需要在連結串列上把該資料移動到 MRU 端,如果有大量資料被訪問,就會帶來很多連結串列移動操作,會很耗時,進而會降低 Redis 快取效能。

所以,在 Redis 中,LRU 演算法被做了簡化,以減輕資料淘汰對快取效能的影響。具體來說,Redis 預設會記錄每個資料的最近一次訪問的時間戳(由鍵值對資料結構 RedisObject 中的 lru 欄位記錄)。然後,Redis 在決定淘汰的資料時,第一次會隨機選出 N 個資料,把它們作為一個候選集合。接下來,Redis 會比較這 N 個資料的 lru 欄位,把 lru 欄位值最小的資料從快取中淘汰出去。

Redis 提供了一個配置引數 maxmemory-samples,這個引數就是 Redis 選出的資料個數 N。例如,我們執行如下命令,可以讓 Redis 選出 100 個資料作為候選資料集:

CONFIG SET maxmemory-samples 100

當需要再次淘汰資料時,Redis 需要挑選資料進入第一次淘汰時建立的候選集合。這兒的挑選標準是:能進入候選集合的資料的 lru 欄位值必須小於候選集合中最小的 lru 值。當有新資料進入候選資料集後,如果候選資料集中的資料個數達到了 maxmemory-samples,Redis 就把候選資料集中 lru 欄位值最小的資料淘汰出去。這樣一來,Redis 快取不用為所有的資料維護一個大連結串列,也不用在每次資料訪問時都移動連結串列項,提升了快取的效能。

除了使用 LFU 演算法以外的 5 種快取淘汰策略,這裡有三個使用建議。

  優先使用 allkeys-lru 策略。這樣,可以充分利用 LRU 這一經典快取演算法的優勢,把最近最常訪問的資料留在快取中,提升應用的訪問效能。如果你的業務資料中有明顯的冷熱資料區分,我建議你使用 allkeys-lru 策略。

  如果業務應用中的資料訪問頻率相差不大,沒有明顯的冷熱資料區分,建議使用 allkeys-random 策略,隨機選擇淘汰的資料就行。

  如果你的業務中有置頂的需求,比如置頂新聞、置頂視訊,那麼,可以使用 volatile-lru 策略,同時不給這些置頂資料設定過期時間。這樣一來,這些需要置頂的資料一直不會被刪除,而其他資料會在過期時根據 LRU 規則進行篩選。

 

相關文章