Go 語言手寫本地 LRU 快取

FunTester發表於2024-08-12

快取和 LRU 演算法

快取是計算機程式設計中的一種技術,用於臨時儲存資料以加速訪問和提高效能。快取是提高系統效能、減少延遲和最佳化使用者體驗的重要工具。合理使用快取技術,開發人員可以構建更加高效和可靠的應用程式。快取的重要性體現在以下方面:

首先,快取透過減少資料讀取時間來提升系統效能。當應用程式頻繁訪問某些資料時,直接從原始資料來源讀取會花費大量時間。將常用資料儲存在快取中,系統可以更快速地訪問所需資料,從而提高響應速度和使用者體驗。其次,快取減少了伺服器負載。在高併發環境中,多個使用者同時請求相同資料會導致伺服器壓力增大。快取透過本地儲存資料,減少對伺服器的重複請求,從而減輕伺服器負載,提高系統的整體穩定性。

在實現快取時,選擇合適的快取替換策略至關重要。LRU(Least Recently Used,最近最少使用)演算法是一種常用的快取替換策略。它透過優先移除最久未使用的資料,確保頻繁訪問的資料保留在快取中,從而提高快取的命中率和使用效率。LRU 演算法在記憶體受限的環境中尤為有效,可以顯著提升系統效能。

除了 LRU(Least Recently Used)演算法,快取中還常用以下幾種演算法:

  1. FIFO(First In, First Out):FIFO 演算法按照資料進入快取的順序來管理快取資料。當快取滿時,最早進入快取的資料會被最先移除。這種演算法實現簡單,但並不考慮資料的訪問頻率和最近的使用情況,可能會導致效能不佳。
  2. LFU(Least Frequently Used):LFU 演算法透過移除訪問頻率最低的資料來管理快取。它統計每個資料項的訪問次數,當快取需要騰出空間時,會優先移除訪問次數最少的資料。LFU 演算法適用於那些某些資料項被頻繁訪問的場景,但實現複雜度較高。
  3. ARC(Adaptive Replacement Cache):ARC 演算法是 IBM 提出的一種自適應快取替換策略,它結合了 LRU 和 LFU 的優點。ARC 透過動態調整 LRU 和 LFU 兩個快取列表的大小,來適應不同的工作負載,提升快取命中率。ARC 演算法在實際應用中表現出色,但實現較為複雜。
  4. MRU(Most Recently Used):MRU 演算法與 LRU 相反,它移除最近使用的資料。MRU 適用於某些特殊情況,例如,當快取資料被頻繁重新生成或重新整理時,最近使用的資料往往是最不重要的。

Go 語言快取

在專案開發當中,通常我們會選擇合適的成熟的快取框架,例如將資料存在 Redismemcache ,包括之前我寫過的 Java 高效能本地快取 Caffeine 。對於 Go 語言來講,例如 go-cachebigcacheristretto 。但這些都不是今天的重點,今天我們將手寫一個 ==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 方法核心邏輯如下:

  1. 檢查是否存在
    • 如果快取中已經存在指定的鍵 ,則更新該鍵的快取值 。
    • 然後,將更新後的節點移動到連結串列的頭部,以表示它是最近使用的。
  2. 插入新項
    • 如果快取中不存在該鍵,則將新的鍵值對插入到連結串列的頭部。
    • 將新節點新增到雜湊表。
  3. 移除最久未使用的項
    • 如果快取的長度超過了預設的容量,則刪除連結串列的尾部節點。
    • 從雜湊表中移除對應的鍵,以確保快取項的數量不會超過容量限制。

併發安全

對於快取來講,併發安全是必須考慮的因素,這一點 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
  • 測試理論雞湯
  • 社群風采&影片合集
如果覺得我的文章對您有用,請隨意打賞。您的支援將鼓勵我繼續創作!
打賞支援
暫無回覆。

相關文章