cache — 防击穿与防雪崩

pkg/core/cache 补的是 redis 客户端给不了的两个模式,它们只在压力下 才暴露:一个热 key 到期时所有在途请求同时回源(击穿),以及一批一起写入的 key 一起到期(雪崩)。GetOrLoad 把并发 miss 合并成一次回源,并给每条 TTL 加随机抖动。

运行

需要 Redis,先 make dev。HTTP 默认监听 :8083 (避开多数示例用的 :8080),配置文件是目录里的 config.yaml,-conf 默认指向它。

make dev                      # 起 redis
go run ./examples/cache       # 等价于 make example NAME=cache
删掉 redis 段也能跑 Setup 用 env.Redis() 逗号-ok 取,取不到就让 s.redis 保持 nil。GetOrLoad 遇到 nil client 直接回源、 不缓存,调用点不用写 if client != nil。

它演示了什么

调用点

来自 examples/cache/main.go 的 getUser。 回源函数只包住那一次查询,不是整个 handler;errNotFound 被映射成 404 而不是 500。

u, err := cache.GetOrLoad(ctx, s.redis, cache.Key("user", id),
	cache.Config{TTL: s.cfg.TTL, Jitter: s.cfg.Jitter},
	func(ctx context.Context) (User, error) {
		return s.users.load(ctx, id)
	})
switch {
case errors.Is(err, errNotFound):
	return nil, errs.NotFound(40401, "user %s not found", id)
case err != nil:
	return nil, errs.Internal(50001, "load user: %v", err)
}
return map[string]any{"user": u, "source_reads": s.users.reads()}, nil

写后失效

来自 putUser。删除而不是更新:并发写者下先写 DB 再写缓存有窗口 会被旧值覆盖,先写 DB 再删缓存则任何顺序都安全。失效失败只记 warn,不让写请求失败。

u, err := s.users.save(id, req.Name)
if err != nil {
	return nil, err
}
// Invalidate rather than update: the next read reloads, and a concurrent
// writer cannot leave a stale value behind.
if err := cache.Invalidate(ctx, s.redis, cache.Key("user", id)); err != nil {
	obs.Warn(ctx, "invalidating the cache failed", "id", id, "err", err)
}
return map[string]any{"user": u, "invalidated": true}, nil
sequenceDiagram
    participant R1 as 请求 1
    participant R2 as 请求 2..N
    participant SF as singleflight(按 key 合并)
    participant DB as 回源(userStore.load)
    R1->>SF: GetOrLoad(key)
    R2->>SF: GetOrLoad(key)
    Note over SF: 同一 key 只跑一个 load
    SF->>DB: load(ctx)  ← 只有一次
    DB-->>SF: User
    SF-->>R1: 值
    SF-->>R2: 同一个值
				
击穿合并。N 个并发 miss 落到同一个 key 上,只有第一个真正回源, 其余等它的结果。回源跑在第一个调用者的 ctx 下。
别把回源挂在随时会取消的短 ctx 上 回源跑在第一个调用者的 ctx 下,它放弃时这一组请求会一起失败——这是刻意的取舍: 另一种做法是让一个调用者早就跑掉的请求继续占着数据库连接。

验证

curl -s localhost:8083/api/v1/users/1        # miss:~50ms,回源一次
curl -s localhost:8083/api/v1/users/1        # hit:很快,不回源
curl -s localhost:8083/api/v1/stats          # 看回源了多少次

# 并发 miss 合并成一次回源
for i in $(seq 1 20); do curl -s -o /dev/null localhost:8083/api/v1/users/2 & done; wait
curl -s localhost:8083/api/v1/stats          # source_reads 只 +1

# 写后失效:下一次读重新回源
curl -s -X PUT localhost:8083/api/v1/users/1 -d '{"name":"alice v2"}'

GET /api/v1/users/{id} 返回 {"code":0,"msg":"ok","data":{"source_reads":1,"user":{"id":"1","name":"alice"}}}; PUT 返回 {"user":{"id":"1","name":"alice v2"},"invalidated":true}。 GET /api/v1/stats 返回 {"source_reads":N}。

测试

main_test.go 用 redistest.Start(t)(miniredis) 加 httptest 走真实路由,完全离线。它钉住五件事: 命中不产生第二次回源、8 个并发请求只回源一次(击穿合并)、 写后失效迫使重新回源并读到新值、不存在的用户回 404 且 结果不被缓存(连续两次都是 40401,回源计数为 2)、 没有 redis 时每个请求都回源(无 client 分支);另外还覆盖空 name / 坏 body 回 400,以及配置默认值 TTL=30s / Jitter=5s。

go test ./examples/cache

相关