Golang

关注公众号 jb51net

关闭
首页 > 脚本专栏 > Golang > Go singleflight合并请求

Go语言中singleflight高并发下合并请求的最佳实践

作者:MeetTanG

本文将深入解析Go官方库singleflight,从基础用法到生产级DoChan+ForgetUnshared,帮你彻底解决并发请求重复执行问题,感兴趣的小伙伴可以跟随小编一起学习一下

这个系列将逐一拆解 Go 生态中那些小巧但强大的库,从使用到原理,再到生产实践。今天我们从每个 Go 开发者都该掌握的 singleflight 开始。

一个经典事故现场

凌晨 2 点,告警群炸了。

某个热点 Key 的 Redis 缓存过期,瞬间 5000+ QPS 直穿数据库,DB CPU 飙到 100%,整个服务雪崩。

这就是缓存击穿。解决方案有很多:互斥锁、逻辑过期、永不过期……但在 Go 的世界里,有一个更优雅的答案——singleflight。

一句话理解 singleflight

相同 key 的并发请求,只执行一次,其余请求等待并共享结果。

想象一个课堂场景:

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函数返回的错误
sharedtrue = 当前结果是共享给其他等待者的

看到这里,你已经掌握了 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] 真正查询数据库,说明合并生效了。

但这个写法有两个致命问题

  1. 无法超时控制:Do 是同步阻塞的,如果 DB 查询卡了 30 秒,所有 100 个 HTTP 请求都会傻等 30 秒,客户端早已超时断开,服务端 goroutine 却还在空等,最终导致 goroutine 泄漏。
  2. 错误会被共享:如果第一次查询返回了错误(比如 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,否则永远优先选 ForgetUnshared。

Do vs DoChan 对比

特性DoDoChan
调用方式同步阻塞返回 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 个避坑指南

  1. 阻塞风险:fn 慢查询会导致所有等待者阻塞 → 用 DoChan + context.WithTimeout
  2. 错误共享:首个请求失败,所有等待者都拿错误 → 出错时 ForgetUnshared
  3. Key 设计:key 要精确标识"同一份数据",如 user:{id},避免不同业务误合并
  4. 单实例限制:只在进程内有效,多实例部署仍需分布式锁
  5. 不要滥用:仅适用于"读多写少、结果可共享"的场景,写操作不适用

总结

维度说明
是什么Go 官方请求合并工具,相同 key 并发请求只执行一次
核心原理Mutex + Map + WaitGroup
入门 APIDo(同步阻塞,适合学习)
生产标配DoChan + context + ForgetUnshared
典型场景防缓存击穿、合并 RPC、防重复提交
验证方式压测接口 + 观察 DB 查询日志次数

singleflight 是 Go 并发编程中不可或缺的"瑞士军刀"。记住:它不是缓存,而是缓存失效时的最后一道防线。

以上就是Go语言中singleflight高并发下合并请求的最佳实践的详细内容,更多关于Go singleflight合并请求的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文