Singleflight 缓存击穿抑制¶
要点卡¶
30 秒回答
Singleflight 只合并“同一进程、同一 key、同一时刻”的重复加载,不等于缓存,也不提供跨实例全局锁。实现时用 map[key]*call 保存进行中的调用,首个请求加载,后续请求等待同一个 done。等待者可因自己的 context 提前离开,但共享加载不应被首个 HTTP 请求的取消随意终止,否则一个客户端断开会让所有等待者失败。
3 分钟展开
- 双检:先查缓存;进入 singleflight leader 后再查一次,避免排队期间已被填充。
- 上下文:waiter context 只控制等待;loader 使用独立、受限时的 context。
- 失败策略:普通错误不缓存;可选短时 negative cache,但要区分“不存在”和“依赖故障”。
- 边界:多实例仍会各加载一次;热点 key 还需 TTL jitter、预热、限流或分布式协调。
| 记忆槽 | 内容 |
|---|---|
| 三个不变量 | singleflight 只合并同进程同 key 的重叠调用;它不是缓存/分布式锁;等待者取消不应随意取消共享事实 |
| 手画图 | cache miss → group[key] leader → loader → done → waiters,每个 waiter 旁接自己的 ctx |
| 项目落点 | 用实际 token 元数据、链 RPC 或配置加载说明击穿窗口、TTL jitter 和多实例边界;只引用本人实际参与部分和可解释指标 |
| 一个取舍 | 共享 loader 独立于首个请求更稳定,但无人等待时仍可能继续;计数取消更省资源却更复杂 |
错误表达
- ❌ “singleflight 能保证全局只查一次;
shared=true表示结果来自缓存。” - ✅ “共享范围仅当前 Group/进程的重叠调用;shared 只说明调用被合并。”
自测追问:为什么 leader 进入后要二次查缓存?所有 waiter 都离开后 loader 是否继续应由什么语义决定?
10 分钟版(原理 + 图示)¶
sequenceDiagram
participant A as Caller A
participant B as Caller B
participant G as Group
participant L as Loader
A->>G: Get(k)
G->>L: leader loads once
B->>G: Get(k)
G-->>B: wait done or B.ctx
L-->>G: value/error
G-->>A: result
G-->>B: shared result
示例实现把 leader 的 loader context 从请求取消中分离,再施加 loadTimeout。这意味着所有 waiter 都离开后,加载可能继续到超时;换来的是不会让第一个调用方拥有取消全部共享工作的权力。更复杂的实现可以计数 waiter,在全部离开时取消,但并发状态明显更难证明。
value, shared, err := cache.Get(ctx, key, loader)
shared=true表示结果来自进行中的共享调用,不代表来自缓存。- loader panic 会唤醒 waiter,leader 仍重新 panic,避免悄悄返回零值成功。
- TTL 仅在加载成功后写入;错误默认不缓存。
- 示例只演示 TTL 与请求合并,没有容量上限、淘汰和后台清理;生产缓存必须限制 key 数/内存,或使用成熟缓存实现。
生产场景¶
- 配置、权限、Token metadata 等热点 key 过期瞬间。
- RPC 查询同一 block/hash,provider 限流前先在进程内合并。
- 缓存重建成本高时配 stale-while-revalidate,而不是让用户全等冷加载。
排查与工具¶
监控 cache_hit_total、singleflight_shared_total、loader latency/error、每 key 等待者数和超时。若 shared 比例很高且延迟仍高,根因可能是单个热点 key 的 loader 太慢,而不是缓存容量不足。
架构取舍¶
| 方案 | 解决范围 | 风险 |
|---|---|---|
| singleflight | 单实例瞬时重复加载 | leader 慢则所有 waiter 慢 |
| TTL jitter | 避免大量 key 同时过期 | 仍会有单 key 击穿 |
| stale-while-revalidate | 降低用户等待 | 允许短时旧数据 |
| 分布式锁 | 跨实例只重建一次 | 锁租约、fencing、可用性复杂 |
深挖问答¶
- 为什么要二次查缓存? → leader 排队前后缓存状态可能已变化。
- 第一个请求取消怎么办? → 不应默认取消共享 loader;请求只放弃等待。
- 错误要共享吗? → 进行中的调用会共享该次错误;是否短时缓存要按错误类别设计。
- 进程重启呢? → singleflight 状态丢失,这正说明它不是持久缓存或分布式锁。
- 热点 key 一直慢? → 限并发、独立超时、stale 数据、预计算和降级。
反模式与事故¶
- 把请求 context 直接传给共享 loader,第一个客户端断开导致全体失败。
- 忘记在 panic/错误路径删除 in-flight entry,key 永久卡死。
- 对所有错误做长 TTL negative cache,短暂依赖故障被放大。
- 以为 singleflight 能防止所有 pod 同时回源。
代码示例¶
见 cache.go:
go test -race ./examples/senior/singleflightcache/...