java 從零開始手寫 redis(九)LRU 緩存淘汰算法如何避免緩存污染

前言

java從零手寫實現redis(一)如何實現固定大小的緩存?java

java從零手寫實現redis(三)redis expire 過時原理node

java從零手寫實現redis(三)內存數據如何重啓不丟失?git

java從零手寫實現redis(四)添加監聽器github

java從零手寫實現redis(五)過時策略的另外一種實現思路redis

java從零手寫實現redis(六)AOF 持久化原理詳解及實現算法

java從零手寫實現redis(七)LRU 緩存淘汰策略詳解api

從零開始手寫 redis(八)樸素 LRU 淘汰算法性能優化緩存

前兩節咱們分別實現了 LRU 算法,而且進行了性能優化。性能優化

本節做爲 LRU 算法的最後一節,主要解決一下緩存污染的問題。數據結構

LRU 基礎知識

是什麼

LRU算法全稱是最近最少使用算法(Least Recently Use),普遍的應用於緩存機制中。

當緩存使用的空間達到上限後,就須要從已有的數據中淘汰一部分以維持緩存的可用性,而淘汰數據的選擇就是經過LRU算法完成的。

LRU算法的基本思想是基於局部性原理的時間局部性:

若是一個信息項正在被訪問,那麼在近期它極可能還會被再次訪問。

拓展閱讀

Apache Commons LRUMAP 源碼詳解

Redis 當作 LRU MAP 使用

java 從零開始手寫 redis(七)redis LRU 驅除策略詳解及實現

樸素 LRU 算法的不足

當存在熱點數據時,LRU的效率很好,但偶發性的、週期性的批量操做會致使LRU命中率急劇降低,緩存污染狀況比較嚴重。

擴展算法

1. LRU-K

LRU-K中的K表明最近使用的次數,所以LRU能夠認爲是LRU-1。

LRU-K的主要目的是爲了解決LRU算法「緩存污染」的問題,其核心思想是將「最近使用過1次」的判斷標準擴展爲「最近使用過K次」。

相比LRU,LRU-K須要多維護一個隊列,用於記錄全部緩存數據被訪問的歷史。只有當數據的訪問次數達到K次的時候,纔將數據放入緩存。

當須要淘汰數據時,LRU-K會淘汰第K次訪問時間距當前時間最大的數據。

數據第一次被訪問時,加入到歷史訪問列表,若是數據在訪問歷史列表中沒有達到K次訪問,則按照必定的規則(FIFO,LRU)淘汰;

當訪問歷史隊列中的數據訪問次數達到K次後,將數據索引從歷史隊列中刪除,將數據移到緩存隊列中,並緩存數據,緩存隊列從新按照時間排序;

緩存數據隊列中被再次訪問後,從新排序,須要淘汰數據時,淘汰緩存隊列中排在末尾的數據,即「淘汰倒數K次訪問離如今最久的數據」。

LRU-K具備LRU的優勢,同時還能避免LRU的缺點,實際應用中LRU-2是綜合最優的選擇。

因爲LRU-K還須要記錄那些被訪問過、但尚未放入緩存的對象,所以內存消耗會比LRU要多。

2. two queue

Two queues(如下使用2Q代替)算法相似於LRU-2,不一樣點在於2Q將LRU-2算法中的訪問歷史隊列(注意這不是緩存數據的)改成一個FIFO緩存隊列,即:2Q算法有兩個緩存隊列,一個是FIFO隊列,一個是LRU隊列。

當數據第一次訪問時,2Q算法將數據緩存在FIFO隊列裏面,當數據第二次被訪問時,則將數據從FIFO隊列移到LRU隊列裏面,兩個隊列各自按照本身的方法淘汰數據。

新訪問的數據插入到FIFO隊列中,若是數據在FIFO隊列中一直沒有被再次訪問,則最終按照FIFO規則淘汰;

若是數據在FIFO隊列中再次被訪問到,則將數據移到LRU隊列頭部,若是數據在LRU隊列中再次被訪問,則將數據移動LRU隊列頭部,LRU隊列淘汰末尾的數據。

3. Multi Queue(MQ)

MQ算法根據訪問頻率將數據劃分爲多個隊列,不一樣的隊列具備不一樣的訪問優先級,其核心思想是:優先緩存訪問次數多的數據

詳細的算法結構圖以下,Q0,Q1....Qk表明不一樣的優先級隊列,Q-history表明從緩存中淘汰數據,但記錄了數據的索引和引用次數的隊列:

新插入的數據放入Q0,每一個隊列按照LRU進行管理,當數據的訪問次數達到必定次數,須要提高優先級時,將數據從當前隊列中刪除,加入到高一級隊列的頭部;爲了防止高優先級數據永遠不會被淘汰,當數據在指定的時間裏沒有被訪問時,須要下降優先級,將數據從當前隊列刪除,加入到低一級的隊列頭部;須要淘汰數據時,從最低一級隊列開始按照LRU淘汰,每一個隊列淘汰數據時,將數據從緩存中刪除,將數據索引加入Q-history頭部。

若是數據在Q-history中被從新訪問,則從新計算其優先級,移到目標隊列頭部。

Q-history按照LRU淘汰數據的索引。

MQ須要維護多個隊列,且須要維護每一個數據的訪問時間,複雜度比LRU高。

LRU算法對比

對比點 對比
命中率 LRU-2 > MQ(2) > 2Q > LRU
複雜度 LRU-2 > MQ(2) > 2Q > LRU
代價 LRU-2 > MQ(2) > 2Q > LRU

我的理解

實際上上面的幾個算法,思想上大同小異。

核心目的:解決批量操做致使熱點數據失效,緩存被污染的問題。

實現方式:增長一個隊列,用來保存只訪問一次的數據,而後根據次數不一樣,放入到 LRU 中。

只訪問一次的隊列,能夠是 FIFO 隊列,能夠是 LRU,咱們來實現一下 2Q 和 LRU-2 兩種實現。

2Q

實現思路

實際上就是咱們之前的 FIFO + LRU 兩者的結合。

代碼實現

基本屬性

public class CacheEvictLru2Q<K,V> extends AbstractCacheEvict<K,V> {

    private static final Log log = LogFactory.getLog(CacheEvictLru2Q.class);

    /**
     * 隊列大小限制
     *
     * 下降 O(n) 的消耗,避免耗時過長。
     * @since 0.0.13
     */
    private static final int LIMIT_QUEUE_SIZE = 1024;

    /**
     * 第一次訪問的隊列
     * @since 0.0.13
     */
    private Queue<K> firstQueue;

    /**
     * 頭結點
     * @since 0.0.13
     */
    private DoubleListNode<K,V> head;

    /**
     * 尾巴結點
     * @since 0.0.13
     */
    private DoubleListNode<K,V> tail;

    /**
     * map 信息
     *
     * key: 元素信息
     * value: 元素在 list 中對應的節點信息
     * @since 0.0.13
     */
    private Map<K, DoubleListNode<K,V>> lruIndexMap;

    public CacheEvictLru2Q() {
        this.firstQueue = new LinkedList<>();
        this.lruIndexMap = new HashMap<>();
        this.head = new DoubleListNode<>();
        this.tail = new DoubleListNode<>();

        this.head.next(this.tail);
        this.tail.pre(this.head);
    }

}

數據淘汰

數據淘汰的邏輯:

當緩存大小,已經達到最大限制時執行:

(1)優先淘汰 firstQueue 中的數據

(2)若是 firstQueue 中數據爲空,則淘汰 lruMap 中的數據信息。

這裏有一個假設:咱們認爲被屢次訪問的數據,重要性高於被只訪問了一次的數據。

@Override
protected ICacheEntry<K, V> doEvict(ICacheEvictContext<K, V> context) {
    ICacheEntry<K, V> result = null;
    final ICache<K,V> cache = context.cache();
    // 超過限制,移除隊尾的元素
    if(cache.size() >= context.size()) {
        K evictKey = null;
        //1. firstQueue 不爲空,優先移除隊列中元素
        if(!firstQueue.isEmpty()) {
            evictKey = firstQueue.remove();
        } else {
            // 獲取尾巴節點的前一個元素
            DoubleListNode<K,V> tailPre = this.tail.pre();
            if(tailPre == this.head) {
                log.error("當前列表爲空,沒法進行刪除");
                throw new CacheRuntimeException("不可刪除頭結點!");
            }
            evictKey = tailPre.key();
        }
        // 執行移除操做
        V evictValue = cache.remove(evictKey);
        result = new CacheEntry<>(evictKey, evictValue);
    }
    return result;
}

數據刪除

當數據被刪除時調用:

這個邏輯和之前相似,只是多了一個 FIFO 隊列的移除。

/**
 * 移除元素
 *
 * 1. 獲取 map 中的元素
 * 2. 不存在直接返回,存在執行如下步驟:
 * 2.1 刪除雙向鏈表中的元素
 * 2.2 刪除 map 中的元素
 *
 * @param key 元素
 * @since 0.0.13
 */
@Override
public void removeKey(final K key) {
    DoubleListNode<K,V> node = lruIndexMap.get(key);
    //1. LRU 刪除邏輯
    if(ObjectUtil.isNotNull(node)) {
        // A<->B<->C
        // 刪除 B,須要變成: A<->C
        DoubleListNode<K,V> pre = node.pre();
        DoubleListNode<K,V> next = node.next();
        pre.next(next);
        next.pre(pre);
        // 刪除 map 中對應信息
        this.lruIndexMap.remove(node.key());
    } else {
        //2. FIFO 刪除邏輯(O(n) 時間複雜度)
        firstQueue.remove(key);
    }
}

數據的更新

當數據被訪問時,提高數據的優先級。

(1)若是在 lruMap 中,則首先移除,而後放入到頭部

(2)若是不在 lruMap 中,可是在 FIFO 隊列,則從 FIFO 隊列中移除,添加到 LRU map 中。

(3)若是都不在,直接加入到 FIFO 隊列中便可。

/**
 * 放入元素
 * 1. 若是 lruIndexMap 已經存在,則處理 lru 隊列,先刪除,再插入。
 * 2. 若是 firstQueue 中已經存在,則處理 first 隊列,先刪除 firstQueue,而後插入 Lru。
 * 1 和 2 是不一樣的場景,可是代碼其實是同樣的,刪除邏輯中作了二種場景的兼容。
 *
 * 3. 若是不在一、2中,說明是新元素,直接插入到 firstQueue 的開始便可。
 *
 * @param key 元素
 * @since 0.0.13
 */
@Override
public void updateKey(final K key) {
    //1.1 是否在 LRU MAP 中
    //1.2 是否在 firstQueue 中
    DoubleListNode<K,V> node = lruIndexMap.get(key);
    if(ObjectUtil.isNotNull(node)
        || firstQueue.contains(key)) {
        //1.3 刪除信息
        this.removeKey(key);
        //1.4 加入到 LRU 中
        this.addToLruMapHead(key);
        return;
    }
    //2. 直接加入到 firstQueue 隊尾
    //        if(firstQueue.size() >= LIMIT_QUEUE_SIZE) {
//            // 避免第一次訪問的列表一直增加,移除隊頭的元素
//            firstQueue.remove();
//        }
    firstQueue.add(key);
}

這裏我想到了一個優化點,限制 firstQueue 的一直增加,由於遍歷的時間複雜度爲 O(n),因此限制最大的大小爲 1024。

若是超過了,則把 FIFO 中的元素先移除掉。

不過只移除 FIFO,不移除 cache,會致使兩者的活躍程度不一致;

若是同時移除,可是 cache 的大小尚未知足,可能會致使超出用戶的預期,這個能夠做爲一個優化點,暫時註釋掉。

測試

代碼

ICache<String, String> cache = CacheBs.<String,String>newInstance()
        .size(3)
        .evict(CacheEvicts.<String, String>lru2Q())
        .build();

cache.put("A", "hello");
cache.put("B", "world");
cache.put("C", "FIFO");

// 訪問一次A
cache.get("A");
cache.put("D", "LRU");

Assert.assertEquals(3, cache.size());
System.out.println(cache.keySet());

效果

[DEBUG] [2020-10-03 13:15:50.670] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict
[D, A, C]

LRU-2 實現

說明

FIFO 中的缺點仍是比較明顯的,須要 O(n) 的時間複雜度作遍歷。

並且命中率和 LRU-2 比起來仍是會差一點。

準備工做

這裏 LRU map 出現了屢次,咱們爲了方便,將 LRU map 簡單的封裝爲一個數據結構。

咱們使用雙向鏈表+HashMap 實現一個簡單版本的。

節點

node 節點和之前一致:

public class DoubleListNode<K,V> {

    /**
     * 鍵
     * @since 0.0.12
     */
    private K key;

    /**
     * 值
     * @since 0.0.12
     */
    private V value;

    /**
     * 前一個節點
     * @since 0.0.12
     */
    private DoubleListNode<K,V> pre;

    /**
     * 後一個節點
     * @since 0.0.12
     */
    private DoubleListNode<K,V> next;

    //fluent getter & setter
}

接口

咱們根據本身的須要,暫時定義 3 個最重要的方法。

/**
 * LRU map 接口
 * @author binbin.hou
 * @since 0.0.13
 */
public interface ILruMap<K,V> {

    /**
     * 移除最老的元素
     * @return 移除的明細
     * @since 0.0.13
     */
    ICacheEntry<K, V> removeEldest();

    /**
     * 更新 key 的信息
     * @param key key
     * @since 0.0.13
     */
    void updateKey(final K key);

    /**
     * 移除對應的 key 信息
     * @param key key
     * @since 0.0.13
     */
    void removeKey(final K key);

    /**
     * 是否爲空
     * @return 是否
     * @since 0.0.13
     */
    boolean isEmpty();

    /**
     * 是否包含元素
     * @param key 元素
     * @return 結果
     * @since 0.0.13
     */
    boolean contains(final K key);
}

實現

咱們基於 DoubleLinkedList + HashMap 實現。

就是把上一節中的實現整理一下便可。

import com.github.houbb.cache.api.ICacheEntry;
import com.github.houbb.cache.core.exception.CacheRuntimeException;
import com.github.houbb.cache.core.model.CacheEntry;
import com.github.houbb.cache.core.model.DoubleListNode;
import com.github.houbb.cache.core.support.struct.lru.ILruMap;
import com.github.houbb.heaven.util.lang.ObjectUtil;
import com.github.houbb.log.integration.core.Log;
import com.github.houbb.log.integration.core.LogFactory;

import java.util.HashMap;
import java.util.Map;

/**
 * 基於雙向列表的實現
 * @author binbin.hou
 * @since 0.0.13
 */
public class LruMapDoubleList<K,V> implements ILruMap<K,V> {

    private static final Log log = LogFactory.getLog(LruMapDoubleList.class);

    /**
     * 頭結點
     * @since 0.0.13
     */
    private DoubleListNode<K,V> head;

    /**
     * 尾巴結點
     * @since 0.0.13
     */
    private DoubleListNode<K,V> tail;

    /**
     * map 信息
     *
     * key: 元素信息
     * value: 元素在 list 中對應的節點信息
     * @since 0.0.13
     */
    private Map<K, DoubleListNode<K,V>> indexMap;

    public LruMapDoubleList() {
        this.indexMap = new HashMap<>();
        this.head = new DoubleListNode<>();
        this.tail = new DoubleListNode<>();

        this.head.next(this.tail);
        this.tail.pre(this.head);
    }

    @Override
    public ICacheEntry<K, V> removeEldest() {
        // 獲取尾巴節點的前一個元素
        DoubleListNode<K,V> tailPre = this.tail.pre();
        if(tailPre == this.head) {
            log.error("當前列表爲空,沒法進行刪除");
            throw new CacheRuntimeException("不可刪除頭結點!");
        }

        K evictKey = tailPre.key();
        V evictValue = tailPre.value();

        return CacheEntry.of(evictKey, evictValue);
    }

    /**
     * 放入元素
     *
     * (1)刪除已經存在的
     * (2)新元素放到元素頭部
     *
     * @param key 元素
     * @since 0.0.12
     */
    @Override
    public void updateKey(final K key) {
        //1. 執行刪除
        this.removeKey(key);

        //2. 新元素插入到頭部
        //head<->next
        //變成:head<->new<->next
        DoubleListNode<K,V> newNode = new DoubleListNode<>();
        newNode.key(key);

        DoubleListNode<K,V> next = this.head.next();
        this.head.next(newNode);
        newNode.pre(this.head);
        next.pre(newNode);
        newNode.next(next);

        //2.2 插入到 map 中
        indexMap.put(key, newNode);
    }

    /**
     * 移除元素
     *
     * 1. 獲取 map 中的元素
     * 2. 不存在直接返回,存在執行如下步驟:
     * 2.1 刪除雙向鏈表中的元素
     * 2.2 刪除 map 中的元素
     *
     * @param key 元素
     * @since 0.0.13
     */
    @Override
    public void removeKey(final K key) {
        DoubleListNode<K,V> node = indexMap.get(key);

        if(ObjectUtil.isNull(node)) {
            return;
        }

        // 刪除 list node
        // A<->B<->C
        // 刪除 B,須要變成: A<->C
        DoubleListNode<K,V> pre = node.pre();
        DoubleListNode<K,V> next = node.next();

        pre.next(next);
        next.pre(pre);

        // 刪除 map 中對應信息
        this.indexMap.remove(key);
    }

    @Override
    public boolean isEmpty() {
        return indexMap.isEmpty();
    }

    @Override
    public boolean contains(K key) {
        return indexMap.containsKey(key);
    }
}

實現思路

LRU 的實現保持不變。咱們直接將 FIFO 替換爲 LRU map 便可。

爲了便於理解,咱們將 FIFO 對應爲 firstLruMap,用來存放用戶只訪問了一次的元素。

將原來的 LRU 中存入訪問了 2 次及其以上的元素。

其餘邏輯和 2Q 保持一致。

實現

基本屬性

定義兩個 LRU,用來分別存儲訪問的信息

public class CacheEvictLru2<K,V> extends AbstractCacheEvict<K,V> {

    private static final Log log = LogFactory.getLog(CacheEvictLru2.class);

    /**
     * 第一次訪問的 lru
     * @since 0.0.13
     */
    private final ILruMap<K,V> firstLruMap;

    /**
     * 2次及其以上的 lru
     * @since 0.0.13
     */
    private final ILruMap<K,V> moreLruMap;

    public CacheEvictLru2() {
        this.firstLruMap = new LruMapDoubleList<>();
        this.moreLruMap = new LruMapDoubleList<>();
    }

}

淘汰實現

和 lru 2Q 模式相似,這裏咱們優先淘汰 firstLruMap 中的數據信息。

@Override
protected ICacheEntry<K, V> doEvict(ICacheEvictContext<K, V> context) {
    ICacheEntry<K, V> result = null;
    final ICache<K,V> cache = context.cache();
    // 超過限制,移除隊尾的元素
    if(cache.size() >= context.size()) {
        ICacheEntry<K,V>  evictEntry = null;
        //1. firstLruMap 不爲空,優先移除隊列中元素
        if(!firstLruMap.isEmpty()) {
            evictEntry = firstLruMap.removeEldest();
            log.debug("從 firstLruMap 中淘汰數據:{}", evictEntry);
        } else {
            //2. 不然從 moreLruMap 中淘汰數據
            evictEntry = moreLruMap.removeEldest();
            log.debug("從 moreLruMap 中淘汰數據:{}", evictEntry);
        }
        // 執行緩存移除操做
        final K evictKey = evictEntry.key();
        V evictValue = cache.remove(evictKey);
        result = new CacheEntry<>(evictKey, evictValue);
    }
    return result;
}

刪除

/**
 * 移除元素
 *
 * 1. 屢次 lru 中存在,刪除
 * 2. 初次 lru 中存在,刪除
 *
 * @param key 元素
 * @since 0.0.13
 */
@Override
public void removeKey(final K key) {
    //1. 屢次LRU 刪除邏輯
    if(moreLruMap.contains(key)) {
        moreLruMap.removeKey(key);
        log.debug("key: {} 從 moreLruMap 中移除", key);
    } else {
        firstLruMap.removeKey(key);
        log.debug("key: {} 從 firstLruMap 中移除", key);
    }
}

更新

/**
 * 更新信息
 * 1. 若是 moreLruMap 已經存在,則處理 more 隊列,先刪除,再插入。
 * 2. 若是 firstLruMap 中已經存在,則處理 first 隊列,先刪除 firstLruMap,而後插入 Lru。
 * 1 和 2 是不一樣的場景,可是代碼其實是同樣的,刪除邏輯中作了二種場景的兼容。
 *
 * 3. 若是不在一、2中,說明是新元素,直接插入到 firstLruMap 的開始便可。
 *
 * @param key 元素
 * @since 0.0.13
 */
@Override
public void updateKey(final K key) {
    //1. 元素已經在屢次訪問,或者第一次訪問的 lru 中
    if(moreLruMap.contains(key)
        || firstLruMap.contains(key)) {
        //1.1 刪除信息
        this.removeKey(key);
        //1.2 加入到屢次 LRU 中
        moreLruMap.updateKey(key);
        log.debug("key: {} 屢次訪問,加入到 moreLruMap 中", key);
    } else {
        // 2. 加入到第一次訪問 LRU 中
        firstLruMap.updateKey(key);
        log.debug("key: {} 爲第一次訪問,加入到 firstLruMap 中", key);
    }
}

實際上使用 LRU-2 的代碼邏輯反而變得清晰了一些,主要是由於咱們把 lruMap 做爲獨立的數據結構抽離了出去。

測試

代碼

ICache<String, String> cache = CacheBs.<String,String>newInstance()
        .size(3)
        .evict(CacheEvicts.<String, String>lru2Q())
        .build();
cache.put("A", "hello");
cache.put("B", "world");
cache.put("C", "FIFO");
// 訪問一次A
cache.get("A");
cache.put("D", "LRU");
Assert.assertEquals(3, cache.size());
System.out.println(cache.keySet());

日誌

爲了便於定位分析,源代碼實現的時候,加了一點日誌。

[DEBUG] [2020-10-03 14:39:04.966] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: A 爲第一次訪問,加入到 firstLruMap 中
[DEBUG] [2020-10-03 14:39:04.967] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: B 爲第一次訪問,加入到 firstLruMap 中
[DEBUG] [2020-10-03 14:39:04.968] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: C 爲第一次訪問,加入到 firstLruMap 中
[DEBUG] [2020-10-03 14:39:04.970] [main] [c.g.h.c.c.s.e.CacheEvictLru2.removeKey] - key: A 從 firstLruMap 中移除
[DEBUG] [2020-10-03 14:39:04.970] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: A 屢次訪問,加入到 moreLruMap 中
[DEBUG] [2020-10-03 14:39:04.972] [main] [c.g.h.c.c.s.e.CacheEvictLru2.doEvict] - 從 firstLruMap 中淘汰數據:EvictEntry{key=B, value=null}
[DEBUG] [2020-10-03 14:39:04.974] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict
[DEBUG] [2020-10-03 14:39:04.974] [main] [c.g.h.c.c.s.e.CacheEvictLru2.updateKey] - key: D 爲第一次訪問,加入到 firstLruMap 中
[D, A, C]

小結

對於 LRU 算法的改進咱們主要作了兩點:

(1)性能的改進,從 O(N) 優化到 O(1)

(2)批量操做的改進,避免緩存污染

其實除了 LRU,咱們還有其餘的淘汰策略。

咱們須要考慮下面的問題:

A 數據被訪問了 10 次,B 數據被訪問了 2 次。那麼兩者誰是熱點數據呢?

若是你認爲確定 A 是熱點數據,這裏其實是另外一種淘汰算法,基於 LFU 的淘汰算法,認爲訪問次數越多,就越是熱點數據

咱們下一節共同窗習下 LFU 淘汰算法的實現。

開源地址: https://github.com/houbb/cache

以爲本文對你有幫助的話,歡迎點贊評論收藏關注一波,你的鼓勵,是我最大的動力~

目前咱們經過兩次優化,解決了性能問題,和批量致使的緩存污染問題。

不知道你有哪些收穫呢?或者有其餘更多的想法,歡迎留言區和我一塊兒討論,期待與你的思考相遇。

深刻學習

相關文章
相關標籤/搜索