详解缓存淘汰策略:LFU

153 阅读6分钟

b146c2c86276ad71e35869515360aa75.png

前言

前面两篇文章介绍了缓存淘汰策略中的LRU,2q

2q解决了LRU的突发访问污染问题:如果短时间内有大量新数据涌入(例如全表扫描,批量查询),LRU 会快速淘汰旧的热点数据,导致缓存命中率骤降

但这两者都没考虑缓存的访问频率,仅考虑数据的最近访问时间,淘汰最久未被访问的数据。这可能导致某些高频访问但近期未被使用的数据被错误淘汰


数据结构

本文介绍第三种缓存淘汰策略:LFU

LFU(Least Frequently Used)是一种基于 访问频率 的缓存淘汰策略,其核心思想是:

  • 优先淘汰访问次数最少的数据
  • 若频率相同
    • 可以随机淘汰一个(本文介绍这种方式)
    • 也可以在频率相同的KV里,淘汰最久未使用的数据,也就是按LRU淘汰

本文将针对开源库github.com/bluele/gcac… 的LFU实现进行整体介绍,源码走读。

这个库目前2.7k star,算是能找到的LFU实现里还不错的


LFU由以下核心结构组成:

  • 哈希表items:从 key 到缓存项lftItem的映射
  • 每个缓存项lfuItem:存储key,value,所属的freqEntry
    • 为啥需要freqEntry?下面介绍读流程时再详细说明
  • 双向链表freqList:其每个节点freqEntry表示一个访问频率等级(如 freq=1、freq=2 等等),链表按频率递增排序。每个节点freqEntry结构有:
    • 频率freq:当前频率值(取值为1,2,3...)
    • 哈希表items:存储属于该频率的所有缓存项,类型为map[*lfuItem]struct{}

image.png

落实到代码层面,结构为:

type LFUCache struct {
     // ...
     // key到lfuItem的映射
     items map[interface{}]*lfuItem

     // 每个 freqEntry 表示一个访问频率等级(如 freq=1, freq=2...)。 
     // items map[*lfuItem]struct{} 存储属于该频率的所有缓存项
     /**
        LFUCache
        ├── items                // key -> *lfuItem
        └── freqList             // list<*freqEntry>
        └── freqEntry     // freq=1
        └── items     // map[*lfuItem]struct{}
        └── freqEntry     // freq=2
        └── items
     */
    freqList *list.List  // list for freqEntry
}

其中lfuItem定义如下:

type lfuItem struct {
    // ...
    key         interface{}
    value       interface{}
    // 属于哪个freqEntry ,用于直接定位到freqEntry
    freqElement *list.Element
    // ...
}

初始化时new好所有结构

func (c *LFUCache) init() {
    c.freqList = list.New()
    c.items = make(map[interface{}]*lfuItem, c.size)
     // 初始化生成一个freq=0的freqEntry,该freqEntry会一直存在
     c.freqList.PushFront(&freqEntry{
       freq:  0,
       items: make(map[*lfuItem]struct{}),
    })
}

读写流程

image.png

  1. 通过哈希表items快速查找 key 对应的缓存项 (O(1))
  2. 如果找到且未过期,则调用频率更新逻辑increment方法增加该项的访问频率,并返回值

频率更新逻辑 (increment):

  1. 将缓存项从当前频率等级中移除,并添加到下一个频率等级中 (O(1))
    1. 如果移除后当前频率等级为空,则将其从 freqList 中删除,以节约内存 (O(1))
    2. 如果下一个频率等级freqEntry不存在,或者freqEntry.freq不是当前freq+1,那就新建一个freqEntry,然后往里面插入当前缓存项item (O(1))

时间复杂度:查找和频率更新的时间复杂度均为 O(1),非常高效

下面进行读流程的源码走读:

func (c *LFUCache) getValue(key interface{}, onLoad bool) (interface{}, error) {
     c.mu.Lock()
     item, ok := c.items[key]
     // 存在
     if ok {
        // 没有过期
        if !item.IsExpired(nil) {
           // 调用 c.increment(item) 增加该项的访问频率
          c.increment(item)
          v := item.value
          c.mu.Unlock()
          if !onLoad {
             c.stats.IncrHitCount()
          }
          return v, nil
       }
       c.removeItem(item)
    }
    c.mu.Unlock()
    if !onLoad {
       c.stats.IncrMissCount()
    }
    return nil, KeyNotFoundError
}

其中核心方法increment流程如下:

这里体现了为啥要在lfuItem存储频率节点freqEntry:通过key从哈希表item定位到lfuItem后,可以快速定位到在哪个频率节点中,以便快速从老的频率节点中删除

func (c *LFUCache) increment(item *lfuItem) {
     // 拿到当前的freqEntry
    currentFreqElement := item.freqElement
    currentFreqEntry := currentFreqElement.Value.(*freqEntry)

     // 计算下一个频率值
    nextFreq := currentFreqEntry.freq + 1
     //  从当前频率条目中移除缓存项
    delete(currentFreqEntry.items, item)

     // 是否可删除currentFreqEntry?currentFreqEntry.items为空时可以删除
    removable := isRemovableFreqEntry(currentFreqEntry)

     // insert item into a valid entry
    nextFreqElement := currentFreqElement.Next()
    switch {
       // 需要创建新的FreqElement: 没有nextFreq的频率map时
       case nextFreqElement == nil || nextFreqElement.Value.(*freqEntry).freq > nextFreq:
       if removable {
           // 复用之前的FreqElement
          currentFreqEntry.freq = nextFreq
          nextFreqElement = currentFreqElement
       } else {
             // 添加一个新的FreqElement
             nextFreqElement = c.freqList.InsertAfter(&freqEntry{
             freq:  nextFreq,
             items: make(map[*lfuItem]struct{}),
          }, currentFreqElement)
       }
       // 不需要创建新的
       case nextFreqElement.Value.(*freqEntry).freq == nextFreq:
       if removable {
          c.freqList.Remove(currentFreqElement)
       }
    default:
       panic("unreachable")
    }
    
    // 往新的freqEntry中插入lfuItem
    nextFreqElement.Value.(*freqEntry).items[item] = struct{}{}
    item.freqElement = nextFreqElement
}

image.png

  1. 如果 key在哈希表items中已存在,则直接更新对应的 value 值 (O(1))
  2. 否则就需要插入新缓存项:
    1. 如果缓存未满,直接插入到 哈希表items中,并将其加入 freqList 中频率最低(即 freq=0)的 freqEntry.items中 (O(1))
    2. 如果缓存已满,调用 evict 方法淘汰随机淘汰频率最低的一个缓存项,然后插入新缓存项
      1. 具体做法:取出freqList的头节点,从其items中随机选择一个item删除 (O(1))

时间复杂度:在哈希表和双向链表的配合下,插入和更新的时间复杂度为O(1)

写流程的源码走读如下:

func (c *LFUCache) set(key, value interface{}) (interface{}, error) {
    
     // 检查是否已存在相同的 key
    item, ok := c.items[key]
    if ok {
        // 如果存在,直接更新value
        item.value = value
    } else {
        // 不存在相同key,那就要创建个新的
        // 如果容量满了,随机淘汰一个频率最低的
       if len(c.items) >= c.size {
          c.evict(1)
       }
       item = &lfuItem{
          clock:       c.clock,
          key:         key,
          value:       value,
          freqElement: nil,
       }
       el := c.freqList.Front()
       fe := el.Value.(*freqEntry)
        // 把当前item添加到频率为0的freqEntry中
       fe.items[item] = struct{}{}

       item.freqElement = el
       c.items[key] = item
    }

    return item, nil
}

删除一个频率最低的缓存项:

func (c *LFUCache) evict(count int) {
     // 从最低频率的map中,随机挑选一个淘汰
    entry := c.freqList.Front()
    for i := 0; i < count; {
       if entry == nil {
          return
       } else {
          for item := range entry.Value.(*freqEntry).items {
             if i >= count {
                return
             }
             c.removeItem(item)
             i++
          }
          entry = entry.Next()
       }
    }
}

具体删除一个KV的流程如下:

func (c *LFUCache) removeItem(item *lfuItem) {
    entry := item.freqElement.Value.(*freqEntry)
     // 从items中删除
     delete(c.items, item.key)
     // 从freqEntry中删除
    delete(entry.items, item)
    if isRemovableFreqEntry(entry) {
        // 如果当前freqEntry.items为空了,把该频率map也删除
        c.freqList.Remove(item.freqElement)
    }
}

优缺点

最后分析LFU缓存淘汰策略的优缺点

  • 优点:
    • 稳定性:对于过往高频,近期低频的数据,LFU能在缓存中很好地保留下来。避免因为短期波动,导致热数据被错误淘汰
    • 比 LRU 更能抵抗突发流量污染:因为突发访问大概率就访问一次,往往频率增加不多,很快会被真正高频的项挤掉
  • 同时LFU也有其局限性:
    • 历史频率权重问题: 稳定性同时是一把双刃剑:一个过去访问频率很高但最近不再访问的项,其频率计数会一直很高,长期占据缓存无法被淘汰,即使它已经不再有用。同时短期内的热点也无法在缓存中长留,导致缓存命中率不高
    • 冷启动问题:新加入的数据初始访问频率低,即使未来会成为热点,也可能被快速淘汰