快取和 LRU 演算法
快取是計算機程式設計中的一種技術,用於臨時儲存資料以加速訪問和提高效能。快取是提高系統效能、減少延遲和最佳化使用者體驗的重要工具。合理使用快取技術,開發人員可以構建更加高效和可靠的應用程式。快取的重要性體現在以下方面:
首先,快取透過減少資料讀取時間來提升系統效能。當應用程式頻繁訪問某些資料時,直接從原始資料來源讀取會花費大量時間。將常用資料儲存在快取中,系統可以更快速地訪問所需資料,從而提高響應速度和使用者體驗。其次,快取減少了伺服器負載。在高併發環境中,多個使用者同時請求相同資料會導致伺服器壓力增大。快取透過本地儲存資料,減少對伺服器的重複請求,從而減輕伺服器負載,提高系統的整體穩定性。
在實現快取時,選擇合適的快取替換策略至關重要。LRU(Least Recently Used,最近最少使用)演算法是一種常用的快取替換策略。它透過優先移除最久未使用的資料,確保頻繁訪問的資料保留在快取中,從而提高快取的命中率和使用效率。LRU 演算法在記憶體受限的環境中尤為有效,可以顯著提升系統效能。
除了 LRU(Least Recently Used)演算法,快取中還常用以下幾種演算法:
- FIFO(First In, First Out):FIFO 演算法按照資料進入快取的順序來管理快取資料。當快取滿時,最早進入快取的資料會被最先移除。這種演算法實現簡單,但並不考慮資料的訪問頻率和最近的使用情況,可能會導致效能不佳。
- LFU(Least Frequently Used):LFU 演算法透過移除訪問頻率最低的資料來管理快取。它統計每個資料項的訪問次數,當快取需要騰出空間時,會優先移除訪問次數最少的資料。LFU 演算法適用於那些某些資料項被頻繁訪問的場景,但實現複雜度較高。
- ARC(Adaptive Replacement Cache):ARC 演算法是 IBM 提出的一種自適應快取替換策略,它結合了 LRU 和 LFU 的優點。ARC 透過動態調整 LRU 和 LFU 兩個快取列表的大小,來適應不同的工作負載,提升快取命中率。ARC 演算法在實際應用中表現出色,但實現較為複雜。
- MRU(Most Recently Used):MRU 演算法與 LRU 相反,它移除最近使用的資料。MRU 適用於某些特殊情況,例如,當快取資料被頻繁重新生成或重新整理時,最近使用的資料往往是最不重要的。
Go 語言快取
在專案開發當中,通常我們會選擇合適的成熟的快取框架,例如將資料存在 Redis
、memcache
,包括之前我寫過的 Java
高效能本地快取 Caffeine
。對於 Go
語言來講,例如 go-cache
和 bigcache
、ristretto
。但這些都不是今天的重點,今天我們將手寫一個 ==Go 本地 LRU 快取== ,下面即將開始探險之旅。
定義快取框架
下面我們來開始,首先我們要設計一個快取介面,用來定義功能和方法:
// LRUCache[Tany]
// @Description: 本地LRU快取, 用於快取一些資料, 當快取滿了之後, 會刪除最近最少使用的資料
type LRUCacheInterface[T any] interface {
// 獲取快取中的值
Get(key string) (value T, found bool)
// 設定快取, 如果快取中存在則更新
Put(key string, value T)
// 獲取快取中所有的key
Keys() []string
// 刪除快取中的key
Remove(key string) bool
// 清空快取
Clear()
// 獲取快取的容量
Capacity() int
// 獲取快取的長度
Len() int
}
接下來我們定義一個快取資料的結構體,因為我們用的是 LRU
演算法,所以出來資料以外,需要增加一個最後訪問時間的屬性。
// cacheEntry[Tany]
// @Description: 快取實體, 用於儲存快取資料
type cacheEntry[T any] struct {
Data T // 快取資料
LastAccessed time.Time // 最後訪問時間
}
但是我們考慮到快取的效能,需要最快地速度將最新的資料插入以及清除被淘汰的資料。所處我們設計的快取結構體,用一個 map
儲存資料。
//
// LRUCache[Tany]
// @Description: LRU快取, 用於快取一些資料, 當快取滿了之後, 會刪除最近最少使用的資料
//
type LRUCache[T any] struct {
capacity int // 快取容量
keyMap map[string]cacheEntry[T] // 快取資料
}
但是這樣寫會引入額外的問題,我們如何找出最早的快取資料,肯定不能遍歷 map
的,所以我們還得想另外的辦法儲存並且排序,一定不能現執行排序方法。
連結串列是一個非常不錯的選擇,插入的時候可以根據快取資料的最後訪問時間作為條件,把新資料插入合適的資料。同時能夠很快取出最新的資料和最舊的資料。下面是一個手寫的連結串列介面和儲存資料結構體:
// LinkedList[Tany]
// @Description: 連結串列介面, 用於定義連結串列的操作
type LinkedList[T any] interface {
Head() *Node[T] // 獲取連結串列頭部
Tail() *Node[T] // 獲取連結串列尾部
Append(data T) *Node[T] // 插入資料到連結串列尾部
Push(data T) *Node[T] // 插入資料到連結串列頭部
Remove(node *Node[T]) *Node[T] // 刪除連結串列中的節點
RemoveTail() *Node[T] // 刪除連結串列尾部的節點
MoveToHead(node *Node[T]) // 將節點移動到連結串列頭部
}
// Node[Tany]
// @Description: 連結串列節點, 用於儲存連結串列中的資料
type Node[T any] struct {
Data T // 節點資料
Prev *Node[T] // 上一個節點
Next *Node[T] // 下一個節點
}
那麼新的快取結構體如下:
// LRUCache[Tany]
// @Description: LRU快取, 用於快取一些資料, 當快取滿了之後, 會刪除最近最少使用的資料
type lruCache[T any] struct {
capacity int // 快取容量
keyMap map[string]*Node[cacheEntry[T]] // 快取資料, 用於快速查詢快取資料
list LinkedList[cacheEntry[T]] // 快取連結串列, 用於儲存快取資料, 並且記錄最近訪問的資料
}
最後,我們需要定義快取的 key-value
結構體,這裡就不需要記錄最近一次訪問時間了,只需要專注於資料結構:
// lruCacheEntry[Tany]
// @Description: 快取實體, 用於儲存快取資料
type lruCacheEntry[T any] struct {
key string // 快取鍵
value T // 快取值
}
實現快取框架
接下來我們就是開動,準備實現我們設計的功能和介面。第一步,實現快取資料的 ==GET== 和 ==PUT== 方法。
// Get
//
// @Description: 獲取快取中的值
// @receiver l *lruCache[T],LRU快取
// @param key string,快取鍵
// @return value T,快取值
// @return found bool,是否發現
func (l *lruCache[T]) Get(key string) (value T, found bool) {
if node, ok := l.keyMap[key]; ok { // 如果快取中存在
l.list.MoveToHead(node) // 將節點移動到連結串列頭部
return node.Data.value, ok // 返回快取值
}
var zero T // 零值
return zero, false // 返回零值和false
}
// Put
//
// @Description: 設定快取, 如果快取中存在則更新
// @receiver l *lruCache[T],LRU快取
// @param key string,快取鍵
// @param value T,快取值
func (l *lruCache[T]) Put(key string, value T) {
if node, ok := l.keyMap[key]; ok { // 如果快取中存在
node.Data = lruCacheEntry[T]{ // 更新快取值
key: key, // 快取鍵
value: value, // 快取值
}
l.list.MoveToHead(node) // 將節點移動到連結串列頭部
} else {
newNode := l.list.Push(lruCacheEntry[T]{ // 插入新的節點到連結串列頭部
key: key, // 快取鍵
value: value, // 快取值
})
l.keyMap[key] = newNode // 更新快取資料
}
if len(l.keyMap) > l.capacity { // 如果快取資料超過容量
nodeRemoved := l.list.RemoveTail() // 刪除連結串列尾部的節點
delete(l.keyMap, nodeRemoved.Data.key) // 刪除快取資料
}
}
Put
方法用於向快取中新增或更新項。它將鍵值對新增到快取中,如果鍵已經存在,則更新該鍵的值並將其移動到連結串列頭部。如果快取超過容量,則移除最舊的項。其中 PUT
方法核心邏輯如下:
-
檢查是否存在:
- 如果快取中已經存在指定的鍵 ,則更新該鍵的快取值 。
- 然後,將更新後的節點移動到連結串列的頭部,以表示它是最近使用的。
-
插入新項:
- 如果快取中不存在該鍵,則將新的鍵值對插入到連結串列的頭部。
- 將新節點新增到雜湊表。
-
移除最久未使用的項:
- 如果快取的長度超過了預設的容量,則刪除連結串列的尾部節點。
- 從雜湊表中移除對應的鍵,以確保快取項的數量不會超過容量限制。
併發安全
對於快取來講,併發安全是必須考慮的因素,這一點 Go
語言提供了非常優雅的解決方案:sync.Mutex
,我們可以對整個快取定義一個 sync.Mutex
,也可以針對每一個 key
定義一個 sync.Mutex
(開銷比較大,沒試過),或者我們採取分組的思想,對於一組 key
定義一個 sync.Mutex
。這裡我採用了最簡單的方式,已 GET
方法為例:
// Get
//
// @Description: 獲取快取中的值
// @receiver l *lruCache[T],LRU快取
// @param key string,快取鍵
// @return value T,快取值
// @return found bool,是否發現
func (l *lruCache[T]) Get(key string) (value T, found bool) {
l.mutex.Lock()
defer l.mutex.Unlock()
if node, ok := l.keyMap[key]; ok { // 如果快取中存在
l.list.MoveToHead(node) // 將節點移動到連結串列頭部
return node.Data.value, ok // 返回快取值
}
var zero T // 零值
return zero, false // 返回零值和false
}
這兩行程式碼可以擴充到其他所有的操作方法上,當然在讓顆粒度更細,對於方法的一部分加鎖,然後解鎖。各位有興趣可以實現一波。
FunTester 原創精華
- 服務端功能測試
- 效能測試專題
- Java、Groovy、Go、Python
- 單元&白盒&工具合集
- 測試方案&BUG&爬蟲&UI
- 測試理論雞湯
- 社群風采&影片合集