這是why哥的第 81 篇原創文章
你面試的時候遇見過LRU嗎?
LRU 演算法,全稱是Least Recently Used。
翻譯過來就是最近最少使用演算法。
這個演算法的思想就是:如果一個資料在最近一段時間沒有被訪問到,那麼在將來它被訪問的可能性也很小。所以,當指定的空間已存滿資料時,應當把最久沒有被訪問到的資料淘汰。
聽描述你也知道了,它是一種淘汰演算法。
這個演算法也是面試的一個高頻考點。
有的面試官甚至要求手擼一個 LRU 演算法出來。
其實我覺得吧,遇到這種情況也不要慌,你就按照自己的思路寫一個出來就行。
賭一把,面試官也許自己短時間內都手擼不出來一個無 bug 的 LRU。他也只是檢查幾個關鍵點、看看你的程式碼風格、觀察一下你的解題思路而已。
但其實大多數情況下面試場景都是這樣的:
面試官:你知道 LRU 演算法嗎?
我:知道,翻譯過來就是最近最少使用演算法。其思想是(前面說過,就不復述了)......
面試官:那你能給我談談你有哪些方法來實現 LRU 演算法呢?
這個時候問的是什麼?
問的是:我們都知道這個演算法的思路了,請你按照這個思路給出一個可以落地的解決方案。
不用徒手擼一個。
方案一:陣列
如果之前完全沒有接觸過 LRU 演算法,僅僅知道其思路。
第一次聽就要求你給一個實現方案,那麼陣列的方案應該是最容易想到的。
假設我們有一個定長陣列。陣列中的元素都有一個標記。這個標記可以是時間戳,也可以是一個自增的數字。
假設我們用自增的數字。
每放入一個元素,就把陣列中已經存在的資料的標記更新一下,進行自增。當陣列滿了後,就將數字最大的元素刪除掉。
每訪問一個元素,就將被訪問的元素的數字置為 0 。
這不就是 LRU 演算法的一個實現方案嗎?
按照這個思路,擼一份七七八八的程式碼出來,問題應該不大吧?
但是這一種方案的弊端也是很明顯:需要不停地維護陣列中元素的標記。
那麼你覺得它的時間複雜度是多少?
是的,每次操作都伴隨著一次遍歷陣列修改標記的操作,所以時間複雜度是O(n)。
但是這個方案,面試官肯定是不會滿意的。因為,這不是他心中的標準答案。
也許他都沒想過:你還能給出這種方案呢?
但是它不會說出來,只會輕輕的說一句:還有其他的方案嗎?
方案二:連結串列
於是你扣著腦殼想了想。最近最少使用,感覺是需要一個有序的結構。
我每插入一個元素的時候,就追加在陣列的末尾。
我每訪問一次元素,就把被訪問的元素移動到陣列的末尾。
這樣最近被用的一定是在最後面的,頭部的就是最近最少使用的。
當指定長度被用完了之後,就把頭部元素移除掉就行了。
這是個什麼結構?
這不就是個連結串列嗎?
維護一個有序單連結串列,越靠近連結串列頭部的結點是越早之前訪問的。
當有一個新的資料被訪問時,我們從連結串列頭部開始順序遍歷連結串列。
如果此資料之前已經被快取在連結串列中了,我們遍歷得到這個資料的對應結點,並將其從原來的位置刪除,並插入到連結串列尾部。
如果此資料沒在快取連結串列中,怎麼辦?
分兩種情況:
- 如果此時快取未滿,可直接在連結串列尾部插入新節點儲存此資料;
- 如果此時快取已滿,則刪除連結串列頭部節點,再在連結串列尾部插入新節點。
你看,這不又是 LRU 演算法的一個實現方案嗎?
按照這個思路,擼一份八九不離十的程式碼出來,問題應該不大吧?
這個方案比陣列的方案好在哪裡呢?
我覺得就是莫名其妙的高階感,就是看起來就比陣列高階了一點。
從時間複雜度的角度看,因為連結串列插入、查詢的時候都要遍歷連結串列,檢視資料是否存在,所以它還是O(n)。
總之,這也不是面試官想要的答案。
當你回答出這個方案之後,面試官也許會說:你能不能給我一個查詢和插入的時間複雜度都是O(1)的解決方案?
到這裡,就得看天分了。
有一說一,如果我之前完全沒有接觸過 LRU 演算法,我可以非常自信的說:
方案三:雙向連結串列+雜湊表。
如果我們想要查詢和插入的時間複雜度都是O(1),那麼我們需要一個滿足下面三個條件的資料結構:
- 1.首先這個資料結構必須是有時序的,以區分最近使用的和很久沒有使用的資料,當容量滿了之後,要刪除最久未使用的那個元素。
- 2.要在這個資料結構中快速找到某個 key 是否存在,並返回其對應的 value。
- 3.每次訪問這個資料結構中的某個 key,需要將這個元素變為最近使用的。也就是說,這個資料結構要支援在任意位置快速插入和刪除元素。
那麼,你說什麼樣的資料結構同時符合上面的條件呢?
查詢快,我們能想到雜湊表。但是雜湊表的資料是亂序的。
有序,我們能想到連結串列,插入、刪除都很快,但是查詢慢。
所以,我們得讓雜湊表和連結串列結合一下,成長一下,形成一個新的資料結構,那就是:雜湊連結串列,LinkedHashMap。
這個結構大概長這樣:
藉助這個結構,我們再來分析一下上面的三個條件:
- 1.如果每次預設從連結串列尾部新增元素,那麼顯然越靠近尾部的元素就越是最近使用的。越靠近頭部的元素就是越久未使用的。
- 2.對於某一個 key ,可以通過雜湊錶快速定位到連結串列中的節點,從而取得對應的 value。
- 3.連結串列顯然是支援在任意位置快速插入和刪除的,修改指標就行。但是單連結串列無法按照索引快速訪問某一個位置的元素,都是需要遍歷連結串列的,所以這裡藉助雜湊表,可以通過 key,快速的對映到任意一個連結串列節點,然後進行插入和刪除。
這才是面試官想要關於 LRU 的正確答案。
但是你以為回答到這裡就結束了嗎?
面試官為了確認你的掌握程度,還會追問一下。
那麼請問:為什麼這裡要用雙連結串列呢,單連結串列為什麼不行?
你心裡一慌:我靠,這題我也背過。一時想不起來了。
所以,別隻顧著背答案,得理解。
你想啊,我們是不是涉及到刪除元素的操作?
那麼連結串列刪除元素除了自己本身的指標資訊,還需要什麼東西?
是不是還需要前驅節點的指標?
那麼我們這裡要求時間複雜度是O(1),所以怎麼才能直接獲取到前驅節點的指標?
這玩意是不是就得上雙連結串列?
咦,你看在一波靈魂追問中,就得到了答案。
面試官的第二個問題又隨之而來了:雜湊表裡面已經儲存了 key ,那麼連結串列中為什麼還要儲存 key 和 value 呢,只存入 value 不就行了?
不會也不要慌,你先分析一波。
剛剛我們說刪除連結串列中的節點,需要藉助雙連結串列來實現O(1)。
刪除了連結串列中的節點,然後呢?
是不是還忘記了什麼東西?
是不是還有一個雜湊表忘記操作了?
雜湊表是不是也得進行對應的刪除操作?
刪除雜湊表需要什麼東西?
是不是需要 key,才能刪除對應的 value?
這個 key 從哪裡來?
是不是隻能從連結串列中的結點裡面來?
如果連結串列中的結點,只有 value 沒有 key,那麼我們就無法刪除雜湊表的 key。那不就完犢子了嗎?
又是一波靈魂追問。
所以,你現在知道答案了嗎?
另外在多說一句,有的小夥伴可能會直接回答藉助 LinkedHashMap 來實現。
我覺得吧,你要是實在不知道,也可以這樣說。
但是,這個回答可能是面試官最不想聽到的回答了。
他會覺得你投機取巧。
但是呢,實際開發中,真正要用的時候,我們還是用的 LinkedHashMap。
你說這個事情,難受不難受。
好了,你以為到這裡面試就結束了?
LRU 在 MySQL 中的應用
面試官:小夥子剛剛 LRU 回答的不錯哈。要不你給我講講,LRU 在 MySQL 中的應用?
LRU 在 MySQL 的應用就是 Buffer Pool,也就是緩衝池。
它的目的是為了減少磁碟 IO。
緩衝池具體是幹啥的,我這裡就不展開說了。
你就知道它是一塊連續的記憶體,預設大小 128M,可以進行修改。
這一塊連續的記憶體,被劃分為若干預設大小為 16KB 的頁。
既然它是一個 pool,那麼必然有滿了的時候,怎麼辦?
就得移除某些頁了,對吧?
那麼問題就來了:移除哪些頁呢?
剛剛說了,它是為了減少磁碟 IO。所以應該淘汰掉很久沒有被訪問過的頁。
很久沒有使用,這不就是 LRU 的主場嗎?
但是在 MySQL 裡面並不是簡單的使用了 LRU 演算法。
因為 MySQL 裡面有一個預讀功能。預讀的出發點是好的,但是有可能預讀到並不需要被使用的頁。
這些頁也被放到了連結串列的頭部,容量不夠,導致尾部元素被淘汰。
哦豁,降低命中率了,涼涼。
還有一個場景是全表掃描的 sql,有可能直接把整個緩衝池裡面的緩衝頁都換了一遍,影響其他查詢語句在緩衝池的命中率。
那麼怎麼處理這種場景呢?
把 LRU 連結串列分為兩截,一截裡面放的是熱資料,一截裡面放的是冷資料。
打住,不能再說了。
再說就是另外一篇文章了,點到為止。
如果你不清楚,建議去學習一下哦。
LRU 在 Redis 中的應用
既然是記憶體淘汰演算法,那麼我們常用的 Redis 裡面必然也有對應的實現。
Redis 的記憶體淘汰策略有如下幾種:
- noenviction:預設策略。不繼續執行寫請求(DEL 請求可以處理),讀請求可以繼續進行。這樣可以保證不會丟失資料,但是會讓線上的業務不能持續進行。
- volatile-lru:從已設定過期時間的資料集中挑選最近最少使用的資料淘汰。沒有設定過期時間的 key 不會被淘汰。
- volatile-random:從已設定過期時間的資料集中隨機選擇資料淘汰。
- volatile-ttl:從已設定過期時間的資料集中挑選將要過期的資料淘汰。
- allkeys-lru:和 volatile-lru 不同的是,這個策略要淘汰的 key 物件是全體的 key 集合。
- allkeys-random:從所有資料集中隨機選擇資料淘汰。
Redis 4.0 之後,還增加了兩個淘汰策略。
- volatile-lfu:對有過期時間的 key 採用 LFU 淘汰演算法
- allkeys-lfu:對全部 key 採用 LFU 淘汰演算法
關於 Redis 中的 LRU 演算法,官網上是這樣說的:
https://github.com/redis/redis-doc/blob/master/topics/lru-cache.md
在 Redis 中的 LRU 演算法不是嚴格的 LRU 演算法。
Redis 會嘗試執行一個近似的LRU演算法,通過取樣一小部分鍵,然後在取樣鍵中回收最適合的那個,也就是最久沒有被訪問的那個(with the oldest access time)。
然而,從 Redis3.0 開始,改善了演算法的效能,使得更接近於真實的 LRU 演算法。做法就是維護了一個回收候選鍵池。
Redis 的 LRU 演算法有一個非常重要的點就是你可以通過修改下面這個引數的配置,自己調整演算法的精度。
maxmemory-samples 5
最重要的一句話我也已經標誌出來了:
The reason why Redis does not use a true LRU implementation is because it costs more memory.
Redis 沒有使用真實的 LRU 演算法的原因是因為這會消耗更多的記憶體。
然後官網上給了一個隨機 LRU 演算法和嚴格 LRU 演算法的對比圖:
對於這個圖官網是這樣說的:
你可以從圖中看到三種不同的小圓點形成的三個不同的帶:
- 淺灰色帶是被回收(被 LRU 演算法淘汰)的物件
- 灰色帶是沒有被回收的物件
- 綠色帶是新新增的物件
由於 Redis 3.0 對 LRU 演算法進行了改進,增加了淘汰池。
所以你可以看到,同樣使用 5 個取樣點,Redis 3.0 表現得比 Redis 2.8 要好。
同時可以看出,在 Redis 3.0 中使用 10 為取樣大小,近似值已經非常接近理論效能。
寫到這裡我突然想起了另外一個面試題。
資料庫中有 3000w 的資料,而 Redis 中只有 100w 資料,如何保證 Redis 中存放的都是熱點資料?
這個題你說它的考點是什麼?
考的就是淘汰策略呀,同志們,只是方式比較隱晦而已。
我們先指定淘汰策略為 allkeys-lru 或者 volatile-lru,然後再計算一下 100w 資料大概佔用多少記憶體,根據算出來的記憶體,限定 Redis 佔用的記憶體。
搞定。
才疏學淺,難免會有紕漏,如果你發現了錯誤的地方,可以在後臺提出來,我對其加以修改。
感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。
我是 why,一個被程式碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人。
還有,歡迎關注我呀。