Redis內存淘汰機制

概述

Redis是基於內存存儲,常用於數據的緩存,所以Redis提供了對鍵的過期時間的設置,實現了幾種淘汰機制便於適應各種場景。


設置過期時間
我們可以在設置鍵時設置expire time,也可以在運行時給存在的鍵設置剩餘的生存時間,不設置則默認為-1,設置為-1時表示永久存儲。

Redis清除過期Key的方式

定期刪除+惰性刪除

定期刪除

Redis設定每隔100ms隨機抽取設置了過期時間的key,並對其進行檢查,如果已經過期則刪除。

為什麼是隨機抽取? 因為如果存儲了大量數據,全部遍歷一遍是非常影響性能的!

惰性刪除

每次獲取key時會對key進行判斷是否還存活,如果已經過期了則刪除。


注意:Redis中過期的key並不會馬上刪除,因為定期刪除可能正好沒抽取到它,我們也沒有訪問它觸發惰性刪除

Redis內存淘汰機制

思考一下,如果定期刪除漏掉了很多過期的key,而我們也沒有再去訪問它,如果不加處理,很可能導致內存耗盡。


Redis配置文件中可以設置maxmemory,內存的最大使用量,到達限度時會執行內存淘汰機制

配置

  • redis.conf 配置文件中配置最大可用內存

    // 設置Redis 最大可用內存為 1024mb
    maxmemory 1024mb
    
  • 命令操作

    //獲取設置的Redis能使用的最大內存大小
    127.0.0.1:6379> config get maxmemory
    //設置Redis最大佔用內存大小為1024M
    127.0.0.1:6379> config set maxmemory 1024mb

Redis中的內存淘汰機制

沒有配置時,默認為no-eviction

名稱 描述
no-eviction 當內存不足寫入新數據時,寫入操作會報錯,同時不刪除數據
volatile-lru 已設置過期時間的數據集中挑選最近最少使用的 Key 淘汰
volatile-ttl 從已設置過期時間的數據集中挑選將要過期的 Key 淘汰
volatile-random 從已設置過期時間的數據集中挑選任意 Key淘汰
volatile-lfu 從已設置過期時間的數據集中挑選最不經常使用的 Key 淘汰
allkeys-lru 當內存不足寫入新數據時淘汰最近最少使用的 Key
allkeys-random 當內存不足寫入新數據時隨機選擇任意 key淘汰
allkeys-lfu 當內存不足寫入新數據時移除最不經常使用的Key
  • volatile為前綴的策略都是從 已過期的數據集 中進行淘汰。
  • allkeys為前綴的策略都是面向 所有key 進行淘汰。
  • LRU(least recently used)最近最少使用的。
  • LFU(Least Frequently Used)最不常用的。
  • 它們的觸發條件都是Redis使用的內存達到閾值時。

內存淘汰機制設置獲取修改

  • redis.conf 配置文件中配置最大可用內存

    // 設置Redis 淘汰機製為 volatile-lfu
    maxmemory-policy volatile-lfu
    
  • 命令操作

    //獲取設置的Redis內存淘汰機制
    127.0.0.1:6379> config get maxmemory-policy
    //設置Redis內存淘汰機制
    127.0.0.1:6379> config set maxmemory-policy volatile-lfu

LRU 算法

概念

LRU(Least Recently Used),最近最少使用,是一種緩存置換算法,其核心思想是:如果一個數據在最近一段時間內沒有被用到,那麼將來被使用的可能性也很小,所以就可以被淘汰掉。

實現原理

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

位於鏈表尾部的元素就是不被重用的元素,所以會被踢掉。位於表頭的元素是剛被使用過的,因此暫時不會被踢。

下面使用 PHP 來實現一個簡單的 LRU 算法。

<?php
class LRUCache
{
    private $cache = [];
    private $maxSize = 0;
    // 訪問時間key=>time
    private $lru = [];

    function __construct($size)
    {
        // 緩存最大存儲數量
        $this->maxSize = $size;
    }

    public function set($key, $value)
    {
        // 如果存在,就先刪除,然後在開頭插入
        if (isset($this->cache[$key])) {
            unset($this->cache[$key]);
        }
        // 長度檢查,超長則刪除尾部元素
        if (count($this->cache) >= $this->maxSize) {
            array_pop($this->cache);
        }
        // 頭部插入元素
        $this->cache = [$key=>$value] + $this->cache;
    }

    public function get($key)
    {
        $resultValue = null;
        if (isset($this->cache[$key])) {
            $resultValue = $this->cache[$key];
            // 移動到頭部
            unset($this->cache[$key]);
            $this->cache = [$key=>$resultValue] + $this->cache;
        }
        
        return $resultValue;
    }

    public function getAll()
    {
        return $this->cache;
    }
}

$cache = new LRUCache(3);
$cache->set('a', 1);
$cache->set('b', 2);
$cache->set('c', 3);
var_dump($cache->getAll());

$cache->set('d', 4);
var_dump($cache->getAll());

LRU 在 redis 中的實現

Redis 使用了一種近似 LRU算法,之所以不使用 LRU 算法,是因為其需要消耗大量的額外內存。

redis 為了實現近似 LRU 算法,給每個 key 增加了一個 24 bit的字段,用於保存最後一次被訪問的時間。

Redis維護了一個24位時鐘,可以簡單理解為當前系統的時間戳,每隔一定時間會更新這個時鐘。每個key對象內部同樣維護了一個24位的時鐘,當新增key對象的時候會把系統的時鐘賦值到這個內部對象時鐘。比如我現在要進行LRU,那麼首先拿到當前的全局時鐘,然後再找到內部時鐘與全局時鐘距離時間最久的(差最大)進行淘汰,這裡值得注意的是全局時鐘只有24位,按秒為單位來表示才能存儲194天,所以可能會出現key的時鐘大於全局時鐘的情況,如果這種情況出現那麼就兩個相加而不是相減來求最久的key。

struct redisServer {
      pid_t pid; 
      char *configfile; 
      //全局時鐘
      unsigned lruclock:LRU_BITS; 
      ...
};
typedef struct redisObject {
   unsigned type:4;
   unsigned encoding:4;
   /* key對象內部時鐘 */
   unsigned lru:LRU_BITS;
   int refcount;
   void *ptr;
} robj;

近似的 LRU 算法實際原理是 維護一個候選池(大小16),第一次選取 5 個(默認值)key 放到池中,隨後每次選取的 key 值只有 訪問時間(與系統時鐘)間隔 大於 池中最小訪問時間間隔的 才會被放到 池中,直到放滿,如果有新加入的,則移除間隔時間最小的 key,當需要淘汰時,則直接從池中選取時間間隔最大(最久沒用被調用)的進行淘汰。

LRU 和 近似 LRU 效果對比

下圖是常規LRU淘汰策略與Redis隨機樣本取一鍵淘汰策略的對比,淺灰色表示已經刪除的鍵,深灰色表示沒有被刪除的鍵,綠色表示新加入的鍵,越往上表示鍵加入的時間越久。從圖中可以看出,在redis 3中,設置樣本數為10的時候能夠很準確的淘汰掉最久沒有使用的鍵,與常規LRU基本持平。

Snipaste_2020-08-27_22-13-36

LFU 算法

概念

LFU(Least Frequently Used),它的核心思想是 如果一個數據在最近一段時間內使用次數很少,那麼在將來一段時間內被使用的可能性也很小,所有就可以被淘汰掉。

實現原理

根據 key 的最近訪問頻率進行淘汰,很少被訪問的優先被淘汰,被訪問多的則留下來。

下面使用 PHP 實現 LFU 算法

class LFUCache
{
    private $cache = [];
    private $maxSize = 0;
    // 訪問時間key=>count
    private $lfu = [];

    function __construct($size)
    {
        // 緩存最大存儲數量
        $this->maxSize = $size;
    }

    public function set($key, $value)
    {
        // 如果存在,就更新訪問次數+1
        if (isset($this->cache[$key])) {
            $this->lfu[$key] += 1; 
        }
        // 長度檢查,超長則刪除最久訪問數據
        $this->cleanup();

        // 插入元素, 更新訪問次數
        $this->cache[$key] = $value;
        if (!isset($this->lfu[$key])) {
            $this->lfu[$key] = 1; 
        }
    }

    public function cleanup()
    {
        if (count($this->cache) >= $this->maxSize) {
            asort($this->lfu);
            var_dump($this->lfu);
            $k = array_keys($this->lfu)[0];
            unset($this->cache[$k]);
            unset($this->lfu[$k]);
        }
        return true;
    }

    public function get($key)
    {
        $resultValue = null;
        if (isset($this->cache[$key])) {
            $resultValue = $this->cache[$key];
            // 更新訪問時間
            $this->lfu[$key] += 1;
        }
        
        return $resultValue;
    }

    public function getAll()
    {
        return $this->cache;
    }
}

$cache = new LFUCache(3);
$cache->set('a', 1);
$cache->set('b', 2);
$cache->set('c', 3);

var_dump($cache->getAll());
$cache->get('a');
$cache->set('d', 4);
var_dump($cache->getAll());

LFU 在 redis 中的實現

LFU是在Redis4.0後出現的,LRU的最近最少使用實際上並不精確,考慮下面的情況,如果在|處刪除,那麼A距離的時間最久,但實際上A的使用頻率要比B頻繁,所以合理的淘汰策略應該是淘汰B。LFU就是為應對這種情況而生的。

A~~A~~A~~A~~A~~A~~A~~A~~A~~A~~~|
B~~~~~B~~~~~B~~~~~B~~~~~~~~~~~B|

LFU 把原來的 key 對象的內部時鐘的 24 位分成兩部分,前16位還代表時鐘,後8位代表一個計數器。16位的情況下如果還按照秒為單位就會導致不夠用,所以一般這裡以時鐘為單位。而後8位表示當前 key 對象的訪問頻率,8位只能代表255,但是redis 並沒有採用線性上升的方式,而是通過一個複雜的公式,通過配置兩個參數來調整數據的遞增速度。
下圖從左到右表示 key 的命中次數,從上到下表示影響因子,在影響因子為100的條件下,經過10M次命中才能把後8位值加滿到255.

# +--------+------------+------------+------------+------------+------------+
# | 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        |
# +--------+------------+------------+------------+------------+------------+
  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;
  }
lfu-log-factor 10
lfu-decay-time 1

上面說的情況是 key 一直被命中的情況,如果一個 key 經過幾分鐘沒有被命中,那麼後8位的值是需要遞減幾分鐘,具體遞減幾分鐘根據衰減因子lfu-decay-time來控制

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;
}
lfu-log-factor 10
lfu-decay-time 1

上面遞增和衰減都有對應參數配置,那麼對於新分配的 key 呢?如果新分配的 key 計數器開始為0,那麼很有可能在內存不足的時候直接就給淘汰掉了,所以默認情況下新分配的 key 的後8位計數器的值為5(應該可配資),防止因為訪問頻率過低而直接被刪除。

低8位我們描述完了,那麼高16位的時鐘是用來幹嘛的呢?目前我的理解是用來衰減低8位的計數器的,就是根據這個時鐘與全局時鐘進行比較,如果過了一定時間(做差)就會對計數器進行衰減。

參考資料

Redis內存淘汰機制

redis內存淘汰機制

Redis的緩存淘汰策略LRU與LFU

Tags: