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
Setup 用 env.Redis() 逗号-ok 取,取不到就让
s.redis 保持 nil。GetOrLoad 遇到 nil client 直接回源、
不缓存,调用点不用写 if client != nil。
它演示了什么
- cache-aside 命中/未命中:第一次读回源(假查询故意慢 50ms), 第二次读直接命中。
- 并发 miss 合并(防击穿):同一 key 的 N 个并发请求只产生 一次回源。
- TTL 抖动(防雪崩):每条 TTL 再随机减掉最多
jitter(示例 5s,配置项cache.jitter)。 - 写后失效:
PUT删缓存而非更新缓存,下一次读必然回源拿到新值。 - 回源失败不写缓存:错误原样返回,下一次请求重新尝试。
- 可观测的回源次数:
/api/v1/stats暴露source_reads,让"合并生效"看得见。
调用点
来自 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: 同一个值
验证
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
相关
- 缓存模式:击穿、雪崩与失效策略。
- examples/redis:底层的 redis JSON 读写与分布式锁。
- examples/database:真实的回源来源(DAO 与事务)。
- Skill:
skills/matex-setup-cache/SKILL.md。 - 示例索引。