Go语言中singleflight高并发下合并请求的最佳实践
作者:MeetTanG
这个系列将逐一拆解 Go 生态中那些小巧但强大的库,从使用到原理,再到生产实践。今天我们从每个 Go 开发者都该掌握的 singleflight 开始。
一个经典事故现场
凌晨 2 点,告警群炸了。
某个热点 Key 的 Redis 缓存过期,瞬间 5000+ QPS 直穿数据库,DB CPU 飙到 100%,整个服务雪崩。
这就是缓存击穿。解决方案有很多:互斥锁、逻辑过期、永不过期……但在 Go 的世界里,有一个更优雅的答案——singleflight。
一句话理解 singleflight
相同 key 的并发请求,只执行一次,其余请求等待并共享结果。
想象一个课堂场景:
- 没有 singleflight:100 个学生同时举手问同一个问题,老师回答 100 遍。
- 有 singleflight:第一个学生提问,老师回答一次,其余 99 个学生直接听到答案。
30 秒上手:最简用法
go get golang.org/x/sync/singleflight
var sf singleflight.Group
val, err, shared := sf.Do("user:1001", func() (interface{}, error) {
return db.QueryUser("1001") // 只有第一个请求会真正查 DB
})
fmt.Println(val, err, shared) // shared=true 表示结果是共享的
三个返回值含义一目了然:
| 返回值 | 说明 |
|---|---|
val | 函数执行结果 |
err | 函数返回的错误 |
shared | true = 当前结果是共享给其他等待者的 |
看到这里,你已经掌握了 singleflight 的核心能力。但请注意:上面的 Do 方法是同步阻塞的,直接用在生产环境会有隐患。我们接着往下看。
真实场景:当 HTTP 接口被并发打爆
为了理解为什么不能直接用 Do,我们先来看一个真实的 Web 服务场景。
以下是一个完整的 Gin 接口示例,模拟多个独立 HTTP 请求同时访问同一个用户详情接口:
package main
import (
"fmt"
"net/http"
"time"
"github.com/gin-gonic/gin"
"golang.org/x/sync/singleflight"
)
var (
sf singleflight.Group
cache = make(map[string]string) // 简化演示,生产请用 Redis
)
func getUserHandler(c *gin.Context) {
userID := c.Param("id")
// 1. 先查缓存
if val, ok := cache[userID]; ok {
c.JSON(http.StatusOK, gin.H{"source": "cache", "data": val})
return
}
// 2. 缓存未命中,使用 singleflight 合并并发请求
// ⚠️ 注意:这里用的是 Do,同步阻塞
val, err, shared := sf.Do("user:"+userID, func() (interface{}, error) {
fmt.Printf("[DB] 真正查询数据库: user=%s\n", userID)
time.Sleep(2 * time.Second) // 模拟慢查询
return "User_" + userID, nil
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
valStr := val.(string)
cache[userID] = valStr
c.JSON(http.StatusOK, gin.H{
"source": "db_singleflight",
"data": valStr,
"shared": shared,
})
}
func main() {
r := gin.Default()
r.GET("/user/:id", getUserHandler)
r.Run(":8080")
}
用 wrk 压测验证:
wrk -t4 -c100 -d5s http://localhost:8080/user/1001
服务端日志只会打印 1 次 [DB] 真正查询数据库,说明合并生效了。
但这个写法有两个致命问题
- 无法超时控制:
Do是同步阻塞的,如果 DB 查询卡了 30 秒,所有 100 个 HTTP 请求都会傻等 30 秒,客户端早已超时断开,服务端 goroutine 却还在空等,最终导致 goroutine 泄漏。 - 错误会被共享:如果第一次查询返回了错误(比如 DB 连接池耗尽),所有等待的请求都会拿到同一个错误。而且在
Do返回之前,新来的请求仍然会被合并到这个失败的调用上,形成"错误放大"。
这就是为什么生产环境不能直接用 Do 的原因。
生产级写法:DoChan + ForgetUnshared
针对上面两个问题,singleflight 提供了对应的解决方案:
func getUserHandler(c *gin.Context) {
userID := c.Param("id")
ctx := c.Request.Context() // ✅ 获取请求级别的 context
// 1. 先查缓存(同上,省略)
// 2. 使用 DoChan 替代 Do
ch := sf.DoChan("user:"+userID, func() (interface{}, error) {
fmt.Printf("[DB] 真正查询数据库: user=%s\n", userID)
time.Sleep(2 * time.Second)
return "User_" + userID, nil
})
// 3. 用 select 实现超时控制
select {
case result := <-ch:
if result.Err != nil {
// ✅ 出错时用 ForgetUnshared 清除 key,允许后续请求重试
sf.ForgetUnshared("user:" + userID)
c.JSON(http.StatusInternalServerError, gin.H{"error": result.Err.Error()})
return
}
val := result.Val.(string)
cache[userID] = val
c.JSON(http.StatusOK, gin.H{
"source": "db_singleflight",
"data": val,
"shared": result.Shared,
})
case <-ctx.Done():
// ✅ 客户端超时断开,服务端不再傻等
c.JSON(http.StatusGatewayTimeout, gin.H{"error": "request timeout"})
}
}
为什么是 ForgetUnshared 而不是 Forget?
很多早期教程推荐用 Forget,但在高并发下它有隐患:
Forget(key):无条件删除 key。如果在 fn 执行期间被调用,map 中的 key 被移除,新请求会创建新的 call,导致 fn 被重复执行,去重保护失效。ForgetUnshared(key):只有在没有其他 goroutine 等待时才删除。从根源上杜绝并发击穿。
记住一条原则:除非你有明确理由必须用 Forget,否则永远优先选 ForgetUnshared。
Do vs DoChan 对比
| 特性 | Do | DoChan |
|---|---|---|
| 调用方式 | 同步阻塞 | 返回 channel,非阻塞 |
| 超时控制 | ❌ 无法做 | ✅ 配合 select + context |
| 错误处理 | 错误被共享,难以重试 | ✅ 可结合 ForgetUnshared 安全重试 |
| 推荐场景 | 简单脚本 / 学习 | 生产环境首选 |
源码核心:三件套
理解了用法,再来看原理就水到渠成了。singleflight 源码不到 200 行,核心就三个组件:
Mutex → 保护 map 并发安全
Map → 记录哪些 key 正在处理中(in-flight)
WaitGroup → 让后续相同 key 的请求阻塞等待
执行流程
请求到来 → 加锁查 Map
├─ key 存在 → dups++ → 释放锁 → wg.Wait() → 拿到共享结果
└─ key 不存在 → 创建 call → 存入 Map → 释放锁 → 执行 fn → wg.Done() → 删除 key → 返回结果
精妙之处:doCall 内部对 panic 做了捕获处理,一个 goroutine panic 不会导致所有等待者崩溃。这是非常重要的安全设计。
5 个避坑指南
- 阻塞风险:fn 慢查询会导致所有等待者阻塞 → 用
DoChan+context.WithTimeout - 错误共享:首个请求失败,所有等待者都拿错误 → 出错时
ForgetUnshared - Key 设计:key 要精确标识"同一份数据",如
user:{id},避免不同业务误合并 - 单实例限制:只在进程内有效,多实例部署仍需分布式锁
- 不要滥用:仅适用于"读多写少、结果可共享"的场景,写操作不适用
总结
| 维度 | 说明 |
|---|---|
| 是什么 | Go 官方请求合并工具,相同 key 并发请求只执行一次 |
| 核心原理 | Mutex + Map + WaitGroup |
| 入门 API | Do(同步阻塞,适合学习) |
| 生产标配 | DoChan + context + ForgetUnshared |
| 典型场景 | 防缓存击穿、合并 RPC、防重复提交 |
| 验证方式 | 压测接口 + 观察 DB 查询日志次数 |
singleflight 是 Go 并发编程中不可或缺的"瑞士军刀"。记住:它不是缓存,而是缓存失效时的最后一道防线。
以上就是Go语言中singleflight高并发下合并请求的最佳实践的详细内容,更多关于Go singleflight合并请求的资料请关注脚本之家其它相关文章!
