動手實現一個localcache - 設計篇

asong發表於2021-12-16

前言

哈嘍,大家好,我是asong。最近想動手寫一個localcache練練手,工作這麼久了,也看過很多同事實現的本地快取,都各有所長,自己平時也在思考如何實現一個高效能的本地快取,接下來我將基於自己的理解實現一版本地快取,歡迎各位大佬們提出寶貴意見,我會根據意見不斷完善的。

本篇主要介紹設計一個本地快取都要考慮什麼點,後續為實現文章,歡迎關注這個系列。

為什麼要有本地快取

隨著網際網路的普及,使用者數和訪問量越來越大,這就需要我們的應用支撐更多的併發量,比如某寶的首頁流量,大量的使用者同時進入首頁,對我們的應用伺服器和資料庫伺服器造成的計算也是巨大的,本身資料庫訪問就佔用資料庫連線,導致網路開銷巨大,在面對如此高的併發量下,資料庫就會面臨瓶頸,這時就要考慮加快取,快取就分為分散式快取和本地快取,大多數場景我們使用分散式快取就可以滿足要求,分散式快取訪問速度也很快,但是資料需要跨網路傳輸,在面對首頁這種高併發量級下,對效能要求是很高的,不能放過一點點的效能優化空間,這時我們就可以選擇使用本地快取來提高效能,本地快取不需要跨網路傳輸,應用和cache都在同一個程式內部,快速請求,適用於首頁這種資料更新頻率較低的業務場景。

綜上所述,我們往往使用本地快取後的系統架構是這樣的:

<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/95d91bee0d0a40998e971bacb7e44bbc~tplv-k3u1fbpfcp-zoom-1.image" style="zoom:67%;" />

本地快取雖然帶來效能優化,不過也是有一些弊端的,快取與應用程式耦合,多個應用程式無法直接的共享快取,各應用或叢集的各節點都需要維護自己的單獨快取,對記憶體是一種浪費,使用快取的是我們程式設計師自己,我們要根據根據資料型別、業務場景來準確判斷使用何種型別的快取,如何使用這種快取,以最小的成本最快的效率達到最優的目的。

思考:如何實現一個高效能本地快取

資料結構

第一步我們就要考慮資料該怎樣儲存;資料的查詢效率要高,首先我們就想到了雜湊表,雜湊表的查詢效率高,時間複雜度為O(1),可以滿足我們的需求;確定是使用什麼結構來儲存,接下來我們要考慮以什麼型別進行儲存,因為不同的業務場景使用的資料型別不同,為了通用,在java中我們可以使用泛型,Go語言中暫時沒有泛型,我們可以使用interface型別來代替,把解析資料交給程式設計師自己來進行斷言,增強了可擴充套件性,同時也增加一些風險。

總結:

  • 資料結構:雜湊表;
  • keystring型別;
  • valueinterface型別;

併發安全

本地快取的應用肯定會面對併發讀寫的場景,這是就要考慮併發安全的問題。因為我們選擇的是雜湊結構,Go語言中主要提供了兩種雜湊,一種是非執行緒安全的map,一種是執行緒安全的sync.map,為了方便我們可以直接選擇sync.map,也可以考慮使用map+sync.RWMutex組合方式自己實現保證執行緒安全,網上有人對這兩種方式進行比較,在讀操作遠多於寫操作的時候,使用sync.map的效能是遠高於map+sync.RWMutex的組合的。在本地快取中讀操作是遠高於寫操作的,但是我們本地快取不僅支援進行資料儲存的時候要使用鎖,進行過期清除等操作時也需要加鎖,所以使用map+sync.RWMutex的方式更靈活,所以這裡我們選擇這種方式保證併發安全。

高效能併發訪問

加鎖可以保證資料的讀寫安全性,但是會增加鎖競爭,本地快取本來就是為了提升效能而設計出來,不能讓其成為效能瓶頸,所以我們要對鎖競爭進行優化。針對本地快取的應用場景,我們可以根據key進行分桶處理,減少鎖競爭。

我們的key都是string型別,所以我們可以使用djb2雜湊演算法把key打散進行分桶,然後在對每一個桶進行加鎖,也就是鎖細化,減少競爭。

物件上限

因為本地快取是在記憶體中儲存的,記憶體都是有限制的,我們不可能無限儲存,所以我們可以指定快取物件的數量,根據我們具體的應用場景去預估這個上限值,預設我們選擇快取的數量為1024。

淘汰策略

因為我們會設定快取物件的數量,當觸發上限值時,可以使用淘汰策略淘汰掉,常見的快取淘汰演算法有:

LFU

LFU(Least Frequently Used)即最近不常用演算法,根據資料的歷史訪問頻率來淘汰資料,這種演算法核心思想認為最近使用頻率低的資料,很大概率不會再使用,把使用頻率最小的資料置換出去。

存在的問題:

某些資料在短時間內被高頻訪問,在之後的很長一段時間不再被訪問,因為之前的訪問頻率急劇增加,那麼在之後不會在短時間內被淘汰,佔據著佇列前頭的位置,會導致更頻繁使用的塊更容易被清除掉,剛進入的快取新資料也可能會很快的被刪除。

LRU

LRU(Least Recently User)即最近最少使用演算法,根據資料的歷史訪問記錄來淘汰資料,這種演算法核心思想認為最近使用的資料很大概率會再次使用,最近一段時間沒有使用的資料,很大概率不會再次使用,把最長時間未被訪問的資料置換出去

存在問題:

當某個客戶端訪問了大量的歷史資料時,可能會使快取中的資料被歷史資料替換,降低快取命中率。

FIFO

FIFO(First in First out)即先進先出演算法,這種演算法的核心思想是最近剛訪問的,將來訪問的可能性比較大,先進入快取的資料最先被淘汰掉。

存在的問題:

這種演算法採用絕對公平的方式進行資料置換,很容易發生缺頁中斷問題。

Two Queues

Two QueuesFIFO + LRU的結合,其核心思想是當資料第一次訪問時,將資料快取在FIFO佇列中,當資料第二次被訪問時將資料從FIFO佇列移到LRU佇列裡面,這兩個佇列按照自己的方法淘汰資料。

存在問題:

這種演算法和LRU-2一致,適應性差,存在LRU中的資料需要大量的訪問才會將歷史記錄清除掉。

ARU

ARU(Adaptive Replacement Cache)即自適應快取替換演算法,是LFULRU演算法的結合使用,其核心思想是根據被淘汰資料的訪問情況,而增加對應 LRU 還是 LFU 連結串列的大小,ARU主要包含了四個連結串列,LRULRU Ghost LFU LFU GhostGhost 連結串列為對應淘汰的資料記錄連結串列,不記錄資料,只記錄 ID 等資訊。

截圖2021-12-04 下午6.52.05

當資料被訪問時加入LRU佇列,如果該資料再次被訪問,則同時被放到 LFU 連結串列中;如果該資料在LRU佇列中淘汰了,那麼該資料進入LRU Ghost佇列,如果之後該資料在之後被再次訪問了,就增加LRU佇列的大小,同時縮減LFU佇列的大小。

存在問題:

因為要維護四個佇列,會佔用更多的記憶體空間。

選擇

每一種演算法都有自己特色,結合我們本地快取使用的場景,選擇ARU演算法來做快取快取淘汰策略是一個不錯的選擇,可以動態調整 LRU 和 LFU 的大小,以適應當前最佳的快取命中。

過期清除

除了使用快取淘汰策略清除資料外,還可以新增一個過期時間做雙重保證,避免不經常訪問的資料一直佔用記憶體。可以有兩種做法:

  • 資料過期了直接刪除
  • 資料過期了不刪除,非同步更新資料

兩種做法各有利弊,非同步更新資料需要具體業務場景選擇,為了迎合大多數業務,我們採用資料過期了直接刪除這種方法更友好,這裡我們採用懶載入的方式,在獲取資料的時候判斷資料是否過期,同時設定一個定時任務,每天定時刪除過期的資料。

快取監控

很多人對於快取的監控也比較忽略,基本寫完後不報錯就預設他已經生效了,這就無法感知這個快取是否起作用了,所以對於快取各種指標的監控,也比較重要,通過其不同的指標資料,我們可以對快取的引數進行優化,從而讓快取達到最優化。如果是企業應用,我們可以使用Prometheus進行監控上報,我們自測可以簡單寫一個小元件,定時列印快取數、快取命中率等指標。

GC調優

對於大量使用本地快取的應用,由於涉及到快取淘汰,那麼GC問題必定是常事。如果出現GC較多,STW時間較長,那麼必定會影響服務可用性;對於這個事項一般是具體case具體分析,本地快取上線後記得經常檢視GC監控。

快取穿透

使用快取就要考慮快取穿透的問題,不過這個一般不在本地快取中實現,基本交給使用者來實現,當在快取中找不到元素時,它設定對快取鍵的鎖定;這樣其他執行緒將等待此元素被填充,而不是命中資料庫(外部使用singleflight封裝一下)。

參考文章

總結

真正想設計一個高效能的本地快取還是挺不容易的,由於我也才疏學淺,本文的設計思想也是個人實踐想法,歡迎大家提出寶貴意見,我們一起做出來一個真正的高效能本地快取。

下篇文章我將分享自己的寫的一個本地快取,盡請期待!!!

歡迎關注公眾號:Golang夢工廠

相關文章