[Redis]過期刪除和記憶體淘汰

Duancf發表於2024-07-22

過期刪除

Redis 提供了四個命令來設定過期時間(生存時間):

  • EXPIRE :表示將鍵 key 的生存時間設定為 ttl 秒;
  • PEXPIRE :表示將鍵 key 的生存時間設定為 ttl 毫秒;
  • EXPIREAT :表示將鍵 key 的生存時間設定為 timestamp 所指定的秒數時間戳;
  • PEXPIREAT :表示將鍵 key 的生存時間設定為 timestamp 所指定的毫秒數時間戳。

在Redis內部實現中,前面三個設定過期時間的命令最後都會轉換成最後一個PEXPIREAT 命令來完成。

PERSIST :表示將 key 的過期時間移除。

檢視鍵的剩餘過期時間
TTL :以秒的單位返回鍵 key 的剩餘生存時間;
PTTL :以毫秒的單位返回鍵 key 的剩餘生存時間。

在 Redis 內部,每當我們設定一個鍵的過期時間時,Redis 就會將該鍵帶上過期時間存放到一個過期字典中。
當我們查詢一個鍵時,Redis 首先檢查該鍵是否存在過期字典中,如果存在,那就獲取其過期時間,然後將過期時間和當前系統時間進行比對,比系統時間大,那就沒有過期;反之判定該鍵過期。

定時刪除

在設定某個 key 的過期時間同時,我們建立一個定時器,讓定時器在該過期時間到來時,立即執行對其進行刪除的操作。

  • 優點:定時刪除對記憶體是最友好的,能夠儲存記憶體的 key 一旦過期就能立即從記憶體中刪除;
  • 缺點:對 CPU 最不友好,在過期鍵比較多的時候,刪除過期鍵會佔用一部分 CPU 時間,對伺服器的響應時間和吞吐量造成影響。

惰性刪除

設定該 key 過期時間後,我們不去管它,當需要該 key 時,我們在檢查其是否過期如果過期,我們就刪掉它,反之返回該 key。

  • 優點:對 CPU 友好,我們只會在使用該鍵時才會進行過期檢查,對於很多用不到的 key 不用浪費時間進行過期檢查;
  • 缺點:對記憶體不友好,如果一個鍵已經過期,但是一直沒有使用,那麼該鍵就會一直存在記憶體中,如果資料庫中有很多這種使用不到的過期鍵,這些鍵便永遠不會被刪除,記憶體永遠不會釋放,從而造成記憶體洩漏。

定期刪除

每隔一段時間,我們就對一些 key 進行檢查刪除裡面過期的 key

  • 優點:可以透過限制刪除操作執行的時長和頻率來減少刪除操作對 CPU 的影響。另外定期刪除,也能有效釋放過期鍵佔用的記憶體。
  • 缺點:難以確定刪除操作執行的時長和頻率。如果執行的太頻繁,定期刪除策略變得和定時刪除策略一樣,對CPU不友好。如果執行的太少,那又和惰性刪除一樣了,過期鍵佔用的記憶體不會及時得到釋放。另外最重要的是,在獲取某個鍵時,如果某個鍵的過期時間已經到了,但是還沒執行定期刪除,那麼就會返回這個鍵的值,這是業務不能忍受的錯誤。

Redis 使用的過期刪除策略

透過前面討論的三種過期刪除策略,可以發現單一使用某一策略都不能滿足實際需求,所以實際的應用都是組合使用的,而 Redis 使用的過期刪除策略就是:惰性刪除和定期刪除兩種策略的組合。

  • Redis 的惰性刪除策略由 db.c/expireIfNeeded 函式實現,所有鍵讀寫命令執行之前都會呼叫 expireIfNeeded 函式對其進行檢查,如果過期,則刪除該鍵,然後執行鍵不存在的操作;未過期則不作操作,繼續執行原有的命令。
  • Redis 的定期刪除策略由 redis.c/activeExpireCycle 函式實現,函式以一定的頻率執行,每次執行時,都從一定數量的資料庫中取出一定數量的隨機鍵進行檢查,並刪除其中的過期鍵。需要注意的是: Redis 的定期刪除策略並不是一次執行就檢查所有的庫、所有的鍵,而是隨機檢查一定數量的鍵。定期刪除函式的執行頻率,在 Redis2.6 版本中,規定每秒執行 10 次,大概 100ms 執行一次。在 Redis2.8 版本後,可以透過修改配置檔案 redis.conf 的 hz 選項來調整這個次數:
# The range is tetween 1 and 500, however a value over 100 is usually not
# a good idea. Most users should use the default of 10 and raise this up to
# 100 only in environments where very low latency is requiried.
hz 10

從這個引數的上面註釋可以看出,建議不要將這個值設定超過100,一般使用預設的10,只有當在需要非常低延遲的場景才設定為100。

Redis 過期刪除策略的問題

雖然 Redis 採用了惰性刪除和定期刪除兩種策略,但對於一些永遠使用不到的鍵,並且經過多次定期刪除也沒有被選定到並刪除,那麼這些鍵就會一直駐留在記憶體中。
所以,這時候就需要使用 Redis 的記憶體淘汰策略來解決了。

記憶體淘汰

設定 Redis 最大記憶體
在配置檔案 redis.conf 中,可以透過引數 maxmemory 來設定最大記憶體:

# In short... if you have slaves attached it is suggested that you set a lower
# limit for maxmemory so that there is some free RAM on the system for slave
# output buffers (but this is not needed if the policy is 'noeviction').
#
maxmemory <bytes>
# MAXMEMORY POLICY: how Redis will select what to remove when maxmemory
# is reached. You can select among five behaviors:
#
# volatile-lru -> remove the key with an expire set using an LRU algorithm
# allkeys-lru -> remove any key according to the LRU algorithm
# volatile-random -> remove a random key with an expire set
# allkeys-random -> remove a random key, any key
# volatile-ttl -> remove the key with the nearest expire time (minor TTL)
# noeviction -> don't expire at all, just return an error on write operations

不設定該引數預設是無限制的,但是通常會設定其為實體記憶體的四分之三

記憶體淘汰策略

當使用的記憶體大於 maxmemory 時,就會觸發 Redis 主動淘汰記憶體方式

volatile-lru:針對設定了過期時間的key,使用 lru 演算法進行淘汰;
allkeys-lru:針對所有key使用 lru 演算法進行淘汰(推薦,使用的較多);
volatile-lfu:針對設定了過期時間的 key,使用 lfu 演算法進行淘汰;
allkeys-lfu:針對所有key使用 lfu 演算法進行淘汰;
volatile-random:針對設定了過期時間的 key ,使用隨機淘汰的方式進行淘汰;
allkeys-random:針對所有的key使用隨機淘汰機制進行淘汰;
volatile-ttl:刪除最近即將過期的一個鍵(minor TTL);
noeviction(預設):不刪除鍵,值返回錯誤(不建議使用)。

volatile 表示有過期的時間的 key;
allkeys 表示所有的 key;
lru(Least Recently Used)表示最近最少使用(根據時間,最不常用的淘汰);
lfu(Least Frequently Used)表示使用次數最少(根據計數器,用的次數最少的 key 淘汰);
ttl(time to live) 表示即將過期;

記憶體淘汰演算法的具體工作原理是:
客戶端執行一條新命令,導致資料庫需要增加資料(比如 set key value);
Redis 會檢查記憶體使用,如果記憶體使用超過 maxmemory,就會按照置換策略刪除一些 key;
新的命令執行成功。
在 redis.conf 配置檔案中,可以設定 maxmemory-policy 來設定記憶體淘汰方式:
maxmemory-policy allkeys-lru

LRU 演算法

LRU 是 Least Recently Used 的縮寫,表示最近最少使用。當記憶體不夠的時候,每次新增一條資料,都需要拋棄一條最久時間沒有使用的舊資料。
標準的 LRU 演算法為了降低查詢和刪除元素的時間複雜度,一般採用 Hash 表和雙向連結串列結合的資料結構,hash 表可以賦予連結串列快速查詢到某個 key 是否存在連結串列中,同時可以快速刪除、新增節點。而雙向連結串列的查詢時間複雜度是O(n),刪除和插入是O(1),藉助HashMap結構,可以使得查詢的時間複雜度變成O(1),Hash表用來查詢在連結串列中的資料位置,連結串列負責資料的插入。
當新資料插入到連結串列頭部時有兩種情況:
當連結串列中沒有這個key,且連結串列滿了,把連結串列尾部的資料丟棄掉,新加入的快取直接加入到連結串列頭中;
當連結串列中的某個快取被命中時,直接把資料移到連結串列頭部,原本在頭節點的快取就向連結串列尾部移動。
這樣,經過多次Cache操作之後,最近被命中的快取,都會存在連結串列頭部的方向,沒有命中的,都會在連結串列尾部方向,當需要替換內容時,由於連結串列尾部是最少被命中的,我們只需要淘汰連結串列尾部的資料即可。

Redis 的 LRU 演算法:

實際上,Redis 使用的 LRU 演算法其實是一種不可靠的 LRU 演算法,它實際淘汰的鍵並不一定是真正最少使用的資料,它的工作機制是:
隨機採集淘汰的 key,每次隨機選出 5 個 key;
然後淘汰這 5 個 key 中最少使用的 key。
這5個key是預設的個數,具體的數值可以在 redis.conf 中配置:
maxmemory-samples 5

當 key 的取樣取值越大的時候就會越接近真實的 LRU 演算法,因為取值越大獲取的資料越完整,淘汰中的資料就更加接近最少使用的資料。
這裡其實涉及一個權衡問題:如果需要在所有的資料中搜尋最符合條件的資料,那麼一定會增加系統的開銷,Redis 是單執行緒的,所以耗時的操作會謹慎一些。為了在一定成本內實現相對的 LRU,早期的 Redis 版本是基於取樣的 LRU,也就是放棄了從所有資料中搜尋解改為取樣空間搜尋最優解。

Redis3.0 版本之後,Redis 作者對於基於取樣的 LRU進行了一些最佳化:

  • Redis中維護一個大小為16的候選池,當第一次隨機選取採用資料時,會把資料放入到候選池中,並且候選池中的資料會更具時間進行排序;
  • 當第二次以後選取資料時,只有小於候選池內最小時間的才會被放進候選池;
  • 當候選池的資料滿了之後,那麼時間最大的 key 就會被擠出候選池。當執行淘汰時,直接從候選池中選取最近訪問時間小的 key 進行淘汰;

LRU 演算法的缺點:

LRU 演算法有一個弊端,加入一個 key 值訪問頻率很低,但是最近一次被訪問到了,那LRU 會認為它是熱點資料,不會被淘汰。同樣,經常被訪問的資料,最近一段時間沒有被訪問,這樣會導致這些資料被淘汰掉,導致誤判而淘汰掉熱點資料,於是在 Redis 4.0 中,新加了一種 LFU 演算法。

LFU 演算法

LFU(Least Frequently Used),表示使用次數最少。它和 key 的使用次數有關,其思想是:根據 key 最近被訪問的頻率進行淘汰,比較少訪問的 key 優先淘汰,反之則保留。
LFU 的原理是使用計數器來對 key 進行排序,每次 key 被訪問時,計數器會增大,當計數器越大,意味著當前 key 的訪問越頻繁,也就是意味著它是熱點資料。 很好的解決了 LRU 演算法的缺陷:一個很久沒有被訪問的key,偶爾被訪問一次,導致被誤認為是熱點資料的問題。

LFU 維護了兩個連結串列,橫向組成的連結串列用來儲存訪問頻率,每個訪問頻率的節點下儲存另外一個具有相同訪問頻率的快取資料。具體的工作原理是:

  • 當新增元素時,找到相同訪問頻次的節點,然後新增到該節點的資料連結串列的頭部。如果該資料連結串列滿了,則移除連結串列尾部的節點
  • 當獲取元素或者修改元素是,都會增加對應key的訪問頻次,並把當前節點移動到下一個頻次節點。
  • 新增元素時,訪問頻率預設為1,隨著訪問次數的增加,頻率不斷遞增。而當前被訪問的元素也會隨著頻率增加進行移動。

總結

Redis 過期刪除策略是採用惰性刪除和定期刪除這兩種方式組合進行的,惰性刪除能夠保證過期的資料我們在獲取時一定獲取不到,而定期刪除設定合適的頻率,則可以保證無效的資料及時得到釋放,而不會一直佔用記憶體資料。

但由於 Redis 是部署在物理機上的,記憶體不可能無限擴充的,當記憶體達到我們設定的界限後,便自動觸發 Redis 記憶體淘汰策略,而具體的策略方式要根據實際業務情況進行選取。

相關文章