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算法全稱是最近最少使用算法(Least Recently Use),普遍的應用於緩存機制中。
當緩存使用的空間達到上限後,就須要從已有的數據中淘汰一部分以維持緩存的可用性,而淘汰數據的選擇就是經過LRU算法完成的。
LRU算法的基本思想是基於局部性原理的時間局部性:
若是一個信息項正在被訪問,那麼在近期它極可能還會被再次訪問。
java 從零開始手寫 redis(七)redis LRU 驅除策略詳解及實現
當存在熱點數據時,LRU的效率很好,但偶發性的、週期性的批量操做會致使LRU命中率急劇降低,緩存污染狀況比較嚴重。
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要多。
Two queues(如下使用2Q代替)算法相似於LRU-2,不一樣點在於2Q將LRU-2算法中的訪問歷史隊列(注意這不是緩存數據的)改成一個FIFO緩存隊列,即:2Q算法有兩個緩存隊列,一個是FIFO隊列,一個是LRU隊列。
當數據第一次訪問時,2Q算法將數據緩存在FIFO隊列裏面,當數據第二次被訪問時,則將數據從FIFO隊列移到LRU隊列裏面,兩個隊列各自按照本身的方法淘汰數據。
新訪問的數據插入到FIFO隊列中,若是數據在FIFO隊列中一直沒有被再次訪問,則最終按照FIFO規則淘汰;
若是數據在FIFO隊列中再次被訪問到,則將數據移到LRU隊列頭部,若是數據在LRU隊列中再次被訪問,則將數據移動LRU隊列頭部,LRU隊列淘汰末尾的數據。
MQ算法根據訪問頻率將數據劃分爲多個隊列,不一樣的隊列具備不一樣的訪問優先級,其核心思想是:優先緩存訪問次數多的數據。
詳細的算法結構圖以下,Q0,Q1....Qk表明不一樣的優先級隊列,Q-history表明從緩存中淘汰數據,但記錄了數據的索引和引用次數的隊列:
新插入的數據放入Q0,每一個隊列按照LRU進行管理,當數據的訪問次數達到必定次數,須要提高優先級時,將數據從當前隊列中刪除,加入到高一級隊列的頭部;爲了防止高優先級數據永遠不會被淘汰,當數據在指定的時間裏沒有被訪問時,須要下降優先級,將數據從當前隊列刪除,加入到低一級的隊列頭部;須要淘汰數據時,從最低一級隊列開始按照LRU淘汰,每一個隊列淘汰數據時,將數據從緩存中刪除,將數據索引加入Q-history頭部。
若是數據在Q-history中被從新訪問,則從新計算其優先級,移到目標隊列頭部。
Q-history按照LRU淘汰數據的索引。
MQ須要維護多個隊列,且須要維護每一個數據的訪問時間,複雜度比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 兩種實現。
實際上就是咱們之前的 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]
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
以爲本文對你有幫助的話,歡迎點贊評論收藏關注一波,你的鼓勵,是我最大的動力~
目前咱們經過兩次優化,解決了性能問題,和批量致使的緩存污染問題。
不知道你有哪些收穫呢?或者有其餘更多的想法,歡迎留言區和我一塊兒討論,期待與你的思考相遇。