百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分类 > 正文

深入理解golang内存缓存利器-FreeCache

ztj100 2025-01-10 18:39 21 浏览 0 评论

在低延迟,高并发的系统中,不可避免的会用到本地内存作为缓存,FreeCache 就是使用golang实现的本地缓存系统,良好的特性使得它目前用在我们的生产环境中。一直以来都对他的实现很感兴趣,这次通过分析源码的方式,学习一下。

项目地址及特性

项目地址 https://github.com/coocood/freecache

特性:

  • 存储数以亿计的条目
  • 零 GC 开销
  • 高并发线程安全访问
  • 纯 Go 实现
  • 支持键值过期
  • 近似 LRU 的淘汰算法
  • 严格限制内存使用
  • 迭代器支持

内部数据结构

freecache 是如何做到 0 GC 和高并发、低延时的呢,先看一下他的主要结构

const (
  segmentCount = 256
  segmentAndOpVal = 255
)

type Cache struct {
  locks    [segmentCount]sync.Mutex
  segments [segmentCount]segment
}

// 每个分片的数据结构
type segment struct {
  rb            RingBuf   // 环形数组
  segId         int     // hashVal & 255 后的id
  hitCount      int64
  missCount     int64
  entryCount    int64
  totalCount    int64      // 环形数组 ring buffer 中的数据的总条数
  totalTime     int64      // 用于计算 lru 
  totalEvacuate int64
  totalExpired  int64
  overwrites    int64
  // 环形数组可用容量,用于维护环形数组,保证写入新数据而不会覆盖旧数据
  vacuumLen     int64
  // entry 索引每个 slot 的长度的数组。当 slotId 冲突时,对应 slot 的值会+1
  slotLens      [256]int32
  // entry 索引每个 slot 的容量
  //只要有一个 slot 的长度等于 slotCap 时,就会触发扩容
  slotCap       int32
  // 存储 entry 索引的切片,按照 hash16 顺序排列,256个 slot 共用
  slotsData     []entryPtr
}


type RingBuf struct {
  begin int64   // 环形数据的起始位置
  end   int64   // 环形数据的结束位置
  data  []byte  // 存储所有的 entry,容量是固定值
  index int
}

光看结构好像不能一目了然,撸个数据结构图

freecache 数据结构图

通过结构图,可以看出 freecache 是将缓存空间划分为 256 个 segment,每个 segment 都有相同都存储空间,并有一把锁。

每个 segment 包含 256 个索引槽,用来对 key 进行快速检索,通过 uint8(hashVal >> 8) 运算获取到 key 对应索引槽的 slotId。每个槽中又有 n 个索引,每个槽中的索引数量是统一的,由 slotCap 进行控制,当某个槽中的索引的数量大于 slotCap 时,就会触发整个索引的扩容。每个槽的索引数据是存储在 slotsData 这个索引切片中的,256 个槽共用同一个索引切片,每个槽在索引切片中都是按照 hash16 顺序排列的(如结构图显示,同一颜色的 solt 对应同一颜色的 ptr 切片)。根据 slotId 和 slotCap ,可以轻松定位到某个槽下所有的索引切片,提高 key 检索效率。

每个 segment 包含1个环形数组 RingBuf ,用来存储实际的数据。在 freecache 中,将每个 k/v 数据定义为一个 entry ,缓存中有多少个 key ,就有多少个 entry。

为什么可以高并发线程安全访问

当对 key 进行 set、get、del 等操作时,freecache 使用 xxhash 这个 hash 方法,对 key 计算得到一个64位的 hashValue。通过 hashVal & 255 得到 segId,将 key 定位到具体的 segment,并对 segment 加锁。由于每次只对单个 segment 加锁,不同 segment 之间可以并发进行 key 操作。

// 以 set 为例,get、del 等其他操作都是相似的
func (cache *Cache) Set(key, value []byte, expireSeconds int) (err error) {
  hashVal := hashFunc(key)
  segID := hashVal & segmentAndOpVal
  cache.locks[segID].Lock()
  err = cache.segments[segID].set(key, value, hashVal, expireSeconds)
  cache.locks[segID].Unlock()
  return
}

为什么是零 GC 的开销

通过结构图和源码,可以看出 freecache 的底层只有 512 个指针(256 个 segment ,每个 segment 包含一个 slotsData 切片和一个 RingBuf),所以 freecache 的对GC开销几乎为0。

set 操作的具体流程

话不多说,先上图

freecache set 流程图

freecache set 时,先查询当前 key 是否已存在,根据 segId 和 slotId 快速定位到 entry 所在的索引槽。由于索引槽中的索引都是按照 hash16 顺序排列的,可以用二分查找法检索(复杂度 O(log2n))。

/**
 * 判断 key 是否在索引切片中存在
*/
slotId := uint8(hashVal >> 8)
hash16 := uint16(hashVal >> 16)

// 根据 slotId 和 slotCap
// 从公用的 slotsData 切片中获取第 slotId 个槽所对应的索引切片
slot := seg.getSlot(slotId)
// 在槽对应的索引中,搜索 key 是否存在
idx, match := seg.lookup(slot, hash16, key)

func (seg *segment) getSlot(slotId uint8) []entryPtr {
  slotOff := int32(slotId) * seg.slotCap
  return seg.slotsData[slotOff : slotOff+seg.slotLens[slotId] : slotOff+seg.slotCap]
}

func (seg *segment) lookup(slot []entryPtr, hash16 uint16, key []byte) (idx int, match bool) {
  // 二分查找法获取到第一个符合条件的 hash16
  idx = entryPtrIdx(slot, hash16)
  for idx < len(slot) {
    ptr := &slot[idx]
    if ptr.hash16 != hash16 {
      break
    }
    // 相同的 hash16 ,需要到 RingBuf 比对 key 是否相同
    match = int(ptr.keyLen) == len(key) && seg.rb.EqualAt(key, ptr.offset+ENTRY_HDR_SIZE)
    if match {
      return
    }
    idx++
  }
  return
}

func entryPtrIdx(slot []entryPtr, hash16 uint16) (idx int) {
  high := len(slot)
  for idx < high {
    mid := (idx + high) >> 1
    oldEntry := &slot[mid]
    if oldEntry.hash16 < hash16 {
      idx = mid + 1
    } else {
      high = mid
    }
  }
  return
}

简单的画了个图,假设根据 slotId 和 slotCap 获取到 freecache 数据结构图中紫色的索引切片

freecache 索引检索图

当 key 对应的索引存在时,需要查看 RingBuf 中, entry 以前的容量是多少。如果 entry 容量大于 set 的 value 长度,直接更新原来 entry 的头部和 value (复杂度O(1))。如果 entry 容量不充足的话,为了避免移动 RingBuf 数组的数据,不直接对原来的 entry 进行扩容,而是将原来的 entry 标记为删除(不直接删除,迁移数据,只标记,当 RingBuf 空间不够需要淘汰数据的时候会真正删除),然后在 RingBuf 的环尾追加新的 entry(复杂度O(1),新 entry 容量如下)。

// 每次扩容原空间的两倍,直到满足插入 value 的空间为止
for hdr.valCap < hdr.valLen {
  hdr.valCap *= 2
}

当 key 对应的索引不存在时,要先将索引添加到索引切片中,由于索引是有序的,在插入索引时,可能会对老的索引数据进行迁移(复杂度O(n)),如果索引切片容量不够,还会触发索引切片扩容。索引添加成功后,再将 entry 追加到 RingBuf 的环尾(复杂度O(1))。

/**
 * 将索引插入到索引切片
*/
func (seg *segment) insertEntryPtr(
  slotId uint8, hash16 uint16, offset int64, idx int, keyLen uint16,
) {
  // 只要有一个索引槽的容量大于 slotCap
  // 就会对整个索引切片进行扩容
  // 扩容后容量是扩容前的两倍
  if seg.slotLens[slotId] == seg.slotCap {
    seg.expand()
  }
  seg.slotLens[slotId]++
  atomic.AddInt64(&seg.entryCount, 1)
  slot := seg.getSlot(slotId)
  copy(slot[idx+1:], slot[idx:])
  slot[idx].offset = offset
  slot[idx].hash16 = hash16
  slot[idx].keyLen = keyLen
}

func (seg *segment) expand() {
  newSlotData := make([]entryPtr, seg.slotCap*2*256)
  for i := 0; i < 256; i++ {
    off := int32(i) * seg.slotCap
    copy(newSlotData[off*2:], seg.slotsData[off:off+seg.slotLens[i]])
  }
  seg.slotCap *= 2
  seg.slotsData = newSlotData
}

以 freecache 数据结构图中紫色的索引切片为例,当插入索引,不需要扩容时

freecache 索引插入不需要扩容图

以 freecache 数据结构图中黄色的索引切片为例当插入索引,需要扩容时

freecache 索引插入扩容前图

freecache 索引插入扩容后图

如果将 entry 追加到 RingBuf 的环尾时,RingBuf 容量不够时,freecache 会根据策略淘汰数据,释放出容量。

近似 LRU 的淘汰流程

freecache lru 图

为什么是近似 LRU 的淘汰呢,先看下源码

leastRecentUsed := int64(oldHdr.accessTime)*atomic.LoadInt64(&seg.totalCount) <= atomic.LoadInt64(&seg.totalTime)

将上边等式变换一下

//oldHdr.accessTime: RingBuf 中第一个 entry 数据最近一次访问的时间戳
//seg.totalCount: RingBuf 中 entry 的总数,包括过期和标记删除的 entry
//seg.totalTime: RingBuf 中每个 entry 最近一次访问的时间戳总和

leastRecentUsed := int64(oldHdr.accessTime) <= atomic.LoadInt64(&seg.totalTime)/atomic.LoadInt64(&seg.totalCount)

atomic.LoadInt64(&seg.totalTime)/atomic.LoadInt64(&seg.totalCount) 表示 RingBuf 所有 entry 最近一次访问时间戳的平均值。 freecach 认为当某个 entry 的 accessTime 小于等于这个平均值,则该 entry 是可以被淘汰的。

freecache 选择将 accessTime 小于等于平均 accessTime 的 entry 淘汰,不是严格意义上的 LRU 算法,但是确实会将最近较少使用的 entry 淘汰掉,所以是一种近 LRU 的算法。 当然,此算法也有可能会将较新的 entry 数据淘汰掉。当 RingBuf 头部数据都比较新,并且没有过期时,会导致一直找不到符合条件的 entry 进行淘汰,这样就无法空出足够的空间让新数据写入,程序会无限循环的将数据从头部迁移到尾部导致 cpu 被耗尽。

为了防止这样的意外发生,也为了保证 set 操作的性能,当连续第6次 RingBuf 的第一个 entry 还不符合淘汰条件时,该 entry 会被强制淘汰。

key 过期后缓存数据的清除策略

freecache 不会主动清除过期的数据(包括索引和 entry 数据)。当数据过期后,在被标记删除之前,key 被重新 set 进来,如果 entry 的容量充足,是可以进行复用的。当数据过期后,当 get/touch 操作或 LRU 的时候,会将 key 对应的索引删除,entry 不会被直接删除,只会被标记为删除状态,等到 LRU 的时候,才会将 entry 删除,为 RingBuf 腾出空间。

相关推荐

sharding-jdbc实现`分库分表`与`读写分离`

一、前言本文将基于以下环境整合...

三分钟了解mysql中主键、外键、非空、唯一、默认约束是什么

在数据库中,数据表是数据库中最重要、最基本的操作对象,是数据存储的基本单位。数据表被定义为列的集合,数据在表中是按照行和列的格式来存储的。每一行代表一条唯一的记录,每一列代表记录中的一个域。...

MySQL8行级锁_mysql如何加行级锁

MySQL8行级锁版本:8.0.34基本概念...

mysql使用小技巧_mysql使用入门

1、MySQL中有许多很实用的函数,好好利用它们可以省去很多时间:group_concat()将取到的值用逗号连接,可以这么用:selectgroup_concat(distinctid)fr...

MySQL/MariaDB中如何支持全部的Unicode?

永远不要在MySQL中使用utf8,并且始终使用utf8mb4。utf8mb4介绍MySQL/MariaDB中,utf8字符集并不是对Unicode的真正实现,即不是真正的UTF-8编码,因...

聊聊 MySQL Server 可执行注释,你懂了吗?

前言MySQLServer当前支持如下3种注释风格:...

MySQL系列-源码编译安装(v5.7.34)

一、系统环境要求...

MySQL的锁就锁住我啦!与腾讯大佬的技术交谈,是我小看它了

对酒当歌,人生几何!朝朝暮暮,唯有己脱。苦苦寻觅找工作之间,殊不知今日之事乃我心之痛,难道是我不配拥有工作嘛。自面试后他所谓的等待都过去一段时日,可惜在下京东上的小金库都要见低啦。每每想到不由心中一...

MySQL字符问题_mysql中字符串的位置

中文写入乱码问题:我输入的中文编码是urf8的,建的库是urf8的,但是插入mysql总是乱码,一堆"???????????????????????"我用的是ibatis,终于找到原因了,我是这么解决...

深圳尚学堂:mysql基本sql语句大全(三)

数据开发-经典1.按姓氏笔画排序:Select*FromTableNameOrderByCustomerNameCollateChinese_PRC_Stroke_ci_as//从少...

MySQL进行行级锁的?一会next-key锁,一会间隙锁,一会记录锁?

大家好,是不是很多人都对MySQL加行级锁的规则搞的迷迷糊糊,一会是next-key锁,一会是间隙锁,一会又是记录锁。坦白说,确实还挺复杂的,但是好在我找点了点规律,也知道如何如何用命令分析加...

一文讲清怎么利用Python Django实现Excel数据表的导入导出功能

摘要:Python作为一门简单易学且功能强大的编程语言,广受程序员、数据分析师和AI工程师的青睐。本文系统讲解了如何使用Python的Django框架结合openpyxl库实现Excel...

用DataX实现两个MySQL实例间的数据同步

DataXDataX使用Java实现。如果可以实现数据库实例之间准实时的...

MySQL数据库知识_mysql数据库基础知识

MySQL是一种关系型数据库管理系统;那废话不多说,直接上自己以前学习整理文档:查看数据库命令:(1).查看存储过程状态:showprocedurestatus;(2).显示系统变量:show...

如何为MySQL中的JSON字段设置索引

背景MySQL在2015年中发布的5.7.8版本中首次引入了JSON数据类型。自此,它成了一种逃离严格列定义的方式,可以存储各种形状和大小的JSON文档,例如审计日志、配置信息、第三方数据包、用户自定...

取消回复欢迎 发表评论: