redis內存回收與內存淘汰策略

給新觀衆老爺的開場

大家好,我是弟弟!
最近讀了一遍 黃健宏大佬的 <<Redis 設計與實現>>,對Redis 3.0版本有了一些認識
該書作者有一版添加了註釋的 redis 3.0源碼
👉官方redis的github傳送門
👉黃健宏大佬添加了註釋的 redis 3.0源碼傳送門
👉antirez的博客

網上說Redis代碼寫得很好,爲了加深印象和學習redis大佬的代碼寫作藝術,瞭解工作中使用的redis 命令背後的源碼邏輯,便有了寫博客記錄學習redis源碼過程的想法。

redis內存回收

日常對redis的使用,除了很少的k/v可以不設置過期時間以外,絕大多數k/v都是帶過期時間的。
這樣當key過期之後redis可以將k/v刪除,以釋放出內存來存儲其他的k/v。

redis作爲一個內存型數據庫,所有的數據都放在內存裏。
內存相對硬盤來說還是比較貴的,內存資源比較少。
對內存的使用能省就省,能回收就回收,提高一些內存資源的利用率。

內存回收的方式

對過期k/v的回收方式有三種,分別是定時刪除,惰性刪除,定期刪除。
redis採用的是 惰性刪除+定期刪除 兩種策略。

1. 定時刪除

比如一個key的過期時間是1小時,那麼設置一個1小時後執行的定時器來定時刪除這個key。
這個方式乍一看上去是內存回收效率最高的方式,但redis因爲主要工作線程是一個單線程的緣故,對cpu的計算耗時非常的敏感
每個key一個定時器的話,100萬個帶過期時間的k/v就有100萬個定時器,會極其消耗cpu,從而影響redis性能。

該單線程工作的cpu被阻塞幾十毫秒會造成這期間所有待處理的請求延遲增加幾十毫秒以上
對於redis這種內存型數據庫來說,請求延時超過10ms以上都算慢的了。

2. 惰性刪除

既然定時刪除的定時器消耗cpu,那乾脆對有過期時間的key不加定時器了。這樣cpu也沒有額外的負擔,當過期的key再次被訪問時,再檢查key的過期時間是否過期,如果過期就刪除掉。
但這樣的缺點也比較明顯,redis中會存在大量過期而又沒被刪除的key,內存回收效率不高。

3. 定期刪除

這種方式算是定時刪除 與 惰性刪除的一種折中方式。
定期刪除每隔一段時間處理部分過期key,
通過少量多次的方式 來提高內存回收效率,
同時將cpu消耗分散到了每一次操作上,限制每次定期刪除執行的最大時間和處理的k/v個數,避免了因單次重度的cpu計算消耗,影響線上正常請求。

  1. 通過配置文件中的 hz 字段來指定 serverCron 1秒運行的次數。
    再每次serverCron中 會檢查部分具有過期時間戳的key,如果過期,則將key刪除。

    默認 hz 10,也就是1秒執行10次serverCron

  2. 在每次事件輪詢前執行的beforeSleep函數中,會檢查部分具有過期時間戳的key,如果過期,則將key刪除。

    定期刪除的策略,一定程度上彌補了惰性刪除的 回收效率不高的問題,也一定程度上避免了定時刪除的高cpu消耗的問題。通過少量多次的方式,可以在整體上達到與完美內存回收差不多的效果。

    可以通過調整配置文件中的 hz 的大小,來調整過期key回收的頻率。
    較大的hz值,意味着較高的過期key回收頻率,也意味着增加了較多的cpu消耗。

4. 大key刪除

通過惰性刪除和定期刪除兩種方式,發現需要刪除的過期k/v時,
在redis 4.0版本之前,會直接將過期的k/v刪除。
在刪除較小的k/v時問題不大。

但是線上不可避免的會出現value較大的k/v,
比如一個實時排行榜,可能一個zset中就會包含幾十萬個成員及其分數。
當value較大時,對大塊內存的釋放操作,將對redis的主要工作的線程產生不小的阻塞。

key/value的長度默認最大都是512mb,不過通常key都是很小的,value可能會比較大。

所以僅通過 惰性刪除+定期刪除兩種方式來刪除過期k/v 是還不夠的,必須對大key特殊處理。
於是在redis4.0 中加入了 unlink 命令 以及 lazyfree機制

unlink
對需要刪除的k/v僅從全局k/v字典中摘除相關的條目,並不立馬刪除真正的k/v。

lazy free
若 value的相關屬性 大於 LAZYFREE_THRESHOLD (默認值 64) 且引用計數爲1 , 屬於較大的value,
會將該value添加到 全局的 lazy_free任務隊列,並由專門進行 lazy free 的線程進行內存釋放操作。
對較大value的lazy free不會阻塞主線程。

相關屬性如 value 長度/元素個數/佔用字節數 根據不同數據類型取不同字段
當然key是在主線程被直接釋放的,若value較小也是在主線程被直接釋放。

redis內存淘汰策略

redis內存淘汰策略有哪些?

雖然可以對具有過期時間的key進行內存回收,
但是內存存儲的了未過期的k/v的大小超過了設置的 maxmemory (最大內存使用量),
新的k/v由於沒有可用內存而無法寫入,
這種情況也是很有可能發生的。
redis提供了幾種內存淘汰策略,以及其組合策略 來應對這種情況。

redis的內存淘汰策略如下👇

/* Redis maxmemory strategies. Instead of using just incremental number
 * for this defines, we use a set of flags so that testing for certain
 * properties common to multiple policies is faster. */
'LRU'
#define MAXMEMORY_FLAG_LRU (1<<0)   				 
'LFU'
#define MAXMEMORY_FLAG_LFU (1<<1)   				 
'對有所key進行淘汰'
#define MAXMEMORY_FLAG_ALLKEYS (1<<2)	             

'1. 只對帶過期時間的key使用LRU'
#define MAXMEMORY_VOLATILE_LRU ((0<<8)|MAXMEMORY_FLAG_LRU)   
'2. 只對帶過期時間的key使用LFU'
#define MAXMEMORY_VOLATILE_LFU ((1<<8)|MAXMEMORY_FLAG_LFU)   
'3. 根據key到期時間長短進行淘汰'
#define MAXMEMORY_VOLATILE_TTL (2<<8)						 
'4. 隨機淘汰帶過期時間的key'
#define MAXMEMORY_VOLATILE_RANDOM (3<<8)			
'5. 對所有key使用LRU'
#define MAXMEMORY_ALLKEYS_LRU ((4<<8)|MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_ALLKEYS)
'6. 對所有key使用LFU'
#define MAXMEMORY_ALLKEYS_LFU ((5<<8)|MAXMEMORY_FLAG_LFU|MAXMEMORY_FLAG_ALLKEYS)
'7. 隨機淘汰所有key'
#define MAXMEMORY_ALLKEYS_RANDOM ((6<<8)|MAXMEMORY_FLAG_ALLKEYS)
'8. 不淘汰key,此策略當內存佔滿時,幾乎大部分寫命令都將失敗,只能讀'
#define MAXMEMORY_NO_EVICTION (7<<8)

'redis的默認內存淘汰策略,不淘汰key'
#define CONFIG_DEFAULT_MAXMEMORY_POLICY MAXMEMORY_NO_EVICTION

從上面redis的源碼定義中可以看到,redis的默認內存淘汰策略是不進行淘汰。
此時的觀衆朋友們是否會感到疑惑?

爲什麼redis默認不進行內存淘汰

讓我們將問題簡化一下,來試着分析一下redis默認不進行淘汰的原因。
以下純屬個人分析

假設的場景是這樣👇

  1. 假設某個redis服務器實例 最多隻能存儲 100萬個 k/v,且已經存了100萬個k/v。
  2. 由於線上環境會持續寫入新的k/v,假設每秒新增1萬個 k/v 。
  3. 按照上述的內存淘汰策略,在接下來的時間內,
    總是有 1萬個 k/v 被淘汰,內存被釋放,有1萬個k/v會分配到內存並寫入。

可能存在的問題👇

  1. 對於緩存類數據的影響
    寫入redis中k/v一般都是從db或者其他二級緩存裏撈出來放到redis裏的,
    這樣操作的目的也是希望通過redis緩存來加速數據的訪問,
    如果由於有新的緩存數據寫入而把之前放到redis中的緩存數據淘汰,
    實際上是在拆東牆補西牆,
    當被刪除的緩存數據再次被訪問還需要重新在db或二級緩存撈一遍
    這樣,redis對整體數據訪問的加速可能沒有提高
    反而可能因爲頻繁的釋放/分配內存、重新緩存db數據到內存而導致性能降低

    當然內存中存在訪問頻率低的數據,被淘汰出內存是沒有什麼問題,但隨着新的k/v不斷的產生,當訪問頻率低的數據大部分都被淘汰出去後,就又回到了這個問題。

  2. 對功能類數據的影響
    redis提供了非常易用的一個 set nx 命令,可以用來做分佈式鎖。
    用來保證在大部分情況下,某些操作只能在規定時間內執行一次。
    如果該類數據因內存淘汰而被刪除,將對線上的業務產生不可預估的影響。

綜上可以看出,
redis的內存淘汰策略僅僅是作爲一個內存不夠用時的兜底策略而並非最終解決方案。
單機內存不夠用時,可以採用分片的方法,擴大redis的整體容量。

但redis仍然提供了額外的內存淘汰策略,
可通過修改配置文件的maxmemory-policy來滿足特殊用途。

redis中的內存淘汰策略,在淘汰內存時需要兼顧 儘量小的內存佔用/以及儘量小的cpu資源消耗,故redis中的內存淘汰策略都是 近似&隨機 的策略。

redis的LRU內存淘汰策略

LRU (Least Recently Used) 也就是淘汰最近最少使用的k/v,
也是 最久未使用的k/v的意思。

嚴格的LRU會有什麼問題

一種能嚴格淘汰 最近最少使用的k/v 的實現方式是

  1. 一個哈希表存儲所有的k/v
    一個雙向鏈表按k/v的訪問時間順序排序,假設以降序排序
  2. 當新增一個k/v時,將其插入哈希表後,再加入雙向鏈表的頭部。
  3. 當一個k/v被訪問時,將其移動到雙向鏈表的頭部。
  4. 當lru需要淘汰掉若干個k/v時,從鏈表尾部依次淘汰就可以了。

redis中是有全局哈希表的,但少了一個雙向鏈表。
讓我們來分析一下爲什麼沒有這個雙向鏈表。

  1. 首先一份雙向鏈表會佔一份空間,假設採用
    ‘對所有key使用LRU’
    #define MAXMEMORY_ALLKEYS_LRU
    的策略,那整個實例如果有幾百萬k/v,那就得再有幾百萬的鏈表節點。
    顯然,很佔內存。
  2. redis中大部分k/v的訪問頻率都是很高的,
    大部分的k/v頻繁的訪問,會讓對應的k/v在雙向鏈表上頻繁的移動,
    而我們想要淘汰掉的是訪問頻率不高的數據
    顯然多出來的這些熱點k/v的頻繁移動操作
    不僅消耗cpu,而且對我們的目的沒什麼幫助

redis中的近似隨機LRU

實際上redis的LRU淘汰策略,放棄了嚴格的 淘汰最近最少使用的k/v。
默認隨機採樣5個k/v再根據其訪問時間,淘汰掉 最近最少使用的 那個k/v。
循環直到釋放出 需要的內存空間。

redis LRU的部分細節:

  1. 每個value都是一個redisObject,其中有一個字段 lru,記錄了value的訪問時間。
  2. 這個lru字段只有24位,精度是秒,194天后24位的lru字段會溢出,
    從0開始從新計算。
  3. redisObject的lru字段,是根據全局的 server.lruclock 字段賦值的,這也是個24位變量。若server.hz 等於 10,那系統的lru時鐘每隔100ms會更新一次。

    取系統時間是系統調用,會切到內核執行,大量的取實時系統時鐘也是很消耗系統資源的。
    redis出於對性能的優化,以秒爲精度,緩存了系統的時鐘。
    對系統時鐘有特殊需求的,比如對key設置毫秒級過期時間時,會取實時的系統時鐘。

  4. 採樣的5個k/v,會根據value的lru與全局的 server.lruclock計算出 該value多長時間未被訪問,並選出最長時間未被訪問的key刪除。

    如果 value.lru <= server.lruclock ,距離上次被訪問的時間是
    server.lruclock - value.lru
    如果 value.lru > server.lruclock ,距離上次被訪問的時間是
    (1 << 24 - 1 ) + server.lruclock - value.lru

雖然redis的LRU是近似隨機的局部算法,但從整體效果來看,其實跟嚴格的lru效果差不多,對於redis的LRU的精度的提高,可以通過提高採樣值。
antirez在redis.conf的備註中寫到👇

# The default of 5 produces good enough results. 10 Approximates very closely
# true LRU but costs more CPU. 3 is faster but not very accurate.
#
# maxmemory-samples 5

再來一張網上的對比圖片😈
在這裏插入圖片描述
淺灰色是被回收的對象
灰色是沒有被回收的對象
綠色是被添加的對象

但,lru本身會有什麼問題嗎?
lru如果按照它的淘汰思路,能夠完美的淘汰掉最近最少使用的k/v嗎?

試想這樣一種情況,某一個訪問頻率很低的數據,冷不丁的被訪問了一下,
此時在lru看來,這個key是一個訪問頻率很高的key,但實際不是這樣。

於是,redis在4.0開始加入了 lfu淘汰策略,也就是按最近使用的頻率高低來決定淘汰key。

redis的LFU內存淘汰策略

LFU ((Least Frequently Use) 也就是淘汰最近訪問頻率最少的k/v,

嚴格的LFU會有什麼問題

嚴格的LFU實現與嚴格的LRU實現,在我看來在redis中落地的話會有相同的問題。
需要花費額外的空間來存儲,各個訪問次數下的k/v。
假設這裏的實現方式是

  1. 一個鏈表按升序記錄訪問的次數
  2. 每個次數的鏈表節點上,掛着另外一個鏈表,用來記錄 相同訪問次數的k/v

可能出現的問題

  1. redis中大部分數據都是會被頻繁訪問的,面對頻繁訪問的熱點數據,
    不得不消耗額外的cpu資源來維護嚴格的LFU數據結構,
    比如一個key被訪問,訪問次數+1,
    需要將key從低訪問次數鏈表 移動到高訪問次數鏈表。

  2. 因redis中的熱點key,訪問頻率可以分分鐘上萬次,
    如果策略是對所有KEY使用LFU進行內存淘汰。
    花費的額外的內存空間也是不小的。

  3. k/v訪問次數的衰減也是一個耗費cpu資源的問題,
    如果k/v訪問次數不衰減,那如果業務上突然不使用一個熱點key了,那這個熱點key會一直保持很高的訪問次數,並且很難被LFU淘汰。

  4. 若內存長時間被佔滿,會陷入循環反覆淘汰的尷尬局面

  5. 而且,redis的LFU內存淘汰策略 僅當內存不可用時,纔會發揮作用,用來淘汰訪問頻次低的key。
    所以在絕大部分時間,redis花費的額外的空間和cpu 用來維護嚴格的LFU數據結構在平時是沒什麼作用的。

綜上嚴格的LFU在redis中落地的話還是有比較多的問題。

redis中的近似隨機LFU

redis中的LFU採用了和LRU類似的 近似隨機策略。
同樣也是隨機採樣默認5個樣本,計算最近訪問次數,並淘汰訪問次數最低的那個k/v
循環直到釋放出需要的內存空間。

具體redis中的LFU是怎麼回事呢?
antirez在redis.conf中做了詳細的描述,那自然是看大佬的描述是最原汁原味的了👇

# Redis LFU eviction (see maxmemory setting) can be tuned. However it is a good
# idea to start with the default settings and only change them after investigating
# how to improve the performances and how the keys LFU change over time, which
# is possible to inspect via the OBJECT FREQ command.
#
# There are two tunable parameters in the Redis LFU implementation: the
# counter logarithm factor and the counter decay time. It is important to
# understand what the two parameters mean before changing them.
#
# The LFU counter is just 8 bits per key, it's maximum value is 255, so Redis
# uses a probabilistic increment with logarithmic behavior. Given the value
# of the old counter, when a key is accessed, the counter is incremented in
# this way:
#
# 1. A random number R between 0 and 1 is extracted.
# 2. A probability P is calculated as 1/(old_value*lfu_log_factor+1).
# 3. The counter is incremented only if R < P.
#
# The default lfu-log-factor is 10. This is a table of how the frequency
# counter changes with a different number of accesses with different
# logarithmic factors:
#
# +--------+------------+------------+------------+------------+------------+
# | factor | 100 hits   | 1000 hits  | 100K hits  | 1M hits    | 10M hits   |
# +--------+------------+------------+------------+------------+------------+
# | 0      | 104        | 255        | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 1      | 18         | 49         | 255        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 10     | 10         | 18         | 142        | 255        | 255        |
# +--------+------------+------------+------------+------------+------------+
# | 100    | 8          | 11         | 49         | 143        | 255        |
# +--------+------------+------------+------------+------------+------------+
#
# NOTE: The above table was obtained by running the following commands:
#
#   redis-benchmark -n 1000000 incr foo
#   redis-cli object freq foo
#
# NOTE 2: The counter initial value is 5 in order to give new objects a chance
# to accumulate hits.
#
# The counter decay time is the time, in minutes, that must elapse in order
# for the key counter to be divided by two (or decremented if it has a value
# less <= 10).
#
# The default value for the lfu-decay-time is 1. A Special value of 0 means to
# decay the counter every time it happens to be scanned.
#
# lfu-log-factor 10
# lfu-decay-time 1

具體上面在說個啥呢?

當redis啓用LFU內存淘汰策略時,redisObject中的lru字段,會被用來記錄訪問時間和訪問頻率
是的,原本24位的lru,

  1. 高16位被用來記錄訪問時間,精度是分鐘,大概45天后溢出從0開始重新計算

  2. 後8位被用來記錄 頻率,最大值是255,這個值的增加是按概率來算的。

    # 1. A random number R between 0 and 1 is extracted.
    # 2. A probability P is calculated as 1/(old_value*lfu_log_factor+1).
    # 3. The counter is incremented only if R < P.
    

    只有當r <p的時候會+1,也就是說如果訪問的次數越大,+1的概率就越高。
    具體訪問頻率大小 與相關 lfu_log_factor 的關係 大致如下面這張圖。

    # +--------+------------+------------+------------+------------+------------+
    # | factor | 100 hits   | 1000 hits  | 100K hits  | 1M hits    | 10M hits   |
    # +--------+------------+------------+------------+------------+------------+
    # | 0      | 104        | 255        | 255        | 255        | 255        |
    # +--------+------------+------------+------------+------------+------------+
    # | 1      | 18         | 49         | 255        | 255        | 255        |
    # +--------+------------+------------+------------+------------+------------+
    # | 10     | 10         | 18         | 142        | 255        | 255        |
    # +--------+------------+------------+------------+------------+------------+
    # | 100    | 8          | 11         | 49         | 143        | 255        |
    # +--------+------------+------------+------------+------------+------------+
    

    lfu_log_factor默認值位10,也就是說 redis不需要知道k/v精確的訪問頻率,
    只要大部分情況下知道哪些k/v的訪問頻率相對比較小就可以了/

整體的LFU的維護是在 一個k/v被訪問的時候,

  1. 會先根據 lfu-decay-time 值對value的訪問頻率進行衰減,
    lfu-decay-time默認值爲1,意思是每過去1分鐘,訪問頻率衰減1
  2. 然後對訪問頻率嘗試+1操作,並更新value的 訪問時間。

具體代碼邏輯如下:

robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict,key->ptr);
    if (de) {
        robj *val = dictGetVal(de);

        /* Update the access time for the ageing algorithm.
         * Don't do it if we have a saving child, as this will trigger
         * a copy on write madness. */
        if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                updateLFU(val);
            } else {
                val->lru = LRU_CLOCK();
            }
        }
        return val;
    } else {
        return NULL;
    }
}
void updateLFU(robj *val) {
    unsigned long counter = LFUDecrAndReturn(val);
    counter = LFULogIncr(counter);
    val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}

unsigned long LFUDecrAndReturn(robj *o) {
    unsigned long ldt = o->lru >> 8;
    unsigned long counter = o->lru & 255;
    unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
    if (num_periods)
        counter = (num_periods > counter) ? 0 : counter - num_periods;
    return counter;
}
#define	RAND_MAX	0x7fffffff
#define LFU_INIT_VAL 5
uint8_t LFULogIncr(uint8_t counter) {
    if (counter == 255) return 255;
    double r = (double)rand()/RAND_MAX;
    double baseval = counter - LFU_INIT_VAL;
    if (baseval < 0) baseval = 0;
    double p = 1.0/(baseval*server.lfu_log_factor+1);
    if (r < p) counter++;
    return counter;
}
unsigned long LFUGetTimeInMinutes(void) {
    return (server.unixtime/60) & 65535; //server.unixtime 是一個全局變量會被定時更新,精度是秒
}

往期博客回顧

  1. redis服務器的部分啓動過程
  2. GET命令背後的源碼邏輯
  3. redis的基礎數據結構之 sds
  4. redis的基礎數據結構之 list
  5. redis的基礎數據結構 之 ziplist
  6. redis 基礎數據結構之 hash表
  7. redis不穩定字典的遍歷
  8. redis 基礎數據結構 之 集合
  9. redis 基礎數據結構 之 有序集合
  10. redisObject 以及 對抽象的理解
  11. redis持久化 之 反面面試官
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章