談談 Redis 的過期策略

在日常開發中,我們使用 Redis 存儲 key 時通常會設置一個過期時間,但是 Redis 是怎麼刪除過期的 key,而且 Redis 是單線程的,刪除 key 會不會造成阻塞。要搞清楚這些,就要了解 Redis 的過期策略和內存淘汰機制。

Redis採用的是定期刪除 + 懶惰刪除策略。

定期刪除策略
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`
異步隊列
主線程將對象的引用從「大樹」中摘除後,會將這個 key 的內存回收操作包裝成一個任務,塞進異步任務隊列,後臺線程會從這個異步隊列中取任務。任務隊列被主線程和異步線程同時操作,所以必須是一個線程安全的隊列。

不是所有的 unlink 操作都會延後處理,如果對應 key 所佔用的內存很小,延後處理就沒有必要了,這時候 Redis 會將對應的 key 內存立即回收,跟 del 指令一樣。

更多異步刪除點
Redis 回收內存除了 del 指令和 flush 之外,還會存在於在 key 的過期、LRU 淘汰、rename 指令以及從庫全量同步時接受完 rdb 文件後會立即進行的 flush 操作。

Redis4.0 爲這些刪除點也帶來了異步刪除機制,打開這些點需要額外的配置選項。

slave-lazy-flush 從庫接受完 rdb 文件後的 flush 操作

lazyfree-lazy-eviction 內存達到 maxmemory 時進行淘汰

lazyfree-lazy-expire key 過期刪除

lazyfree-lazy-server-del rename 指令刪除 destKey

內存淘汰機制
Redis 的內存佔用會越來越高。Redis 爲了限制最大使用內存,提供了 redis.conf 中的
配置參數 maxmemory。當內存超出 maxmemory,Redis 提供了幾種內存淘汰機制讓用戶選擇,配置 maxmemory-policy:

noeviction:當內存超出 maxmemory,寫入請求會報錯,但是刪除和讀請求可以繼續。(使用這個策略,瘋了吧)

allkeys-lru:當內存超出 maxmemory,在所有的 key 中,移除最少使用的key。只把 Redis 既當緩存是使用這種策略。(推薦)。

allkeys-random:當內存超出 maxmemory,在所有的 key 中,隨機移除某個 key。(應該沒人用吧)

volatile-lru:當內存超出 maxmemory,在設置了過期時間 key 的字典中,移除最少使用的 key。把 Redis 既當緩存,又做持久化的時候使用這種策略。

volatile-random:當內存超出 maxmemory,在設置了過期時間 key 的字典中,隨機移除某個key。

volatile-ttl:當內存超出 maxmemory,在設置了過期時間 key 的字典中,優先移除 ttl 小的。

LRU 算法
實現 LRU 算法除了需要 key/value 字典外,還需要附加一個鏈表,鏈表中的元素按照一定的順序進行排列。當空間滿的時候,會踢掉鏈表尾部的元素。當字典的某個元素被訪問時,它在鏈表中的位置會被移動到表頭。所以鏈表的元素排列順序就是元素最近被訪問的時間順序。

使用 Python 的 OrderedDict(雙向鏈表 + 字典) 來實現一個簡單的 LRU 算法:

from collections import OrderedDict

class LRUDict(OrderedDict):

    def __init__(self, capacity):
        self.capacity = capacity
        self.items = OrderedDict()

    def __setitem__(self, key, value):
        old_value = self.items.get(key)
        if old_value is not None:
            self.items.pop(key)
            self.items[key] = value
        elif len(self.items) < self.capacity:
            self.items[key] = value
        else:
            self.items.popitem(last=True)
            self.items[key] = value

    def __getitem__(self, key):
        value = self.items.get(key)
        if value is not None:
            self.items.pop(key)
            self.items[key] = value
        return value

    def __repr__(self):
        return repr(self.items)


d = LRUDict(10)

for i in range(15):
    d[i] = i
print d

近似 LRU 算法
Redis 使用的並不是完全 LRU 算法。不使用 LRU 算法,是爲了節省內存,Redis 採用的是隨機LRU算法,Redis 爲每一個 key 增加了一個24 bit的字段,用來記錄這個 key 最後一次被訪問的時間戳。

注意 Redis 的 LRU 淘汰策略是懶惰處理,也就是不會主動執行淘汰策略,當 Redis 執行寫操作時,發現內存超出 maxmemory,就會執行 LRU 淘汰算法。這個算法就是隨機採樣出5(默認值)個 key,然後移除最舊的 key,如果移除後內存還是超出 maxmemory,那就繼續隨機採樣淘汰,直到內存低於 maxmemory 爲止。

如何採樣就是看 maxmemory-policy 的配置,如果是 allkeys 就是從所有的 key 字典中隨機,如果是 volatile 就從帶過期時間的 key 字典中隨機。每次採樣多少個 key 看的是 maxmemory_samples 的配置,默認爲 5。

LFU
Redis 4.0 裏引入了一個新的淘汰策略 —— LFU(Least Frequently Used) 模式,作者認爲它比 LRU 更加優秀。

LFU 表示按最近的訪問頻率進行淘汰,它比 LRU 更加精準地表示了一個 key 被訪問的熱度。

如果一個 key 長時間不被訪問,只是剛剛偶然被用戶訪問了一下,那麼在使用 LRU 算法下它是不容易被淘汰的,因爲 LRU 算法認爲當前這個 key 是很熱的。而 LFU 是需要追蹤最近一段時間的訪問頻率,如果某個 key 只是偶然被訪問一次是不足以變得很熱的,它需要在近期一段時間內被訪問很多次纔有機會被認爲很熱。

Redis 對象的熱度

Redis 的所有對象結構頭中都有一個 24bit 的字段,這個字段用來記錄對象的熱度。

// redis 的對象頭
typedef struct redisObject {
    unsigned type:4; // 對象類型如 zset/set/hash 等等
    unsigned encoding:4; // 對象編碼如 ziplist/intset/skiplist 等等
    unsigned lru:24; // 對象的「熱度」
    int refcount; // 引用計數
    void *ptr; // 對象的 body
} robj;

LRU 模式

在 LRU 模式下,lru 字段存儲的是 Redis 時鐘 server.lruclock,Redis 時鐘是一個 24bit 的整數,默認是 Unix 時間戳對 2^24 取模的結果,大約 97 天清零一次。當某個 key 被訪問一次,它的對象頭的 lru 字段值就會被更新爲 server.lruclock。

LFU 模式

在 LFU 模式下,lru 字段 24 個 bit 用來存儲兩個值,分別是 ldt(last decrement time) 和 logc(logistic counter)。

logc 是 8 個 bit,用來存儲訪問頻次,因爲 8 個 bit 能表示的最大整數值爲 255,存儲頻次肯定遠遠不夠,所以這 8 個 bit 存儲的是頻次的對數值,並且這個值還會隨時間衰減。如果它的值比較小,那麼就很容易被回收。爲了確保新創建的對象不被回收,新對象的這 8 個 bit 會初始化爲一個大於零的值,默認是 LFU_INIT_VAL=5。

ldt 是 16 個位,用來存儲上一次 logc 的更新時間,因爲只有 16 位,所以精度不可能很高。它取的是分鐘時間戳對 2^16 進行取模,大約每隔 45 天就會折返。

同 LRU 模式一樣,我們也可以使用這個邏輯計算出對象的空閒時間,只不過精度是分鐘級別的。圖中的 server.unixtime 是當前 redis 記錄的系統時間戳,和 server.lruclock 一樣,它也是每毫秒更新一次。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章