FunTester Go 语言手写本地 LRU 缓存

FunTester · 2024年08月12日 · 2547 次阅读

缓存和 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 原创精华
如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!
暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册