1. 程式人生 > 實用技巧 >回顧7 Redis 的過期策略

回顧7 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

todo:弄個java的

近似 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 一樣,它也是每毫秒更新一次。