CCache:面向高并发场景的 Go LRU 缓存库完整实践指南
【免费下载链接】inngestThe leading workflow orchestration platform. Run stateful step functions and AI workflows on serverless, servers, or the edge.项目地址: https://gitcode.com/GitHub_Trending/in/inngest
CCache 是一个用 Go 编写的 LRU(最近最少使用)缓存库,其核心设计目标是支撑高并发访问。它通过三项关键技术降低链表锁竞争:引入提升窗口限制条目被提升的频率、使用带缓冲的 channel 将提升操作排队给单一 worker 处理、并在同一 worker 线程内完成垃圾回收。本指南以 ccache v2 的 README 为骨架,结合本仓库 vendor 目录中的源码实现,全面讲解其安装、配置、核心 API、跟踪模式、分层缓存等能力,读完即可在项目(如 pkg/expressions/expressions.go 中用它缓存预编译表达式)中落地使用。除特别说明外,ccache 的所有方法都是线程安全的。
一、快速安装与基本使用
首先通过 Go 模块系统引入 ccache v2:
go get github.com/karlseguin/ccache/v2然后在代码中导入并创建Cache实例:
import ( "github.com/karlseguin/ccache/v2" ) var cache = ccache.New(ccache.Configure())Configure返回一个配置对象,并暴露了链式(chainable)API,可以一次性设置多个参数:
var cache = ccache.New(ccache.Configure().MaxSize(1000).ItemsToPrune(100))创建完成后即可对缓存执行Get、Set、Delete等操作。在本仓库中,ccache v2 的实际用法可以参见 pkg/expressions/expressions.go:项目将 ccache 用作"预编译表达式"的全局缓存,CacheMaxSize被设置为 50000,TTL 与扩展时间均为 30 分钟——这是一个典型的"编译结果昂贵、读多写少"的缓存场景,非常适合 LRU 缓存。
二、配置项详解:高频调参与内部结构参数
Configure()提供的默认配置在 configuration.go 中定义。配置分为两类:日常最可能调整的,以及改变缓存内部结构的。
2.1 最常调整的配置
| 配置方法 | 作用 | 默认值 |
|---|---|---|
MaxSize(int) | 缓存可存储的最大条目数(注意 README 原文为 "maximum number size",语义上指条目数量上限) | 5000 |
GetsPerPromote(int) | 条目被获取多少次后才提升一次。对于大缓存 + 长 TTL 的场景,不必每次 Get 都提升 | 3 |
ItemsToPrune(int) | 达到MaxSize时一次剪枝的条目数。一次释放多个槽位能改善性能 | 500 |
GetsPerPromote的实现细节:从 item.go 的shouldPromote可以看到,每个条目维护一个promotions计数器,每次命中时自增,只有恰好等于getsPerPromote时才返回 true 触发提升并清零。这形成了一个"提升窗口",避免了热数据每次访问都争抢链表锁。ItemsToPrune的实现细节:gc()函数(cache.go)从 LRU 链表尾部(最久未使用)向前遍历,最多剪掉itemsToPrune个条目;若启用了跟踪模式,还会跳过refCount > 0的条目。
2.2 改变内部结构的配置
| 配置方法 | 作用 | 默认值 |
|---|---|---|
Buckets(count uint32) | ccache 将内部 map 分片以提供更高并发度。必须是 2 的幂(1、2、4、8、16...),传入非法值时回退为 16 | 16 |
PromoteBuffer(int) | 排队提升操作的缓冲 channel 大小。队列满时提升操作会被跳过 | 1024 |
DeleteBuffer(int) | 排队删除操作的缓冲 channel 大小。队列满时Delete()调用会阻塞 | 1024 |
Buckets的合法性校验:源码用位运算count&(^count+1) == count判断是否为 2 的幂,非法值一律重置为 16(configuration.go)。分片后,键通过 FNV-1a 哈希(fnv.New32a)取模定位到对应 bucket(cache.go),每个 bucket 持有独立的sync.RWMutex,读操作只用读锁,从而大幅降低写锁竞争。PromoteBuffer/DeleteBuffer的差异:Cache.promote()使用select + default非阻塞发送,队列满直接丢弃本次提升;而Delete()是阻塞发送到deletables通道(cache.go),这是两者在满队列时行为的关键区别。
此外还有一个 README 中未展开、但源码中存在的回调配置:OnDelete(func(item *Item))(configuration.go),用于在条目被删除/淘汰时做资源清理(如调用缓存对象的Close()),是管理连接、文件句柄类缓存的实用配置。
三、核心 API:Get / Set / Fetch / Delete 全家桶
3.1 Get:返回 *Item 而非裸值
item := cache.Get("user:4") if item == nil { // 未命中,处理缺失 } else { user := item.Value().(*User) }Get返回的*Item暴露以下方法(实现见 item.go):
Value() interface{}—— 缓存的值Expired() bool—— 条目是否已过期(内部基于纳秒时间戳比较)TTL() time.Duration—— 距过期剩余时长(已过期则为负值)Expires() time.Time—— 条目的过期时刻
关键设计:返回过期条目而非直接删除。这让调用方自行决定是否提供"旧内容"。例如:可以容忍 30 秒内的过期数据直接返回,同时在后台异步刷新;当数据源不可用时,甚至可以无限期提供陈旧内容(stale-while-revalidate 策略)。
3.2 Set:键 + 值 + TTL
cache.Set("user:4", user, time.Minute*10)Set内部会覆盖已有键:旧条目被放入deletables队列等待回收,新条目立即触发提升(cache.go)。
3.3 Fetch:Get 与 Set 的原子组合
item, err := cache.Fetch("user:4", time.Minute*10, func() (interface{}, error) { // 缓存未命中时执行:拉取数据并返回;返回 error 则不缓存 return fetchUser("4") })Fetch的语义(cache.go):先Get,命中且未过期则直接返回;否则调用 fetch 函数,若 fetch 返回错误则不缓存并把错误原样返回给调用方。注意:命中但已过期的条目,也会触发重新拉取。这是实现 cache-aside 模式的标准入口。
3.4 Delete 系列:删除、前缀删除与函数式删除
cache.Delete("user:4") // 对不存在的键调用是安全的DeletePrefix(prefix string) int—— 删除所有以指定前缀开头的键,返回删除数量。DeleteFunc(matches func(key string, item *Item) bool) int—— 删除所有满足匹配函数的条目,返回删除数量。
实现细节(性能优化亮点):deleteFunc采用"两遍扫描"策略(bucket.go):第一遍在读锁下收集匹配项并写入deletables队列,第二遍仅在确有匹配项时才获取写锁执行真正的 map 删除;无匹配时完全避免写锁。前缀匹配deletePrefix就是deleteFunc加上strings.HasPrefix匹配函数的特化实现(bucket.go)。
3.5 ForEachFunc:随机顺序遍历
ForEachFunc(fn func(key string, item *Item) bool)遍历所有键值对并传入回调;回调返回false时终止遍历。遍历顺序是随机的(Go map 迭代本身无序),因此不适合依赖顺序的场景。
3.6 Clear / Extend / Replace
Clear()—— 清空整个缓存。若 GC 正在运行会等待其结束(通过control通道与 worker 同步,见 cache.go)。Extend(duration)—— 按相对当前时间的指定时长延长条目存活期(内部直接原子写入新的过期时间戳,见 item.go)。Replace(key, value)—— 只更新值,不重置 TTL,也不改变条目在 LRU 链表中的位置;返回true表示键存在并被替换。若键不存在,值不会被插入并返回false(cache.go)。
3.7 GetDropped:淘汰统计
dropped := cache.GetDropped()返回自上次调用以来因内存压力(达到 MaxSize)被淘汰的条目数,每次调用后计数器归零。若 GC 正在运行会等待其结束,因此它设计为异步调用,用于统计/监控目的(如周期性上报淘汰速率)。
3.8 Stop:停止后台 worker
cache.Stop()停止缓存的单一后台 worker 协程。调用后缓存不应再被使用(操作大概率 panic)。Stop是让 GC 能够回收缓存对象的必要步骤——因为后台 worker 持有 cache 引用,不停止就会造成泄漏。
四、后台 worker 与三条通信通道的架构剖析
要理解 ccache 为何高并发表现好,关键是 cache.go 中Cache结构的三个通道 + 一个 worker 协程的架构:
┌──────────────────────────────────────┐ Get ─────┼─► bucket(分片 map,RWMutex)──► promote ──► promotables(缓冲 chan,非阻塞) Set ─────┼─► bucket ──► deletables(缓冲 chan,阻塞) Delete ──┼─► bucket ──► deletables Clear/ ┼─► control(控制消息:clear / getDropped / setMaxSize) GetDropped┘ └───────────────┬──────────────────────┘ ▼ worker 协程(单线程消费) - 处理提升:doPromote / shouldPromote 窗口 - 处理删除:doDelete - 执行 GC:超过 MaxSize 时从链表尾部剪枝- LRU 链表:
list *list.List是唯一的数据结构,只由 worker 协程访问(doPromote、doDelete、gc),因此对链表的操作天然无锁。 - promotables(非阻塞):
promote()用select/default发送,队列满即丢弃——提升只是优化,丢掉一次不会影响正确性。 - deletables(阻塞):
Delete阻塞发送,保证删除请求最终被处理。 - control:用于
Clear、GetDropped、SetMaxSize等需要与 worker 同步的操作,worker 处理完通过回传 channel 应答(如clear{done})。 - SetMaxSize 的动态调整:
SetMaxSize(size)通过 control 通道实时修改上限,若新上限小于当前占用,会立即触发一轮 GC(cache.go)。 - Stop 的排水逻辑:
Stop()关闭 promotables 后,worker 进入drain阶段,把剩余 deletables 全部处理完才退出并关闭 control 通道(cache.go)。
这套"读路径只碰分片 map 的读锁、写路径通过通道汇聚到单线程"的设计,把最频繁的读操作开销压到最低。
五、Tracking 模式:长生命周期引用的数据一致性保证
普通缓存对"缓存值如何被使用"毫不知情,这在"调用方会长期持有缓存对象引用"的场景会出问题:缓存可能淘汰一个对象,而代码里还持有它的引用;随后重新加载后又产生同一数据的新对象——同一份数据出现两个版本,浪费内存且可能引发诡异行为(这正是 identity map 想解决的问题)。
启用跟踪模式:
cache = ccache.New(ccache.Configure().Track())之后用TrackingGet取出的条目,在调用Release()之前不会被淘汰:
item := cache.TrackingGet("user:4") user := item.Value() // 键不存在时返回 nil item.Release() // 即使 Value() 为 nil 也可以调用实际使用中Release通常在代码的其他位置、稍后时机调用;TrackingSet则可以设置一个受跟踪的值。TrackingGet的实现(cache.go)对条目执行track(),即原子自增引用计数;键不存在时返回NilTracked哨兵对象(item.go),它对所有方法都提供安全的空实现。
淘汰时的引用计数检查:gc()中if c.tracking == false || atomic.LoadInt32(&item.refCount) == 0(cache.go)——启用跟踪时,被引用的条目即使处于 LRU 尾部也不会被剪枝,这本质是一个轻量引用计数器。
使用跟踪模式有两个理由:
- 如果代码本来就持有这些对象的引用,放进缓存并不会额外占用内存,没有理由不缓存;
- 更重要的,它保证系统返回一致的数据:不跟踪时,"user:4" 可能被淘汰,随后的
Fetch会重新加载,导致系统不同部分返回不同版本的数据。
六、LayeredCache:主键 + 次键的两级缓存
LayeredCache用主键 + 次键两级键来存取值。删除可以针对(主键, 次键)组合,也可以只针对主键——一次性删除共享该主键的所有条目。它特别适合HTTP 缓存场景:想按请求清除某一资源的所有变体时非常方便。
cache := ccache.Layered(ccache.Configure()) cache.Set("/users/goku", "type:json", "{value_to_cache}", time.Minute*5) cache.Set("/users/goku", "type:xml", "<value_to_cache>", time.Minute*5) json := cache.Get("/users/goku", "type:json") xml := cache.Get("/users/goku", "type:xml") cache.Delete("/users/goku", "type:json") cache.Delete("/users/goku", "type:xml") // 或者一步到位: cache.DeleteAll("/users/goku")LayeredCache 接受与主缓存相同的配置对象,暴露相同的可选跟踪能力,API 与主缓存几乎一一对应,差异在于所有方法都多一个主键参数。其内部结构是"两级 bucket":先按主键哈希分片到layeredBucket,其中再嵌套一个map[string]*bucket用次键定位(layeredcache.go)。它同样提供DeletePrefix(primary, prefix)与DeleteFunc(primary, matches)用于按主键内的次键前缀/条件删除(layeredcache.go),以及ForEachFunc(primary, fn)遍历单个主键下的全部条目。
七、SecondaryCache:把次键层级当作独立缓存使用
有时我们希望代码中始终只操作条目的次键部分——例如主键在别处被当作 key 使用。此时用GetOrCreateSecondaryCache取出一个SecondaryCache:
cache := ccache.Layered(ccache.Configure()) sCache := cache.GetOrCreateSecondaryCache("/users/goku") sCache.Set("type:json", "{value_to_cache}", time.Minute*5)SecondaryCache的交互语义与普通Cache完全相同(Get/Set/Fetch/Delete/Replace/TrackingGet一应俱全)。唯一区别:对不存在的主键调用Get不会返回 nil,而是返回一个空的次级缓存(GetOrCreateSecondaryCache在键缺失时自动创建底层 bucket 并返回,见 layeredcache.go)。注意这个空缓存实际也会被写入到父 LayeredCache 中。
八、Size 语义与内存估算
默认情况下,每个条目的 size 为 1,因此MaxSize(10000)大约能存 10000 个条目。
如果值类型实现了Size() int64方法(即满足 item.go 定义的Sized接口),ccache 会采用该方法返回的大小来计算:
type Sized interface { Size() int64 }newItem中通过类型断言value.(Sized)检测(item.go),命中则用自定义大小,否则为 1。注意:ccache 每个条目有约 350 字节的自身开销,这个开销不计入 size。例如:MaxSize(4096000)且条目Size()返回 2048 时,预计可容纳约 2000 个条目(4096000/2048),而实际占用约 4796000 字节(含 2000 × 350 字节的条目开销)。
九、在本仓库中的真实应用
ccache v2(v2.0.8,见 go.mod)在本仓库中是一个被广泛使用的依赖。典型场景是把编译/计算成本高、重复命中率高的结果缓存起来:
- pkg/expressions/expressions.go:用
ccache.New(ccache.Configure().MaxSize(50000))构建全局缓存,存放预编译的 CEL 表达式,配合 30 分钟的 TTL 与CacheExtendTime延长机制。源码注释给出经验值:平均 20 个编译后的表达式约占 1MB 内存。 - pkg/constraintapi/cache.go:在约束 API 层使用 ccache 缓存内部状态。
- pkg/api/apiv1/checkpoint.go与pkg/execution/queue/processor.go:在 API 与执行队列处理器中利用其高并发读取能力。
- pkg/execution/batch/buffer_test.go:测试代码中也会使用 ccache 辅助构造缓存。
(仓库同时引入了 ccache v3,v2 与 v3 的主要差异在于 v3 支持泛型类型参数,例如 dnscache.go 中使用的是ccache.Configure[cacheType]()的 v3 写法。)
十、常见问题与最佳实践小结
- 何时该调
GetsPerPromote:大缓存 + 长 TTL、热点相对分散时,保持默认 3 即可;热点高度集中时调高可进一步减少链表操作。 - 何时该调
Buckets:写入密集型场景下,分片数越高写锁竞争越小(需保持 2 的幂);但分片本身有内存开销,不宜盲目调大。 - 过期条目会占用容量吗:会。过期条目只有在被访问(
Get仍会返回它)或 LRU 剪枝时才被清理,因此MaxSize指的是"条目数量上限"而非"未过期条目上限";如需保证过期数据不堆积,可结合Fetch的重新拉取机制自然替换。 - 用
Fetch替代手写 miss 逻辑:它能天然规避"先查缓存、再回源、再写回"的样板代码,且错误路径(fetch 返回 error 不写入缓存)已内置。 - 长期持有引用的数据务必开
Track():否则可能同时存在同一数据的新旧两个版本,导致数据不一致。 - 进程退出前记得
Stop():否则后台 worker 协程会阻止缓存对象被 GC 回收。 - 淘汰监控用
GetDropped():它是"自上次调用以来"的增量计数,适合周期性采集以观察缓存压力。
CCache 用"分片 map + 单线程 worker + 缓冲通道"的组合,在保证全部方法线程安全的同时,把 LRU 维护成本从每次访问都竞争链表锁,降低为"读路径只碰读锁、写路径异步汇聚"。对于读多写少、热点数据命中的高并发 Go 服务,它是一个轻量、可控且易于嵌入的缓存方案。
【免费下载链接】inngestThe leading workflow orchestration platform. Run stateful step functions and AI workflows on serverless, servers, or the edge.项目地址: https://gitcode.com/GitHub_Trending/in/inngest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考