用 Go 写一个并发安全的内存缓存

《用 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 为准);压测结论为定性口径,无编造数字。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇