singleflight 是缓存击穿的“闸门”而非“开关”,需在布隆过滤器和空值缓存之后启用;其 key 应为操作标识(如 "db:get_user_by_id:123"),非法 key 需提前拦截;Do 回调中须先查 DB、再判空、最后写 Null{} 空值缓存;布隆过滤器必须前置预热,key 格式须与 DB 字段严格对齐。

singleflight 不是缓存击穿的“开关”,而是“闸门”——它只在布隆过滤器和空值缓存之后起效,否则反而放大攻击。
singleflight 的 key 必须和业务逻辑解耦
很多人直接把缓存 key(比如 "user:123")原样传给 singleflight.Group.Do(),这会导致两个问题:一是不同查询路径(如 DB 查询 vs RPC 调用)共用同一 key,互相阻塞;二是恶意构造的非法 key 也会被合并等待,拖慢整个队列。
建议做法:
- Do 的 key 应该是「实际执行操作」的唯一标识,比如
"db:get_user_by_id:123"或"rpc:fetch_profile:123",而非原始缓存 key - 对非法 key(如不含数字 ID、含特殊字符)提前 return,不进 Do 流程
- 如果业务有多种加载方式(DB / 备份库 / fallback API),应为每种路径分配独立 Do key,避免串行化干扰
Do 回调里必须先查 DB,再判空,最后写空值缓存
常见错误是把 cache.Set(..., Null{}, ...) 写在 Do 外部,或者在回调里没做空值判断就直接写缓存。结果就是:一次 DB 查询失败,所有等待 goroutine 都拿到 error,但空值缓存却没写进去,下一轮请求照样击穿。
立即学习“go语言免费学习笔记(深入)”;
正确顺序必须是:
- 在
Do回调函数中调用 DB 查询(如db.QueryRow(...)) - 检查 err 和 scan 结果:若 err != nil 或 no rows,才构造
Null{}并调cache.Set(key, Null{}, ttl+rand.Jitter(1*time.Second)) - 确保
cache.Set成功返回后再 return,不要忽略其 error(尤其用 redis client 时,网络失败会导致空值漏写)
空值缓存不能用 nil,必须用显式占位结构体
Go 的 interface{} 对 nil 的序列化行为不一致:go-cache 某些版本会把 nil 序列化成 JSON null,反序列化后变成 *struct{} 而非 nil,导致 if val != nil 判断永远为 true,空值缓存失效。
安全写法:
- 定义统一占位类型:
type Null struct{} - 写缓存:
cache.Set("user:9999999999", Null{}, 60*time.Second) - 读缓存后判断:
if _, ok := val.(Null); ok { /* 是空值 */ } - 所有空值缓存必须走和正常值相同的序列化路径(如都过
json.Marshal),否则类型错乱
布隆过滤器必须前置,且预热不可运行时动态写入
singleflight 是兜底,布隆过滤器才是第一道防线。如果把它放在 cache.Get 之后、singleflight.Do 之前,等于放行所有非法 key 去竞争 Do 锁,攻击者发 1000 个随机 user:xxx 就能让 1000 个 goroutine 卡在 Do 等待,服务假死。
关键约束:
- 初始化阶段必须全量预热合法 key(如从 DB 主键 or Redis SCAN 扫描),不能靠
bloom.Add()动态写入 - key 字符串必须和 DB 查询字段完全一致:大小写、前缀(
"user:")、编码(UTF-8)、是否带引号,全部对齐 - 若 key 来自 JSON 或结构体,务必先哈希(如
sha256.Sum256([]byte(key)).Sum(nil))再bloom.Test(),否则布隆位图误判率飙升
最易被忽略的一点:布隆过滤器的容量和误差率要按「合法 key 总量」预估,而不是当前在线量——上线后扩容不重置 bloom,会导致新 key 永远 test 失败。


















