Redis(八) LRU Cache

Redis(八)—— LRU Cachegit

在計算機中緩存可謂無所不在,不管仍是應用仍是操做系統中,爲了性能都須要作緩存。然緩存必然與緩存算法息息相關,LRU就是其中之一。筆者在最早接觸LRU是大學學習操做系統時的瞭解到的,至今已經很是模糊。在學習Redis時,又再次與其相遇,這裏將這塊內容好好梳理總結。github

  LRU(Least Recently Used)是緩存算法家族的一員——最近最少使用算法,相似的算法還有FIFO(先進先出)、LIFO(後進先出)等。由於緩存的選擇通常都是用內存(RAM)或者計算機中的L1甚至L2高速緩存,無疑這些存儲器都是有大小限制,使用的代價都很是高昂,不可能將全部須要緩存的數據都緩存起來,須要使用特定的算法,好比LRU將不經常使用的數據驅逐出緩存,以騰出更多的空間存儲更有價值的數據。redis

  上圖引用wiki上描述LRU算法的圖。其中A是最近最少被使用,因此當緩存E時,根據LRU的特徵,驅逐A,存儲E。算法

  在LRU中最核心的部分是"最近最少",因此必然須要對緩存數據的訪問(讀和寫)作跟蹤記錄——即何時使用了什麼數據!數據庫

  本文先介紹Redis中的LRU特色,再介紹如何自實現一個LRU。瀏覽器

  • Redis中的LRU
  • 自實現LRU Cache
  • Guava Cache

Redis中的LRU

1.內存限制

  前文中介紹Redis是基於內存的K-V數據結構服務器。Redis在存儲方面有內存容量限制。在Redis中經過maxmemory配置設置Redis可使用的最大內存。如:緩存

maxmemory 100mb

以上配置則限制Redis可使用的最大內存爲100M。當設置爲0時,表示沒有進行內存限制。安全

  上述說到Redis的內存限制問題,若是Redis使用的內存達到限制,再進行寫入操做,Redis該如何處理?
  Redis中有不少驅逐策略——Eviction Policies,當內存達到限制時,Redis會使用其配置的策略對內存中的數據進行驅逐回收,釋放內存空間,以便存儲待寫入的數據。服務器

2.驅逐策略

Redis中有如下的內存策略可供選擇數據結構

  • noeviction:沒有任何驅逐策略,當內存達到限制,若是執行的命令須要使用更多的內存,則直接返回錯誤;
  • allkeys-lru:使用lru算法,即驅逐最近最少被使用的鍵,回收空間以便寫入新的數據;
  • volatile-lru:使用lru算法,即驅逐最近最少被使用的被設置過時的鍵,回收空間以便寫入新的數據;
  • allkeys-random:使用隨機算法,即隨機驅逐任意的鍵,回收空間以便寫入新的數據
  • volatile-random:使用隨機算法,即隨機驅逐被設置的過時鍵,回收空間以便寫入新的數據;
  • volatile-ttl:驅逐只有更短的有效期的被設置的過時鍵,回收空間以便寫入新的數據;

其中volatile-lru、volatile-random和volatile-ttl策略驅逐,若是沒有可驅逐的鍵,則結果和noeviction策略同樣,都是返回錯誤。

Notes:
在Redis中的LRU算法並非徹底精確的LRU實現,只是近似的LRU算法——即經過採樣一些鍵,而後從採樣中按照LRU驅逐。若是要實現徹底精確的LRU算法,勢必須要跨越整個Redis內存進行統計,這樣對性能就有折扣。在性能和LRU之間的trade off。
關於更多詳細內容,請參考:Approximated LRU algorithm

自實現LRU Cache

上述介紹了Redis中對於內存限制實現其LRU策略,下面筆者綜合平時所學,簡單實現一個LRU Cache,加深對其理解。

實現LRU Cache的關鍵點在於:

  1. Cache目的爲了提升查找的性能,因此如何設計Cache的數據結構保證查找的算法複雜度比較低是關鍵;
  2. LRU算法決定了必需要對Cache中的全部數據進行年齡追蹤,即LRU中的數據的訪問(讀和寫)都須要實時記錄;

對於第一點,可以實現快速查找的數據結構Map是必選,映射表結構具備自然的快速查找的特色。
對於第二點,要麼對每一個元素維護一份訪問信息——每一個元素都有一個訪問時間字段,要麼以特定的順序表示其訪問信息——列表先後順序表示訪問時間排序。

基於以上分析,JDK中提供了Map和訪問順序的數據結構——LinkedHashMap。這裏爲了簡單,基於LinkedHashMap實現。固然感興趣還能夠基於Map接口自實現,不過都是大同小異。
固然還可使用第二點中的第一種方式,可是這樣須要跨越整個緩存空間,遍歷比較每一個元素的訪問時間,代價高昂。

先定義LRUCache的接口:

public interface LRUCache<K, V> {

    V put(K key, V value);

    V get(K key);

    int getCapacity();

    int getCurrentSize();
}

而後繼承實現LinkedHashMap,在LinkedHashMap中有布爾accessOrder屬性控制其順序:true表示按照訪問順序,false表示按照插入順序。 在構造LinkedHashMap時須要設置true,表示按照訪問順序進行迭代。

重寫removeEldestEntry方法:若是當前緩存中的元素個數大於緩存的容量,則返回true,表示須要移除元素。

/**
 * 基於{@link LinkedHashMap}實現的LRU Cache,該緩存是非線程安全,須要caller保證同步。
 * 原理:
 * 1.LinkedHashMap中使用雙向循環鏈表的順序有兩種,其中訪問順序表示最近最少未被訪問的順序
 * 2.基於HashMap,因此get的算法複雜度O(1)
 *
 * @author huaijin
 */
public final class LRUCacheBaseLinkedHashMap<K, V>
        extends LinkedHashMap<K, V> implements LRUCache<K, V>{

    private static final int DEFAULT_CAPACITY = 16;
    private static final float DEFAULT_LOAD_FACTOR = 0.75F;

    /**
     * 緩存大小
     */
    private int capacity;


    public LRUCacheBaseLinkedHashMap() {
        this(DEFAULT_CAPACITY);
    }

    public LRUCacheBaseLinkedHashMap(int capacity) {
        this(capacity, DEFAULT_LOAD_FACTOR);
    }

    public LRUCacheBaseLinkedHashMap(int capacity, float loadFactor) {
        super(capacity, loadFactor, true);
        this.capacity = capacity;
    }

    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > capacity;
    }

    @Override
    public V put(K key, V value) {
        Objects.requireNonNull(key, "key must not be null.");
        Objects.requireNonNull(value, "value must not be null.");
        return super.put(key, value);
    }

    @Override
    public String toString() {
        List<String> sb = new ArrayList<>();
        Set<Map.Entry<K, V>> entries = entrySet();
        for (Map.Entry<K, V> entry : entries) {
            sb.add(entry.getKey().toString() + ":" + entry.getValue().toString());
        }
        return String.join("  ", sb);
    }

    @Override
    public int getCapacity() {
        return capacity;
    }

    @Override
    public int getCurrentSize() {
        return size();
    }
}

若是對LinkedHashMap不是很熟悉,請移步至Map 綜述(二):徹頭徹尾理解 LinkedHashMap

Guava Cache

對於使用Cache而言,Guava工具庫中的Guava Cache是一個很是不錯的選擇。其優點在於:

  • 適用性:緩存在不少場景中都適用,不管是大到操做系統、數據庫、瀏覽器和大型網站等等,小到平時開發的小型應用、移動app等等;
  • 多樣性:Guava Cache提供多種方式載入數據至緩存;
  • 可驅逐:內存資源是寶貴的,這點無能否認!因此緩存數據須要置換策略,Guava Cache從多種維度提供了不一樣的策略;

詳細狀況能夠參考:Caches。在Guava Cache中的驅逐策略有基於大小的策略,該策略就是LRU的實現:

Cache<String, String> guavaCache = CacheBuilder.newBuilder()
        .maximumSize(5)
        .build();

當Cache的容量達到5個時,若是再往緩存中寫入數據,Cache將淘汰最近最少被使用的數據。

測試以下案例以下:

@Test
public void testLRUGuavaCacheBaseSize() throws ExecutionException {
    Cache<String, String> guavaCache = CacheBuilder.newBuilder()
            .maximumSize(5)
            .build();
    guavaCache.put("1", "1");
    guavaCache.put("2", "2");
    guavaCache.put("3", "3");
    guavaCache.put("4", "4");
    guavaCache.put("5", "5");
    printGuavaCache("原cache:", guavaCache);
    guavaCache.getIfPresent("1");
    guavaCache.put("6", "6");
    printGuavaCache("put一次後cache:", guavaCache);
}

執行結果:

原cache:2:2  3:3  1:1  5:5  4:4
put一次後cache:6:6  3:3  1:1  5:5  4:4

由於數據1被get一次,致使2是最近最少被使用,當put 6時,將2淘汰驅逐。

總結

  緩存目的就是爲了提升訪問速度以帶來性能上質的提高。可是緩存的容量和命中率倒是從反比。   基於內存存儲數據,不管應用本地緩存,仍是分佈式緩存服務器甚至操做系統,都須要考慮存儲容量的限制對命中率的影響。採用合適緩存算法,對提升緩存命中率至爲關鍵。

相關文章
相關標籤/搜索