本地缓存库分析(一):golang-lru

在这里插入图片描述

本系列

本地缓存概览

在业务中,一般会将极高频访问的数据缓存到本地。以减少网络IO的开销,下游服务的压力,提高性能

一般来说能放到本地的数据需要满足下面两个限制:

  1. 数据量不是非常大:数据量大了本地内存撑不住
  2. 一致性,时效性要求不是非常高:毕竟多个服务的本地缓存很难做到同步更新,及时更新

如果用go自带的map实现本地缓存,大概有两种实现方式:

  1. sync.Map
  2. map + mutex.RWLock

但有以下缺点:

  1. 锁竞争严重
  2. 大量缓存写入,导致gc标记阶段占用cpu多
  3. 内存占用不可控
  4. 不支持缓存按时效性淘汰
  5. 不支持缓存过期
  6. 缓存数据可以被污染:如果缓存的V是指针,那么业务修改了V的某个值为当前请求用户自己的值,在缓存中的V就被污染了

本系列要介绍的开源缓存库如何解决上述问题?

  1. 大map拆分成多个小map,每个小map使用各自的锁

  2. 零GC:

    1. 使用堆外内存,不把对象放到堆上,自然不会被gc扫描。但要注意手动管理,需要及时释放内存

    2. map的非指针优化

      1. 如果kv都没有指针,不会扫描map。注意常用作key的string类型含有指针,会被gc扫描
      2. 将hash值作为key,value在底层数组中的offset作为value,这样KV都是int,就不会被GC扫描了
  3. 内存占用可控

    1. 初始化时制定好底层数组的容量,数据写满时会覆写,这样永远不会超过容量
    2. 或者指定好最多能放多少KV对,但如果V大小不一,极端情况下内存占用会很大
  4. 支持缓存按时效性淘汰,例如使用LRU算法

  5. 支持数据过期

    1. 某些库在后台启定时任务,定时清理过期的KV
    2. 某些库会在Get时,惰性检查KV是否过期
  6. 避免缓存污染:存储Value序列化后的字节数组,而不是指针

    1. 但cpu开销会增大,每次写入缓存都要经过序列化,每次从缓存读都要经过反序列化。内存开销也变大,每次读都相当于拷贝一份出来
    2. 也就是用性能换取安全性

golang-lru

本文阅读源码:https://github.com/hashicorp/golang-lru,版本:v2.0.7

该库提供了3种LRU的实现:

  • lru:标准lru

  • 2q:类似mysql的buffer pool,分为冷数据热数据两部分。如果某对KV只被添加到缓存中,而没有被查询,那么只会待在冷数据区域直到被淘汰,而不会占用热数据的空间

    • 为啥要冷热分离?考虑这样一种场景:假设缓存中有10条热点数据,突然有用户往缓存中写10条冷数据,因为容量不够,要淘汰掉所有的热数据。如果之后也不查这些冷数据,而是继续查之前的热数据。产生的后果是所有热数据都会cacheMiss
    • 如果应用了冷热分离机制,这些冷数据只会写到冷数据区,然后在冷数据区被淘汰,而不会占用热数据的空间,避免了大量热数据的cacheMiss
  • expirable_lru:支持过期时间的lru


标准lru

数据结构:包含一个双向链表和hash表
在这里插入图片描述

type LRU[K comparable, V any] struct {
   
   
	// 容量
	size int
	// entry越靠近头部,越新
	evictList *internal.LruList[K, V]
    // hash表
	items     map[K]*internal.Entry[K, V]
	onEvict   EvictCallback[K, V]
}

LruList就是个带哨兵头节点root的双向链表,每个节点是Entry结构。哈希表items的value也是Entry机构

那么真正的头节点是root.Next

尾节点是root.Prev

type LruList[K comparable, V any] struct {
   
   
	// 哨兵entry
	root Entry[K, V]

	// 已经放了多少entry
	len int
}

entry结构如下:

type Entry[K comparable, V any] struct {
   
   
    // 前后指针
	next, prev *Entry[K, V]

	// 属于哪个list,主要用于遍历链表时,在lru算法中没啥用
	list *LruList[K, V]

	Key K

	Value V
}

初始化时root自己先形成一个环:

func (l *LruList[K, V]) Init() *LruList[K, V] {
   
   
	l.root.next = &l.root
	l.root.prev = &l.root
	l.len = 0
	return l
}

下面介绍一些在LRU算法中会用到的小方法

获取最后一个节点:

func (l *LruList[K, V]) Back() *Entry[K, V] {
   
   
	if l.len == 0 {
   
   
		return nil
	}
	return l.root.prev
}

将entry添加到头部:

func (l *LruList[K, V]) PushFront(k K, v V) *Entry[K, V] {
   
   
	l.lazyInit()
	return l.insertValue(k, v, time.Time{
   
   }, &l.root)
}
func (l *LruList[K, V]) insertValue(k K, v V, expiresAt time.Time, at *Entry[K, V]) *Entry[K, V] {
   
   
	return l.insert(&Entry[K, V]{
   
   Value: v, Key: k, ExpiresAt: expiresAt}, at)
}

就是普通的双链表操作,将e插到at的后面

func (l *LruList[K, V]) insert(e, at *Entry[K, V]) *Entry[K, V] {
   
   
	e.prev = at
	e.next = at.next
	e.prev.next = e
	e.
golang-lruGolang 语言实现的 LRU 缓存,它提供了个简单易用的 API 用于创建和使用 LRU 缓存。 下面是个简单的使用示例: ```go package main import ( "fmt" "github.com/hashicorp/golang-lru" ) func main() { // 创建LRU 缓存,容量为 2 cache, _ := lru.New(2) // 添加两个元素到缓存中 cache.Add("key1", "value1") cache.Add("key2", "value2") // 从缓存中获取个元素 if v, ok := cache.Get("key1"); ok { fmt.Println(v.(string)) } // 添加个新元素到缓存中,此时缓存容量已满,会自动淘汰最久未使用的元素 "key2" cache.Add("key3", "value3") // 遍历缓存中的所有元素 for _, k := range cache.Keys() { if v, ok := cache.Get(k); ok { fmt.Println(k, v.(string)) } } } ``` 运行上述代码,将会输出: ``` value1 key1 value1 key3 value3 ``` 在这个示例中,我们首先使用 `lru.New()` 函数创建了个容量为 2 的 LRU 缓存。然后我们添加了两个元素到缓存中,并从缓存中获取了个元素。接着我们添加了个新元素,此时缓存已满,会自动淘汰最久未使用的元素 "key2"。最后我们遍历了缓存中的所有元素,输出了它们的键和值。 除了 `Add()` 和 `Get()` 方法外,golang-lru 还提供了 `Remove()` 和 `Contains()` 方法来删除和判断缓存中是否存在某个元素,以及 `Len()` 和 `Clear()` 方法来获取缓存中元素的数量和清空缓存golang-lru 还支持并发安全,你可以通过 `NewWithOptions()` 函数创建个并发安全的 LRU 缓存,具体用法请参考官方文档:https://pkg.go.dev/github.com/hashicorp/golang-lru
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值