前言
哈嘍,大家好,我是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
型別來代替,把解析資料交給程式設計師自己來進行斷言,增強了可擴充套件性,同時也增加一些風險。
總結:
- 資料結構:雜湊表;
key
:string
型別;value
:interface
型別;
併發安全
本地快取的應用肯定會面對併發讀寫的場景,這是就要考慮併發安全的問題。因為我們選擇的是雜湊結構,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 Queues
是FIFO
+ LRU
的結合,其核心思想是當資料第一次訪問時,將資料快取在FIFO
佇列中,當資料第二次被訪問時將資料從FIFO
佇列移到LRU
佇列裡面,這兩個佇列按照自己的方法淘汰資料。
存在問題:
這種演算法和LRU-2
一致,適應性差,存在LRU
中的資料需要大量的訪問才會將歷史記錄清除掉。
ARU
ARU
(Adaptive Replacement Cache)即自適應快取替換演算法,是LFU
和LRU
演算法的結合使用,其核心思想是根據被淘汰資料的訪問情況,而增加對應 LRU
還是 LFU
連結串列的大小,ARU
主要包含了四個連結串列,LRU
和 LRU Ghost
, LFU
和 LFU Ghost
, Ghost
連結串列為對應淘汰的資料記錄連結串列,不記錄資料,只記錄 ID 等資訊。
當資料被訪問時加入LRU
佇列,如果該資料再次被訪問,則同時被放到 LFU
連結串列中;如果該資料在LRU
佇列中淘汰了,那麼該資料進入LRU Ghost
佇列,如果之後該資料在之後被再次訪問了,就增加LRU
佇列的大小,同時縮減LFU
佇列的大小。
存在問題:
因為要維護四個佇列,會佔用更多的記憶體空間。
選擇
每一種演算法都有自己特色,結合我們本地快取使用的場景,選擇ARU
演算法來做快取快取淘汰策略是一個不錯的選擇,可以動態調整 LRU 和 LFU 的大小,以適應當前最佳的快取命中。
過期清除
除了使用快取淘汰策略清除資料外,還可以新增一個過期時間做雙重保證,避免不經常訪問的資料一直佔用記憶體。可以有兩種做法:
- 資料過期了直接刪除
- 資料過期了不刪除,非同步更新資料
兩種做法各有利弊,非同步更新資料需要具體業務場景選擇,為了迎合大多數業務,我們採用資料過期了直接刪除這種方法更友好,這裡我們採用懶載入的方式,在獲取資料的時候判斷資料是否過期,同時設定一個定時任務,每天定時刪除過期的資料。
快取監控
很多人對於快取的監控也比較忽略,基本寫完後不報錯就預設他已經生效了,這就無法感知這個快取是否起作用了,所以對於快取各種指標的監控,也比較重要,通過其不同的指標資料,我們可以對快取的引數進行優化,從而讓快取達到最優化。如果是企業應用,我們可以使用Prometheus
進行監控上報,我們自測可以簡單寫一個小元件,定時列印快取數、快取命中率等指標。
GC調優
對於大量使用本地快取的應用,由於涉及到快取淘汰,那麼GC問題必定是常事。如果出現GC較多,STW時間較長,那麼必定會影響服務可用性;對於這個事項一般是具體case具體分析,本地快取上線後記得經常檢視GC
監控。
快取穿透
使用快取就要考慮快取穿透的問題,不過這個一般不在本地快取中實現,基本交給使用者來實現,當在快取中找不到元素時,它設定對快取鍵的鎖定;這樣其他執行緒將等待此元素被填充,而不是命中資料庫(外部使用singleflight
封裝一下)。
參考文章
- https://zhuanlan.zhihu.com/p/...
- https://cloud.tencent.com/dev...
- https://tech.meituan.com/2017...
- https://www.cnblogs.com/vanca...
總結
真正想設計一個高效能的本地快取還是挺不容易的,由於我也才疏學淺,本文的設計思想也是個人實踐想法,歡迎大家提出寶貴意見,我們一起做出來一個真正的高效能本地快取。
下篇文章我將分享自己的寫的一個本地快取,盡請期待!!!
歡迎關注公眾號:Golang夢工廠