缓存模式

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 的 10%~20% Jitter >= TTL 时会被压到 TTL/2:在 redis 里 0 过期等于立即删除,不是"短暂缓存"。抖动绝不会把 TTL 抖到 0。

几个不写进调用点的约定:

并发 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(共享结果,不重复回源)
N 个并发 miss 只有一次回源。合并键是字面 key 字符串,所以 key 必须带命名空间。
回源跑在第一个调用者的 ctx 下 第一个调用者放弃时,这一组合并的请求会一起失败。这是刻意的:另一种做法是让一个调用者 早就跑掉的请求继续占着数据库连接。所以别把 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
删缓存后,下一次读必然 miss 并回源拿到新值——任何交错顺序都不会留下旧值。

对比"写 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(防同时过期)

四条约定:

相关示例