直接并发请求导致N+1查询问题是因为为每个用户ID发起独立SQL查询,而非批量查询;singleflight.Group仅去重相同ID的重复请求,不解决批量ID查询;批处理需攒批ID后单次IN查询。

为什么直接并发请求会导致 N+1 查询问题
当你在 Go 服务中为每个用户请求都单独查一次数据库,比如根据 user_id 查用户头像、权限、配置等,哪怕用了 goroutine 并发,本质仍是 N 次独立查询。数据库连接池压力大,SQL 解析开销高,网络往返多——尤其当上游批量触发(如推送通知拉取 100 个用户的 profile),实际发出 100 条单行 SELECT * FROM users WHERE id = ?,远不如一条 SELECT * FROM users WHERE id IN (?, ?, ...) 高效。
用 singleflight.Group 合并同一时刻的重复请求
它不解决“批量 ID 查询”,而是防止同一毫秒内多个 goroutine 重复发起相同参数的请求(比如 5 个协程同时查 user_id=123)。适合缓存穿透防护或高频读热点数据。
- 调用
g.Do(key, fn),相同key的所有调用会阻塞并共享一次fn执行结果 -
key必须能唯一标识请求,例如fmt.Sprintf("user:%d", id) - 注意:它不合并不同 ID,只去重相同 ID;返回值需是
interface{},记得类型断言 - 失败时默认不缓存错误,如需缓存失败结果,得自己包装
err到返回值里
var userGroup singleflight.Group
result, err := userGroup.Do(fmt.Sprintf("user:%d", id), func() (interface{}, error) {
return db.QueryRow("SELECT name, avatar FROM users WHERE id = ?", id).Scan(&name, &avatar)
})
用批处理函数把多个 ID 合并成单次 SQL 查询
核心是收集一段时间窗口内的请求 ID,攒够一批再查。不能等太久(影响延迟),也不能太小(失去批量意义)。常见做法是加一层异步缓冲。
- 用
chan []int64接收待查 ID 列表,启动一个常驻 goroutine 消费 - 写入时用
select { case ch 非阻塞发送,避免调用方卡住 - 单次最多合并 100 个 ID(MySQL
IN建议不超过 1k,但业务上 100 更稳) - 超时强制 flush:比如 5ms 内没攒满也执行查询,平衡延迟和吞吐
- 注意 ID 去重,避免同个 ID 被多次加入批次(可用
map[int64]struct{}临时去重)
示例关键逻辑:
type BatchUserLoader struct {
ch chan []int64
}
func (l *BatchUserLoader) Load(id int64) (*User, error) {
respCh := make(chan *User, 1)
select {
case l.ch <- []int64{id}:
default:
// 缓冲满,降级为单查
return l.loadOne(id)
}
select {
case u := <-respCh:
return u, nil
case <-time.After(10 * time.Millisecond):
return nil, errors.New("timeout")
}
}
别忽略上下文取消和错误传播
批量合并后,单次 DB 查询失败会影响整批结果。如果调用方传了 ctx,你必须把 ctx 透传给 DB 查询,并在收到 ctx.Done() 时及时中断当前批次。
- 不要在批处理 goroutine 里忽略
ctx.Err(),否则可能造成请求堆积、goroutine 泄漏 - 单个 ID 查询失败时,不应让整批返回 error,而应返回部分成功结果 + 错误映射(如
map[int64]error) - 若使用
database/sql,确保QueryContext替代Query,并检查返回的err是否为context.Canceled或context.DeadlineExceeded
最易被忽略的是:合并后的 SQL 查询虽快,但一旦 DB 连接超时或锁表,整个批次都会失败——这比单查更难定位问题。上线前务必压测混合场景(正常/超时/部分失败)。


















