緩存-淘汰策略原理及其實現(xiàn)

一、引言

在日常開發(fā)使用中,我們經(jīng)常會使用key-value,也就是hash的數(shù)據(jù)結(jié)構(gòu),在java中我們用的HashMap通常是沒有淘汰策略的,大小在超過我們設(shè)定的值之后會自動擴容。而我們在使用緩存的場景下,其大小通常是有限制的,在容量有限的情況下,怎么合理的淘汰沒有價值的內(nèi)存,提高緩存的命中率是一門優(yōu)雅的藝術(shù)。

二、常見的淘汰策略算法

首先要定義怎樣的數(shù)據(jù)才算是“低價值”?由于緩存的通用性,這個問題的答案必須是與具體業(yè)務(wù)邏輯是無關(guān)的。我們從何種維度、如何定義數(shù)據(jù)的價值高低,其實也就決定了我們要使用什么樣的緩存淘汰策略。我們最常見的緩存淘汰策略有:FIFO(First In First Out)、LRU(Least Recent Used),LFU(Least Frequently Used)下面我們依次講解這幾種淘汰策略的實現(xiàn)原理及其實現(xiàn)。

FIFO:

先淘汰最早進入被緩存的數(shù)據(jù)。FIFO 實現(xiàn)十分簡單,但一般來說它并不是優(yōu)秀的淘汰策略,越是頻繁被用到的數(shù)據(jù),往往會越早被存入緩存之中。如果采用這種淘汰策略,很可能會大幅降低緩存的命中率。

LRU(The Least Recently Used):

優(yōu)先淘汰最近最久未被使用訪問過的數(shù)據(jù)。在計算機科學(xué)中,通常認(rèn)為越是最近經(jīng)常訪問的數(shù)據(jù),越是將要被訪問到,從這個角度看,那么越久沒有被訪問到的數(shù)據(jù),越是應(yīng)該被優(yōu)先淘汰掉。下面我來看看一下具體的實現(xiàn)。
首先具體的操作大致有兩個,一個是put操作儲存元素,另一個是get操作獲取元素值。
為了使獲取數(shù)據(jù)的時間復(fù)雜度盡可能低,我們優(yōu)先使用的是hash的數(shù)據(jù)結(jié)構(gòu),但是想要實現(xiàn)優(yōu)先淘汰最近最久沒有被訪問的數(shù)據(jù),那么我們首先想到可以存數(shù)據(jù)的訪問時間,在容量到達閾值時,根據(jù)根據(jù)訪問時間來判斷,但是這顯然不是最優(yōu)解。另一種辦法是我們維護一個鏈表,最久沒有訪問過的數(shù)據(jù)放在尾部,最近訪問過的數(shù)據(jù)放在頭部,這樣我們淘汰數(shù)據(jù)時,就可以直接刪除鏈表尾部的數(shù)據(jù),其時間復(fù)雜度是O(1)。而且插入數(shù)據(jù)時,我們需要維護鏈表元素的順序性時,移動元素的時間復(fù)雜也可以達到O(1)。這樣我們基本就確定了要使用 hash 和 鏈表的數(shù)據(jù)結(jié)構(gòu),具體兩種數(shù)據(jù)結(jié)構(gòu)定義,我們可以從思考兩種操作如何實現(xiàn)上來得到答案。

實現(xiàn)邏輯

首先put操作:存儲元素時,需要判斷元素是否已經(jīng)存在,
1、如果元素已經(jīng)存在,則更新map中key對應(yīng)的value值,并且將鏈表中的這個元素的節(jié)點移動到鏈表的頭部。
2、 如果不存在則map和鏈表中直接新增元素。在map中新增元素時,需要判斷容量是否已滿,如果容量已滿,我們需要先淘汰元素,然后需要在map和List中都新增一個元素;如果容量未滿,則直接在map和鏈表中新增。
get操作:
如果元素不存在,直接返回null,如果元素存在,則需要移動鏈表中的節(jié)點至頭部,然后返回key對應(yīng)的value值。

根據(jù)以上操作,我們定義一個LRUCache類,這個LURCache中肯定有一個 cacheMap,map中的value存的肯定不能是key對應(yīng)的value值,而應(yīng)該是一個鏈表中的一個節(jié)點Node。因為我們在put和get操作中都需要移動鏈表節(jié)點,這個節(jié)點是從map中取出來的。那Node節(jié)點應(yīng)該包含哪些屬性呢,首先value值肯定有,因為get操作我們需要返回value值。其次還需要 prev 和 next屬性,也就是這個鏈表是一個雙向鏈表,因為在刪除和移動節(jié)點的過程中需要訪問節(jié)點的前置和后置。Node中key也需要,因為刪除節(jié)點時,map中是根據(jù)key刪除的。為了方便操作雙向鏈表,使得刪除(刪除尾節(jié)點)和插入(插入頭結(jié)點)操作時間復(fù)雜度尾O(1),LRUCache 中還需要 head 節(jié)點 和 tail 尾節(jié)點。 最后還需要有個 capital容量的屬性,判斷map中元素是否達到閾值。那么至此,LRUCache類的數(shù)據(jù)結(jié)構(gòu)就出來了

class LRUCache {
     private ListNode head ;
    private ListNode tail ;
    private Map<Integer,ListNode> lruMap ;
    private int capacity = 65535;

    public LRUCache(int capacity){
        this.capacity = capacity;
        head = new ListNode(null,0,0,null);
        tail = new ListNode(head,0,0,null);
        head.next = tail;
        lruMap = new HashMap<>();
    }
    
    public void put(Integer key,Integer value){
     ...
    }

    public Integer get(Integer key){
     ...
    }


   class ListNode{
        Integer key;
        Integer val;
        ListNode pre;
        ListNode next;

        public ListNode(ListNode pre,Integer key,Integer val,ListNode next){
            this.key = key;
            this.val = val;
            this.pre = pre;
            this.next = next;
        }
    }
}

LRU算法相比FIFO雖然已經(jīng)能夠顯著提高緩存的命中率,但是從另外一個角度來看,還是存在一些問題,比如一些熱點數(shù)據(jù)在系統(tǒng)中經(jīng)常被頻繁訪問,但最近一段時間因為某種原因未被訪問過(周期性熱點數(shù)據(jù)),此時這些熱點數(shù)據(jù)依然要面臨淘汰的命運,LRU 依然可能錯誤淘汰價值更高的數(shù)據(jù)。

LFU(The Least Frequently Used):

優(yōu)先淘汰最不經(jīng)常使用的數(shù)據(jù)。如果定義一個數(shù)據(jù)會不會經(jīng)常被使用呢,可能就需要給這個元素定義一個訪問計數(shù)器, 根據(jù)訪問計數(shù)器的大小來判斷,訪問得越少,就優(yōu)先淘汰。下面我們來思考怎么實現(xiàn)。
淘汰數(shù)據(jù)時要根據(jù)元素訪問計數(shù)器的大小,怎么判斷訪問計數(shù)器大小有兩種方式,一種是在元素淘汰時,先把所有元素取出來對比一下,找到最小的淘汰,這種時間復(fù)雜度要O(N),通常不采用。另外一種是在元素插入時,就按照其大小排列好,可以采用紅黑樹的數(shù)據(jù)結(jié)構(gòu),時間復(fù)雜度可以達到logN,這種顯然要更優(yōu)。
基于上面所說,基本數(shù)據(jù)結(jié)構(gòu)就出來了,采用Map 和 TreeSet的數(shù)據(jù)結(jié)構(gòu)。

public class LFUCache<K,V> {

    /**
     * 存對象key,Node
     */
    private Map<K,LFUNode<K,V>> cacheMap = new HashMap<K,LFUNode<K,V>>();

    /**
     * 排序的set,根據(jù)活躍數(shù)
     */
    private TreeSet<LFUNode> activeSet;

    private Integer capital = 65535;

    public LFUCache(Integer capital) {
        this.capital = capital;
    }

    public V get(K key) {
       ...
    }

    public void put(K key,V value) {
     ...
    }

    class LFUNode<K,V> implements Comparable<LFUNode>{
        private K key;

        private V value;

        private Integer activeCount;

        public LFUNode(K key,V value,Integer activeCount) {
            this.key = key;
            this.value = value;
            this.activeCount = activeCount;
        }

        @Override
        public int compareTo(LFUNode o) {
            return activeCount - o.activeCount;
        }
    }
}

實現(xiàn)邏輯

首先put操作:存儲元素時,需要判斷元素是否已經(jīng)存在,
1、如果元素已經(jīng)存在,則更新map中key對應(yīng)元素的value值,并且元素訪問計數(shù)器+1,treeSet中刪除此key對應(yīng)的元素,并將更新后的元素添加到treeSet。
2、 如果不存在則map和set中直接新增元素。在map中新增元素時,需要判斷容量是否已滿,如果容量已滿,我們需要先淘汰treeSet中的第一個元素(默認(rèn)訪問計數(shù)器升序排列),然后再在map和TreeSet中新增一個元素;如果容量未滿,則直接在map和TreeSet中新增一個元素。
get操作:
如果元素不存在,直接返回null,如果元素存在,從map中取出key對應(yīng)元素,元素訪問計數(shù)器+1,treeSet中刪除此key對應(yīng)的元素,并將更新后的元素添加到treeSet,最后返回元素的valuie值

優(yōu)化升級版的LFU

以上的實現(xiàn)我們上文提到我們在像treeSet中添加元素或者刪除元素時的時間復(fù)雜度都是logn,那有沒有更優(yōu)解呢,比如把時間復(fù)雜度降低為O(1)呢。在計算機科學(xué)中,還有一種常見的做法,當(dāng)我們要追求時間上的極致性能時,往往要犧牲空間上的性能,也就是以空間換時間。
使用的數(shù)據(jù)結(jié)構(gòu):

比如我們可以建一個freqMap,key用來存訪問次數(shù),value則使用LinkedHashSet來存儲Node節(jié)點,之所以使用LinkedHashSet,而不用List,是因為首先刪除元素操作是常數(shù)階的時間復(fù)雜度,另外, 同一個訪問次數(shù)可能對應(yīng)多個元素,需要保證這些元素是有序的,這樣我們就可以在同一個訪問次數(shù)的情況下,先刪除set中的第一個元素(也就是最久未使用的元素)。另外,我們還需要維護一個min,用來表示當(dāng)前最小訪問次數(shù)。這樣在容量超過閾值時,我們就可以直接刪除這個訪問次數(shù)的節(jié)點元素。

min的維護

具體min 怎么維護呢,我們新增元素時,min = 1。另外刪除元素時,有兩個場景,一個是cache中已經(jīng)存在此元素,這個時候,freqMap中需要刪除這個元素訪問次數(shù)中set對應(yīng)的元素節(jié)點,刪除完了之后,需要判斷一下當(dāng)前set中是否還有元素,如果沒有元素,說明當(dāng)前訪問次數(shù)中的元素訪問次數(shù)全部升級了。那么如果當(dāng)前訪問次數(shù) = min,那么min就需要+1; 還有一個場景是cache中元素已經(jīng)滿了的情況下,需要刪除min對應(yīng)set中的第一個元素,刪除之后,如果set中沒有數(shù)據(jù),同樣需要對 min進行+1;
具體的put操作和get操作的實現(xiàn)邏輯此處不再贅述,下面只列出相關(guān)的數(shù)據(jù)結(jié)構(gòu)。

class LFUCache {
    Map<Integer, Node> cache;  // 存儲緩存的內(nèi)容
    Map<Integer, LinkedHashSet<Node>> freqMap;
    int size;
    int capacity;
    int min; // 存儲當(dāng)前最小頻次

    public LFUCache(int capacity) {
        cache = new HashMap<> (capacity);
        freqMap = new HashMap<>();
        this.capacity = capacity;
    }
    
    public int get(int key) {
     ...
    }
    
    public void put(int key, int value) {
        .....
    }
}

class Node {
    int key;
    int value;
    int freq = 1;

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

LFU可以解決LRU周期性的熱點數(shù)據(jù)問題,但是同樣也存在以下幾個問題:一是我們需要維護好每個元素的訪問計數(shù)器,這個維護帶來的成本是高昂的。另一個是有些數(shù)據(jù)曾經(jīng)被頻繁訪問過,但是最近不需要了,這些數(shù)據(jù)就很難被清除。比如對突發(fā)性的局部熱點數(shù)據(jù)/稀疏流量無法被淘汰,就會導(dǎo)致算法中緩存的命中率急劇下降,這個是它最大弊端。

TinyLFU

這么看來,其實LRU和LFU各有優(yōu)缺點,LRU使用于突發(fā)性流量的場景,而LFU使用于局部周期性流量。那么,有沒有一種緩存淘汰算法,能夠兼容他們兩個的優(yōu)點呢?TinyLFU算法出現(xiàn)了,顧名思義,TinyLFU是一種LFU算法,只不過在LFU算法的基礎(chǔ)上,它又對LFU面臨的兩個問題給予了解決。
首先,在第一個問題上,對每個元素的維護上帶來的時間和空間上的開銷,首先對于每個元素的維護時間上的開銷,采用了異步的方式,而不是在put或者get的時候直接取維護,而是采用了類似數(shù)據(jù)庫的基于日志提交再去處理的方式。此外,對于在維護每個元素的訪問計數(shù)所占用的空間問題上,采用了 Count–Min Sketch算法(類似布隆過濾器的原理去降低存儲的空間開銷)。最后在局部熱點數(shù)據(jù)無法被淘汰的問題上,TinyLFU采用了熱度衰減機制,就是當(dāng)整體的統(tǒng)計計數(shù)(當(dāng)前所有記錄的頻率統(tǒng)計之和,這個數(shù)值內(nèi)部維護)達到某一個值時,那么所有記錄的統(tǒng)計計數(shù)除以 2。
下面我們詳細(xì)說一下Count–Min Sketch算法的具體原理

Count–Min Sketch

W-TinyLFU

W-TinyLFU 又是 TinyLFU 的改進版本。TinyLFU 在實現(xiàn)減少計數(shù)器維護頻率的同時,也帶來了無法很好地應(yīng)對稀疏突發(fā)訪問的問題,所謂稀疏突發(fā)訪問是指有一些絕對頻率較小,但突發(fā)訪問頻率很高的數(shù)據(jù),譬如某些運維性質(zhì)的任務(wù),也許一天、一周只會在特定時間運行一次,其余時間都不會用到,此時 TinyLFU 就很難讓這類元素通過 Sketch 的過濾,因為它們無法在運行期間積累到足夠高的頻率。應(yīng)對短時間的突發(fā)訪問是 LRU 的強項,W-TinyLFU 就結(jié)合了 LRU 和 LFU 兩者的優(yōu)點,從整體上看是它是 LFU 策略,從局部實現(xiàn)上看又是 LRU 策略。具體做法是將新記錄暫時放入一個名為 Window Cache 的前端 LRU 緩存里面,讓這些對象可以在 Window Cache 中累積熱度,如果能通過 TinyLFU 的過濾器,再進入名為 Main Cache 的主緩存中存儲,主緩存根據(jù)數(shù)據(jù)的訪問頻繁程度分為不同的段(LFU 策略,實際上 W-TinyLFU 只分了兩段),但單獨某一段局部來看又是基于 LRU 策略去實現(xiàn)的(稱為 Segmented LRU)。每當(dāng)前一段緩存滿了之后,會將低價值數(shù)據(jù)淘汰到后一段中去存儲,直至最后一段也滿了之后,該數(shù)據(jù)就徹底清理出緩存。
僅靠以上簡單的、有限的介紹,你不一定能完全理解 TinyLFU 和 W-TinyLFU 的工作原理,但肯定能看出這些改進算法比起原來基礎(chǔ)版本的 LFU 要復(fù)雜了許多。有時候為了取得理想的效果,采用較為復(fù)雜的淘汰策略是不得已的選擇。如果對具體的實現(xiàn)原理感興趣,可以看一下這篇 caffeine的緩存設(shè)計,里面有詳細(xì)的原理解析。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容