详解缓存淘汰策略:2q

89 阅读5分钟

b146c2c86276ad71e35869515360aa75.png

前言

前一篇文章介绍了缓存淘汰策略LRU,其中有个缺点为:

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

例如:

  1. 假设缓存大小 = 3,现有数据 [A, B, C]
  2. 突然访问 [D, E, F],LRU 会淘汰 [A, B, C],即使[D, E, F] 只会访问这么一次,之后不会在访问了,而[A, B, C]是长期热点数据
    1. 很显然,这会降低缓存命中率

2q策略能解决这一问题

下面对经典开源库 github.com/hashicorp/g… 的2q实现进行源码走读,版本:v2.0.7

数据结构

2q由以下结构组成:

  • recent(LRU结构):存储首次访问的冷数据
  • frequent(LRU结构):存储被频繁访问的热数据
  • recentEvict (LRU结构):存储进去被淘汰的key

image.png


LRU 的主要缺陷在于:突发访问会导致新数据瞬间挤占热点数据的缓存空间,而 2Q 通过冷热数据分离机制解决该问题:

  • 首次访问的数据进入 recent(冷数据队列),不会直接影响 frequent(热数据队列)。
  • 只有被二次访问的数据才会从 recent 晋升frequent,避免一次性访问数据污染热点缓存

例如:

  1. 假设缓存大小 = 5,现有数据 [A, B, C, D, E],其中[A]属于recent,剩下的[B, C, D, E]属于frequent
  2. 突然访问 [F, G, H],LRU 会淘汰 [A, F, G]
    1. 因为冷数据只有1个空间,使得原来的冷数据[A]被淘汰,新加的冷数据[F,G]也马上被淘汰
  3. 最后缓存剩下:[H, B, C, D, E]。也就是原来frequent中的热数据[B, C, D, E]还在缓存中,没有被冷数据替换

某些2q的实现增加了recentEvict(也是一个LRU结构)。其作用是记录最近被驱逐(淘汰)的缓存条目(称为 ghost entries),以便在这些条目再次被访问时能够快速判断并将其重新提升到频繁使用队列(frequent)中

  1. 当一个条目从 recent 队列被淘汰时,它的键会被加入到 recentEvict 中,作为“被驱逐的历史记录”
  2. 在 Add 和 Get 操作中,如果发现某个键存在于 recentEvict 中,说明该键曾被缓存但刚刚被淘汰。这表明该键具有一定的访问热度,因此会触发“提升”行为:将该键从 recentEvict 中移除,直接添加到 frequent 队列中

recentEvict也是一个LRU结构,不会永久保留所有被淘汰的条目。当它自身容量不足时,会根据 LRU 算法淘汰最久未使用的键

recentEvict的优点体现在:

  • 减少误淘汰:通过 ghost entries 判断哪些被淘汰的条目仍具有访问热度
  • 提升缓存命中率:将再次访问的 ghost entry 快速恢复为频繁使用条目

type TwoQueueCache[K comparable, V any] struct {
     // 表示整个 2Q 缓存的最大容量
     size int
     // 表示 recent 队列的最大容量。 
     recentSize int
     // 表示 recent 队列在总容量中的占比,默认为25% 
     recentRatio float64
     // 表示 ghost entries 在总容量中的占比,默认为50% 
     ghostRatio float64

     // 存储最近添加或访问过一次的条目。 
     recent simplelru.LRUCache[K, V]
     // 存储频繁访问的条目。 
     frequent simplelru.LRUCache[K, V]
     // 记录最近被驱逐的条目(称为 ghost entries)。 
     recentEvict simplelru.LRUCache[K, struct{}]
     lock        sync.RWMutex
}

初始化:

func New2Q[K comparable, V any](size int) (*TwoQueueCache[K, V], error) {
     // Default2QRecentRatio = 0.25
     // Default2QGhostEntries = 0.5
     return New2QParams[K, V](size, Default2QRecentRatio, Default2QGhostEntries)
}

分别对recent,frequent,recentEvict初始化为3个LRU结构 其中recent默认容量为总容量size的25%,recentEvict默认容量为size的50%

func New2QParams[K comparable, V any](size int, recentRatio, ghostRatio float64) (*TwoQueueCache[K, V], error) {
   
    recentSize := int(float64(size) * recentRatio)
    evictSize := int(float64(size) * ghostRatio)

    // Allocate the LRUs
    recent, err := simplelru.NewLRU[K, V](size, nil)
    if err != nil {
       return nil, err
    }
    frequent, err := simplelru.NewLRU[K, V](size, nil)
    if err != nil {
       return nil, err
    }
    recentEvict, err := simplelru.NewLRU[K, struct{}](evictSize, nil)
    if err != nil {
       return nil, err
    }

     // Initialize the cache
     c := &TwoQueueCache[K, V]{
       size:        size,
       recentSize:  recentSize,
       recentRatio: recentRatio,
       ghostRatio:  ghostRatio,
       recent:      recent,
       frequent:    frequent,
       recentEvict: recentEvict,
    }
    return c, nil
}

添加KV

image.png

func (c *TwoQueueCache[K, V]) Add(key K, value V) {
    c.lock.Lock()
    defer c.lock.Unlock()

    // 首先检查键是否已经存在于 frequent 队列中。 
    // 如果存储,更新其value。内部会将entry更新到LRU结构中,双向链表头部
    if c.frequent.Contains(key) {
       c.frequent.Add(key, value)
       return
    }

    // 如果键不在 frequent 队列中,则检查其是否存在于 recent 队列中。 
    // 如果存在,则从 recent 队列中移除该条目,并将其添加到 frequent 队列中
    if c.recent.Contains(key) {
       c.recent.Remove(key)
       c.frequent.Add(key, value)
       return
    }

    // 再检查其是否存在于 recentEvict 中
    // 如果存在,说明该键曾被缓存但刚刚被淘汰,表明该键具有一定的访问热度:并将该条目从 recentEvict 中移  除,然后添加到 frequent 队列中
    if c.recentEvict.Contains(key) {
       c.ensureSpace(true)
       c.recentEvict.Remove(key)
       c.frequent.Add(key, value)
       return
    }

    // Add to the recently seen list
    c.ensureSpace(false)
    c.recent.Add(key, value)
}

ensureSpace方法:确保空间足够,可能要从recent 或frequent 腾出一个空间

func (c *TwoQueueCache[K, V]) ensureSpace(recentEvict bool) {
recentLen := c.recent.Len()
    freqLen := c.frequent.Len()
    // 计算当前 recent 和 frequent 队列中条目的总数,如果小于缓存的最大容量 size,则无需淘汰条目,直接返回
    if recentLen+freqLen < c.size {
       return
    }

    // 接下来就算需要淘汰
    // 如果recentLen超过限制了,从recent 队列中移除一个条目
    if recentLen > 0 && (recentLen > c.recentSize || (recentLen == c.recentSize && !recentEvict)) {
       k, _, _ := c.recent.RemoveOldest()
       // 加入到recentEvict中
       c.recentEvict.Add(k, struct{}{})
       return
    }

    // 否则从frequent移除一个条目
    c.frequent.RemoveOldest()
}

查询KV

image.png


func (c *TwoQueueCache[K, V]) Get(key K) (value V, ok bool) {
    c.lock.Lock()
    defer c.lock.Unlock()

    // 首先尝试从 frequent 队列中查找键对应的值
    if val, ok := c.frequent.Get(key); ok {
       return val, ok
    }

    // 如果在 frequent 队列中未找到,则尝试从 recent 队列中查找
    if val, ok := c.recent.Peek(key); ok {
    // 如果存在,则从 recent 队列中移除该条目,并将其添加到 frequent 队列中
    // 这种行为称为“提升”(promotion),表示该条目已经多次被访问,因此需要升级为频繁使用的条目
    c.recent.Remove(key)
       c.frequent.Add(key, val)
       return val, ok
    }

    // No hit
    return
}