《用 Go 写一个并发安全的内存缓存》
要做什么,不做什么
给业务里最常见的那个需求写个底座:函数结果、外部接口响应、配置对象,放内存里复用,别每次都打穿到数据库。本文产出一个单文件、约 300 行的缓存包,三个能力:TTL 过期、容量淘汰、并发回源去重。边界先划死:单机内存缓存,多实例不共享、重启即失、不做持久化——这三条里任何一条是你的硬需求,直接上 Redis,别在本地缓存上叠分布式逻辑;淘汰用近似 LRU(先清过期、再赶最久没访问的),不做教科书级的 O(1) 双向链表实现;内存占用统计不做,按条数淘汰。Go 版本口径:1.22(2024-02 GA),本文会用上它的循环变量新语义,有专门一节交代。
先把接口和验收钉死
动手前把 API 面定小,缓存这种东西接口越大越难测:
c := cachex.New(5*time.Minute, 10000) // TTL、最大条数
c.Set("user:42", user)
v, ok := c.Get("user:42")
c.Delete("user:42")
v, err := c.GetOrLoad("user:42", loadFromDB) // miss 回源,并发去重
c.Close() // 停后台清扫 goroutine
验收两条,写完必须过:一是 go test -race 全绿,用例里故意读写删混着来;二是压测口径能自圆其说——读多写少下锁竞争不构成瓶颈(定性判断,依据见压测一节)。
选型:sync.Map 还是 Mutex+Map
Go 面试经典题,放到缓存场景我的答案很干脆:默认 Mutex+Map,不选 sync.Map。理由三条:
- 类型安全。sync.Map 的 key 和 value 都是 any,取出来要断言,编译器帮不上忙;Mutex 包一层普通 map 则完全吃泛型红利,想做成
Cache[K comparable, V any]只是改个签名的事。 - 缓存的操作是复合的。读过期条目顺手删、访问时更新 lastAccess、满了挑一个淘汰——全是”读一下再改”的复合动作,必须待在同一个临界区里。sync.Map 没有锁把手给你,Load 和 Store 之间永远有缝。
- sync.Map 的适用面正好是缓存的反面。官方文档给它划的场景是:读多写少、key 集合基本稳定。缓存恰恰是 key 不断增删的典型。
结构定下来就是一把锁守一个 map:
type entry struct {
value any
expireAt time.Time
lastAccess time.Time // 近似 LRU 用
}
type Cache struct {
mu sync.Mutex
data map[string]*entry
ttl time.Duration
max int
group singleflight.Group
now func() time.Time // 可注入,测试里拨表用
stop chan struct{}
stopOnce sync.Once
}
锁粒度先做一把全局锁,测出瓶颈再谈分片——分片不过是 N 把锁 N 个 map 加个取模路由,随时能加,不必第一天就加。
TTL:time.AfterFunc 还是惰性删除
过期有两种主流做法,两种我都写过,踩坑后固定用第二种。
第一种,每条目挂一个定时器:
time.AfterFunc(c.ttl, func() {
c.mu.Lock()
delete(c.data, key)
c.mu.Unlock()
})
直觉很美好,问题藏在细节里。同 key 被 Set 覆盖后旧 timer 还挂着,到点照样执行 delete,把新值误删——要么接受这个 bug,要么 stop 旧 timer 再重排,每条目多一份簿记,竞态窗口也跟着来。写路径也不省心:每次 Set 都生成一个 timer,runtime 的 timer 堆压力是真实成本(公开口径,量级随写入频率走)。第三,Close 时没法顺手清掉全部在途 timer。
第二种,惰性删除:Get 时检查 expireAt,过期就当场删掉、返回 miss。检查动作本来就要做,等于零成本拿到正确性;代价是冷 key 不被访问就永远躺着,内存只进不出。所以补一个后台清扫兜底:单 goroutine,每 ttl/2 扫一轮:
func (c *Cache) janitor() {
t := time.NewTicker(c.ttl / 2)
defer t.Stop()
for {
select {
case <-c.stop:
return
case <-t.C:
now := c.now()
c.mu.Lock()
for k, e := range c.data {
if now.After(e.expireAt) {
delete(c.data, k)
}
}
c.mu.Unlock()
}
}
}
清扫精度无所谓——过期条目多活半拍不影响正确性,Get 永远会挡住它。真要求秒级过期精度的业务(比如限流计数),那不是缓存该干的活,换专用结构。
singleflight:过期瞬间的并发回源只放一个
缓存击穿的剧本:热点 key 过期那一瞬间,一百个请求同时 miss,同时回源打数据库。防它的标准件是 golang.org/x/sync 里的 singleflight:同 key 的并发调用合并成一个在途请求,结果分发给所有等待者。
func (c *Cache) GetOrLoad(key string, load func() (any, error)) (any, error) {
if v, ok := c.Get(key); ok {
return v, nil
}
v, err, _ := c.group.Do(key, func() (any, error) {
if v, ok := c.Get(key); ok { // 排队期间别人已经回源过了
return v, nil
}
loaded, err := load()
if err != nil {
return nil, err // 回源失败不写缓存,下次照常重试
}
c.Set(key, loaded)
return loaded, nil
})
return v, err
}
两个容易漏的细节:group.Do 里要再 Get 一次——你排队等结果的时候,前面那个调用可能已经把值放进缓存了;回源失败绝不能写缓存,否则一个瞬时错误被缓存住,等于把故障放大成持续故障。
验收这个能力有个直观办法:load 函数里睡 50 毫秒并计数,一百个 goroutine 并发 GetOrLoad 同一个 key,跑完打印计数。预期输出就一行:
load 被调用次数: 1
计数是一,去重就成立。把 singleflight 那行删掉重跑,这个数会变成不确定的小数字——这个对照值得亲手做一遍,比读十遍文档记得牢。
容量淘汰:近似 LRU,够用就好
满员的处理放在 Set 入口:先腾位再放新。腾位先扫一轮把过期条目全删掉(多数时候腾完就够),还不够就淘汰 lastAccess 最老的一个:
// 调用方已持锁
func (c *Cache) evictLocked(now time.Time) {
for k, e := range c.data {
if now.After(e.expireAt) {
delete(c.data, k)
}
}
if len(c.data) < c.max {
return
}
var oldestKey string
var oldest time.Time
first := true
for k, e := range c.data {
if first || e.lastAccess.Before(oldest) {
oldestKey, oldest, first = k, e.lastAccess, false
}
}
if !first {
delete(c.data, oldestKey)
}
}
这是 O(n) 扫描,不是教科书 O(1) LRU(那要 container/list 双向链表配合,代码量翻一倍)。但注意触发条件:只在写满那一刻发生。max 设到万级、写入中等频度的业务里,这笔扫描摊下来可以忽略;真到了热点大缓存的程度,容量早该换 Redis 或者上分片了。这也是边界声明的一部分:本文的近似 LRU 扛不到那个量级,别拿它硬扛。
测试:-race 全绿是硬验收
并发代码的测试就一条铁律:开着 race detector 跑。go test -race 不绿,其他一切白谈。我的混压用例:八个 goroutine 各两千轮,读写删混着来,key 故意留碰撞:
func TestConcurrentMixed(t *testing.T) {
c := New(time.Minute, 64)
defer c.Close()
var wg sync.WaitGroup
for i := range 8 { // Go 1.22:range over int
wg.Add(1)
go func() {
defer wg.Done()
for n := range 2000 {
key := fmt.Sprintf("k%d", n%32)
c.Set(key, i*1000+n)
c.Get(key)
c.GetOrLoad(key, func() (any, error) { return n, nil })
if n%97 == 0 {
c.Delete(key)
}
}
}()
}
wg.Wait()
}
预期输出是干净的一行:
$ go test -race ./...
ok example.com/cachex 1.314s
这个用例里藏着 Go 1.22 的顺手之处:goroutine 直接捕获循环变量 i 和 n,前提是 go.mod 声明 go 1.22 及以上——从此每轮迭代都是新变量,写了几年的 v := v 影子拷贝正式退休。老模块(声明 go 1.21 及以下)还是老语义,循环变量照旧要传参进闭包,官方用 go.mod 门槛划兼容线,升级时值得知道。
再补一个拨表测试:把 now 注入成假时钟,Set 完把表拨过 TTL,Get 必须返回 miss 且条目已被删——过期逻辑的验收不靠 sleep。
压测口径
压测用 go test -bench 加 b.RunParallel,读写按 9:1 跑一把。我的结论只给定性:千级条目、一把全局锁的规模下,Mutex+Map 和 sync.Map 同一量级,锁竞争不构成瓶颈;写多时 sync.Map 反而吃亏。不给绝对 QPS 数字——机器不同,数字没有可比性。你要自己验:go test -bench . -benchtime 2s,对比改动前后的 ns/op 趋势就够了。
验收清单
go test -race ./...全绿,输出里没有任何 DATA RACE;- 拨假时钟的过期用例通过:过期后 Get 返回 miss,且惰性删除真的删掉了条目;
- 一百并发 GetOrLoad 同 key,load 计数为 1;删掉 singleflight 重跑,数字变大——对照成立;
- 塞入 max+10 条,map 长度不超 max,且先被淘汰的是最久没访问的条目;
- 调用 Close 后 janitor 退出,goroutine 数回落(用 runtime.NumGoroutine 粗验即可);
go test -bench . -benchtime 2s跑通,读写混合下 ns/op 无随时间恶化的趋势。
学完之后
演进路线写清楚,免得有人拿近似 LRU 硬扛不该扛的量:要跨实例共享、要持久化、容量要超内存——任何一条成立就换 Redis,本地缓存留作 L1 一层也不冲突。单机继续深挖的话,按 hash 分片降锁竞争、把 map[string]*entry 泛型化成 Cache[K, V]、给 GetOrLoad 换 singleflight 的 DoChan 变体(回源需要超时控制时用),都是几十行的活。并发原语的底子——goroutine、channel、WaitGroup、Mutex、-race——在站内《Go 并发基础》里过过一遍;load 返回的 error 怎么包装、怎么被调用方识别,处理思路照《Go 错误处理》来;环境从零装起看《Go 环境搭建与工具链》。
本文数据核对时间:2024-06-17:Go 1.22(2024-02-06 GA,go.mod 声明 go 1.22+ 后循环变量每轮迭代新变量、range over int 可用);golang.org/x/sync 为 singleflight 所在包(2024-06 时最新为 v0.7.0,2024-02-27 发布,本文未钉死版本,以 pkg.go.dev 为准);压测结论为定性口径,无编造数字。