singleflight 通过在请求发起瞬间拦截相同 key 的多个 goroutine,仅放行一个执行函数,其余等待其结果,从而避免重复数据库查询;它非缓存非锁,核心是共享执行过程。

为什么 singleflight 能挡住重复数据库查询
它不是缓存,也不是锁,而是在请求发起的「瞬间」把相同 key 的多个 goroutine 拦下来,只让其中一个真正执行函数,其余等待结果返回——哪怕这个函数是查 MySQL、调 HTTP 或读文件。关键在于:所有并发请求共享同一份执行过程,避免 N 个 goroutine 同时打到数据库。
常见错误现象:context deadline exceeded 突然变多、慢查询日志里出现大量一模一样的 SQL、Prometheus 上数据库 QPS 峰值远超业务实际请求数。
- 适用场景:用户详情页(多个微服务同时查同一
user_id)、配置中心热加载(多个协程争抢拉取config.json)、GraphQL 字段解析中反复查同一条关联数据 - 不适用场景:需要各自隔离上下文(如不同
context.WithTimeout)、函数本身有副作用(如发消息、扣库存)且不能被合并 -
singleflight.Group.Do返回的是(interface{}, error, bool),第三个bool表示是否为本次执行者;别直接用err != nil判断失败,要结合shared字段看是不是别人执行出错了
Do 和 DoChan 怎么选
Do 是阻塞式,适合普通 HTTP handler 或同步逻辑;DoChan 返回 chan singleflight.Result,适合想控制超时、或需要和 select 配合的场景(比如等 DB 查询 + 等 Redis 缓存,谁先来用谁)。
容易踩的坑:DoChan 不会自动关闭 channel,如果函数 panic 或 context cancel,channel 会永远卡住;必须用 select 配合 default 或 timeout 来兜底。
立即学习“go语言免费学习笔记(深入)”;
- 参数差异:
Do(key string, fn func() (interface{}, error))—— fn 无参;DoChan(key string, fn func() (interface{}, error))—— 返回 channel,但 fn 还是一样的无参函数 - 性能影响:两者底层共用同一 map+mutex,开销几乎一致;但
DoChan多一次 goroutine 调度和 channel 发送,QPS 极高时(>5w/s)可测出微小差距 - 示例:
v, err, shared := g.Do("user:123", func() (interface{}, error) { return db.QueryRow("SELECT name FROM users WHERE id = ?", 123).Scan(&name) })
key 设计不当会导致合并失效或误合并
key 是字符串,但业务参数往往是 struct 或 map。直接 fmt.Sprintf("%v", req) 看似方便,实际会因字段顺序、空字段、浮点数精度导致 key 不稳定;而用固定字符串(如硬写 "get_user")又会让所有请求挤进同一个队列,失去并发性。
正确做法是提取真正决定结果一致性的字段,拼成确定性字符串,比如 user_id + lang,中间加分隔符。
- 常见错误:把
context.Context当作 key 一部分(地址每次不同)、把时间戳或 traceID 写进 key(必然不合并)、用 JSON 序列化 struct 但没排序字段 - 兼容性注意:如果后期加了新参数(比如支持
with_profile),旧 key 格式必须兼容,否则老客户端和新客户端的请求无法合并,变成两股流量 - 建议用
strings.Join([]string{strconv.Itoa(u.ID), u.Lang}, ":"),比fmt.Sprintf更快更可控
panic、context cancel 和超时怎么处理
singleflight 本身不处理 panic,也不感知 context;它只管“谁来跑函数”。所以函数内部必须自己 recover panic,且显式检查 ctx.Err() 并提前返回——否则一个 goroutine 卡死,整个 key 下的所有等待者都会 hang 住。
错误信息如 panic: runtime error: invalid memory address 出现在日志里,往往是因为没 recover;而 context canceled 被忽略,则表现为某些请求永远收不到响应。
- 必须在 fn 里做:
select { case ,不能只依赖上层 handler 的 timeout - panic 后的结果不会被缓存,但当前这一轮所有等待者都会收到 panic 对应的 error;下一轮相同 key 请求仍会重试
- 如果 DB 查询用了
db.QueryContext(ctx, ...),它自身会响应 cancel,但仍需在外层加 defer recover,防止驱动 bug 或其他逻辑 panic
singleflight 能替代缓存——它只消重,不存结果。










