Redis 爲何使用近似 LRU 算法淘汰數據,而不是真實 LRU?

在《Redis 數據緩存滿了怎麼辦?》我們知道 Redis 緩存滿了之後能通過淘汰策略刪除數據騰出空間給新數據。

淘汰策略如下所示:

redis內存淘汰

設置過期時間的 key

volatile-ttl、volatile-random、volatile-lru、volatile-lfu 這四種策略淘汰的數據範圍是設置了過期時間的數據。

所有的 key

allkeys-lru、allkeys-random、allkeys-lfu 這三種淘汰策略無論這些鍵值對是否設置了過期時間,當內存不足都會進行淘汰。

這就意味着,即使它的過期時間還沒到,也會被刪除。當然,如果已經過了過期時間,即使沒有被淘汰策略選中,也會被刪除。

volatile-ttl 和 volatile-randon 很簡單,重點在於 volatile-lru 和 volatile-lfu,他們涉及到 LRU 算法 和 LFU 算法。

今天碼哥帶大家一起搞定 Redis 的 LRU 算法…

近似 LRU 算法

什麼是 LRU 算法呢?

LRU 算法的全程是 Least Rencently Used,顧名思義就是按照最近最久未使用的算法進行數據淘汰。

核心思想「如果該數據最近被訪問,那麼將來被髮放穩的機率也更高」。

我們把所有的數據組織成一個鏈表:

  • MRU:表示鏈表的表頭,代表着最近最常被訪問的數據;
  • LRU:表示鏈表的表尾,代表最近最不常使用的數據。

LRU 算法

可以發現,LRU 更新和插入新數據都發生在鏈表首,刪除數據都發生在鏈表尾

被訪問的數據會被移動到 MRU 端,被訪問的數據之前的數據則相應往後移動一位。

使用單鏈表可以麼?

如果選用單鏈表,刪除這個結點,需要 O(n) 遍歷一遍找到前驅結點。所以選用雙向鏈表,在刪除的時候也能 O(1) 完成。

Redis 使用該 LRU 算法管理所有的緩存數據麼?

不是的,由於 LRU 算法需要用鏈表管理所有的數據,會造成大量額外的空間消耗。

除此之外,大量的節點被訪問就會帶來頻繁的鏈表節點移動操作,從而降低了 Redis 性能。

所以 Redis 對該算法做了簡化,Redis LRU 算法並不是真正的 LRU,Redis 通過對少量的 key 採樣,並淘汰採樣的數據中最久沒被訪問過的 key。

這就意味着 Redis 無法淘汰數據庫最久訪問的數據。

Redis LRU 算法有一個重要的點在於可以更改樣本數量來調整算法的精度,使其近似接近真實的 LRU 算法,同時又避免了內存的消耗,因爲每次只需要採樣少量樣本,而不是全部數據。

配置如下:

maxmemory-samples 50

運行原理

大家還記得麼,數據結構 redisObject 中有一個 lru 字段, 用於記錄每個數據最近一次被訪問的時間戳。

typedef struct redisObject {
    unsigned type:4;
    unsigned encoding:4;
    /* LRU time (relative to global lru_clock) or
     * LFU data (least significant 8 bits frequency
     * and most significant 16 bits access time).
     */
    unsigned lru:LRU_BITS; 
    int refcount;
    void *ptr;
} robj;

Redis 在淘汰數據時,第一次隨機選出 N 個數據放到候選集合,將 lru 字段值最小的數據淘汰。

再次需要淘汰數據時,會重新挑選數據放入第一次創建的候選集合,不過有一個挑選標準:進入該集合的數據的 lru 的值必須小於候選集合中最小的 lru 值。

如果新數據進入候選集合的個數達到了 maxmemory-samples 設定的值,那就把候選集合中 lru 最小的數據淘汰。

這樣就大大減少鏈表節點數量,同時不用每次訪問數據都移動鏈表節點,大大提升了性能。

Java 實現 LRU Cahce

LinkedHashMap 實現

完全利用 Java 的LinkedHashMap實現,可以採用組合或者繼承的方式實現,「碼哥」使用組合的形式完成。

public class LRUCache<K, V> {
    private Map<K, V> map;
    private final int cacheSize;

    public LRUCache(int initialCapacity) {
        map = new LinkedHashMap<K, V>(initialCapacity, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
                return size() > cacheSize;
            }
        };
        this.cacheSize = initialCapacity;
    }
}

重點在於 LinkedHashMap的第三個構造函數上,要把這個構造參數accessOrder設爲true,代表LinkedHashMap內部維持訪問順序。

另外,還需要重寫removeEldestEntry(),這個函數如果返回true,代表把最久未被訪問的節點移除,從而實現淘汰數據。

自己實現

其中代碼是從 LeetCode 146. LRU Cache 上摘下來的。代碼裏面有註釋。

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 在鏈頭放最久未被使用的元素,鏈尾放剛剛添加或訪問的元素
 */
class LRUCache {
    class Node {
        int key, value;
        Node pre, next;

        Node(int key, int value) {
            this.key = key;
            this.value = value;
            pre = this;
            next = this;
        }
    }

    private final int capacity;// LRU Cache的容量
    private Node dummy;// dummy節點是一個冗餘節點,dummy的next是鏈表的第一個節點,dummy的pre是鏈表的最後一個節點
    private Map<Integer, Node> cache;//保存key-Node對,Node是雙向鏈表節點

    public LRUCache(int capacity) {
        this.capacity = capacity;
        dummy = new Node(0, 0);
        cache = new ConcurrentHashMap<>();
    }

    public int get(int key) {
        Node node = cache.get(key);
        if (node == null) return -1;
        remove(node);
        add(node);
        return node.value;
    }

    public void put(int key, int value) {
        Node node = cache.get(key);
        if (node == null) {
            if (cache.size() >= capacity) {
                cache.remove(dummy.next.key);
                remove(dummy.next);
            }
            node = new Node(key, value);
            cache.put(key, node);
            add(node);
        } else {
            cache.remove(node.key);
            remove(node);
            node = new Node(key, value);
            cache.put(key, node);
            add(node);
        }
    }

    /**
     * 在鏈表尾部添加新節點
     *
     * @param node 新節點
     */
    private void add(Node node) {
        dummy.pre.next = node;
        node.pre = dummy.pre;
        node.next = dummy;
        dummy.pre = node;
    }

    /**
     * 從雙向鏈表中刪除該節點
     *
     * @param node 要刪除的節點
     */
    private void remove(Node node) {
        node.pre.next = node.next;
        node.next.pre = node.pre;
    }
}

不要吝嗇讚美,當別人做的不錯,就給予他正反饋。少關注用「讚美」投票的事情,而應該去關注用「交易」投票的事情。

判斷一個人是否牛逼,不是看網上有多少人誇讚他,而是要看有多少人願意跟他發生交易或讚賞、支付、下單。

因爲讚美太廉價,而願意與他發生交易的纔是真正的信任和支持。

碼哥到現在已經寫了近 23+ 篇 Redis 文章,贈送了很多書籍,收到過許多讚美和少量讚賞,感謝曾經讚賞過我的讀者,謝謝。

我是「碼哥」,大家可以叫我靚仔,好文請點贊,關於 LFU 算法,我們下一篇見。

歷史好文

參考文獻

https://redis.io/docs/manual/eviction/

http://antirez.com/news/109

https://time.geekbang.org/column/article/294640

https://halfrost.com/lru_lfu_interview/

https://blog.csdn.net/csdlwzy/article/details/95635083

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