從零開始手寫 redis(七)LRU 快取淘汰策略詳解

葉止水發表於2020-10-03

前言

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

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

java從零手寫實現redis(三)記憶體資料如何重啟不丟失?

java從零手寫實現redis(四)新增監聽器

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

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

我們前面簡單實現了 redis 的幾個特性,java從零手寫實現redis(一)如何實現固定大小的快取? 中實現了先進先出的驅除策略。

但是實際工作實踐中,一般推薦使用 LRU/LFU 的驅除策略。

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-CDmJtApe-1601733022771)(https://cdn.pixabay.com/photo/2018/12/01/18/12/box-3849936_1280.jpg)]

LRU 基礎知識

擴充學習

Apache Commons LRUMAP 原始碼詳解

Redis 當做 LRU MAP 使用

LRU 是什麼

LRU 是由 Least Recently Used 的首字母組成,表示最近最少使用的含義,一般使用在物件淘汰演算法上。

也是比較常見的一種淘汰演算法。

其核心思想是如果資料最近被訪問過,那麼將來被訪問的機率也更高

連續性

在電腦科學中,有一個指導準則:連續性準則。

時間連續性:對於資訊的訪問,最近被訪問過,被再次訪問的可能性會很高。快取就是基於這個理念進行資料淘汰的。

空間連續性:對於磁碟資訊的訪問,將很有可能訪問連續的空間資訊。所以會有 page 預取來提升效能。

實現步驟

  1. 新資料插入到連結串列頭部;

  2. 每當快取命中(即快取資料被訪問),則將資料移到連結串列頭部;

  3. 當連結串列滿的時候,將連結串列尾部的資料丟棄。

其實比較簡單,比起 FIFO 的佇列,我們引入一個連結串列實現即可。

一點思考

我們針對上面的 3 句話,逐句考慮一下,看看有沒有值得優化點或者一些坑。

如何判斷是新資料?

(1) 新資料插入到連結串列頭部;

我們使用的是連結串列。

判斷新資料最簡單的方法就是遍歷是否存在,對於連結串列,這是一個 O(n) 的時間複雜度。

其實效能還是比較差的。

當然也可以考慮空間換時間,比如引入一個 set 之類的,不過這樣對空間的壓力會加倍。

什麼是快取命中

(2)每當快取命中(即快取資料被訪問),則將資料移到連結串列頭部;

put(key,value) 的情況,就是新元素。如果已有這個元素,可以先刪除,再加入,參考上面的處理。

get(key) 的情況,對於元素訪問,刪除已有的元素,將新元素放在頭部。

remove(key) 移除一個元素,直接刪除已有元素。

keySet() valueSet() entrySet() 這些屬於無差別訪問,我們不對佇列做調整。

移除

(3)當連結串列滿的時候,將連結串列尾部的資料丟棄。

連結串列滿只有一種場景,那就是新增元素的時候,也就是執行 put(key, value) 的時候。

直接刪除對應的 key 即可。

java 程式碼實現

介面定義

和 FIFO 的介面保持一致,呼叫地方也不變。

為了後續 LRU/LFU 實現,新增 remove/update 兩個方法。

public interface ICacheEvict<K, V> {

    /**
     * 驅除策略
     *
     * @param context 上下文
     * @since 0.0.2
     * @return 是否執行驅除
     */
    boolean evict(final ICacheEvictContext<K, V> context);

    /**
     * 更新 key 資訊
     * @param key key
     * @since 0.0.11
     */
    void update(final K key);

    /**
     * 刪除 key 資訊
     * @param key key
     * @since 0.0.11
     */
    void remove(final K key);

}

LRU 實現

直接基於 LinkedList 實現:

/**
 * 丟棄策略-LRU 最近最少使用
 * @author binbin.hou
 * @since 0.0.11
 */
public class CacheEvictLRU<K,V> implements ICacheEvict<K,V> {

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

    /**
     * list 資訊
     * @since 0.0.11
     */
    private final List<K> list = new LinkedList<>();

    @Override
    public boolean evict(ICacheEvictContext<K, V> context) {
        boolean result = false;
        final ICache<K,V> cache = context.cache();
        // 超過限制,移除隊尾的元素
        if(cache.size() >= context.size()) {
            K evictKey = list.get(list.size()-1);
            // 移除對應的元素
            cache.remove(evictKey);
            result = true;
        }
        return result;
    }


    /**
     * 放入元素
     * (1)刪除已經存在的
     * (2)新元素放到元素頭部
     *
     * @param key 元素
     * @since 0.0.11
     */
    @Override
    public void update(final K key) {
        this.list.remove(key);
        this.list.add(0, key);
    }

    /**
     * 移除元素
     * @param key 元素
     * @since 0.0.11
     */
    @Override
    public void remove(final K key) {
        this.list.remove(key);
    }

}

實現比較簡單,相對 FIFO 多了三個方法:

update():我們做一點簡化,認為只要是訪問,就是刪除,然後插入到隊首。

remove():刪除就是直接刪除。

這三個方法是用來更新最近使用情況的。

那什麼時候呼叫呢?

註解屬性

為了保證核心流程,我們基於註解實現。

新增屬性:

/**
 * 是否執行驅除更新
 *
 * 主要用於 LRU/LFU 等驅除策略
 * @return 是否
 * @since 0.0.11
 */
boolean evict() default false;

註解使用

有哪些方法需要使用?

@Override
@CacheInterceptor(refresh = true, evict = true)
public boolean containsKey(Object key) {
    return map.containsKey(key);
}

@Override
@CacheInterceptor(evict = true)
@SuppressWarnings("unchecked")
public V get(Object key) {
    //1. 重新整理所有過期資訊
    K genericKey = (K) key;
    this.expire.refreshExpire(Collections.singletonList(genericKey));
    return map.get(key);
}

@Override
@CacheInterceptor(aof = true, evict = true)
public V put(K key, V value) {
    //...
}

@Override
@CacheInterceptor(aof = true, evict = true)
public V remove(Object key) {
    return map.remove(key);
}

註解驅除攔截器實現

執行順序:放在方法之後更新,不然每次當前操作的 key 都會被放在最前面。

/**
 * 驅除策略攔截器
 * 
 * @author binbin.hou
 * @since 0.0.11
 */
public class CacheInterceptorEvict<K,V> implements ICacheInterceptor<K, V> {

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

    @Override
    public void before(ICacheInterceptorContext<K,V> context) {
    }

    @Override
    @SuppressWarnings("all")
    public void after(ICacheInterceptorContext<K,V> context) {
        ICacheEvict<K,V> evict = context.cache().evict();

        Method method = context.method();
        final K key = (K) context.params()[0];
        if("remove".equals(method.getName())) {
            evict.remove(key);
        } else {
            evict.update(key);
        }
    }

}

我們只對 remove 方法做下特判,其他方法都使用 update 更新資訊。

引數直接取第一個引數。

測試

ICache<String, String> cache = CacheBs.<String,String>newInstance()
        .size(3)
        .evict(CacheEvicts.<String, String>lru())
        .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());
  • 日誌資訊
[D, A, C]

通過 removeListener 日誌也可以看到 B 被移除了:

[DEBUG] [2020-10-02 21:33:44.578] [main] [c.g.h.c.c.s.l.r.CacheRemoveListener.listen] - Remove key: B, value: world, type: evict

小結

redis LRU 淘汰策略,實際上並不是真正的 LRU。

LRU 有一個比較大的問題,就是每次 O(n) 去查詢,這個在 keys 數量特別多的時候,還是很慢的。

如果 redis 這麼設計肯定慢的要死了。

個人的理解是可以用空間換取時間,比如新增一個 Map<String, Integer> 儲存在 list 中的 keys 和下標,O(1) 的速度去查詢,但是空間複雜度翻倍了。

不過這個犧牲還是值得的。這種後續統一做下優化,將各種優化點統一考慮,這樣可以統籌全域性,也便於後期統一調整。

下一節我們將一起來實現以下改進版的 LRU。

Redis 做的事情,就是將看起來的簡單的事情,做到一種極致,這一點值得每一個開源軟體學習。

文中主要講述了思路,實現部分因為篇幅限制,沒有全部貼出來。

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

覺得本文對你有幫助的話,歡迎點贊評論收藏關注一波~

你的鼓勵,是我最大的動力~

深入學習

相關文章