直接并发请求导致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,攒够一批再查。不能等太久(影响延迟),也不能太小(失去批量意义)。常见做法是加一层异步缓冲。
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
- 用
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 <h3>别忽略上下文取消和错误传播</h3><p>批量合并后,单次 DB 查询失败会影响整批结果。如果调用方传了 <code>ctx</code>,你必须把 <code>ctx</code> 透传给 DB 查询,并在收到 <code>ctx.Done()</code> 时及时中断当前批次。</p>
- 不要在批处理 goroutine 里忽略
ctx.Err(),否则可能造成请求堆积、goroutine 泄漏 - 单个 ID 查询失败时,不应让整批返回 error,而应返回部分成功结果 + 错误映射(如
map[int64]error) - 若使用
database/sql,确保QueryContext替代Query,并检查返回的err是否为context.Canceled或context.DeadlineExceeded
最易被忽略的是:合并后的 SQL 查询虽快,但一旦 DB 连接超时或锁表,整个批次都会失败——这比单查更难定位问题。上线前务必压测混合场景(正常/超时/部分失败)。










