前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 ><源码阅读>GoZero里的Cache组件

<源码阅读>GoZero里的Cache组件

原创
作者头像
Porco1Rosso
修改2021-05-17 17:28:24
1.7K0
修改2021-05-17 17:28:24
举报

源码阅读是2020年开始的一个长期计划,主要目的有两个:1.提高自己对GO语言的理解,2.理解功能设计的原理。关于第二点,说的详细点就是,不仅要了解怎么做的,还要知道为什么这么做,有哪些好处,在什么场景下适用。最终提高自己对代码的敏感度,丰富自己的工具箱,让自己面对业务问题时能够从容不迫。

源码地址:go-zero/cache.go at master · tal-tech/go-zero · GitHub

学习总结

  1. 这块代码只有300行,基本实现了一下四个功能:
    • 缓存增删改,自动失效,可以指定过期时间
    • 缓存大小限制,可以指定缓存个数,有LRU算法
    • 缓存命中率统计
    • 并发安全,解决缓存击穿问题
  2. Go语言简单实现LRU淘汰算法
  3. 使用syncx.SharedCalls解决缓存击穿问题的方法

代码主要分成三个模块:缓存操作,LRU淘汰算法,命中统计模块。

数据结构

代码语言:txt
复制
type (
	CacheOption func(cache *Cache) //用来操作缓存的函数,用在实例化缓存时

	Cache struct {
		name           string //缓存名称
		lock           sync.Mutex //并发锁
		data           map[string]interface{}//缓存内容
		expire         time.Duration //过期时间
		timingWheel    *TimingWheel //框架封装的定时器
		lruCache       lru //LRU组件
		barrier        syncx.SharedCalls//缓存并发安全组件,可以解决缓存击穿的问题
		unstableExpiry mathx.Unstable //生成随机数的插件
		stats          *cacheStat //统计命中率模块
	}
)

初始化缓存

代码语言:txt
复制
func NewCache(expire time.Duration, opts ...CacheOption) (*Cache, error) {
	cache := &Cache{//声明缓存结构,初始化内容
		data:           make(map[string]interface{}),
		expire:         expire,
		lruCache:       emptyLruCache, //默认是一个空的LRU结构,可以通过opts来控制
		barrier:        syncx.NewSharedCalls(),//解决缓存击穿的核心方法
		unstableExpiry: mathx.NewUnstable(expiryDeviation),//框架自己做的一个并发安全的随机数
	}

	for _, opt := range opts {
		opt(cache) //执行预加载函数
	}

 ...
	cache.stats = newCacheStat(cache.name, cache.size)//缓存命中统计模块,初始化

	timingWheel, err := NewTimingWheel(time.Second, slots, func(k, v interface{}) {
		key, ok := k.(string)
		if !ok {
			return
		}

		cache.Del(key)
	})//定时器模块,初始化
	...
	return cache, nil
}

这里有三个模块是调用的go-zero中实现好的模块,这些代码块也很小,实现的功能很强大。可以参考:

1.syncx.NewSharedCalls

2.mathx.NewUnstable

3.NewTimingWheel

缓存的增删改查方法

代码语言:txt
复制
func (c *Cache) Del(key string) {
	c.lock.Lock() //上锁
	delete(c.data, key) // 删除元素
	c.lruCache.remove(key) //移除LRU
	c.lock.Unlock() // 解锁
	c.timingWheel.RemoveTimer(key) //移除定时器, 注意先解锁,后移除。
}

func (c *Cache) Get(key string) (interface{}, bool) {
	...
	if ok { //统计命中率
		c.stats.IncrementHit()
	} else {
		c.stats.IncrementMiss()
	}
  ...
}


func (c *Cache) Set(key string, value interface{}) {
	c.lock.Lock() //上锁
	_, ok := c.data[key] //判断KEY是否存在
	c.data[key] = value //赋值
	c.lruCache.add(key) //添加到LRU
	c.lock.Unlock() //解锁

	expiry := c.unstableExpiry.AroundDuration(c.expire) //设置过期值
	if ok {
		c.timingWheel.MoveTimer(key, expiry)
	} else {
		c.timingWheel.SetTimer(key, value, expiry)
	}
}	

func (c *Cache) doGet(key string) (interface{}, bool) {
 	...
	value, ok := c.data[key]
	if ok {
		c.lruCache.add(key) //添加到LRU中
	}
 ...
}

这一块代码,这一块代码大同小异,都是加锁,操作,再解锁的方式来进行并发管理的。通过这一块代码的阅读,基本可以明确LRU模块和命中率统计模块是如何工作的。

解决缓存击穿问题的方法

代码语言:txt
复制
//这个方法可以获取KEY的值,如果这个值不存在,那么执行fetch方法,拿到返回值设置到缓存里,并返回。当出现并发情况时,barrier方法会保证并发安全。
func (c *Cache) Take(key string, fetch func() (interface{}, error)) (interface{}, error) {
	if val, ok := c.doGet(key); ok {//直接获取KEY的值
		c.stats.IncrementHit() //记录命中
		return val, nil
	}

	var fresh bool
	//核心方法,主要是用了syncx.NewSharedCalls实现的功能
	val, err := c.barrier.Do(key, func() (interface{}, error) {
		//这里进行了一次dobble check。解决并发时,有些协程可能已经把数据查出来并加载到缓存了。
		if val, ok := c.doGet(key); ok {
			return val, nil
		}

		v, e := fetch()//执行方法,获取CACHE。这个方法应该尽量的保证效率
		if e != nil {
			return nil, e
		}

		fresh = true
		c.Set(key, v) //设置缓存
		return v, nil
	})
 	...

	if fresh {
		//fetch 获取到数据为空,记录miss次数
		c.stats.IncrementMiss()
		return val, nil
	}

	// 直接把之前查到的数据返回,并记录命中次数
	c.stats.IncrementHit()
	return val, nil
}

这里实现缓存击穿安全的方法主要是依赖了syncx.NewSharedCalls,他的主要作用是:使用SharedCalls可以使得同时多个请求只需要发起一次拿结果的调用,其他请求”坐享其成”,这种设计有效减少了资源服务的并发压力,可以有效防止缓存击穿

详细的可以直接看源码,或者参考文章:更简的并发代码,更强的并发控制 - 知乎

LRU的淘汰算法

LRU的核心思想:

  1. 新数据插入到链表头部;
  2. 每当缓存命中(即缓存数据被访问),则将数据移到链表头部;
  3. 当链表满的时候,将链表尾部的数据丢弃。

具体实现方法:

代码语言:txt
复制
type (
	lru interface { //声明一个接口
		add(key string)
		remove(key string)
	}

	emptyLru struct{} //默认的空结构,newCache的时候默认用的就这个

	keyLru struct {//带有限制的结构,ewCache的时候通过调用withLimit方法设置
		limit    int //总长度
		evicts   *list.List //元素链
		elements map[string]*list.Element//存放的是元素在链表中的地址。利用MAP操作O1的特性,不用遍历链表即可找到需要的元素的地址。
		onEvict  func(key string) //删除的后置操作
	}
)


func newKeyLru(limit int, onEvict func(key string)) *keyLru {
...
}

func (klru *keyLru) add(key string) {
	if elem, ok := klru.elements[key]; ok {
		klru.evicts.MoveToFront(elem)//如果新增元素已存在,就直接移到最前面
		return
	}

	elem := klru.evicts.PushFront(key)//在链表最前面增加一个元素
	klru.elements[key] = elem //记录这个元素的地址

	if klru.evicts.Len() > klru.limit {
		klru.removeOldest()//如果链表的最大长度超过配置,移除最老的元素
	}
}

func (klru *keyLru) remove(key string) {
...
}

func (klru *keyLru) removeOldest() {
	elem := klru.evicts.Back()//取链表最后一个元素
	if elem != nil {
		klru.removeElement(elem) //移除元素
	}
}

func (klru *keyLru) removeElement(e *list.Element) {
	klru.evicts.Remove(e)//移除链表中的元素
	key := e.Value.(string)//获取Key
	delete(klru.elements, key)//移除MAP中的元素
	klru.onEvict(key) //执行删除的后置操作
}

这块代码的结构非常简单。每个函数的声明和实现都非常的简洁、标准。整个LRU的代码风格,具体实现的方式,非常值得学习。 参考地址缓存淘汰算法—LRU算法 - 知乎

命中统计模块

这块代码的逻辑很简单,这里不做详细展开。

代码语言:txt
复制
type cacheStat struct {
	name         string //名称,最后打印日志记录是要用到
	hit          uint64 //命中缓存次数
	miss         uint64 //未命中次数
	sizeCallback func() int //自定义回调函数,会在打印结果的时候用到
}

func newCacheStat(name string, sizeCallback func() int) *cacheStat {...}

func (cs *cacheStat) IncrementHit() {
	atomic.AddUint64(&cs.hit, 1) //记录命中次数
}

func (cs *cacheStat) IncrementMiss() {
	atomic.AddUint64(&cs.miss, 1)
}

func (cs *cacheStat) statLoop() {
...
}

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 学习总结
  • 数据结构
    • 初始化缓存
      • 缓存的增删改查方法
        • 解决缓存击穿问题的方法
        • LRU的淘汰算法
        • 命中统计模块
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档