始因
有時候線上可能會遇到這樣的問題:
明明我設置了對應的 key 以及超時時間,但是在使用的過程當中發現對應的 key 丟失了,尤其是在用戶賬號登錄狀態保持有效期的場景下,會越發的明顯。即:一個用戶正常登錄會產生一個有效期為一天的 token,這樣用戶再次進入網站是不需要登錄的。但是發生 key 丟失問題就會導致用戶需要頻繁的重新登錄,用戶體驗相當不好。導致這種問題的原因一般有以下兩種情況:
1. token 生成時出現邏輯問題
2. 驗證 token 時出問題了
對於上線穩定的項目來說,發生 1 的概率基本為 0。那么會立馬定位到 2 的情況。這種情況就會引發我們今天討論的問題:
redis 如何自動清理過期 key,以及對應 key 沒有過期但是也會被清理掉呢?說人話:redis 內部如何清理過期 key?
常見刪除策略(拋開 redis)
- 定時刪除:在設置鍵的過期時間的同時,創建一個定時器 timer。讓定時器在鍵的過期時間來臨時,立即執行對鍵的刪除操作。
- 惰性刪除:放任鍵過期不管,但是每次從鍵空間中獲取鍵時,都檢查取得的鍵是否過期,如果過期的話,就刪除該鍵;如果沒有過期,就返回該鍵。
- 定期刪除:每隔一段時間程序就對數據庫進行一次檢查,刪除里面的過期鍵。至於要刪除多少過期鍵,以及要檢查多少個數據庫,則由算法決定。
在上述的三種策略中 定時刪除 和 定期刪除 屬於不同時間粒度的 主動刪除,惰性刪除屬於 被動刪除。
以上三種策略都有各自的優缺點:
1. 定時刪除 對內存使用率有優勢,但是對 CPU 不友好;
2. 惰性刪除 對內存不友好,如果某些鍵值對一直不被使用,那么會造成一定量的內存浪費;
3. 定期刪除 是 定時刪除 和 惰性刪除 的折中。
正常情況下 redis 中的實現
Reids 采用的是 惰性刪除 和 定時刪除 的結合,一般來說可以借助 最小堆 來實現 定時器,不過 Redis 的設計考慮到時間事件的有限種類 和 數量,使用了 無序鏈表 存儲時間事件,這樣如果在此基礎上實現定時刪除,就意味着 O(N)
遍歷獲取最近需要刪除的數據。
定期刪除策略
Redis 會將每個設置了過期時間的 key 放入到一個獨立的字典中,默認每 100ms 進行一次過期掃描:
-
隨機抽取 20 個 key
-
刪除這 20 個key中過期的key
-
如果過期的 key 比例超過 1/4,就重復步驟 1,繼續刪除。
為什不掃描所有的 key?
Redis 是單線程,全部掃描豈不是卡死了。而且為了防止每次掃描過期的 key 比例都超過 1/4,導致不停循環卡死線程,Redis 為每次掃描添加了上限時間,默認是 25ms。
如果客戶端將超時時間設置的比較短,比如 10ms,那么就會出現大量的鏈接因為超時而關閉,業務端就會出現很多異常。而且這時你還無法從 Redis 的 slowlog 中看到慢查詢記錄,因為慢查詢指的是邏輯處理過程慢,不包含等待時間。
如果在同一時間出現大面積 key 過期,Redis 循環多次掃描過期詞典,直到過期的 key 比例小於 1/4。這會導致卡頓,而且在高並發的情況下,可能會導致緩存雪崩。
為什么 Redis 為每次掃描添的上限時間是 25ms,還會出現上面的情況?
因為 Redis 是單線程,每個請求處理都需要排隊,而且由於 Redis 每次掃描都是 25ms,也就是每個請求最多 25ms,100 個請求就是 2500ms。
如果有大批量的 key 過期,要給過期時間設置一個隨機范圍,而不宜全部在同一時間過期,分散過期處理的壓力。
從庫的過期策略
從庫不會進行過期掃描,從庫對過期的處理是被動的。主庫在 key 到期時,會在 AOF 文件里增加一條 del 指令,同步到所有的從庫,從庫通過執行這條 del 指令來刪除過期的 key。
因為指令同步是異步進行的,所以主庫過期的 key 的 del 指令沒有及時同步到從庫的話,會出現主從數據的不一致,主庫沒有的數據在從庫里還存在。
懶惰刪除策略
Redis 為什么要懶惰刪除(lazy free)?
刪除指令 del 會直接釋放對象的內存,大部分情況下,這個指令非常快,沒有明顯延遲。不過如果刪除的 key 是一個非常大的對象,比如一個包含了千萬元素的 hash,又或者在使用 FLUSHDB 和 FLUSHALL 刪除包含大量鍵的數據庫時,那么刪除操作就會導致單線程卡頓。
redis 4.0 引入了 lazyfree 的機制,它可以將刪除鍵或數據庫的操作放在后台線程里執行, 從而盡可能地避免服務器阻塞。
unlink
unlink 指令,它能對刪除操作進行懶處理,丟給后台線程來異步回收內存。
> unlink key OK
flush
flushdb 和 flushall 指令,用來清空數據庫,這也是極其緩慢的操作。Redis 4.0 同樣給這兩個指令也帶來了異步化,在指令后面增加 async 參數就可以將整棵大樹連根拔起,扔給后台線程慢慢焚燒。
> flushall async OK
內存淘汰通過近似 LRU來實現
在解釋近似LRU之前,先來簡單了解一下LRU。
當 Redis 的內存占用超過我們設置的 maxmemory 時,會把長時間沒有使用的key清理掉。按照 LRU算法,我們需要對所有key(也可以設置成只淘汰有過期時間的key)按照空閑時間進行排序,然后淘汰掉空閑時間最大的那部分數據,使得Redis的內存占用降到一個合理的值。
LRU算法的缺點:
1. 我們需要維護一個全部(或只有過期時間)key的列表,還要按照最近使用時間排序。這會消耗大量內存
2. 每次操作 key 時更新對應維護列表的排序也會占用額外的CPU資源。
對於Redis這樣對性能要求很高的系統來說是不被允許的。
因此,Redis采用了一種 近似LRU 的算法。當 Redis 接收到新的寫入命令,而內存又不夠時,就會觸發 近似LRU 算法來強制清理一些key。
具體清理的步驟是:
1. Redis會對 key 進行采樣,通常是取5個,然后會把過期的key放到我們上面說的“過期池”中
2. 過期池中的 key 是按照空閑時間來排序的,Redis 會優先清理掉空閑時間最長的 key,直到內存小於 maxmemory。
清理策略
最后我們來看一下Redis支持的幾種清理策略
1. noeviction:不會繼續處理寫請求(DEL可以繼續處理)。
2. allkeys-lru:對所有key的近似LRU
3. volatile-lru:使用近似LRU算法淘汰設置了過期時間的key
4. allkeys-random:從所有key中隨機淘汰一些key
5. volatile-random:對所有設置了過期時間的key隨機淘汰
6. volatile-ttl:淘汰有效期最短的一部分key
Redis4.0 開始支持了 LFU 策略,和 LRU 類似,它分為兩種:
7. volatile-lfu:使用LFU算法淘汰設置了過期時間的key
8. allkeys-lfu:從全部key中進行淘汰,使用LFU
最后
針對文章開始提到的問題,最好的解決辦法是將使用內存量較大的業務 和 用戶賬號服務 使用的 redis 隔離開,這樣就單個用戶賬號正常情況下是不會發生以上類似的問題了。