源码分析 golang bigcache 高性能无 GC 开销的缓存设计实现

一、bigcache 的核心设计思想

特性 实现方式 目的
分片(Sharding) 默认 1024 个 shard,每个 shard 独立加锁 降低并发写入时的锁竞争
索引结构 map[uint64]uint32(哈希 → ringbuffer 偏移) 利用 Go 1.5+ 的"无指针 map 不被 GC 扫描"优化
数据存储 每个 shard 一个 []byte 环形缓冲区(ring buffer) 避免 GC 遍历大量小对象
淘汰策略 覆盖写 + 时间窗口过期(非 LRU/LFU) 简化实现,避免维护复杂数据结构
过期清理 后台协程定期扫描 ringbuffer,释放过期条目内存 渐进式回收,避免 STW

🔍 关键洞察:bigcache 的"淘汰"其实是两阶段的:

  1. 逻辑过期 :超过 LifeWindow 后,Get() 返回 ErrNotFound
  2. 物理回收 :由 CleanWindow 触发后台清理,真正释放内存。

bigcache 是 golang 编写的高性能的缓存库,其设计很巧妙,通过数据分片(多个shared)解决高并发下锁竞争的问题,通过把数据存到 ringbuffer 来规避 golang gc 的开销。

bigcache 内部使用cacheShard来存储数据,每个cacheShard内使用 hashmap 存储key 的索引,而真正的数据通过编码后放在BytesQueue (ringbuffer) 里。bigcache 没有使用主流的 lru 和 lfu 缓存淘汰算法,而是使用覆盖写来覆盖老数据,在 ringbuffer 已满时,先删除老数据,再尝试插入新数据。另外还通过 gc 垃圾回收期删掉过期的数据。

BigCache 的设计决定了其缓存值必须为 []byte 类型------因为底层数据实际存储在基于 []byte 实现的环形队列(BytesQueue)中。这意味着业务中常见的结构体、map、自定义对象等无法直接存入,必须在写入前序列化为字节流,读取时再反序列化还原。

这一限制在实际应用中带来了显著的工程权衡:

虽然 BigCache 通过规避 GC 实现了极高的吞吐和低延迟,但频繁的序列化/反序列化操作会引入可观的 CPU 开销,尤其在高并发、大对象或复杂嵌套结构的场景下,编解码可能成为性能瓶颈,甚至抵消掉 GC 优化带来的收益。

因此,在选择 BigCache 时,需评估:

  • 序列化方案的效率(如 Protobuf、MessagePack 优于 JSON);
  • 缓存对象的大小与访问频率;
  • 是否值得为 GC 优化而承担编解码成本。

对于无法接受序列化开销的场景,可考虑支持泛型且内置高效内存管理的替代方案(如 Ristretto),或在架构层面将 BigCache 用于缓存已序列化的原始数据(如 HTTP 响应体、数据库行的二进制表示),从而将编解码成本前置或转移。

go 复制代码
// BytesQueue is a non-thread safe queue type of fifo based on bytes array.// For every push operation index of entry is returned. It can be used to read the entry later  
type BytesQueue struct {  
    full         bool  
    array        []byte  
    capacity     int  
    maxCapacity  int  
    head         int  
    tail         int  
    count        int  
    rightMargin  int  
    headerBuffer []byte  
    verbose      bool  
}

bigcache 的实现原理跟 freecache、fastcache 大同小异,都使用了 ringbuffer 存放数据,可以很大程度降低 GC 的开销。这里的 ringbuffer 当然可以使用有名或匿名的 mmap 来构建,俗称堆外内存,但对于 golang gc 来说,mmap 和直接申请 []byte 的 gc 开销没大区别。如果使用文件 mmap 映射,当系统一直有文件读写,势必会对 page cache 进行 page 淘汰,这样基于 mmap 构建的 ringbuffer,必然会受之影响。

bigcache 用法示例

go 复制代码
import (  
    "log"  
    "fmt"  
​  
    "github.com/allegro/bigcache/v3"  
)  
​  
func main() {  
    config := bigcache.Config {  
            // 预设多少个数据分片,其大小必须是 2 的幂次方,因为这里使用位运算取摸,而非使用 %.  
            Shards: 1024,  
​  
            // 缓存对象的生命周期,也就是过期时长  
            LifeWindow: 10 * time.Minute,  
​  
            // 垃圾回收的运行周期,每隔 5 分钟尝试进行一次垃圾回收.  
            CleanWindow: 5 * time.Minute,  
​  
            // rps * lifeWindow, used only in initial memory allocation  
            MaxEntriesInWindow: 1000 * 10 * 60,  
​  
            // 设定的 value 的大小  
            MaxEntrySize: 500,  
​  
            // bigcache 缓存的大小,单位是 MB.  
            // 注意这是总大小,每个分片的大小则需要除以分片数,当为 0 时不限制。  
            HardMaxCacheSize: 8192,  
    }  
​  
    // 构建 bigcache 缓存对象  
    cache, initErr := bigcache.New(context.Background(), config)  
    if initErr != nil {
        log.Fatal(initErr)  
    }  
​  
    // 写数据  
    cache.Set("my-unique-key", []byte("value"))  
​  
    // 读数据  
    if entry, err := cache.Get("my-unique-key"); err == nil {  
        fmt.Println(string(entry))  
    }  
}

bigcache 实现原理

  • 写入时 :将数据([]byte)追加写入分片内的环形缓冲区(ring buffer),同时将键的哈希值与该数据在缓冲区中的偏移索引(offset)存入一个 map[uint64]uint32 哈希表中;
  • 读取时:先对 key 计算哈希,通过哈希表查到对应的偏移索引,再根据该索引从环形缓冲区中定位并读取原始字节数据。

其实简单点理解可以作为LRU/LFU等算法的变种都借助hash桶的快速查询和其他基础数据类型的便利优势来组合成新的算法。

bigcache 中数据结构及布局

![[Pasted image 20251014191908.png]]

BigCache 数据结构.

go 复制代码
// BigCache 是一个高性能、并发安全、支持自动淘汰的内存缓存实现,
// 专为存储大量缓存条目(百万至千万级)而设计,且对 GC(垃圾回收)几乎无影响。
//
// 核心思想:
// - 所有缓存数据以 []byte 形式存储在堆上,但通过特殊设计使 Go GC 忽略这些数据;
// - 用户在使用时通常需要在存入前序列化、取出后反序列化(如 JSON、Protobuf 等);
// - 内部采用分片(sharding)机制提升并发性能,每个分片独立加锁;
// - 使用环形字节队列(BytesQueue)作为底层存储,配合无指针哈希表实现高效索引。
type BigCache struct {
	// shards 是缓存分片数组,每个分片包含独立的哈希表和数据队列。
	// 分片数量必须是 2 的幂,以便通过位运算快速定位分片(shard = hash & shardMask)。
	shards []*cacheShard

	// lifeWindow 表示缓存条目的最大存活时间(单位:纳秒)。
	// 超过此时间的条目被视为"过期",读取时返回错误,但物理内存不会立即释放。
	lifeWindow uint64

	// clock 用于获取当前时间戳(纳秒),支持测试时 mock 时间。
	clock clock

	// hash 是用于计算 key 哈希值的哈希函数,默认使用 fnv64a。
	// 哈希结果用于定位分片和在分片内索引条目。
	hash Hasher

	// config 是用户传入的配置,包含分片数、清理周期、内存限制等参数。
	config Config

	// shardMask 用于快速计算 key 所属分片索引。
	// 由于 shards 数量为 2 的幂(如 1024),shardMask = shards - 1,
	// 因此 shardIndex = hash(key) & shardMask。
	shardMask uint64

	// close 是用于优雅关闭后台清理协程的信号通道。
	// 调用 BigCache.Close() 时会关闭此通道,通知所有分片停止清理任务。
	close chan struct{}
}

// cacheShard 表示一个缓存分片,是并发控制和数据存储的基本单元。
// 每个分片拥有独立的锁、哈希表、环形队列,避免多 goroutine 争用同一把锁。
type cacheShard struct {
	// hashmap 是无指针哈希表:key 为 entry key 的哈希值(uint64),
	// value 为该条目在 entries(BytesQueue)中的起始偏移量(低 32 位)和长度(高 32 位),
	// 或仅存储偏移(具体取决于实现版本)。由于 key/value 均为整数,Go GC 会跳过扫描此 map。
	hashmap map[uint64]uint64

	// entries 是底层环形字节队列(BytesQueue),实际缓存数据以序列化后的 []byte 形式追加存储于此。
	// 所有写入操作都是 append-only,当空间不足时会覆盖最旧的有效数据(滑动窗口式淘汰)。
	entries queue.BytesQueue

	// lock 用于保护本分片的并发读写操作。
	// 读操作使用 RLock,写操作使用 Lock,支持高并发读。
	lock sync.RWMutex

	// entryBuffer 是一个临时缓冲区,用于在写入前拼装完整的缓存条目(含元数据如时间戳、key 长度等),
	// 避免频繁分配内存,提升写入性能。
	entryBuffer []byte

	// onRemove 是淘汰回调函数,在条目因过期、空间不足或显式删除而被移除时触发。
	// 可用于记录日志、更新指标或执行清理逻辑。
	onRemove onRemoveCallback

	// isVerbose 控制是否打印详细的内存分配日志(如 BytesQueue 扩容信息)。
	isVerbose bool

	// statsEnabled 表示是否启用统计信息收集(如命中率、请求数等)。
	statsEnabled bool

	// logger 用于输出日志(如 verbose 信息或错误)。
	logger Logger

	// clock 与 BigCache.clock 一致,用于获取当前时间(纳秒),支持时间 mock。
	clock clock

	// lifeWindow 本分片继承的条目最大存活时间(纳秒),用于判断条目是否过期。
	lifeWindow uint64

	// hashmapStats 用于收集哈希表的统计信息(如冲突次数),仅在 statsEnabled 为 true 时使用。
	// key 为哈希值,value 为该桶中的条目数(用于分析哈希分布)。
	hashmapStats map[uint64]uint32

	// stats 存储本分片的运行时指标,如请求总数、命中数、写入失败数等。
	stats Stats

	// cleanEnabled 表示是否启用后台过期条目清理任务。
	// 若 Config.CleanWindow <= 0,则为 false,不启动清理协程。
	cleanEnabled bool
}

数据在 ringbuffer 中的编码.

Set 写流程

go 复制代码
// Set saves entry under the key
func (c *BigCache) Set(key string, entry []byte) error { 
	// 使用 fnv hash 算法计算 key的hashcode 
    hashedKey := c.hash.Sum64(key)  
    // 通过位运算得出 key 对应的 shard 分片  
    shard := c.getShard(hashedKey)  
    return shard.set(key, hashedKey, entry)  
}

set 用来把数据写到 shard 的 ringbuffer 里,并设置 hashmap 索引,其流程如下。

  1. 获取当前的秒级别的时间戳,这里抽象了 clock 方法,只要是为了方便的后面的单元测试 ;

  2. 在 hashmap 里获取 key 以前的 ringbuffer 的 index 位置信息,如果不为 0,且在 ringbuffer 又可拿到该 entry,则进行删除 ;

  3. 编码待写入 ringbuffer 里的结构 ;

  4. 尝试把编码的数据写到 ringbuffer 里,如果空间小于 max 值,则会扩容,当无法扩容时,写入失败,说明无空闲空间,则尝试剔除最老的数据,然后再进行写入。

fnv hash算法
复制代码
algorithm fnv-1 is
    hash := FNV_offset_basis

    for each byte_of_data to be hashed do
        hash := hash × FNV_prime
        hash := hash XOR byte_of_data

    return hash 

go代码实现

go 复制代码
package bigcache  
  
// newDefaultHasher returns a new 64-bit FNV-1a Hasher which makes no memory allocations.// Its Sum64 method will lay the value out in big-endian byte order.  
// See https://en.wikipedia.org/wiki/Fowler--Noll--Vo_hash_function  
func newDefaultHasher() Hasher {  
    return fnv64a{}  
}  
  
type fnv64a struct{}  
  
const (  
    // offset64 FNVa offset basis. See https://en.wikipedia.org/wiki/Fowler--Noll--Vo_hash_function#FNV-1a_hash    offset64 = 14695981039346656037  
    // prime64 FNVa prime value. See https://en.wikipedia.org/wiki/Fowler--Noll--Vo_hash_function#FNV-1a_hash    prime64 = 1099511628211  
)  
  
// Sum64 gets the string and returns its uint64 hash value.func (f fnv64a) Sum64(key string) uint64 {  
    var hash uint64 = offset64  
    for i := 0; i < len(key); i++ {  
       hash ^= uint64(key[i])  
       hash *= prime64  
    }  
  
    return hash  
}
取摸算法

关于取模的计算,大家一般使用 x%len 公式进行计算,但是这样计算性能比较低效。计算机执行最快的是进行位运算,因此在redis等众多开源软件中,一般都是采用安位与的方式取模 x&(len - 1) ,在bigcache中取模也是按照这种方式进行计算的。

go 复制代码
func (c *BigCache) getShard(hashedKey uint64) (shard *cacheShard) {  
    // 采用按位计算,来计算出该使用哪个 shards
    return c.shards[hashedKey&c.shardMask]  
}

// Number of cache shards, value must be a power of two  
// Shards int
cache := &BigCache{  
    shards:     make([]*cacheShard, config.Shards),  
    lifeWindow: lifeWindowSeconds,  
    clock:      clock,  
    hash:       config.Hasher,  
    config:     config,  
    // Shards 长度是2的次幂
    shardMask:  uint64(config.Shards - 1),  
    close:      make(chan struct{}),  
}

根据 intel asm 的文档资料,& 操作只需 5 个 CPU 周期,而 % 最少需要 20 个 CPU 周期,显而易见,如果在意性能我们应该使用前者。

cacheShard.set
go 复制代码
// set 向当前分片中插入或更新一个缓存条目。
// 参数说明:
//   - key: 原始键(用于序列化到存储中,便于调试或未来扩展)
//   - hashedKey: key 的哈希值(uint64),作为 hashmap 的索引,避免重复计算
//   - entry: 用户提供的值(已序列化为 []byte)
// 返回 error,仅在条目过大无法写入时返回错误。
func (s *cacheShard) set(key string, hashedKey uint64, entry []byte) error {
	// 1. 获取当前时间戳(单位:秒或纳秒,取决于 clock 实现)
	//    该时间戳将作为条目的"写入时间",用于后续判断是否过期。
	currentTimestamp := uint64(s.clock.Epoch())

	// 2. 加写锁,保证分片内并发安全(同一 shard 的读写互斥)
	s.lock.Lock()

	// 3. 检查是否已存在相同 hashedKey 的条目(即 key 冲突或更新)
	if previousIndex := s.hashmap[hashedKey]; previousIndex != 0 {
		// previousIndex != 0 表示该 key 已存在(BigCache 约定:0 表示无效索引)

		// 3.1 从环形队列 entries 中读取旧条目数据
		if previousEntry, err := s.entries.Get(int(previousIndex)); err == nil {
			// 3.2 清除旧条目中的哈希值(安全措施,防止残留数据被误解析)
			//     注:resetHashFromEntry 会将 entry 中存储的 hashedKey 字段置零
			resetHashFromEntry(previousEntry)

			// 3.3 从 hashmap 中删除旧索引(逻辑删除)
			//     注意:环形队列中的旧数据不会立即释放,等待后续覆盖或清理
			delete(s.hashmap, hashedKey)
		}
	}

	// 4. 如果未启用后台清理(cleanEnabled == false),
	//    则在每次写入前尝试检查并驱逐最老条目(仅当空间不足时触发?此处逻辑需注意)
	//    实际上,此分支主要用于触发 onEvict 回调(如统计、日志),而非真正释放空间。
	if !s.cleanEnabled {
		// Peek() 返回环形队列中最老的有效条目(不弹出)
		if oldestEntry, err := s.entries.Peek(); err == nil {
			// 调用驱逐逻辑:检查 oldestEntry 是否过期,
			// 若过期则调用 s.removeOldestEntry 删除;
			// 同时会触发 onRemove 回调(如果配置了)
			s.onEvict(oldestEntry, currentTimestamp, s.removeOldestEntry)
		}
	}

	// 5. 将用户数据封装为 BigCache 内部格式的字节流
	//    格式通常为:[timestamp(8B)][hashedKey(8B)][keyLen(2B)][key][value]
	//    封装结果写入 s.entryBuffer(复用缓冲区,避免频繁分配)
	w := wrapEntry(currentTimestamp, hashedKey, key, entry, &s.entryBuffer)

	// 6. 循环尝试将封装后的数据写入环形队列(BytesQueue)
	for {
		// 6.1 尝试 Push:若队列有足够空间,返回写入位置 index(从 1 开始)
		if index, err := s.entries.Push(w); err == nil {
			// 6.2 写入成功:将 hashedKey -> index 的映射存入 hashmap
			s.hashmap[hashedKey] = uint64(index)
			// 6.3 解锁并返回成功
			s.lock.Unlock()
			return nil
		}

		// 6.4 Push 失败(空间不足):尝试删除最老条目以腾出空间
		//     removeOldestEntry 会:
		//       - 从 entries 弹出最老条目
		//       - 若该条目在 hashmap 中仍存在(未被覆盖),则删除其索引
		//       - 触发 onRemove 回调
		if s.removeOldestEntry(NoSpace) != nil {
			// 6.5 如果连最老条目都无法删除(例如队列为空,或条目过大),
			//     说明当前要写入的 entry 比整个 shard 的最大容量还大,
			//     此时放弃写入,返回错误。
			s.lock.Unlock()
			return errors.New("entry is bigger than max shard size")
		}
		// 6.6 继续循环,再次尝试 Push(可能已腾出空间)
	}
}
resetKeyFromEntry 把 entry 中 hashcode 置为 0.

entry 的 [8:16] 字节存储了数据的 key hashcode,通过 resetKeyFromEntry 方法则可以把 hashcode 置为 0.

go 复制代码
func resetKeyFromEntry(data []byte) {  
    binary.LittleEndian.PutUint64(data[timestampSizeInBytes:], 0)  
}
对数据进行编码

BigCache 内部用于将缓存条目(key + value + 元数据)序列化为连续字节数组 的核心函数 wrapEntry。其设计目标是:

  • 高效拼装:避免频繁内存分配;
  • 紧凑布局 :所有元数据和数据连续存放,便于后续从 BytesQueue(环形缓冲区)中解析;
  • 规避 GC :最终数据存入大块 []byte,不产生小对象。

下面是对以上ringbuffer entry的逐行详细注释与原理剖析


常量定义
go 复制代码
const (
	timestampSizeInBytes = 8  // 时间戳占用 8 字节(uint64,纳秒或秒)
	hashSizeInBytes      = 8  // 哈希值占用 8 字节(uint64,key 的哈希)
	keySizeInBytes       = 2  // key 长度占用 2 字节(uint16,最大支持 65535 字节的 key)
	headersSizeInBytes   = timestampSizeInBytes + hashSizeInBytes + keySizeInBytes // = 8+8+2 = 18 字节
)

✅ 总头部固定 18 字节 ,后续紧跟 keyvalue(即 entry)。


函数:wrapEntry
go 复制代码
// wrapEntry 将缓存条目的元数据(时间戳、哈希、key)和值(entry)打包成一个连续的 []byte。
// 使用传入的 buffer(*[]byte)进行内存复用,避免每次分配新切片。
//
// 参数说明:
//   - timestamp: 条目写入时间(用于过期判断)
//   - hash: key 的哈希值(用于快速查找和校验)
//   - key: 原始 key 字符串(可选,主要用于调试或未来扩展,实际查找不依赖它)
//   - entry: 用户提供的值(已序列化的 []byte)
//   - buffer: 指向一个可复用的 []byte 缓冲区(通常来自 cacheShard.entryBuffer)
//
// 返回值:
//   - 打包后的完整字节切片(长度 = headers + len(key) + len(entry))
func wrapEntry(timestamp uint64, hash uint64, key string, entry []byte, buffer *[]byte) []byte {
1. 计算所需总长度
go 复制代码
	keyLength := len(key)
	blobLength := len(entry) + headersSizeInBytes + keyLength
  • blobLength = 18(头部) + key 长度 + value 长度
2. 复用或扩容缓冲区
go 复制代码
	if blobLength > len(*buffer) {
		*buffer = make([]byte, blobLength)
	}
	blob := *buffer
  • 内存复用技巧cacheShard 持有一个 entryBuffer []byte 字段,作为临时缓冲区;
  • 若当前 buffer 不够大,则分配新的;
  • 避免每次 setmake([]byte, N),减少 GC 压力和分配开销。

💡 这是高性能 Go 程序的常见模式:对象池 / 缓冲区复用

3. 填充分部(Little-Endian 编码)
go 复制代码
	// 1. 写入时间戳(偏移 0,8 字节)
	binary.LittleEndian.PutUint64(blob, timestamp)

	// 2. 写入哈希值(偏移 8,8 字节)
	binary.LittleEndian.PutUint64(blob[timestampSizeInBytes:], hash)

	// 3. 写入 key 长度(偏移 16,2 字节)
	binary.LittleEndian.PutUint16(blob[timestampSizeInBytes+hashSizeInBytes:], uint16(keyLength))
  • 使用 小端序(LittleEndian),确保跨平台一致性;
  • 位置计算清晰:
    • timestamp[0:8)
    • hash[8:16)
    • key length[16:18)
4. 拷贝 key 和 entry
go 复制代码
	// 4. 拷贝 key(从偏移 18 开始)
	copy(blob[headersSizeInBytes:], key)

	// 5. 拷贝 value(entry)(紧跟在 key 之后)
	copy(blob[headersSizeInBytes+keyLength:], entry)
  • headersSizeInBytes = 18,所以:
    • key 起始位置:18
    • entry 起始位置:18 + len(key)
5. 返回有效切片
go 复制代码
	return blob[:blobLength]
  • 虽然 blob 可能比 blobLength 长(因复用),但只返回实际使用的部分。

🔍 最终内存布局示例

假设:

  • timestamp = 1710000000
  • hash = 0x1234567890abcdef
  • key = "user:123"(9 字节)
  • entry = []byte{0x01, 0x02}(2 字节)

blob 布局为(共 18 + 9 + 2 = 29 字节):

复制代码
[0:8)    → timestamp (8B)
[8:16)   → hash (8B)
[16:18)  → key length = 9 (2B)
[18:27)  → "user:123" (9B)
[27:29)  → entry (2B)

✅ 设计优点总结
特性 说明
紧凑存储 元数据 + key + value 连续存放,减少内存碎片
快速解析 读取时只需按固定偏移解析头部,无需复杂结构
GC 友好 最终存入 BytesQueue(大 []byte),无指针
内存复用 通过 buffer *[]byte 避免高频分配
平台无关 显式指定 LittleEndian,保证一致性
ringbuffer 写时扩容

ringbuffer 扩容代码

go 复制代码
// allocateAdditionalMemory 为 BytesQueue 分配额外内存,使其容量至少能容纳 minimum 字节。
// 该方法在当前容量不足时被调用(例如 Push 操作空间不够)。
// 扩容策略:至少翻倍,但不超过 maxCapacity(若设置)。
func (q *BytesQueue) allocateAdditionalMemory(minimum int) {
	start := time.Now() // 记录扩容开始时间(用于性能日志)

	// Step 1: 确保新容量至少比 minimum 大
	if q.capacity < minimum {
		q.capacity += minimum
	}

	// Step 2: 将容量翻倍(即使已满足 minimum,也尝试翻倍以减少频繁扩容)
	q.capacity = q.capacity * 2

	// Step 3: 如果设置了最大容量限制(maxCapacity > 0),则不能超过它
	if q.capacity > q.maxCapacity && q.maxCapacity > 0 {
		q.capacity = q.maxCapacity
	}

	// Step 4: 保存旧数组指针,用于后续数据迁移
	oldArray := q.array

	// Step 5: 分配新的大容量字节数组
	q.array = make([]byte, q.capacity)

	// Step 6: 判断是否需要迁移旧数据
	// leftMarginIndex 是一个常量(通常为 0),q.rightMargin 表示已使用数据的右边界
	if leftMarginIndex != q.rightMargin {
		// 6.1 将旧数组中 [0, q.rightMargin) 的数据拷贝到新数组开头
		copy(q.array, oldArray[:q.rightMargin])

		// 6.2 处理"环形队列已绕回"的情况:即 tail <= head(数据跨越了数组末尾)
		if q.tail <= q.head {
			if q.tail != q.head {
				// 说明中间有一段"空洞"(已被弹出的数据),但 head 到数组末尾还有有效数据?
				// 实际上,这里逻辑存疑或为特殊处理 ------ 更可能是将"尾部空闲段"用 dummy 数据填充?
				// 注:make([]byte, q.head-q.tail) 创建零值切片,然后 push 它(可能用于对齐?)
				// 但此操作在扩容后似乎多余,可能是历史遗留或调试代码。
				q.push(make([]byte, q.head-q.tail), q.head-q.tail)
			}

			// 6.3 重置 head 和 tail 指针:
			// - head 移到起始位置(leftMarginIndex = 0)
			// - tail 指向原数据末尾(即新数据的末尾)
			q.head = leftMarginIndex
			q.tail = q.rightMargin
		}
		// else: 如果 tail > head(正常线性状态),则 head/tail 不变,数据已连续拷贝到开头
	}

	// Step 7: 标记队列不再"满"(因为刚扩容)
	q.full = false

	// Step 8: 若启用 verbose 模式,打印扩容耗时和新容量
	if q.verbose {
		log.Printf("Allocated new queue in %s; Capacity: %d \n", time.Since(start), q.capacity)
	}
}

Get 读取流程

Get 用来获取数据,计算 hashcode,获取对应的 shard,然后调用 get() 读取。

go 复制代码
func (c *BigCache) Get(key string) ([]byte, error) {  
    hashedKey := c.hash.Sum64(key)  
    shard := c.getShard(hashedKey)  
    return shard.get(key, hashedKey)  
}

get 用来从 shard 里获取数据,其流程是先从 ringbuffer 里获取编码过的数据,然后通过解码获取 value。

Delete 删除 kv 流程

Delete 用来删除数据,先获取 key hashcode 对应分片,再执行分片的 del 进行数据删除。

go 复制代码
func (c *BigCache) Delete(key string) error {  
    hashedKey := c.hash.Sum64(key)  
    shard := c.getShard(hashedKey)  
    return shard.del(hashedKey)  
}

del 用来在分片中删除数据,其流程如下。

  1. 乐观读锁定检查

    • 使用 RLock() 进行只读锁定。
    • 检查 hashedKey 是否存在于 hashmap 中,若不存在立即返回 ErrEntryNotFound 并记录一次删除未命中 (delmiss)。
    • 使用 CheckGet 方法检查索引对应的条目是否有效,无效则释放读锁并返回错误。
  2. 升级为写锁定执行删除

    • 在确认条目存在且有效后,释放读锁并获取写锁 (Lock())。
    • 再次检查 hashedKey 是否仍然存在于 hashmap 中(因为自上次检查以来数据可能已更改或被删除)。
    • 如果 hashedKey 存在,则从 entries 获取对应条目。
    • 删除 hashmap 中对应的 hashedKey 条目。
    • 调用 onRemove 回调函数处理条目移除事件,并标记状态为 Deleted
    • 如果统计功能启用,则从 hashmapStats 中也删除相应的 hashedKey
    • 最后,重置条目中的哈希信息 (resetHashFromEntry),避免残留数据导致混淆。
  3. 完成操作

    • 释放写锁 (Unlock())。
    • 记录一次删除命中 (delhit)。
    • 返回 nil 表示成功删除。

这里先使用读锁来预检查 key 是否存在,存在后,再使用写锁来数据删除。首先 bigcache 已经分成了 1024 分片,避免了大量锁竞争问题,又因为删除的多是已知存在需删除的场景,预检查可能是徒劳的,虽然 cache 是读多写少的场景,但还是怀疑其优化是否有效。

go 复制代码
func (s *cacheShard) del(hashedKey uint64) error {  
    // 预先使用读锁检查值是否存在,如果存在走删除流程.  
    s.lock.RLock()  
    {  
        itemIndex := s.hashmap[hashedKey]  
        // ringbuffer 起始位是 1  
        if itemIndex == 0 {  
            s.lock.RUnlock()  
            s.delmiss()  
            return ErrEntryNotFound  
        }  
​  
        if err := s.entries.CheckGet(int(itemIndex)); err != nil {  
            s.lock.RUnlock()  
            s.delmiss()  
            return err  
        }  
    }  
    s.lock.RUnlock()  
​  
    // 这里用写锁操作执行数据的删除.  
    s.lock.Lock()  
    {  
        // 根据 key hashcode 获取 ringbuffer index  
        itemIndex := s.hashmap[hashedKey]  
​  
        if itemIndex == 0 {  
            s.lock.Unlock()  
            s.delmiss()  
            return ErrEntryNotFound  
        }  
​  
        // 从 ringbuffer 里获取编码过的 entry.  
        wrappedEntry, err := s.entries.Get(int(itemIndex))  
        if err != nil {  
            s.lock.Unlock()  
            s.delmiss()  
            return err  
        }  
​  
        // 删除 key hashcode 对应的位置.  
        delete(s.hashmap, hashedKey)  
        // 尝试执行回调  
        s.onRemove(wrappedEntry, Deleted)  
​  
        // 把 entry 的 hash 值编码成 0.  
        resetKeyFromEntry(wrappedEntry)  
    }  
    s.lock.Unlock()  
​  
    // 删除命中统计  
    s.delhit()  
    return nil  
}

GC 垃圾回收的设计

bigcache 的 gc 垃圾回收是指淘汰清理过期数据,其实现很简单,从头到尾遍历数据,看到过期就淘汰,不过期就中断 gc 任务。

需要注意的是 bigcache 的过期时间为固定的,不像 redis、ristretto 可随意配置不同的过期时间。bigcache 是按照 fifo 先进先出来存储的,所以先入 ringbuffer 的对象必然要比后进来的先淘汰。

bigcache 实例化 cache 对象时,会启动一个协程来进行垃圾回收。每隔一段时间进行一次垃圾回收,默认时长为 1 秒。

go 复制代码
if config.CleanWindow > 0 {  
    go func() {  
        // 定时器,默认为 1秒  
        ticker := time.NewTicker(config.CleanWindow)  
        defer ticker.Stop()  
​  
        for {  
            select {  
            case <-ctx.Done():  
                return  
            case t := <-ticker.C:  
                // 执行垃圾回收.  
                cache.cleanUp(uint64(t.Unix()))  
            case <-cache.close:  
                return  
            }  
        }  
    }()  
}

下面为垃圾回收的具体的实现,由于 bigcache 预设了 1024 个数据分片,那么进行垃圾回收时,自然需要遍历每个 shard 分片。清理的原理如下。

  1. 从 ringbuffer 的 head 头部指针获取最先入队的对象,这里是 get,不是 pop 操作 ;
  2. 从数据中获取数据的插入时间,通过 ( 当前的时间 - 写入时间 ) > lifeWindow 判断是否过期 ;
  3. 如过期,则把 ringbufer 中头部的数据,也就是最老的数据干掉 ;
  4. 如没过期,则直接中断本地的垃圾回收任务。

简单说,每次判断 shard ringbuffer 中最老数据是否过期,过期则删掉,直到遍历到不过期的数据为止。这里的删除其实移动了 ringbuffer 的 header 偏移量位置指针。

lifeWindow 参数来控制过期的时长,当太小时,缓存数据很快会被 gc 清理掉。bigcache 的 delete 只是标记删除,数据依然在 ringbuffer 中。

那么被删除的数据什么时候会被清理掉,这里有两个时机。

  • 写操作,当 ringbuffer 已满,又无法扩容时,则先删掉老数据,再把新数据写进去。
  • 垃圾回收,从头开始进行过期判断,只要过期就清理。当满足 ringbuffer 的空闲阈值时,ringbuffer 也会回收空间。
go 复制代码
func (c *BigCache) cleanUp(currentTimestamp uint64) {  
    // 每个 shard 都需要执行清理.  
    for _, shard := range c.shards {  
        shard.cleanUp(currentTimestamp)  
    }  
}  
​  
func (s *cacheShard) cleanUp(currentTimestamp uint64) {  
    s.lock.Lock()  
    for {  
        // 从 ringbuffer 的 head 获取最先入队的对象,这里是 get,不是 pop 操作.  
        if oldestEntry, err := s.entries.Peek(); err != nil {  
            // err 不为空,中断.  
            break  
        } else if evicted := s.onEvict(oldestEntry, currentTimestamp, s.removeOldestEntry); !evicted {  
            // 判断是否过期,如过期则把 ringbufer 中头部的数据删掉,也就是最老的数据删掉.  
            break  
        }  
    }  
    s.lock.Unlock()  
}  
​  
func (s *cacheShard) onEvict(oldestEntry []byte, currentTimestamp uint64, evict func(reason RemoveReason) error) bool {  
    // 如果已过期,则执行回调把最老的数据删掉.  
    if s.isExpired(oldestEntry, currentTimestamp) {  
        evict(Expired)  
        return true  
    }  
    return false  
}

isExpired 用来判断是否过期,当 ( 当前的时间 - 写入时间 ) > lifeWindow 时,说明该 entry 已过期。

go 复制代码
func (s *cacheShard) isExpired(oldestEntry []byte, currentTimestamp uint64) bool {  
    // 从 entry 中获取插入的时间  
    oldestTimestamp := readTimestampFromEntry(oldestEntry)  
    if currentTimestamp <= oldestTimestamp {  
        return false  
    }  
    // ( 当前的时间 - 写入时间 ) > lifeWindow 为已过期.  
    return currentTimestamp-oldestTimestamp > s.lifeWindow  
}

总结

bigcache 是 golang 编写的高性能的缓存库,其设计很巧妙,通过数据分片解决高并发下锁竞争的问题,通过把数据存到 ringbuffer 来规避 golang gc 的开销。

bigcache 不太适合业务上的缓存对象,原因有两个。

其一 bigcache 不支持 lru / lfu 这类缓存淘汰算法,而使用 fifo 淘汰旧数据,这样势必影响缓存命中率和缓存效果。

其二 bigcache 不能支持除了 []byte 以外的数据结构,毕竟业务上的对象多为自定义 struct。大家存取时不能每次都进行 encode、decode 编解码吧?毕竟使用社区中有一堆黑科技的 sonic json 库,序列化小对象也至少几十个 us,反序列化更是序列化的两倍。实践中推荐使用 ristretto

相关推荐
绵羊20233 小时前
R语言绘制热图
开发语言·r语言
小冯记录编程3 小时前
深入解析C++ for循环原理
开发语言·c++·算法
为java加瓦3 小时前
Lombok @Data 注解在 Spring Boot 项目中的深度应用与实践指南
java·开发语言·数据库
董世昌413 小时前
js怎样改变元素的内容、属性、样式?
开发语言·javascript·ecmascript
CodeCraft Studio3 小时前
国产化Excel开发组件Spire.XLS教程:将Python列表转换为Excel表格(3种实用场景)
开发语言·python·excel·spire.xls·python列表转excel·国产化文档开发
我要学脑机3 小时前
C语言面试题问题+答案(claude生成)
c语言·开发语言
金涛03193 小时前
QT-day1
开发语言·qt
曹牧3 小时前
C#:可选参数
开发语言·c#
磨十三4 小时前
C++ 容器详解:std::list 与 std::forward_list 深入解析
开发语言·c++·list