詳解工程師不可不會的LRU緩存淘汰算法

2020-10-14 承志的算法課堂

大家好,歡迎大家來到算法數據結構專題,今天我們和大家聊一個非常常用的算法,叫做LRU。

LRU的英文全稱是Least Recently Used,也即最不經常使用。我們看著好像挺迷糊的,其實這個含義要結合緩存一起使用。對於工程而言,緩存是非常非常重要的機制,尤其是在當下的網際網路應用環境當中,起到的作用非常重要。為了便於大家更好地理解,我們從緩存的機制開始說起。

緩存

緩存的英文是cache,最早其實指的是用於CPU和主存數據交互的。早年這塊存儲被稱為高速緩存,最近已經聽不到這個詞了,不知道是不是淘汰了。因為緩存的讀寫速度要高於CPU低於主存,所以是用來過渡數據用的。CPU從緩存當中讀取數據,主存的數據也會先加載到緩存當中來,之後再進入CPU。

後來緩存有了更多的應用和意為,在後端服務當中一般用來快速響應請求。其實這裡的思想和記憶化搜索有點像,我們把可能要用到的數據先存起來,後期如果要用到的話,就可以直接從內存當中讀取而不是再去計算一遍了。原理也是一樣的,有了緩存我們可以把要返回給用戶的數據儲存在內存中,當同樣的請求過來的時候,我們就可以直接從內存當中讀取結果,而不是再走一次鏈路獲取數據了。

舉一個很簡單的例子,比如說我們打開淘寶首頁會看到很多商品的推薦。其實推薦商品的流程是非常複雜的,首先要根據一定的策略去商品庫當中召回商品。比如根據用戶之前的行為召回和歷史行為相關的商品,召回了商品之後還要進行清洗,過濾掉一些明確不感興趣或者是非法、違規的商品。過濾了之後需要使用機器學習或者是深度學習的模型來進行點擊率預測,從而將發生點擊可能性最高的商品排在前面。

到這裡還沒結束,推薦商品列表其實也是分展位的,有些位置的商品是運營配好的,有些位置固定展示的是廣告。廣告往往也有自己的一條鏈路,還有些位置有一些其他的邏輯。這些商品的數據都拿到了之後,還要獲取圖片以及其他一些零零散散的信息,最後才能展示出來。因此大家可以試一下打開淘寶首頁要比打開百度首頁慢得多,這並不是淘寶技術差,而是因為這中間的鏈路實在是太長了。

我們很容易發現,對於一些經常打開淘寶的用戶來說,其實沒有必要每一次都把完整的鏈路走一遍。我們大可以把之前展示的結果存儲在內存裡,下一次再遇到同樣請求的時候,直接從內存當中讀取並且返回就可以了。這樣可以節省大量的時間以及計算資源,比如在大促的時候,就可以把計算資源節省下來用在更加需要的地方。

緩存雖然好用,但是也不是萬能的,因為內存是很貴的,我們不可能把所有數據都存在內存裡。內存裡只能放一些我們認為比較高價值的數據,在這種情況下,計算科學家們想出了種種策略來調度緩存,保持緩存當中數據的高價值。LRU就是其中一種比較常用的策略。

LRU含義

我們前面也說了,LRU的意思是最長不經常使用,也可以理解成最久沒有使用。在這種策略下我們用最近一次使用的時間來衡量一塊內存的價值,越久之前使用的價值也就越低,最近剛剛使用過的,後面接著會用到的概率也就越大,那麼自然也就價值越高。

當然只有這個限制是不夠的,我們前面也說了,由於內存是非常金貴的,導致我們可以存儲在緩存當中的數據是有限的。比如說我們固定只能存儲1w條,當內存滿了之後,緩存每插入一條新數據,都要拋棄一條最長沒有使用的舊數據。這樣我們就保證了緩存當中的數據的價值都比較高,並且內存不會超過限制。

我們把上面的內容整理一下,可以得到幾點要求:

  1. 保證緩存的讀寫效率,比如讀寫的複雜度都是O(1)
  2. 當一條緩存當中的數據被讀取,將它最近使用的時間更新
  3. 當插入一條新數據的時候,彈出更新時間最遠的數據

LRU原理

我們仔細想一下這個問題會發現好像沒有那麼簡單,顯然我們不能通過數組來實現這個緩存。因為數組的查詢速度是很慢的,不可能做到O(1)。其次我們用HashMap好像也不行,因為雖然查詢的速度可以做到O(1),但是我們沒辦法做到更新最近使用的時間,並且快速找出最遠更新的數據。

如果是在面試當中被問到想到這裡的時候,可能很多人都已經束手無策了。但是先別著急,我們冷靜下來想想會發現問題其實並沒有那麼模糊。首先HashMap是一定要用的,因為只有HashMap才可以做到O(1)時間內的讀寫,其他的數據結構幾乎都不可行。但是只有HashMap解決不了更新以及淘汰的問題,必須要配合其他數據結構進行。這個數據結構需要能夠做到快速地插入和刪除,其實我這麼一說已經很明顯了,只有一個數據結構可以做到,就是鍊表。

鍊表有一個問題是我們想要查詢鍊表當中的某一個節點需要O(n)的時間,這也是我們無法接受的。但這個問題並非無法解決,實際上解決也很簡單,我們只需要把鍊表當中的節點作為HashMap中的value進行儲存即可,最後得到的系統架構如下:

對於緩存來說其實只有兩種功能,第一種功能就是查找,第二種是更新

查找

查找會分為兩種情況,第一種是沒查到,這種沒什麼好說的,直接返回空即可。如果能查到節點,在我們返回結果的同時,我們需要將查到的節點在鍊表當中移動位置。我們假設越靠近鍊表頭部表示數據越舊,越靠近鍊表尾部數據越新,那麼當我們查找結束之後,我們需要把節點移動到鍊表的尾部。

移動可以通過兩個步驟來完成,第一個步驟是在鍊表上刪除該節點,第二個步驟是插入到尾部:

更新

更新也同樣分為兩種情況,第一種情況就是更新的key已經在HashMap當中了,那麼我們只需要更新它對應的Value,並且將它移動到鍊表尾部。對應的操作和上面的查找是一樣的,只不過多了一個更新HashMap的步驟,這沒什麼好說的,大家應該都能想明白。

第二種情況就是要更新的值在鍊表當中不存在,這也會有兩種情況,第一個情況是緩存當中的數量還沒有達到限制,那麼我們直接添加在鍊表結尾即可。第二種情況是鍊表現在已經滿了,我們需要移除掉一個元素才可以放入新的數據。這個時候我們需要刪除鍊表的第一個元素,不僅僅是鍊表當中刪除就可以了,還需要在HashMap當中也刪除對應的值,否則還是會佔據一份內存。

因為我們要進行鍊表到HashMap的反向刪除操作,所以鍊表當中的節點上也需要記錄下Key值,否則的話刪除就沒辦法了。刪除之後再加入新的節點,這個都很簡單:

我們理順了整個過程之後來看代碼:

class Node:    def __init__(self, key, val, prev=None, succ=None):        self.key = key        self.val = val        # 前驅        self.prev = prev        # 後繼        self.succ = succ    def __repr__(self):        return str(self.val)class LinkedList:    def __init__(self):        self.head = Node(None, 'header')        self.tail = Node(None, 'tail')        self.head.succ = self.tail        self.tail.prev = self.head    def append(self, node):        # 將node節點添加在鍊表尾部        prev = self.tail.prev        node.prev = prev        node.succ = prev.succ        prev.succ = node        node.succ.prev = node    def delete(self, node):        # 刪除節點        prev = node.prev        succ = node.succ        succ.prev, prev.succ = prev, succ    def get_head(self):        # 返回第一個節點        return self.head.succclass LRU:    def __init__(self, cap=100):        # cap即capacity,容量        self.cap = cap        self.cache = {}        self.linked_list = LinkedList()    def get(self, key):        if key not in self.cache:            return None        self.put_recently(key)        return self.cache[key]    def put_recently(self, key):        # 把節點更新到鍊表尾部        node = self.cache[key]        self.linked_list.delete(node)        self.linked_list.append(node)    def put(self, key, value):        # 能查到的話先刪除原數據再更新        if key in self.cache:            self.linked_list.delete(self.cache[key])            self.cache[key] = Node(key, value)            self.linked_list.append(self.cache[key])            return         if len(self.cache) >= self.cap:            # 如果容量已經滿了,刪除最舊的節點            node = self.linked_list.get_head()            self.linked_list.delete(node)            del self.cache[node.key]        u = Node(key, value)        self.linked_list.append(u)        self.cache[key] = u

在這種實現當中我們沒有用除了dict之外的其他任何工具,連LinkedList都是自己實現的。實際上在Python語言當中有一個數據結構叫做OrderedDict,它是一個字典,但是它當中的元素都是有序的。我們利用OrderedDict來實現LRU就非常非常簡單,代碼量也要少得多。

我們來看代碼:

class LRU(OrderedDict):    def __init__(self, cap=128, /, *args, **kwds):        self.cap = cap        super().__init__(*args, **kwds)    def __getitem__(self, key):        # 查詢函數        value = super().__getitem__(key)        # 把節點移動到末尾        self.move_to_end(key)        return value    def __setitem__(self, key, value):        # 更新函數        super().__setitem__(key, value)        if len(self) > self.cap:            oldest = next(iter(self))            del self[oldest]

在上面一種實現當中由於只用了一個數據結構,所以整個代碼要簡潔許多。使用起來也更加方便,直接像是dict一樣使用方括號進行查詢以及更新就可以了。不過在其他語言當中可能沒有OrderedDict這種數據結構,這就需要我們自己來編碼實現了。

好了,今天的文章就到這裡。衷心祝願大家每天都有所收穫。如果還喜歡今天的內容的話,請來一個三連支持吧~(點讚、關注、轉發

- END -

本文始發於公眾號:TechFlow,求個關注

相關焦點

  • 從零開始手寫 redis(九)LRU 緩存淘汰避免緩存汙染
    java從零手寫實現redis(四)添加監聽器java從零手寫實現redis(五)過期策略的另一種實現思路java從零手寫實現redis(六)AOF 持久化原理詳解及實現java從零手寫實現redis(七)LRU 緩存淘汰策略詳解從零開始手寫 redis(八)樸素 LRU 淘汰算法性能優化前兩節我們分別實現了 LRU
  • 從零開始手寫 redis(八)樸素 LRU 淘汰算法性能優化
    java從零手寫實現redis(四)添加監聽器java從零手寫實現redis(五)過期策略的另一種實現思路java從零手寫實現redis(六)AOF 持久化原理詳解及實現我們前面簡單實現了 redis 的幾個特性,java從零手寫實現redis(一)如何實現固定大小的緩存? 中實現了先進先出的驅除策略。
  • LRU算法
    LRU算法的設計原則是:如果一個數據在最近一段時間沒有被訪問到,那麼在將來它被訪問的可能性也很小。也就是說,當限定的空間已存滿數據時,應當把最久沒有被訪問到的數據淘汰。當List空間已滿時,將時間戳最大的數據項淘汰。利用一個鍊表來實現,每次新插入數據的時候將新數據插到鍊表的頭部;每次緩存命中(即數據被訪問),則將數據移到鍊表頭部;那麼當鍊表滿的時候,就將鍊表尾部的數據丟棄。利用鍊表和hashmap。
  • 「Redis源碼分析」Redis中的LRU算法實現
    LRU是什麼LRU(least recently used)是一種緩存置換算法。即在緩存有限的情況下,如果有新的數據需要加載進緩存,則需要將最不可能被繼續訪問的緩存剔除掉。Redis初始的實現算法很簡單,隨機從dict中取出五個key,淘汰一個lru欄位值最小的。(隨機選取的key是個可配置的參數maxmemory-samples,默認值為5).在3.0的時候,又改進了一版算法,首先第一次隨機選取的key都會放入一個pool中(pool的大小為16),pool中的key是按lru大小順序排列的。
  • 緩存淘汰算法LRU和LFU
    前言LRU算法和LFU算法是屬於頁面置換的一種算法,或者更通俗的說,就是緩存如何淘汰的一種策略。我們通常在設計一個系統的時候,由於資料庫的讀取速度遠小於內存的讀取速度,所以為了加快讀取速度,會將一部分數據放到內存中,稱為緩存。但是內存容量是有限的,當你要緩存的數據超出容量,就得有部分數據刪除,這時候哪些數據刪除,哪些數據保留,就是LRU算法和LFU算法要幹的事。
  • LRU緩存算法的實現
    ,將最近長時間未使用的頁面淘汰,其實也很簡單,就是要將不受歡迎的頁面及時淘汰,不讓它佔著茅坑不拉shit,浪費資源。常見的頁面置換算法有如下幾種:LRU 最近最久未使用FIFO 先進先出置換算法 類似隊列OPT 最佳置換算法 (理想中存在的)NRU Clock置換算法LFU 最少使用置換算法PBA 頁面緩衝算法
  • 從零開始手寫 redis(11)時鐘淘汰算法詳解及實現
    java從零手寫實現redis(四)添加監聽器java從零手寫實現redis(五)過期策略的另一種實現思路java從零手寫實現redis(六)AOF 持久化原理詳解及實現java從零開始手寫 redis(七)LRU 緩存淘汰策略詳解前面我們實現了 FIFO/LRU/LFU 等常見的淘汰策略,不過在作業系統中,實際上使用的是時鐘頁面置換算法
  • 從零開始手寫 redis(十)緩存淘汰LFU算法詳解
    java從零手寫實現redis(四)添加監聽器java從零手寫實現redis(五)過期策略的另一種實現思路java從零手寫實現redis(六)AOF 持久化原理詳解及實現java從零手寫實現redis(七)LRU 緩存淘汰策略詳解java從零開始手寫 redis(八)樸素 LRU 淘汰算法性能優化本節一起來學習下另一個常用的緩存淘汰算法
  • LRU(Least Recently Used)緩存算法的實現
    作者:LuckDay 來源:JavaScript忍者秘籍LRU就是Least Recently Used,即最近最少使用,是一種常用的頁面置換算法,將最近長時間未使用的頁面淘汰,其實也很簡單,就是要將不受歡迎的頁面及時淘汰,不讓它佔著茅坑不拉shit,浪費資源。
  • 地鐵五分鐘理解LRU算法
    LRU(Least recently used,最近最少使用)算法作為內存管理的一種有效算法,其含義是在內存有限的情況下,當內存容量不足時,為了保證程序的運行,這時就不得不淘汰內存中的一些對象,釋放這些對象佔用的空間,那麼選擇淘汰哪些對象呢?
  • 了解常用緩存淘汰算法,這就夠了
    如果查詢一個數據,這個數據剛好在緩存裡,我們稱之為命中緩存,顯而易見,命中率,是衡量一個緩存的重要指標。淘汰算法因為需要淘汰掉數據,所以需要設計淘汰算法,總不能跟機器說,幫我淘汰掉沒價值的,那樣估計程式設計師們就要失業了,常用的淘汰算法有哪些呢?
  • 手寫一下 LRU 代碼實現?
    redis 是緩存,你給當存儲了是吧?啥叫緩存?用內存當緩存。內存是無限的嗎,內存是很寶貴而且是有限的,磁碟是廉價而且是大量的。可能一臺機器就幾十個 G 的內存,但是可以有幾個 T 的硬碟空間。redis 主要是基於內存來進行高性能、高並發的讀寫操作的。那既然內存是有限的,比如 redis 就只能用 10G,你要是往裡面寫了 20G 的數據,會咋辦?
  • LRU原理和Redis實現——一個今日頭條的面試題
    我的第一反應是作業系統課程裡學過,應該是內存不夠的場景下,淘汰舊內容的策略。LRU ... Least Recent Used,淘汰掉最不經常使用的。但是如果讓我們自己設計一個基於 LRU 的緩存,這樣設計可能問題很多,這段內存按照訪問時間進行了排序,會有大量的內存拷貝操作,所以性能肯定是不能接受的。那麼如何設計一個LRU緩存,使得放入和移除都是 O(1) 的,我們需要把訪問次序維護起來,但是不能通過內存中的真實排序來反應,有一種方案就是使用雙向鍊表。
  • 以及訂閱模式、LRU
    使用場景就是搞促銷活動時,會做預緩存,會往緩存裡放大批數據,如果直接放的話那麼會很慢,怎麼能提高效率呢?Redis的批量操作-管道(pipeline)首先Redis的管道(pipeline)並不是Redis服務端提供的功能,而是Redis客戶端為了減少網絡交互而提供的一種功能。
  • 面試必問:請手寫淘汰算法LRU與LFU
    所以「Redis」使用了一些淘汰算法來處理這些來不及刪除的數據。下面我們來說說「LRU」淘汰算法。>「Redis」 4.0中引入了一個新的淘汰算法LFU,可以說是LRU的進階版。LFU算法規定的是按最近訪問的頻率進行淘汰,與LRU算法相比,LFU更精準的表示了一個「key」被訪問的熱度。為什麼Redis要引入LFU算法呢?
  • Redis內存淘汰機制
    Martini | 作者urlify.cn/vQrUJb | 來源1、概述Redis是基於內存存儲,常用於數據的緩存,所以Redis提供了對鍵的過期時間的設置,實現了幾種淘汰機制便於適應各種場景。從已設置過期時間的數據集中挑選任意 Key淘汰volatile-lfu從已設置過期時間的數據集中挑選最不經常使用的 Key 淘汰allkeys-lru當內存不足寫入新數據時淘汰最近最少使用的 Keyallkeys-random當內存不足寫入新數據時隨機選擇任意 key淘汰allkeys-lfu當內存不足寫入新數據時移除最不經常使用的Keyvolatile為前綴的策略都是從 已過期的數據集
  • 面試不再怕,程式設計師大佬教你20行Python代碼,搞懂LRU算法
    LRU算法在後端工程師面試中,是一個比較常出現的題目,這篇文章帶大家一起,理解LRU算法,並最終用Python輕鬆實現一個基於LRU算法的緩存。緩存是什麼先看一張圖,當我們訪問網頁,瀏覽器會給伺服器發請求,伺服器會經過一系列的運算,把頁面返回給瀏覽器。
  • Redis5.0數據淘汰策略詳解(最新版本,面試常問)
    作為一個內存資料庫,redis在內存空間不足的時候,為了保證命中率,就會選擇一定的數據淘汰策略,這篇文章主要講解常見的幾種內存淘汰策略。和我們作業系統中的頁面置換算法類似。一、參數設置我們的redis資料庫的最大緩存、主鍵失效、淘汰機制等參數都是通過配置文件來配置的。
  • 了解LRU緩存的實現方法,這就夠了
    在之前的文章,我們介紹過常用緩存淘汰算法(文末有連結)。常見的緩存淘汰算法有先進先出淘汰算法(FIFO),最近最少使用淘汰算法(LSU),最近最久未使用算法(LRU),MRU(最近最常使用算法)。今天我們來討論其中的一種,也是最常用的LRU算法,看看它是如何實現的。