快取 LRU 和 LFU 實現

胡云Troy發表於2024-10-25

0. 前言

快取是一個非常大的 topic。常用的快取演算法有 LRU(Latest Recently Used)最近最少使用和 LFU(Latest Frenquency Used)最不經常最少使用演算法。

本文會介紹這兩種演算法,並且給出快取使用的一些介紹。

1. LRU

首先,LRU 是最近最少使用演算法,根據時間的順序淘汰最久沒被使用到的快取。既然是有順序那麼我們在實現過程中就可以用能體現順序性的資料結構來表示 LRU 中的快取。常用的資料結構陣列,連結串列,雜湊表,
樹和圖中先把非線性的樹和圖排除掉,接著雜湊表是無序的,不能滿足我們對順序性的要求,也可以排除。陣列和連結串列都可以表示順序性,但陣列涉及到快取搬移效能就會變差,而快取更新是非常常見的,
這裡使用能表示順序性且更加靈活的連結串列作為快取儲存的資料結構。

對於快取演算法,不僅有新增快取物件到連結串列,也有刪除連結串列中的快取。因此,我們使用雙向連結串列透過 O(1) 的複雜度來實現刪除操作。同時,LRU 涉及到淘汰最久的快取,我們可以指定連結串列頭或者尾作為最久的快取,
相應的連結串列尾或頭就是最新的快取。在快取達到容量時,淘汰連結串列中最久的快取。那麼插入,刪除,更新(刪除,插入)的複雜度都為 O(1) 了。

由於連結串列的限制,當我們要查詢連結串列中的快取時必須遍歷連結串列,複雜度較高。我們知道雜湊表對查詢特別友好,只有 O(1) 的複雜度。透過空間換時間的思想,引入雜湊表來儲存連結串列中的快取。當我們查詢指定快取時就可以先透過
雜湊表確定其在連結串列中的位置。

根據上述思路,可以寫出 LRU 程式碼如下:

type LRUCache struct {
	head, tail *Node
	caches     map[int]*Node
	capacity   int
}

func (c *LRUCache) Get(key int) bool {
	node, ok := c.caches[key]
	if ok {
		c.moveToTail(node)
		return true
	}

	return false
}

// modeToTail is to move the existed node to tail
func (c *LRUCache) moveToTail(node *Node) {
	if c.head == nil {
		fmt.Println("Failed to move the node to tail because the cache is empty")
		return
	}

	if c.tail == node {
		return
	}

	if c.head == node {
		c.head = node.Next
		c.head.Pre = nil
		node.Next = nil
	} else {
		node.Pre.Next = node.Next
		node.Next.Pre = node.Pre
		node.Pre, node.Next = nil, nil
	}

	node.Pre = c.tail
	c.tail.Next = node
	c.tail = node
}

func (c *LRUCache) removeHead() {
	delete(c.caches, c.head.key)

	if c.head == nil {
		fmt.Println("Failed to move the head node because the cache is empty")
		return
	}

	if c.head == c.tail {
		c.head, c.tail = nil, nil
		return
	}

	c.head = c.head.Next
	c.head.Pre.Next = nil
	c.head.Pre = nil
}

func (c *LRUCache) addToTail(node *Node) {
	if c.head == nil {
		c.head, c.tail = node, node
		return
	}

	node.Pre = c.tail
	c.tail.Next = node
	node.Next = nil
	c.tail = node

	if c.len() > c.capacity {
		c.removeHead()
	}
}

func (c *LRUCache) Put(key int, value int) {
	node, ok := c.caches[key]
	if ok {
		node.value = value
		c.moveToTail(node)
		return
	}

	newNode := &Node{key: key, value: value}
	c.addToTail(newNode)
	c.caches[key] = newNode
}

1.1 併發 LRU

進一步的我們想能不能實現併發的 LRU。當我們想併發時,就會想引入讀寫鎖或者互斥鎖。假設這裡訪問快取是讀多寫少的場景,我們使用讀寫鎖防止競態發生。

確定讀寫鎖,鎖的粒度又是問題,要加多大的鎖。簡單粗暴點可以每個大的讀操作加讀鎖,每個大的寫操作加寫鎖,如 concurrency LRU with coarse locking 所示。
更細化點可以把程式碼拆分更細粒度的加讀寫鎖,如 concurrency LRU with fine-grained locking 所示。

加粗鎖示例中,對每個讀寫操作加同一把大鎖。加細粒度鎖中,對於 map 的訪問,引入 sync.map 併發訪問,sync.map 內包含兩個 map,一個讀 map,一個寫 map,每個 map 都是併發安全的,這是一種對讀友好的 map。接著,給快取引入一把鎖,主要是給連結串列的操作加鎖,這裡根據每個步驟,更細化的加鎖。

粗鎖和細鎖並沒有絕對的誰好誰壞。加粗鎖,具有更少的協程切換,減少了 Go 執行時排程器的壓力,但是由於加的鎖粒度粗,多協程在序列的等待同一把鎖,導致效能下降。相對的,細瑣如果加的太過連續,會導致頻繁的協程切換,增加排程器的壓力,
同時如果太過連續也起不到太好的併發執行的效果。

image

使用什麼粒度的鎖要結合具體程式碼具體分析,如果像上圖 case3 的例子一樣,那用細粒度的鎖是比較適合的。

繼續參考 霜神的併發版本,使用分片 map 來從訪問物件上分流協程,提高併發效率。

分片的思路是把協程訪問的 map 分片成更加細粒度的 map,每個協程根據訪問的 key 確定自己要訪問哪個 map,這樣從資料結構上分流了協程,各協程之間不干擾。當然,每個分片 map 還是要加鎖的,這個鎖是為了訪問到同樣
分片 map 的協程而加,防止分片 map 的競態。

示意圖如下:

image

圖片來源於 面試中 LRU / LFU 的青銅與王者

使用分片 map 可以分流協程,但是協程操作的連結串列還是共享的,我們需要對連結串列加鎖。

這裡有一個問題,可以使用通道來避免對連結串列加鎖嗎?筆者個人覺得應該不行,協程從通道讀取的資料還是要作用在共享的連結串列上,通道是實現了協程之間訪問的有序性,但是沒有限制訪問共享資源的有序性。邏輯可能有點繞,舉個例子如下:

func main() {
	var x = make(chan int, 10)
	var c = 0

	for i := 0; i < 10; i++ {
		go func(i int) {
			x <- i
		}(i)
	}

	for i := 0; i < 10; i++ {
		go func(c *int) {
			for n := range x {
				*c = *c + n
				fmt.Printf("%d ", *c)
			}
		}(&c)
	}

	time.Sleep(1 * time.Second)
	fmt.Println()
}

輸出:

1 4 6 10 15 1 38 32 24 45

上述示例中,多個協程從通道中讀取傳送協程傳送的變數,並且和共享變數 c 相加。如果沒有競爭的話,我們希望輸出是不一致的,結果顯示多執行緒讀完資料之後發生競態(輸出兩個相同的 1)。

所以,我們這裡沒有通道還是老老實實的給連結串列加鎖,那麼主要的提高效能的地方就是 map 的分片,實現見 這裡

給 map 分片是透過不同的 key 定位協程訪問到不同的分片 map 中,這是透過雜湊實現的,如下所示:

h := fnv.New32a()
h.Write([]byte(strconv.Itoa(key)))
return h.Sum32() & mask

2. LFU

LFU 是最近最不常用快取淘汰演算法。LFU 是根據使用頻率進行快取淘汰的演算法,而不是基於時間的,是時間不敏感的淘汰演算法。

既然是根據頻率來進行淘汰的,每個快取物件需要維護一個使用頻率的引用計數,用來指示當前快取物件使用了多少次。每種次數(頻率)都可以有多個快取物件。同樣的,需要快速查詢到快取物件,使用雜湊表儲存每個快取物件。

透過每種頻率有多個快取物件,很自然的想到可以結合陣列/map 和連結串列來表達這一結構。示意圖如下:

image

這裡需要透過頻率快速訪問到對應的連結串列,使用 map 加連結串列的方式表徵該結構更合適。

給出 LFU 程式碼如下:

type LFUCache struct {
	lists    map[int]*list.List
	nodes    map[int]*list.Element
	min      int
	capacity int
}

type node struct {
	key, value int
	frenquency int
}

func (c *LFUCache) updateNode(n *node) {
	c.lists[n.frenquency].Remove(c.nodes[n.key])

	if n.frenquency == c.min && c.lists[c.min].Len() == 0 {
		c.min++
	}

	n.frenquency++
	if _, ok := c.lists[n.frenquency]; !ok {
		c.lists[n.frenquency] = list.New()
	}

	ne := c.lists[n.frenquency].PushFront(n)
	c.nodes[n.key] = ne
}

func (c *LFUCache) Get(key int) bool {
	e, ok := c.nodes[key]
	if ok {
		n := e.Value.(*node)
		c.updateNode(n)
		return true
	}

	return false
}

func (c *LFUCache) Put(key int, value int) {
	e, ok := c.nodes[key]
	if ok {
		n := e.Value.(*node)
		c.updateNode(n)
		return
	}

	if len(c.nodes) == c.capacity {
		e := c.lists[c.min].Back()
		c.lists[c.min].Remove(e)
		delete(c.nodes, e.Value.(*node).key)
	}

	c.min = 1
	if _, ok = c.lists[1]; !ok {
		c.lists[1] = list.New()
	}

	n := &node{key: key, value: value, frenquency: 1}
	e = c.lists[1].PushFront(n)
	c.nodes[key] = e
}

func New(capacity int) *LFUCache {
	return &LFUCache{
		lists:    make(map[int]*list.List),
		nodes:    make(map[int]*list.Element),
		capacity: capacity,
	}
}

引申:由於刪除只會刪除最少使用頻次的,我們可以透過維護一個最小堆代替連結串列。這裡也可以在思考下,為什麼不能用最小堆代替 LRU 中的連結串列呢?

3. LRU 和 LFU 對比

特性 LRU LFU
淘汰策略 最近最少使用 最少使用頻率
時間區域性性適用性
頻率區域性性適用性
適合的訪問模式 近期資料更有可能被再次訪問 頻繁訪問的資料應被長期儲存
實現複雜度 較低 較高
優缺點 更簡單高效,但對頻率支援較差。如果有一個熱資料訪問頻率高但不在最近訪問,
則會被淘汰
更適合頻繁訪問,但實現複雜對於“突發訪問”可能不夠靈活,
因為短期內高頻訪問的資料可能會被快取為“高頻”,而後續訪問頻率降低時依然佔據快取。

選擇策略的依據

  • LRU:適合大多數對快取要求不高的場景,且最近訪問的資料在未來更可能被訪問。
  • LFU:適合需要長時間儲存高頻資料的場景。

4. 快取場景

當我們構建好快取演算法後,有一些快取的場景需要在注意的。

4.1 快取擊穿

如果多流量訪問快取中某一個資料,而該資料不存在,這些快取將擁到資料庫,試圖從資料庫讀取資料。如果這時候未對訪問進行限制,可能會導致資料庫響應緩慢甚至無法響應正常請求等問題。這種場景被稱為快取擊穿。我們可以透過在訪問資料庫時,為訪問加鎖來限制多執行緒。同時,在訪問之後,更新該資料到快取,在把流量引導到快取中讀取資料。這是一種雙向檢測技術,示例程式碼如下:

func (c *Cache) GetOrSet(key string, duration time.Duration, wg *sync.WaitGroup) {
	defer wg.Done()

    // step1: 重試
	retryTimes := 5
	for i := 0; i < retryTimes; i++ {
		value, found := c.Get(key)
		if found {
			fmt.Printf("Cache hit for [%s:%s]\n", key, value)
			return
		}
		fmt.Printf("Cache miss for key: %s\n", key)
	}

	// add lock from caller
    // step2: 加鎖,從資料庫讀取資料
	c.dbLock.Lock()

    // step3: 再讀快取中的資料
	value, found := c.Get(key)
	if found {
		fmt.Printf("Cache hit for [%s:%s]\n", key, value)
		c.dbLock.Unlock()
		return
	}

    // 從資料庫中讀取資料,並更新至快取
	value = queryFromDB(key)
	c.Set(key, value, duration)
	fmt.Printf("Updated cache for key: %s\n", key)
	c.dbLock.Unlock()
}

4.2 快取穿透

大流量或者惡意流量請求訪問快取而找不到資料,會擁到資料庫請求,這種場景叫做快取穿透。這時候因為併發量很大,即使我們加鎖很有可能會導致正常的資料庫請求難以響應。並且,如果這些流量是惡意流量,即刻意構造大量不存在的資料,那麼按照快取擊穿的邏輯,把資料庫中不存在的資料載入到快取中會引起快取很多無效資料。

我們可以在加鎖的基礎上引入布隆過濾器用來過濾惡意流量。布隆過濾器會同步資料庫和自身,保證查詢的是實時的資料庫資訊。

如果布隆過濾器判斷請求資料不存在,則直接返回,起到攔截大流量,惡意流量的目的。示例程式碼如下:

// 布隆過濾器 + 快取的實現
type CacheWithBloom struct {
	Cache3
	Bf *bloom.BloomFilter
}

// 建立帶有布隆過濾器的快取
func NewCacheWithBloom() *CacheWithBloom {
	// 初始化布隆過濾器,假設有 1000 個元素,錯誤率為 0.01
	bf := bloom.New(1000*20, 5) // 5 個雜湊函式
	return &CacheWithBloom{
		Cache3: *NewCache3(),
		Bf:     bf,
	}
}

// 高併發獲取資料,使用布隆過濾器防止快取穿透
func (c *CacheWithBloom) GetOrSet(key string, duration time.Duration, wg *sync.WaitGroup) {
	defer wg.Done()

	// 使用布隆過濾器判斷 key 是否有可能存在
	if !c.Bf.TestString(key) {
		fmt.Printf("Bloom filter: Key %s does not exist, skipping DB query\n", key)
		return
	}

	// 先從快取獲取資料
	value, found := c.Get3(key)
	if found {
		fmt.Printf("Cache hit for key: %s\n", key)
		return
	}

	// 如果快取未命中,模擬從資料庫載入資料
	fmt.Printf("Cache miss for key: %s, querying from DB\n", key)
	value = queryFromDB(key)

	// 更新快取
	c.Set3(key, value, duration)

	// 同時將 key 新增到布隆過濾器中
	c.Bf.AddString(key)

	fmt.Printf("Updated cache for key: %s and added to Bloom filter\n", key)
}

4.3 快取雪崩

如果大量快取資料在同一時間點失效,那麼請求的流量會擁到資料庫請求,導致伺服器後端或者資料庫產生巨大壓力,造成響應變慢,甚至不可用的場景,這種場景叫做快取雪崩。

避免快取雪崩,我們可以透過在過期時間點上加上隨機區間,防止資料在同一時間過期。

同時,我們可以甚至多級快取防止雪崩。

在快取的場景設計上,可以結合這三種場景進行設計,給出示意圖如下:

image


相關文章