缓存模式
pkg/core/cache 补的是 redis 客户端本身给不了的两个模式。
它们只在压力下暴露:一个热 key 到期时所有在途请求同时回源,一批一起写入的 key
又会在同一时刻过期。
两个失效模式
| 模式 | 现象 |
|---|---|
| 击穿 / 惊群(stampede) | 一个热 key 到期,N 个在途请求同时回源,DB 吃到本该被缓存挡下的那波流量。 |
| 同时过期(synchronized expiry) | 一批一起写入的 key 一起到期,按时钟周期性地制造上面那波流量。 |
GetOrLoad 一次解决两个:同一 key 的并发 miss 合并成一次回源;
每条 TTL 写入前都随机摊开。它不做本地多级缓存——本地副本会引入多实例失效问题,
而收益只是省一次网络往返。
读:GetOrLoad
u, err := cache.GetOrLoad(ctx, s.redis, cache.Key("user", id),
cache.Config{TTL: 30 * time.Second, Jitter: 5 * time.Second},
func(ctx context.Context) (User, error) {
return dao.ByID(ctx, db, id) // 回源:DB / 下游 / 计算
})
一次调用做四件事:查缓存 → 命中就返回 → miss 时按 key 合并并发请求 → 回源结果按抖动后的 TTL 写回。
// DefaultTTL 是 Config.TTL 未设时用的值。
const DefaultTTL = time.Minute
type Config struct {
TTL time.Duration // 基础存活时间
Jitter time.Duration // 每个 TTL 最多再随机减掉这么多,防止一起过期
}
Jitter >= TTL 时会被压到 TTL/2:在 redis 里
0 过期等于立即删除,不是"短暂缓存"。抖动绝不会把 TTL 抖到 0。
几个不写进调用点的约定:
-
client 为 nil 不是错误:不配 redis 就直接回源、不缓存。调用点因此不用写
if env.Redis != nil。 - 读缓存失败降级为 miss:redis 抖动时记一条 warn、照常回源,请求不会因为缓存故障而失败。
- 回源失败不写缓存:错误原样返回,下一次请求重新尝试。
- 写缓存失败只是慢:值已经在手上,只记 warn,不影响正确性。
并发 miss 合并
合并由一个进程级的 singleflight.Group 完成——惊群是进程级事件,
所以这个组是全包共享的。loads.Do(key, …) 里先给排队者再看一眼缓存:
它们排队期间值可能已经被写入。
sequenceDiagram
autonumber
participant R1 as 请求 1(第一个)
participant R2 as 请求 2..N
participant G as singleflight.Group
按 key 合并
participant C as redis
participant S as 回源(DB)
R1->>C: GET user:2
C-->>R1: miss
R2->>C: GET user:2
C-->>R2: miss
R1->>G: Do("user:2", fn)
R2->>G: Do("user:2", fn) — 排队等待
G->>C: 再看一眼(可能已被写入)
C-->>G: 仍然 miss
G->>S: load(ctx) —— 只调用一次
S-->>G: User
G->>C: SetJSON(key, v, 抖动后的 TTL)
G-->>R1: User
G-->>R2: 同一个 User(共享结果,不重复回源)
GetOrLoad 挂在一个随时取消的
短 ctx 上。
写:Invalidate
写完 DB 之后删掉对应的 key,而不是更新它。删除是关键:
if err := cache.Invalidate(ctx, s.redis, cache.Key("user", id)); err != nil {
obs.Warn(ctx, "invalidate failed", "id", id, "err", err)
}
flowchart LR
W1["写者 A
UPDATE user 1"] --> DB[("源数据")]
DB --> INV["cache.Invalidate
DEL user:1"]
R["后续读
GetOrLoad"] -->|miss| DB
DB --> R
R --> SET["写回缓存
新值"]
classDef ok fill:#eef7ef,stroke:#3f7d3f,color:#2b2722
class INV,R,SET ok
对比"写 DB + 写缓存":两个并发写者可以交错成"A 写 DB、B 写 DB、B 写缓存、A 写缓存", 缓存里最终留下 A 的旧值。删除没有这个窗口——缓存要么是空的(会回源),要么是某次 回源的结果。失效失败不要让写请求失败,但要记 warn:它意味着在 TTL 内可能读到旧值。 所以 TTL 应该被当成"最坏情况下的脏读窗口"来设定。
Key:必须带命名空间
cache.Key("user", 42) // "user:42"
cache.Key("order", id, "items") // "order:9:items"
缓存 key 和服务的其它 redis 数据共用一个库,而且 GetOrLoad 是按字面 key
合并的——两个功能都用 "1" 会互相串。命名空间不是风格问题,是正确性问题。
在服务里怎么用
func (s *cacheService) Setup(_ context.Context, env *verticle.Env) error {
if err := env.DecodeService(&s.cfg); err != nil {
return err
}
if rc, ok := env.Redis(); ok { // 逗号-ok:没配 redis 就留 nil
s.redis = rc
}
s.users = newUserStore()
return nil
}
# 服务段(key = Service.Name())
cache:
ttl: 30s # 基础存活时间
jitter: 5s # 每个 key 的 TTL 再随机减掉最多 5s(防同时过期)
四条约定:
- 回源范围要小:只包住那一次查询,别把整个 handler 塞进
load。 - TTL 是业务答案,不是拍脑袋的数字:它等于"这份数据可以有多旧"。
- 有写路径就要有对应的 Invalidate:只写不删的缓存最终会变成 bug 来源。
- 不加本地多级缓存,除非有压测数据支撑。
相关示例
-
examples/cache:hit/miss 对比、
/stats看回源次数、并发 miss 合并成一次回源。 - 数据库:
load回源通常就是一次 DAO 查询。 - 离线测试:
redistest.Start(t)起一个 miniredis。 - 包参考:
pkg/core/cache/pkg/core/redis。