使用Golang Singleflight合并请求_高并发下的数据库保护

1次阅读

singleflight 通过在请求发起瞬间拦截相同 key 的多个 goroutine,仅放行一个执行函数,其余等待其结果,从而避免重复数据库查询;它非缓存非锁,核心是共享执行过程。

使用Golang Singleflight合并请求_高并发下的数据库保护

为什么 singleflight 能挡住重复数据库查询

它不是缓存,也不是锁,而是在请求发起的「瞬间」把相同 key 的多个 goroutine 拦下来,只让其中一个真正执行函数,其余等待结果返回——哪怕这个函数是查 mysql、调 http 或读文件。关键在于:所有并发请求共享同一份执行过程,避免 N 个 goroutine 同时打到数据库。

常见错误现象:context deadline exceeded 突然变多、慢查询日志里出现大量一模一样的 SQL、prometheus 上数据库 QPS 峰值远超业务实际请求数。

  • 适用场景:用户详情页(多个微服务同时查同一 user_id)、配置中心热加载(多个协程争抢拉取 config.json)、graphql 字段解析中反复查同一条关联数据
  • 不适用场景:需要各自隔离上下文(如不同 context.WithTimeout)、函数本身有副作用(如发消息、扣库存)且不能被合并
  • singleflight.Group.Do 返回的是 (Interface{}, Error, bool),第三个 bool 表示是否为本次执行者;别直接用 err != nil 判断失败,要结合 shared 字段看是不是别人执行出错了

DoDoChan 怎么选

Do 是阻塞式,适合普通 HTTP handler 或同步逻辑;DoChan 返回 chan singleflight.Result,适合想控制超时、或需要和 select 配合的场景(比如等 DB 查询 + 等 redis 缓存,谁先来用谁)。

容易踩的坑:DoChan 不会自动关闭 channel,如果函数 panic 或 context cancel,channel 会永远卡住;必须用 select 配合 defaulttimeout 来兜底。

立即学习go语言免费学习笔记(深入)”;

  • 参数差异:Do(key String, fn func() (interface{}, error)) —— fn 无参;DoChan(key string, fn func() (interface{}, error)) —— 返回 channel,但 fn 还是一样的无参函数
  • 性能影响:两者底层共用同一 map+mutex,开销几乎一致;但 DoChan 多一次 goroutine 调度和 channel 发送,QPS 极高时(>5w/s)可测出微小差距
  • 示例:v, err, shared := g.Do("user:123", func() (interface{}, error) { return db.QueryRow("SELECT name FROM users WHERE id = ?", 123).Scan(&name) })

key 设计不当会导致合并失效或误合并

key 是字符串,但业务参数往往是 Struct 或 map。直接 fmt.Sprintf("%v", req) 看似方便,实际会因字段顺序、空字段、浮点数精度导致 key 不稳定;而用固定字符串(如硬写 "get_user")又会让所有请求挤进同一个队列,失去并发性。

正确做法是提取真正决定结果一致性的字段,拼成确定性字符串,比如 user_id + lang,中间加分隔符。

  • 常见错误:把 context.Context 当作 key 一部分(地址每次不同)、把时间戳或 traceID 写进 key(必然不合并)、用 JSON 序列化 struct 但没排序字段
  • 兼容性注意:如果后期加了新参数(比如支持 with_profile),旧 key 格式必须兼容,否则老客户端和新客户端的请求无法合并,变成两股流量
  • 建议用 strings.Join([]string{strconv.Itoa(u.ID), u.Lang}, ":"),比 fmt.Sprintf 更快更可控

panic、context cancel 和超时怎么处理

singleflight 本身不处理 panic,也不感知 context;它只管“谁来跑函数”。所以函数内部必须自己 recover panic,且显式检查 ctx.Err() 并提前返回——否则一个 goroutine 卡死,整个 key 下的所有等待者都会 hang 住。

错误信息如 panic: runtime error: invalid memory address 出现在日志里,往往是因为没 recover;而 context canceled 被忽略,则表现为某些请求永远收不到响应。

  • 必须在 fn 里做:select { case ,不能只依赖上层 handler 的 timeout
  • panic 后的结果不会被缓存,但当前这一轮所有等待者都会收到 panic 对应的 error;下一轮相同 key 请求仍会重试
  • 如果 DB 查询用了 db.QueryContext(ctx, ...),它自身会响应 cancel,但仍需在外层加 defer recover,防止驱动 bug 或其他逻辑 panic

事情说清了就结束。最常漏掉的是:函数里没检查 context、key 拼错导致合并失效、以及以为 singleflight 能替代缓存——它只消重,不存结果。

text=ZqhQzanResources