singleflight 是击穿拦截器而非穿透拦截器;防穿透必须前置布隆过滤器或空值缓存,再进 singleflight,否则恶意 key 会导致 goroutine 阻塞放大攻击。

singleflight 不是穿透拦截器,它是击穿拦截器;想防穿透,必须先过布隆过滤器或空值缓存,再进 singleflight。
为什么不能把 singleflight 当作穿透拦截器用
缓存穿透指请求根本不存在的 key(比如恶意构造的 user:999999999),这类请求本不该查 DB。但如果你把 singleflight.Group.Do 放在布隆过滤器之前,所有非法 key 都会卡在 Do 的等待队列里——100 个并发请求打同一个坏 key,结果变成 100 个 goroutine 一起阻塞,等一个失败的 DB 查询返回 sql.ErrNoRows,再集体失败。这反而放大了攻击效果。
真正该拦截穿透的位置,在缓存层之前:布隆过滤器快速判否,或先查空值缓存。只有确认 key “大概率存在”之后,才值得用 singleflight 去重回源请求。
- 穿透 = key 不存在 → 应该 0 次 DB 查询
- 击穿 = key 存在但缓存刚过期 → 允许 1 次 DB 查询,其余等待
-
singleflight只解决后者,对前者无效甚至有害
正确的调用链顺序:bloom → cache → singleflight → db
标准流程必须是线性且带短路的:
立即学习“go语言免费学习笔记(深入)”;
bloomFilter.Test(key) 返回 false → 直接返回 nil,不进后续任何环节
返回 true → cache.Get(key) 命中 → 直接返回
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
未命中 → sg.Do(key, loadFn),其中 loadFn 内部做:db.QueryRowContext → 成功则 cache.Set → 返回结果;失败则不写缓存
- 布隆过滤器必须前置,否则 singleflight 成为 DDoS 放大器
-
cache.Get必须在Do外层,否则每次请求都进等待队列,哪怕缓存已热 -
loadFn里不能只查 DB,必须包含成功后的cache.Set,否则下次请求仍 miss - DB 查询务必带
context.WithTimeout,避免单个慢查询拖垮全部等待者
key 和 Group 的常见踩坑点
singleflight.Group 必须全局复用,绝不能在 handler 里 var g singleflight.Group。每个新 Group 都是独立 map,互相看不到对方的 pending call,去重完全失效。
key 必须稳定、幂等、不含动态字段:
- ✅ 正确:
"user:" + strconv.Itoa(userID) - ❌ 错误:
r.URL.String()(含 query 参数如ts=1719892080)、fmt.Sprintf("%v", req)(结构体字段顺序不保证) - ❌ 错误:直接传
userIDint 类型,Do签名要求string,会 panic
如果业务上同一语义请求被拆成多个 key(比如 "user:123:profile" 和 "user:123:orders" 都走同一个 loadFn),那它们不会被合并——singleflight 只认字符串 key,不管业务逻辑是否相关。
错误处理和 fallback 容易被忽略
Do 返回的 err 是共享的:只要 loadFn 返回非 nil error,所有等待者都收到同一个 error。这意味着 DB 挂了,100 个请求全失败,没机会 fallback 或返回兜底数据。
实际工程中应:
- 在
loadFn里对sql.ErrNoRows单独判断,写空值缓存(配合 TTL),避免反复穿透 - 对其他 DB 错误(如连接超时、timeout)不做缓存,但可返回预设 fallback(如空对象、降级数据)
- 不要依赖
shared返回值做日志或监控决策——它只表示“是否首个执行”,不反映业务成功与否
最麻烦的不是写错,而是以为加了 Do 就万事大吉:key 不稳、Group 错位、布隆漏掉、超时没设,四个点任一出问题,singleflight 就形同虚设。

















