go原生不支持请求合并,因http.client是无状态单次调用模型,http协议层无合并语义,需业务层基于singleflight.group+双阈值批处理实现,且优先推动后端提供幂等批量接口。

为什么 Go 原生不支持请求合并,而你得自己写
Go 的 http.Client 本身是无状态、单次调用模型,它不会自动把多个并发的 GET /user?id=1 和 GET /user?id=2 合并成一次 GET /users?id=1,2。这不是缺陷,而是设计取舍:HTTP 协议层不定义“合并语义”,框架或业务层才清楚哪些请求可合并、如何批量、失败时怎么降级。
常见误操作是直接用 sync.WaitGroup 或 chan 粗暴攒一批再发——这会引入不可控延迟(等不满就超时?等满又卡住?),也难处理中间某个 ID 失败后的局部重试。
真正可用的方案,是基于 singleflight.Group + 自定义批处理逻辑,按 key 聚合 + 时间/数量双触发阈值控制。
用 singleflight.Group 防止重复请求,但别只靠它做合并
singleflight.Group 能确保相同 key 的并发请求只执行一次,后续等待结果返回,但它不改变请求形态——50 个 GetUser(123) 还是 50 次独立调用,只是后 49 个不走网络。要真合并,得在 key 设计和执行函数里动手脚。
- key 不该是原始 ID,而应是带分组标识的字符串,比如
"batch_user_" + strconv.FormatInt(time.Now().UnixNano()/1e6, 10)(按毫秒分桶) - 执行函数里需收集所有 pending 的 ID,调用批量接口
GET /users?ids=1,2,3,4,5,再把结果按 ID 分发回对应 channel 或 callback - 注意:如果某次 batch 请求失败,
singleflight会把错误广播给所有等待者——你得在执行函数里做 fallback,比如对每个 ID 单独重试
实现一个带超时与容量限制的批量提交器
核心是维护一个缓冲区(slice)+ 定时器 + 互斥锁,而不是依赖 channel 缓冲大小硬限流。下面是一个轻量实现的关键骨架:
type BatchSubmitter struct {
mu sync.Mutex
pending []int64
timer *time.Timer
maxSize int
timeout time.Duration
doBatch func([]int64) (map[int64]*User, error)
}
func (b *BatchSubmitter) Add(id int64) = b.maxSize {
b.flush()
} else if b.timer == nil {
b.timer = time.AfterFunc(b.timeout, b.flush)
}
b.mu.Unlock()
go func() {
defer close(ch)
// 等待 flush 后从结果 map 中取值,或超时返回 nil
}()
return ch
}
关键点:
-
maxSize和timeout必须同时生效:宁可少等 10ms,也不能让请求卡住 500ms 等凑够 100 条 -
doBatch函数必须幂等且能处理部分失败(例如返回map[int64]error而非全量失败) - 不要在
Add里阻塞等待结果,否则高并发下 goroutine 泄漏风险极高
HTTP 客户端侧合并 vs 后端 API 支持,哪个更值得投入
前端合并逻辑再精巧,如果后端没有 /users?ids=... 这类批量端点,一切白搭。优先确认后端是否提供以下能力:
- 批量接口支持 URL 参数或 JSON body 传 ID 列表(推荐后者,避免 URL 长度限制)
- 返回结构是
map[string]User或含明确 ID 字段的数组,而非只返回数组顺序隐式对应请求顺序 - 有合理错误粒度:比如 5 个 ID 中 1 个不存在,不应整个 400,而应返回成功数据 +
{"id": "xxx", "error": "not found"}
如果没有这些,强行在客户端合并只会让问题下沉——错误难定位、监控指标失真、重试逻辑爆炸。先推动后端补全批量能力,比写一堆 client-side 合并胶水代码划算得多。
最常被忽略的是批量响应的 schema 一致性:前端假设返回数组索引=请求顺序,但网络重排或服务端并发处理可能打破这个假设。永远用 ID 字段做映射,别信顺序。











