深入解析 GCache:scan4all 中内置的 Go 多策略缓存库(LFU / LRU / ARC / Simple)
【免费下载链接】scan4allOfficial repository vuls Scan: 15000+PoCs; 23 kinds of application password crack; 7000+Web fingerprints; 146 protocols and 90000+ rules Port scanning; Fuzz, HW, awesome BugBounty( ͡° ͜ʖ ͡°)...项目地址: https://gitcode.com/GitHub_Trending/sca/scan4all
导读
GCache 是 scan4all 项目 vendor 目录中随源码一起引入的一个通用 Go 缓存库,支持可过期缓存、LFU、LRU、ARC 四种淘汰策略、协程安全、可选的事件回调与自动加载(Loader)能力。本文以 GCache 官方 README 为主体,结合仓库内实际实现(cache.go、lru.go、lfu.go、arc.go、simple.go)以及 scan4all 中的真实使用场景(xray 请求缓存、httpx 主机错误缓存),带你从 API 用法一路深入到源码原理,掌握在 Go 项目中正确选型与使用 gcache 的完整技能。
一、GCache 是什么
GCache(github.com/bluele/gcache)是面向 Go 语言的缓存库,核心特性包括:
- 支持expirable Cache(可过期缓存)、LFU、LRU、ARC四种缓存算法;
- Goroutine safe:所有公开读写操作均通过互斥锁(
sync.RWMutex)保护,可安全并发使用; - 支持事件回调(可选):条目被驱逐(evict)、清空(purge)、新增(add)时触发;
- 支持自动加载(可选):缓存中不存在 key 时,由
LoaderFunc自动生成并回填缓存。
在 scan4all 中,gcache 已被实际应用于关键链路:pocs_yml/pkg/xray/requests/cache.go使用gcache.New(size).ARC().Build()构造全局缓存GC,为 xray 规则引擎缓存 HTTP 请求/响应对象与 TCP/UDP 连接;pkg/httpx/runner/runner.go(第 274-279 行)则用gcache.New(1000).ARC().Build()构造HostErrorsCache记录主机错误计数。这两处场景都是典型的“热点数据频繁查询、需要容量上限、希望命中率高”的场景,而 ARC 策略在混合访问模式下通常能取得比纯 LRU/LFU 更好的综合命中效果。
二、安装与最小示例
2.1 安装
$ go get github.com/bluele/gcachescan4all 的 go.mod 中已将该库纳入依赖,并固化在vendor/github.com/bluele/gcache/目录下,因此项目自身构建无需额外下载。
2.2 手动写入 key-value
package main import ( "github.com/bluele/gcache" "fmt" ) func main() { gc := gcache.New(20). LRU(). Build() gc.Set("key", "ok") value, err := gc.Get("key") if err != nil { panic(err) } fmt.Println("Get:", value) }输出:
Get: okgcache.New(20)创建一个容量为 20 的构建器,.LRU()指定淘汰策略,.Build()完成构建。从 cache.go 可以看到,New(size)默认的tp是TYPE_SIMPLE,只有显式调用.LRU()/.LFU()/.ARC()才会切换策略。
2.3 手动写入并指定过期时间
package main import ( "github.com/bluele/gcache" "fmt" "time" ) func main() { gc := gcache.New(20). LRU(). Build() gc.SetWithExpire("key", "ok", time.Second*10) value, _ := gc.Get("key") fmt.Println("Get:", value) // Wait for value to expire time.Sleep(time.Second * 10) value, err = gc.Get("key") if err != nil { panic(err) } fmt.Println("Get:", value) }输出:
Get: ok // 10 seconds later, new attempt: panic: ErrKeyNotFoundSetWithExpire为单条记录设置独立的过期时间。从 lru.go 的实现看,它会先调用内部set写入条目,再以clock.Now().Add(expiration)为该条目单独覆盖expiration字段。注意:未命中时Get返回的正是KeyNotFoundError(定义在 cache.go),因此上面示例会以panic结束。
三、四种缓存算法与选型
CacheBuilder通过EvictType记录策略类型(cache.go),Build()时按类型分派到不同实现(cache.go):
TYPE_SIMPLE→SimpleCacheTYPE_LRU→LRUCacheTYPE_LFU→LFUCacheTYPE_ARC→ARC
| 策略 | 淘汰依据 | 适用场景 | 构造方式 |
|---|---|---|---|
| SimpleCache | 无明确优先级,取决于 map 迭代顺序(由源码注释可知) | 无需淘汰策略、需要最简单行为时 | gcache.New(10).Build() |
| LRU | 最久未使用(least recently used) | 访问具有时间局部性(短时间内重复访问) | gcache.New(10).LRU().Build() |
| LFU | 最不经常使用(least frequently used) | 访问具有频率局部性(少量热点高频访问) | gcache.New(10).LFU().Build() |
| ARC | 在 LRU 与 LFU 之间动态平衡 | 访问模式多变、希望综合命中率更高 | gcache.New(10).ARC().Build() |
3.1 LRU:淘汰最久未使用的条目
func main() { // size: 10 gc := gcache.New(10). LRU(). Build() gc.Set("key", "value") }从 lru.go 的实现看,LRUCache内部用container/list双向链表 +map[interface{}]*list.Element实现 O(1) 查找与移动:每次Get命中会把条目MoveToFront(lru.go),容量满时evict(1)从链表尾部Back()移除最久未使用项(lru.go)。
3.2 LFU:淘汰最不经常使用的条目
func main() { // size: 10 gc := gcache.New(10). LFU(). Build() gc.Set("key", "value") }LFU 的实现是“频率桶”结构:freqList中每个freqEntry保存一个访问频率及其下的条目集合,新条目初始进入 freq=0 的桶(lfu.go)。每次命中时increment会把条目迁移到freq+1的桶中(lfu.go);容量满时evict从频率最低的桶开始驱逐(lfu.go)。
3.3 ARC:LRU 与 LFU 的动态平衡
func main() { // size: 10 gc := gcache.New(10). ARC(). Build() gc.Set("key", "value") }ARC(Adaptive Replacement Cache,自适应替换缓存)通过四个列表t1(近期一次访问)、t2(近期多次访问)、b1、b2(各自对应的“幽灵”目录)持续在 LRU 与 LFU 之间调整,以提升综合命中率。从 arc.go 可以看到,setPart根据b1/b2的长度比例动态调整part分区参数(arc.go),replace负责在容量满时从合适列表中移出旧条目(arc.go)。这正是 scan4all 的 xray 规则缓存与 httpx 主机错误缓存选用 ARC 的原因——面对混合工作负载,ARC 比固定策略更“稳”。
3.4 SimpleCache:默认的简单实现
func main() { // size: 10 gc := gcache.New(10).Build() gc.Set("key", "value") v, err := gc.Get("key") if err != nil { panic(err) } }SimpleCache不调用任何策略方法时默认启用(New的默认tp为TYPE_SIMPLE),没有明确的淘汰优先级,容量满时按 map 迭代顺序驱逐(simple.go)。注意一个差异:当size <= 0时,SimpleCache的init()会创建一个无容量限制的 map(simple.go),而其他策略在Build()时对size <= 0会直接panic("gcache: Cache size <= 0")(cache.go)。
四、Loading Cache:自动加载缺失值
如果指定了LoaderFunc,当 key 不存在时,缓存会自动调用加载函数生成值并存入缓存,直到被驱逐或手动失效。
func main() { gc := gcache.New(10). LRU(). LoaderFunc(func(key interface{}) (interface{}, error) { return "value", nil }). Build() v, _ := gc.Get("key") // output: "value" fmt.Println(v) }GCache 会协调缓存填充过程:在整个复制进程中,同一 key 的加载只执行一次,然后把加载结果分发给所有调用方(singleflight 语义)。
这一机制来自 singleflight.go(源码注释标明源自 Google 的 singleflight 设计):Group.Do通过map[interface{}]*call记录正在执行的加载任务,并发的重复请求会等待首个请求完成并共享同一结果(singleflight.go)。同时baseCache.load对 Loader 做了recover保护,Loader 一旦 panic 会以fmt.Errorf("Loader panics: %v", r)返回错误而不是拖垮整个程序(cache.go)。
4.1 带过期时间的自动加载
LoaderExpireFunc允许加载函数为每次加载的值单独指定过期时长;返回的*time.Duration为nil表示该值永不过期。
func main() { var evictCounter, loaderCounter, purgeCounter int gc := gcache.New(20). LRU(). LoaderExpireFunc(func(key interface{}) (interface{}, *time.Duration, error) { loaderCounter++ expire := 1 * time.Second return "ok", &expire, nil }). EvictedFunc(func(key, value interface{}) { evictCounter++ fmt.Println("evicted key:", key) }). PurgeVisitorFunc(func(key, value interface{}) { purgeCounter++ fmt.Println("purged key:", key) }). Build() value, err := gc.Get("key") if err != nil { panic(err) } fmt.Println("Get:", value) time.Sleep(1 * time.Second) value, err = gc.Get("key") if err != nil { panic(err) } fmt.Println("Get:", value) gc.Purge() if loaderCounter != evictCounter+purgeCounter { panic("bad") } }输出:
Get: ok evicted key: key Get: ok purged key: key这里演示了一个完整的“加载—过期—重新加载—清空”生命周期:第一次Get触发 Loader 加载并设置 1 秒过期;1 秒后条目过期,再次Get触发重新加载;Purge()清空缓存时逐条回调PurgeVisitorFunc。最后的断言loaderCounter == evictCounter+purgeCounter验证了加载次数与驱逐+清空次数的守恒关系。
五、Expirable Cache:全局过期时间
通过Expiration(duration)为整个缓存设置统一过期时间:
func main() { // LRU cache, size: 10, expiration: after a hour gc := gcache.New(10). LRU(). Expiration(time.Hour). Build() }从实现看,Expiration把time.Duration指针存入构建器(cache.go),各策略在set时若c.expiration != nil则以clock.Now().Add(*c.expiration)为条目填充过期时间(如 lru.go)。SetWithExpire与Expiration的区别在于:前者针对单条记录,后者作用于缓存整体;二者可同时存在,单条过期时间优先。
5.1 时钟抽象与测试友好
Clock接口(clock.go)把时间来源抽象出来:默认使用RealClock(真实系统时间),同时提供FakeClock(通过NewFakeClock()获得),支持Advance(d)手动推进时间。这意味着在编写过期逻辑的单元测试时,无需真实 sleep 等待,只需推进假时钟即可验证过期行为。
六、Event Handlers:事件回调
gcache 提供三种可选回调,均在 Builder 上注册:
| 回调 | 触发时机 | 示例 |
|---|---|---|
EvictedFunc | 条目被驱逐(evict)时 | 容量满淘汰、过期移除、显式Remove |
AddedFunc | 条目新增(add)时 | Set/SetWithExpire插入新键 |
PurgeVisitorFunc | 缓存被清空(purge)时 | Purge()对每个条目回调 |
6.1 Evicted 回调
容量为 2 的缓存连续写入 3 个键,第 3 次写入触发淘汰,第一个键被驱逐:
func main() { gc := gcache.New(2). EvictedFunc(func(key, value interface{}) { fmt.Println("evicted key:", key) }). Build() for i := 0; i < 3; i++ { gc.Set(i, i*i) } }输出:
evicted key: 06.2 Added 回调
每次新增键值对都会触发AddedFunc:
func main() { gc := gcache.New(2). AddedFunc(func(key, value interface{}) { fmt.Println("added key:", key) }). Build() for i := 0; i < 3; i++ { gc.Set(i, i*i) } }输出:
added key: 0 added key: 1 added key: 2注意二者的差异:AddedFunc在每次Set新键时触发(重复 Set 同一键是否触发取决于具体策略实现中对已存在条目的分支处理);EvictedFunc在条目真正被移出缓存时触发,例如 lru.go 的removeElement中,从链表移除并deletemap 后回调evictedFunc。
七、完整 API 一览与并发安全
Cache接口(cache.go)定义了全部公开方法,四种策略均实现该接口(如lfu.go中var _ Cache = (*LFUCache)(nil)的编译期断言):
| 方法 | 说明 |
|---|---|
Set(key, value) | 写入键值对 |
SetWithExpire(key, value, expiration) | 写入并指定过期时间 |
Get(key) | 取值;不存在且配置了 Loader 时自动加载 |
GetIFPresent(key) | 仅当存在时取值;配置了 Loader 时后台异步刷新 |
GetALL(checkExpired) | 返回全部键值对,可过滤已过期项 |
Remove(key) | 删除指定键,返回是否删除成功 |
Purge() | 清空整个缓存 |
Keys(checkExpired) | 返回键列表,可过滤已过期项 |
Len(checkExpired) | 返回条目数,可过滤已过期项 |
Has(key) | 判断键是否存在且未过期 |
HitCount / MissCount / LookupCount / HitRate | 命中统计(stats.go) |
其中Get与GetIFPresent的行为差异值得注意:命中失败时,Get调用getWithLoader(key, true)(同步等待加载结果),而GetIFPresent调用getWithLoader(key, false)(不等待,直接返回KeyNotFoundError,由后台 goroutine 完成加载),参见 lru.go。
关于并发安全:所有公开方法均通过baseCache.mu(sync.RWMutex)加锁,读多写少的场景使用RLock(如Keys、Len、GetALL、Has),写场景使用Lock;命中/未命中计数则使用sync/atomic的AddUint64/LoadUint64(stats.go),因此多个 goroutine 并发读写是安全的。
八、scan4all 中的真实应用
gcache 在 scan4all 中不是死代码,而是被两个核心模块直接依赖:
1. xray 规则引擎的请求/连接缓存(pocs_yml/pkg/xray/requests/cache.go)
var GC gcache.Cache func InitCache(size int) { GC = gcache.New(size).ARC().Build() }该模块以gcache.Cache为全局接口,围绕它实现了一套完整的去重缓存:
getHttpRuleHash将Method + Path + Headers + Body + FollowRedirects归一化为 MD5 哈希,作为缓存 key(第 22-37 行);XraySetHttpRequestCache/XrayGetHttpRequestCache负责 HTTP 请求/响应对象的写入与读取(第 39-71 行);XraySetTcpUdpConnectionCache/XrayGetTcpUdpConnectionCache缓存 TCP/UDP 连接对象(第 73-97 行);XraySetTcpUdpResponseCache/XrayGetTcpUdpResponseCache缓存 TCP/UDP 响应(第 99-131 行)。
这正体现了 gcache 的interface{}泛型键值设计——同一个 ARC 缓存可以同时容纳请求对象、连接对象、响应对象等异构类型。
2. httpx 模块的主机错误计数缓存(pkg/httpx/runner/runner.go)
if options.HostMaxErrors >= 0 { gc := gcache.New(1000). ARC(). Build() runner.HostErrorsCache = gc }当HostMaxErrors开启时,用容量 1000 的 ARC 缓存记录各主机的错误状态,用于快速判断目标是否已超出错误阈值,避免重复请求无响应主机。
这两个用例都是“高并发查询 + 有限容量 + 热点频繁访问”的典型缓存场景,ARC 的自适应特性使其成为项目内的默认首选。
九、最佳实践建议
结合 README 与源码实现,归纳几点实战建议:
- 策略选择:访问模式清晰时,读多写少、热点集中选
LFU;时间局部性强(短期重复读同一批 key)选LRU;模式不明确或混合负载选ARC(scan4all 内部即如此)。 - 容量设置:LRU/LFU/ARC 的
size必须大于 0,否则Build()直接 panic;SimpleCache 允许size <= 0(视为无上限),但无淘汰策略意味着潜在内存风险。 - 过期策略:需要全局 TTL 用
Expiration,需要单键差异化 TTL 用SetWithExpire,动态 TTL 用LoaderExpireFunc。 - 利用 Loader 防击穿:配置
LoaderFunc后,Get未命中会自动加载并回填,配合 singleflight 机制可避免并发场景下的缓存穿透与重复计算。 - 事件回调用于统计与清理:
EvictedFunc可统计淘汰率、PurgeVisitorFunc可做清理钩子,这些回调在Remove/Purge/容量淘汰/过期移除等路径上都会触发,注意回调内不要对缓存再执行加锁写操作以免死锁。 - 测试中使用 FakeClock:通过
CacheBuilder.Clock(NewFakeClock())注入假时钟(clock.go),配合Advance即可无需真实 sleep 验证过期行为。
GCache 以极简的 Builder API 包装了多种成熟的缓存算法,既适合作为业务模块的本地缓存,也适合像 scan4all 这样在扫描引擎内部做去重与限流缓存,是一份值得深入阅读的 Go 缓存实现范本。
【免费下载链接】scan4allOfficial repository vuls Scan: 15000+PoCs; 23 kinds of application password crack; 7000+Web fingerprints; 146 protocols and 90000+ rules Port scanning; Fuzz, HW, awesome BugBounty( ͡° ͜ʖ ͡°)...项目地址: https://gitcode.com/GitHub_Trending/sca/scan4all
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考