技术笔记: SingleFlight把“同一件事并发做 N 次”变成“只做 1 次

0 阅读4分钟

1)SingleFlight 解决的到底是什么问题?

很多线上问题本质都是“并发放大”:

  • 同一时间多个请求都发现 Token 过期 → 都去刷新 → refresh 被打爆,甚至互相把 token 搞失效
  • 热点详情页:同一个 id 在列表预取、详情页、评论模块里同时拉 → 同一个接口回源 8 次
  • 缓存击穿:TTL 到期的一瞬间,大家同时 miss → 同一个 key 回源 N 次
  • 甚至不是网络:多个地方同时读同一条 DB 记录 → IO/锁竞争放大,掉帧

SingleFlight 的思想很简单:同一个 key 在同一时间窗口内,只允许“第一个”去真正回源,其余请求不重复回源,只等待复用结果。

你可以把它理解成“每个 key 一把小闸门”。


2)这个 Demo 怎么演示 SingleFlight 的?

ViewController 里有:

  • 一个 SingleFlight 开关
  • 7 个按钮(对应 7 个常见必用场景)
  • 每次点击都触发 burst x8(8 个并发请求)

UI 顶部会显示一行指标(示例):

  • origin:真实回源次数
  • coalesced:被合并的次数(多少请求没有自己回源)
  • stale:用了陈旧缓存返回(SWR)
  • bypass:绕过 singleflight 的次数(比如开关关闭、或不可合并副作用)
  • reject:等待队列太大被拒绝 join
  • timeout:singleflight 超时兜底次数
  • waitP95 / waitP99:等待时延分位
  • inflightMax:同一时刻有多少个 key 在回源

直观现象:

  • singleflight=ON:一次 burst,origin 大约 +1,coalesced 大约 +7
  • singleflight=OFF:一次 burst,origin 大约 +8,coalesced 不怎么增长

3)SingleFlight 核心实现长什么样?

核心在 SingleFlight

  • inflight: [String: Entry]
  • Entry 里保存:
    • callbacks:所有等待者的回调
    • startedAt:用于统计等待时间
    • timeoutWorkItem:兜底超时,防“回调永不回来”导致 inflight 泄漏
    • start:真实回源闭包(用于失败时“让 joiner 单独重试”)

流程(通俗版):

  1. 来了一个请求 (key)
  2. 如果 inflight[key] 已存在:把自己的 completion 加进队列 → 直接返回(不回源)
  3. 如果不存在:创建 entry,把自己也放进队列 → 执行真实回源 start
  4. 回源结束:
    • remove inflight[key]
    • 把同一个结果广播给所有等待者

4)为什么要做“生产边界”?Demo 里补了哪些坑位?

只做“合并”很容易出事故,生产通常要补这些边界。这个 Demo 已经把它们落成了代码策略:

  • 不可合并副作用绕过
    写接口/扣费/上报等不能随便合并,否则语义会被破坏。Demo 用 policy.effect 控制是否 bypass。

  • 超时兜底防 inflight 泄漏
    如果真实回源永远不回调,inflight 会一直卡住,后续请求永远 join。Demo 用 timeoutMs + timeoutWorkItem 清理并给等待者返回超时。

  • maxWaiters:热点保护
    热点 key 可能瞬间上千等待者,回调队列会爆内存/回调风暴。Demo 用 maxWaiters 限流,多出来的直接 reject。

  • 失败传播策略(很关键)
    共享失败会“一次失败炸一片”。Demo 支持两种:

    • broadcastSharedFailure:所有等待者共享失败(省 origin)
    • retryJoinersIndividually:首个失败广播给首个请求,其余等待者改为单独重试(避免失败放大,但 origin 会增加)

这些策略在 DemoRepository 里按场景配置。


5)7 个必用场景,在 Demo 里怎么映射?

DemoRepository 提供了 7 个方法(对应 7 个按钮),每个方法都做了两件事:

  1. 构造 singleflight key(不同维度避免串数据)
  2. 选择 policy(合并/超时/失败传播/SWR)

举几个例子(你可以直接在文件里搜方法名):

  • Token 刷新:refreshToken
    key 通常要包含 userId/authScope/appId/env/tokenFamily,避免跨账号/环境误合并。

  • 配置中心:fetchConfig
    常适合 stale-while-revalidate:先返回缓存(哪怕 stale),后台 singleflight 刷新。

  • 热点详情:fetchItemDetail
    失败可以选择 retryJoinersIndividually,降低“偶发失败扩散”。

  • 缓存击穿:getOrLoadCache
    miss 时用 singleflight 合并回源,防止同一 cache key 回源 N 次;同时支持 SWR。


6)怎么用这个 Demo 自测理解更深?

建议你按下面顺序点:

  • 先开 singleflight,点“热点资源详情(同 id)”几次,看 origin 的增长是否接近 1/次
  • 关 singleflight,再点同一个按钮,看 origin 是否接近 8/次
  • 点“缓存失效回源(击穿)”,观察 stale 是否增长(如果配置了 SWR 且有旧值)
  • 多点几次 burst,看 waitP95/waitP99,理解“合并的代价是排队等待”

截图:

singleflight.png 项目