一、bigcache 的核心设计思想
特性 | 实现方式 | 目的 |
---|---|---|
分片(Sharding) | 默认 1024 个 shard,每个 shard 独立加锁 | 降低并发写入时的锁竞争 |
索引结构 | map[uint64]uint32 (哈希 → ringbuffer 偏移) |
利用 Go 1.5+ 的"无指针 map 不被 GC 扫描"优化 |
数据存储 | 每个 shard 一个 []byte 环形缓冲区(ring buffer) |
避免 GC 遍历大量小对象 |
淘汰策略 | 覆盖写 + 时间窗口过期(非 LRU/LFU) | 简化实现,避免维护复杂数据结构 |
过期清理 | 后台协程定期扫描 ringbuffer,释放过期条目内存 | 渐进式回收,避免 STW |
🔍 关键洞察:bigcache 的"淘汰"其实是两阶段的:
- 逻辑过期 :超过
LifeWindow
后,Get()
返回ErrNotFound
;- 物理回收 :由
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 索引,其流程如下。
-
获取当前的秒级别的时间戳,这里抽象了 clock 方法,只要是为了方便的后面的单元测试 ;
-
在 hashmap 里获取 key 以前的 ringbuffer 的 index 位置信息,如果不为 0,且在 ringbuffer 又可拿到该 entry,则进行删除 ;
-
编码待写入 ringbuffer 里的结构 ;
-
尝试把编码的数据写到 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 字节 ,后续紧跟
key
和value
(即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 不够大,则分配新的;
- 避免每次
set
都make([]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)
- 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
用来在分片中删除数据,其流程如下。
-
乐观读锁定检查
- 使用
RLock()
进行只读锁定。 - 检查
hashedKey
是否存在于hashmap
中,若不存在立即返回ErrEntryNotFound
并记录一次删除未命中 (delmiss
)。 - 使用
CheckGet
方法检查索引对应的条目是否有效,无效则释放读锁并返回错误。
- 使用
-
升级为写锁定执行删除
- 在确认条目存在且有效后,释放读锁并获取写锁 (
Lock()
)。 - 再次检查
hashedKey
是否仍然存在于hashmap
中(因为自上次检查以来数据可能已更改或被删除)。 - 如果
hashedKey
存在,则从entries
获取对应条目。 - 删除
hashmap
中对应的hashedKey
条目。 - 调用
onRemove
回调函数处理条目移除事件,并标记状态为Deleted
。 - 如果统计功能启用,则从
hashmapStats
中也删除相应的hashedKey
。 - 最后,重置条目中的哈希信息 (
resetHashFromEntry
),避免残留数据导致混淆。
- 在确认条目存在且有效后,释放读锁并获取写锁 (
-
完成操作
- 释放写锁 (
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 分片。清理的原理如下。
- 从 ringbuffer 的 head 头部指针获取最先入队的对象,这里是 get,不是 pop 操作 ;
- 从数据中获取数据的插入时间,通过
( 当前的时间 - 写入时间 ) > lifeWindow
判断是否过期 ; - 如过期,则把 ringbufer 中头部的数据,也就是最老的数据干掉 ;
- 如没过期,则直接中断本地的垃圾回收任务。
简单说,每次判断 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