LRU算法及應用

一 什麼是LRU算法

  LRU的全名爲Least Recently Used,意指最近少用,這是一種非常經典的算法,應用範圍非常的廣,典型的Redis緩存就採用了各類變種的LRU算法。

  LRU常常被用來處理大量緩存數據的排序問題。當下應用項目隨着業務規模的上升,數據訪問量也不斷的攀升,對於靜態、熱點數據的訪問已經對生產環境造成了巨大壓力。

  常規的解決思路爲對靜態數據或熱點數據進行緩存處理,比如說我當下參與的項目中,數據緩存就被分爲三層(數據庫自身的緩存處理不談):

  1. 應用進程級別全局緩存
  2. 數據庫事務級別緩存
  3. 數據庫連接級別緩存

  這裏還不包括分佈式部署的Redis緩存處理(我們僅將靜態數據交予Redis管理)。

  雖然緩存數據使DB的訪問壓力問題得以解決,但是隨之而來的是緩存造成的內存開支以及定位緩存所產生的效率問題。一旦緩存的數據量過大,那麼對應用節點的內存消耗就非常嚴重;而且對於被緩存數據的檢索也變得愈加困難,怎麼辦?對於內存消耗問題,解決思路是限制緩存大小;對於檢索效率問題,解決思路爲對數據進行熱度排序。

  不論是緩存的內存消耗還是熱度排序問題,其解決方案均爲——LRU,將頻繁訪問的數據安置在緩存隊列的頭部,以實現快速檢索;對於熱度較低的數據,將其移除緩存節省內存開支(淘汰策略)。

  綜上,LRU是一種緩存置換算法,一種數據淘汰策略,那麼如何確定哪些數據屬於“最近少用”就很關鍵了,大致從兩個問題考慮,一是時間,二是頻度。長時間沒有被訪問的數據可以淘汰,同時間段內訪問頻次低的也可以淘汰,下面就針對各種這種核心思想來看看如何實現LRU算法。

二 手工實現LRU

  手工實現LRU算法的思路非常多,之所以把這個章節放在最前,就是爲了讓道友門能理解算法的核心思想,後續的章節會把Redis中的LRU算法應用再整理一遍。

2.1 簡單鏈表實現

  這是一種最簡單的實現原型,算法處理過程如下:

  1. 新數據插入到鏈表頭部;
  2. 每當緩存命中(即緩存數據被訪問),則將數據移到鏈表頭部;
  3. 當鏈表滿的時候,將鏈表尾部的數據丟棄。

  這就需要程序中維護一個鏈表,並且每次有數據訪問的時候,從鏈表頭部開始遍歷。常規情況下我們在訪問一個數據的時候,一定是通過數據的ID來進行檢索,所以用如下結構來示意存儲數據的格式:

/**
 * @author 檸檬睡客
 */
class Data {
	/**
	 * 數據ID,一般爲數據表的主鍵,如果是存放於Redis的緩存數據則ID更爲複雜,需通過既定的編碼規則來生成,甚至需要包含版本信息
	 */
	private String id;

	public String getId() {
		return this.id;
	}
}

  接下來我們使用LinkedList來描述緩存隊列,並且限制隊列的長度以控制緩存大小:

/**
 * @author 檸檬睡客
 * 
 * 以鏈表來實現的LRU算法
 */
public class ListLRU {

	/**
	 * 用以存放緩存數據的鏈表
	 */
	private static final LinkedList<Data> LRUCacheList = new LinkedList<Data>();
	/**
	 * 限制緩存鏈表的長度
	 */
	private static final int CacheLimit = 100;
}

  接下來需要實現數據的讀取,注意:緩存工具不應該放開數據存放接口,對於應用來說數據訪問應該是透明的,我們假定優先從緩存讀取數據,若無緩存則從數據庫讀取,若數據庫讀取成功則將數據加入緩存,再返回給應用:

/**
 * 供應用訪問,優先從緩存獲取數據,無緩存再訪問DB
 */
public static Data getData(String id) {
	Data data = null;
	synchronized (LRUCacheList) {
		for (int i = 0; i < LRUCacheList.size(); i++) {
			// 當命中數據時,需要將數據移動至鏈表頭部
			if (id.equals(LRUCacheList.get(i).getId())) {
				data = LRUCacheList.remove(i);
				putData(data);
				return data;
			}
		}
	}
	if (data == null) {
		data = getDataFromDB(id);
	}
	if (data != null) {
		putData(data);
	}
	return data;
}

/**
 * 從DB訪問數據,此方法對應用不放開
 */
private static Data getDataFromDB(String id) {
	return null;
}

  因爲getData方法已經解決了數據重複的問題,所以存放數據的時候一律添加至頭部,如果此時隊列已滿,則刪除隊列末尾的數據即可,以此實現淘汰策略:

/**
 * 增加緩存數據的方法不應對應用放開
 */
private static void putData(Data data) {
	synchronized (LRUCacheList) {
		if (LRUCacheList.size() == CacheLimit) {
			LRUCacheList.removeLast();
		}
		LRUCacheList.addFirst(data);
	}
}

  OK,一個基於鏈表的簡單LRU算法已經實現了,仔細分析下這個實現過程,它是存在缺陷的:

  1. 命中率問題:每次訪問數據都需要從鏈表頭開始遍歷,這對於熱點數據的訪問效果很好(熱點數據都聚集在鏈表頭部附近,很快就能遍歷到結果),可一旦數據分散較爲均勻時,訪問命中率急劇下降;
  2. 性能損耗問題:每次命中數據後,都需要將數據重新移動至鏈表頭部,存在相當大的性能損耗;
  3. 緩存污染問題:並非所有數據有必要緩存(可能整個應用運行過程中某些數據被訪問的次數很少),但是算法實現過程沒辦法對熱點數據(頻繁訪問的數據,這纔是有價值進行緩存的)進行區分,使得緩存隊列貶值。

  針對上述問題,我們再從不同的維度看如何優化,見下文。

2.2 解決命中率和緩存污染問題

  爲了解決上述實現過程中的命中率問題,我們對緩存數據結構再封裝,加入一個表示命中次數的成員,並且額外開闢一個臨時隊列,用以存儲當前訪問過的數據,每次命中數據則對計數器+1,達到臨界值的時候纔將數據從臨時隊列轉移到緩存隊列,實現邏輯如下:

  1. 初次訪問數據,將數據加入臨時隊列,命中次數+1;
  2. 再次訪問數據,命中次數繼續+1;
  3. 命中次數大於臨界值,轉入緩存隊列;
  4. 臨時隊列可採取FIFO或者LRU或者時間排序算法實現淘汰;
  5. 緩存隊列可按命中次數排序、FIFO或者時間排序算法實現淘汰;

  這裏爲化簡演示過程,我對臨時隊列採用FIFO模式進行淘汰處理,緩存隊列則按命中次數排序後淘汰訪問最少,首先調整緩存數據結構:

class CacheData {
	Data data;
	/**
	 * 補充命中次數統計
	 */
	int hitCount;

	public CacheData(Data data) {
		this.data = data;
		this.hitCount = 1;
	}

	public Data getData() {
		return this.data;
	}

	public int getHitCount() {
		return this.hitCount;
	}

	public int hitData() {
		return ++hitCount;
	}
}

  然後補充臨時隊列,以及數據命中次數的閾值,並調整緩存數據類型:

/**
 * @author 檸檬睡客
 * 
 *         補充命中統計解決命中率問題
 *
 */
public class HitListLRU {
	/**
	 * 用以存放緩存數據的鏈表
	 */
	private static final LinkedList<CacheData> LRUCacheList = new LinkedList<CacheData>();
	/**
	 * 限制緩存鏈表的長度
	 */
	private static final int CacheLimit = 100;
	/**
	 * 用以臨時存放數據的鏈表
	 */
	private static final LinkedList<CacheData> TemporaryList = new LinkedList<CacheData>();
	/**
	 * 限制臨時鏈表的長度
	 */
	private static final int TemporaryLimit = 100;
	/**
	 * 命中次數閾值,達到閾值後從臨時隊列轉移到緩存隊列
	 */
	private static final int HitLimit = 5;
}

  接下來需要爲緩存隊列設計排序,這裏簡單的用一個比較器來實現,實現一個Comparator與CacheData解耦:

class CacheDataComparator implements Comparator<CacheData> {
	public int compare(CacheData data1, CacheData data2) {
		if (data1.getHitCount() > data2.getHitCount()) {
			return 1;
		} else if (data1.getHitCount() < data2.getHitCount()) {
			return -1;
		} else {
			return 0;
		}
	}
}

  然後我們重寫getDate方法:

/**
 * 供應用訪問,優先從緩存獲取數據,無緩存再訪問DB
 */
public static Data getData(String id) {
	Data data = null;
	// 優先從緩存隊列中訪問
	synchronized (LRUCacheList) {
		for (int i = 0; i < LRUCacheList.size(); i++) {
			// 命中數據時,更新命中次數統計值
			if (id.equals(LRUCacheList.get(i).getData().getId())) {
				LRUCacheList.get(i).hitData();
				return LRUCacheList.get(i).getData();
			}
		}
	}
	// 若緩存隊列沒有,再遍歷臨時隊列
	data = getDataFromTemporaryList(id);
	// 如果臨時隊列也沒有,則訪問DB
	if (data == null) {
		data = getDataFromDB(id);
	}
	if (data != null) {
		putTemporaryData(new CacheData(data));
	}
	return data;
}

  從臨時隊列遍歷數據的方法和getData差不多,只不過多了處理命中閾值的邏輯而已:

/**
 * 從臨時隊列查詢數據
 */
private static Data getDataFromTemporaryList(String id) {
	CacheData cacheData = null;
	synchronized (TemporaryList) {
		for (int i = 0; i < TemporaryList.size(); i++) {
			cacheData = LRUCacheList.get(i);
			// 命中數據時,更新命中次數統計值,並且一旦達到命中閾值需要轉移到緩存隊列
			if (id.equals(cacheData.getData().getId())) {
				if (cacheData.hitData() >= HitLimit) {
					TemporaryList.remove(i);
					putCacheData(cacheData);
					return cacheData.getData();
				}
			}
		}
	}
	return null;
}

  最後,還有兩個方法需要實現,將數據加入臨時隊列和緩存隊列:

/**
 * 將數據從臨時隊列加入到緩存隊列
 */
private static void putCacheData(CacheData cacheData) {
	synchronized (LRUCacheList) {
		if (LRUCacheList.size() >= CacheLimit) {
			// 對緩存隊列按命中次數排序,移除最末數據
			Collections.sort(LRUCacheList, new CacheDataComparator());
			LRUCacheList.removeLast();
		}
		LRUCacheList.addFirst(cacheData);
	}
}

/**
 * 將數據加入臨時隊列
 */
private static void putTemporaryData(CacheData cacheData) {
	synchronized (TemporaryList) {
		// 以FIFO模式淘汰數據
		if (TemporaryList.size() >= TemporaryLimit) {
			TemporaryList.removeLast();
		}
		TemporaryList.addFirst(cacheData);
	}
}

  結束,這一次我們沒有輕易的將數據加入到緩存隊列,而是像新入職的實習生一樣,待觀察一段時間,如果這段時間內數據的訪問頻次很高,再將其加入到緩存隊列,這樣就有效的解決了命中問題。並且置於緩存隊列中的數據一定是經過命中頻次保證的,如此也解決了緩存污染問題,使緩存隊列中的數據都是熱點數據。

  再總結一下,如上只是一種解決思路,其實還是有很多問題的,算法複雜度高了,內存開銷雖然可以通過調整隊列長度來限制,但是臨時隊列的額外內存開銷還是客觀存在的。

  而且對於數據的命中過程,依然是從頭部開始遍歷整個隊列,訪問效率問題還是沒有得到解決。

  這裏我再給出一些其他思路,比如說以訪問頻度來劃分出多個隊列,然後數據在多個隊列間傳遞,直到頻度最高的隊列中的數據將其轉入緩存等等。但不論如何,因其數據結構的限制,訪問效率問題永遠存在,內存開銷問題也無法解決,那麼到底該怎麼解決?改變數據結構!見下文。

2.3 解決執行效率問題

  當我們無法通過算法進一步提升程序執行效率,並減少內存開支的時候,我們就需要轉過頭來重新考慮一下數據結構的問題了。執行效率目前的瓶頸就在於鏈表的遍歷,有什麼數據結構能快速的命中數據呢?HashMap

  在鏈表的基礎上,我們在補充一個HashMap,Key值存放數據的ID,Value則存放緩存數據,如此快速定位緩存數據問題得到解決,只要Key存在,那麼緩存數據一定存在。

  那麼如何解決鏈表元素的移動問題呢?因爲緩存鏈表還是需要調整數據的位置的,當前的數據結構無法做到不對全表(最糟糕情況,數據在鏈表末尾)遍歷,可行辦法是雙向鏈表,鏈表中的每一個元素(鏈表節點)都包含前、後節點的引用,此時不再需要遍歷鏈表,僅需要調整元素的前後節點引用即可。這種設計思想跟CLHLock自旋鎖很像,CLHLock在本地成員自旋(可以參考我之前寫過的博客Java鎖手冊),雙向鏈表的元素移動則在自己的成員引用上做手腳。

  開工,先設計一個雙向鏈表結構,元素(後文稱節點吧)定義如下:

/**
 * @author 檸檬睡客
 *
 *         鏈表節點
 */
class Node {
	/**
	 * 數據ID
	 */
	private String id;
	private Data data;
	/**
	 * 指向前一個節點的引用
	 */
	private Node pre;
	/**
	 * 指向後一個節點的引用
	 */
	private Node next;

	public Node(String key, Data data) {
		this.id = key;
		this.data = data;
	}

	public String getKey() {
		return id;
	}

	public void setKey(String key) {
		this.id = key;
	}

	public Data getData() {
		return data;
	}

	public void setData(Data data) {
		this.data = data;
	}

	public Node getPre() {
		return pre;
	}

	public void setPre(Node pre) {
		this.pre = pre;
	}

	public Node getNext() {
		return next;
	}

	public void setNext(Node next) {
		this.next = next;
	}
}

  接下來重新設計緩存結構,注意咯:

  1. 因爲Map的Value爲鏈表節點,而節點中已經包含了緩存數據,以及前後節點的引用,那麼就無需在額外的定義鏈表結構了,Map中的Value集合已經隱式的形成了一個鏈表結構(節省結構定義產生的內存開支);
  2. 但是!!!仍然需要在程序中保留頭部和尾部節點的哨兵引用,目的是方便在頭尾操作節點的引用;
  3. 爲了避免每次都對Map長度進行計算(對Map尺寸進行計算還是有開銷的),我們需要一個全局的長度計數器。

  所以工具定義如下:

/**
 * @author 檸檬睡客
 *
 *         以雙向鏈表+HashMap結構實現LRU算法
 */
public class NodeListLRU {
	/**
	 * 限制緩存鏈表的長度
	 */
	private static final int CacheLimit = 100;
	/**
	 * 當前有多少節點的統計,避免計算Map尺寸產生的開銷
	 */
	private static volatile int count = 0;
	/**
	 * 緩存數據的Map結構
	 */
	private static HashMap<String, Node> CacheMap;
	/**
	 * 頭節點哨兵(不是頭節點,它的後繼節點纔是頭節點)
	 */
	private static Node head;
	/**
	 * 尾節點哨兵(不是尾節點,它的前驅節點纔是尾節點)
	 */
	private static Node tail;
}

  接下來需要在靜態代碼塊中對靜態成員進行初始化動作:

static {
	CacheMap = new HashMap<String, Node>();
	// 初始化頭節點和尾節點,利用哨兵模式減少判斷頭結點和尾節點爲空
	Node headNode = new Node(null, null);
	Node tailNode = new Node(null, null);
	headNode.next = tailNode;
	tailNode.pre = headNode;
	head = headNode;
	tail = tailNode;
}

  基礎設施準備完畢後需要重寫getData方法:

/**
 * 優先從緩存獲取數據,沒有緩存則再訪問DB
 */
public static Data getData(String id) {
	Data data = null;
	synchronized (CacheMap) {
		// 如果存在緩存,則將數據移動至鏈表頭部
		Node node = CacheMap.get(id);
		if (node != null) {
			moveNodeToHead(node);
			data = node.getData();
		} else {
			data = getDataFromDB(id);
		}
		if (data != null) {
			putData(data);
		}
	}
	return data;
}

  如果是新增數據,那麼需要將其加入到緩存鏈表,這時需要注意鏈表長度(由count計數器替代)是否已經達到閾值:

/**
 * 將數據加入緩存鏈表,一定是頭部
 */
private static void putData(Data data) {
	synchronized (CacheMap) {
		// 如果隊列超限,則將尾部節點移除
		if (count > CacheLimit) {
			removeTail();
		}
		// 將節點插入到頭部
		Node node = new Node(data.getId(), data);
		addHead(node);
	}
}

  最後我們需要把移除節點、新增節點和轉移節點三個方法補充完整:

/**
 * 新增節點到頭部
 */
private static void addHead(Node node) {
	synchronized (CacheMap) {
		// 原頭部節點的後繼取出
		Node next = head.getNext();
		// 設置原頭部節點的前驅爲新增節點
		next.setPre(node);
		// 設置新增節點的後繼爲原頭部節點
		node.setNext(next);
		// 設置新增節點的前驅爲頭部哨兵
		node.setPre(head);
		// 設置頭部哨兵的後繼爲新增節點
		head.setNext(node);
		CacheMap.put(node.getKey(), node);
		count++;
	}
}

/**
 * 移除尾節點
 */
private static void removeTail() {
	synchronized (CacheMap) {
		// 被移除的節點
		Node node = tail.getPre();
		// 被移除節點的前驅和後繼
		Node pre = node.getPre();
		Node next = node.getNext();
		// 拼接被移除節點的前驅和後繼,使兩者相連
		pre.setNext(next);
		next.setPre(pre);
		// 從鏈表裏面移除
		node.setNext(null);
		node.setPre(null);
		CacheMap.remove(node.getKey());
		count--;
	}
}

/**
 * 移動節點到頭
 */
private static void moveNodeToHead(Node node) {
	// 從鏈表裏面移除,參考removeTail
	Node pre = node.getPre();
	Node next = node.getNext();

	pre.setNext(next);
	next.setPre(pre);

	node.setNext(null);
	node.setPre(pre);
	// 添加節點到頭部
	addHead(node);
}

  完活,一個基於雙向鏈表和HashMap的LRU算法就實現了。如此我們不但能夠通過散列KEY值快速的定位緩存數據,還能夠節省一個鏈表的結構定義,這些緩存數據的物理內存不是連續的,全部通過Node節點自身的成員引用串聯。

  上述的實現方式依然採用尾部淘汰策略,這樣做不是不可以,但是相對來說還是存在更多優化空間的,典型的LRU算法的應用——Redis,大名鼎鼎,它幾乎將LRU算法實現的出神入化了。

  在完全理解並手動實現了若干版本的LRU之後,我們再看看Redis是如何實現的,源碼部分建議大家自行閱讀,後文我僅把實現的理念整理一遍。

三 Redis如何利用LRU

3.1 緩存淘汰策略

  在介紹Redis如何利用LRU之前,必須要先說明下Redis對與緩存的淘汰策略(當緩存達到上限後),上文介紹過一些簡單的淘汰策略,包括FIFO、用時最久或者命中頻度等等,而Redis給出了更爲豐富和高效的策略模式:

  1. noeviction:默認策略,不接受任何寫入請求,直接報錯,簡單粗暴;
  2. allkeys-lru:對KEY進行LRU計算,執行淘汰;
  3. volatile-lru:對設置了過期時間的KEY進行LRU計算,執行淘汰;
  4. allkeys-random:隨機取KEY,執行淘汰;
  5. volatile-random:對設置了過期時間的KEY,隨機選取執行淘汰;
  6. volatile-ttl:對設置了過期時間的KEY,按時間排序淘汰,越早過期越優先 。

  需要注意的是,當使用volatile-lru、volatile-random、volatile-ttl這三種策略時,如果沒有計算出可淘汰的key,結果和noeviction一樣直接報錯。

  再介紹下和淘汰策略相關的指令:

  1. config get maxmemory-policy:獲取當前的淘汰策略;
  2. config set maxmemory-policy allkeys-lru(模式自選):設置淘汰策略。

  當然可以直接修改配置文件,不多說。我更想多說的是Redis中對LRU算法的實現。

3.2 LRU實現

  Redis的不同版本中對LRU算法的實現略有不同,我按照自己檢索到的資料分步整理。這部分內容大部分摘自想不到!面試官問我:Redis 內存滿了怎麼辦?

3.2.1 近似算法

  Redis使用的是近似LRU算法,它跟常規的LRU算法還不太一樣。近似LRU算法通過隨機採樣法淘汰數據,每次隨機出5(默認)個key,從裏面淘汰掉最近最少使用的key。

  可以通過maxmemory-samples參數修改採樣數量:例:maxmemory-samples 10,參數值越大,淘汰的結果越接近於嚴格的LRU算法。

  Redis爲了實現近似LRU算法,給每個key增加了一個額外增加了一個24bit的字段,用來存儲該key最後一次被訪問的時間。

  之所以採用近似算法而非精準算法,是因爲LRU算法是需要內存開支的,精準的LRU對內存的消耗遠大於近似算法,而且近似算法已經能夠應對大多數應用場景了。

3.2.2 近似算法優化

  Redis3.0版本中對近似LRU算法進行了一些優化。

  新算法會維護一個候選池(大小爲16),池中的數據根據訪問時間進行排序,第一次隨機選取的key都會放入池中,隨後每次隨機選取的key只有在訪問時間小於池中最小的時間纔會放入池中,直到候選池被放滿。當放滿後,如果有新的key需要放入,則將池中最後訪問時間最大(最近被訪問)的移除。

  當需要淘汰的時候,則直接從池中選取最近訪問時間最小(最久沒被訪問)的key淘汰掉就行。

3.2.3 LFU算法

  LFU算法是Redis4.0裏面新加的一種淘汰策略。它的全稱是Least Frequently Used,它的核心思想是根據key的最近被訪問的頻率進行淘汰,很少被訪問的優先被淘汰,被訪問的多的則被留下來。

  LFU算法能更好的表示一個key被訪問的熱度。假如你使用的是LRU算法,一個key很久沒有被訪問到,只剛剛是偶爾被訪問了一次,那麼它就被認爲是熱點數據,不會被淘汰,而有些key將來是很有可能被訪問到的則被淘汰了。如果使用LFU算法則不會出現這種情況,因爲使用一次並不會使一個key成爲熱點數據。

  LFU一共有兩種策略:

  1. volatile-lfu:在設置了過期時間的key中使用LFU算法淘汰key;
  2. allkeys-lfu:在所有的key中使用LFU算法淘汰數據。

  設置使用這兩種淘汰策略跟前面講的一樣,不過要注意的一點是這兩週策略只能在Redis4.0及以上設置,如果在Redis4.0以下設置會報錯。

四 結語

  如果想關注更多硬技能的分享,可以參考積少成多系列傳送門,未來每一篇關於硬技能的分享都會在傳送門中更新鏈接。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章