跳到主要内容
约 14 分钟6 张卡

singleflight

singleflight 是 Go 的同键调用合并工具:同一个 Group 中,一项工作仍在执行时,后续使用相同 key 的调用可以等待并共享它的结果,减少重复执行。它位于 golang.org/x/sync/singleflight,不属于标准库。

例如商品详情缓存刚好失效,多个请求同时查询同一商品,可以只安排一次回源,让其他请求等待这个结果。这里合并的是正在进行的工作;结果保留多久、等待多久、多个服务实例怎样协调,需要另外设计。本文以 Go 1.22.12、golang.org/x/sync v0.10.0 为准。

1. 面试核心​

先记住这几条,再读正文
  • 合并范围是同一个 Group、相同 key、重叠的执行过程。任务结束并移除记录后,后续调用会重新执行;singleflight 不是结果缓存。卡 · 合并范围与结果
  • Do 同步等待结果,DoChan 返回结果 channel。正常返回时共享的值和错误都会交给等待者,重复调用传入的另一个函数不会执行。
  • Shared 表示这次结果是否给了多个调用者,不表示当前调用者是第一个还是后来者;最初执行函数的调用者也可能得到 true。卡 · 一次执行与多个结果接收者
  • 取消一个调用者的等待,不等于取消共享工作。等待期限和底层工作的执行期限需要分别确定。卡 · 等待取消与工作期限
  • 缓存回源通常要在合并函数内再次检查缓存,并在返回前完成必要的缓存写入。合并同键重叠请求能缓解回源压力,不能独自解决所有穿透、击穿和雪崩问题。卡 · 缓存回源
  • Forget 只让未来的同键调用不再加入旧任务,不会终止旧任务,也不会改变旧等待者的归属;新旧工作可能并行执行。卡 · 忘记登记不等于停止执行
  • 不同 Group、不同进程不会自动合并。跨进程方案还要解决任务归属、结果共享、故障接管和过期执行者写回等问题。卡 · 综合题

2. 合并范围与结果​

2.1 同一项工作的标识​

key 是调用方对“一项可以共享结果的工作”的命名。singleflight 不会检查两个函数是否真的等价,只要同一个 Group 中有相同 key 的任务正在执行,后来者就加入那个任务。

因此 key 必须包含所有会影响共享结果的条件。例如租户、商品 ID、语言和数据版本不同,结果也可能不同。如果遗漏这些维度,不同请求可能拿到本不应该共享的数据;若把每次请求的随机 ID 都放入 key,则本来相同的工作也无法合并。

鉴权也不能省略。可以共享公开的商品基础数据,再由各请求单独完成用户相关处理;如果共享的是用户专属结果,就必须让访问控制和 key 的范围一致。

Group 的零值可以直接使用,内部状态会在需要时初始化。需要合并的请求必须复用同一个 Group,而不是每次请求临时创建一个。它内部含有锁和进行中任务表,使用后不要复制。

2.2 一次执行与多个结果接收者​

下面用 DoChan 安排两次确定重叠的调用。第一个函数会等 release,保证第二次调用有机会加入:

package main

import (
"fmt"
"golang.org/x/sync/singleflight"
)

func main() {
var g singleflight.Group
started := make(chan struct{})
release := make(chan struct{})
calls := 0

first := g.DoChan("product:7", func() (any, error) {
calls++
close(started)
<-release
return "book", nil
})
<-started
second := g.DoChan("product:7", func() (any, error) {
panic("duplicate function must not run")
})
close(release)

a, b := <-first, <-second
fmt.Println(a.Val, a.Err, a.Shared)
fmt.Println(b.Val, b.Err, b.Shared)
fmt.Println("calls:", calls)

_, _, shared := g.Do("product:7", func() (any, error) {
calls++
return "new book", nil
})
fmt.Println("later:", calls, shared)
}

前两行都输出 book <nil> true,随后是 calls: 1 和 later: 2 false。第二个函数没有运行;第一次任务结束后再调用 Do,函数重新执行,说明结果没有被 Group 长期保存。

最初的调用者也得到 Shared=true,因为它的结果被另一个调用者共享。若需要记录实际回源次数,应在真正执行的函数中统计,不能简单把 Shared=false 的返回次数当成回源次数。

共享也不意味着深拷贝。函数若返回指针、map 或切片,多个调用者可能引用同一个可变对象。应该按不可变结果使用,或为每个调用者复制所需数据,不能因为请求已经合并就省略共享数据保护。

2.3 Do 与 DoChan​

方法等待方式结果与限制
Do(key, fn)调用在结果产生前不返回;首次调用在当前 goroutine 执行 fn返回值、错误和 shared,没有 context 参数
DoChan(key, fn)先返回 channel;首次调用由内部 goroutine 执行 fn正常返回后发送一个 Result,channel 不会关闭
Forget(key)移除当前登记的 key后续调用可以启动新工作;不等待、也不取消旧工作

Result 的三个字段是 Val、Err、Shared。DoChan 返回的是每个调用者自己的结果 channel,多个等待者不会抢同一条消息。收到一次结果后就应结束接收,不能用 for range 等它关闭,也不能期待第二个结果。

函数返回普通 error 时,已经加入这次执行的调用者得到相同错误。记录清除后,新的调用仍可以重新执行,因此失败可能形成一轮又一轮的重试;退避、熔断或短期负结果缓存属于额外策略。

3. 进行中任务的协调​

v0.10.0 的核心结构是一个受互斥锁保护的 map[string]*call。call 记录一次进行中的执行,包括完成等待、结果、错误、重复调用数量,以及需要接收结果的 channel。

一项正常工作的处理过程可以分成四步:

  1. 持锁查找 key。如果已经有 call,登记后来者,然后离开任务表的临界区,等待已有结果。
  2. 如果没有,就建立新 call 并放入表中。放入表后释放锁,再执行用户函数。
  3. 用户函数返回后保存结果和错误,通知同步等待者完成,并向登记的结果 channel 分发结果。
  4. 清理任务表中仍指向这次 call 的记录,允许下一轮同键工作开始。

这里描述的是职责。具体实现的完成阶段持有组内锁,先 wg.Done(),检查并删除仍属于旧 call 的映射,再分发 channel 结果。外部调用者不能依赖这些内部步骤之间的瞬间状态来安排业务。

锁保护的是任务表,不是整个回源操作。如果执行用户函数时一直持有组内锁,一个慢 key 就会把所有其他 key 也堵住。当前实现会在函数执行前释放锁,因此不同 key 的函数可以同时运行;这也意味着它没有为不同 key 的总并发数设置上限。

完成等待保证后来者读取到对应结果,但不替用户函数内部的共享访问提供保护。函数访问全局 map、缓存对象或其他状态时,仍要遵守这些对象的并发约定。

同一个函数里再次同步调用同一个 Group、相同 key,会等到自己完成,形成自我等待。递归回源、依赖环或同键重入需要从调用关系上消除,singleflight 不会替我们识别这种环。

4. 等待取消与工作期限​

4.1 调用者停止等待​

使用 DoChan 时,可以把结果接收和调用者的取消信号放入同一个 select。下面第一个调用者取消等待,第二个调用者继续得到结果:

package main

import (
"context"
"fmt"
"golang.org/x/sync/singleflight"
)

func main() {
var g singleflight.Group
started := make(chan struct{})
release := make(chan struct{})
first := g.DoChan("product:7", func() (any, error) {
close(started)
<-release
return "book", nil
})
<-started
second := g.DoChan("product:7", func() (any, error) {
panic("duplicate function must not run")
})

ctx, cancel := context.WithCancel(context.Background())
cancel()
select {
case result := <-first:
fmt.Println(result.Val)
case <-ctx.Done():
fmt.Println("first:", ctx.Err())
}

close(release)
result := <-second
fmt.Println("second:", result.Val, result.Err)
}

输出是 first: context canceled 和 second: book <nil>。在取消发生时,工作仍被 release 挡住,所以第一个 select 只能走取消分支;实际工作直到 release 被关闭后才继续。

这个版本为每个 DoChan 调用创建容量为 1 的 channel。即使第一个调用者不再接收,正常完成后发送一次结果也不会因此阻塞。但它登记的 channel 在工作完成前仍由这次调用持有,取消等待不会立即把它从内部等待列表删除。

如果结果与取消都已经就绪,select 不保证优先选择取消,不能用本例的受控先后关系推导所有场景都返回 context.Canceled。

4.2 共享工作由谁取消​

共享工作的期限要根据业务所有权决定。直接让首次调用传入的请求 context 控制回源,意味着它一旦取消,其他仍需要结果的调用者也可能共同收到回源错误。这不是 singleflight 自动实现了“最后一个等待者离开才停止”,而是第一个函数闭包选择了那个 context。

若回源应在个别请求离开后继续,可以由服务生命周期 context 派生一项有明确超时的工作,让每个请求只控制自己的等待。工作必须真正把这个 context 传给支持取消的 I/O;在外层创建一个超时 context,却不让执行函数观察它,仍不能终止工作。

这种设计也有代价:所有请求都已离开时,工作可能继续到结束或期限到达。需要“无人等待立即取消”时,还要设计引用计数、加入与离开的竞态,以及新请求如何接手,标准 singleflight.Group 没有直接提供这项能力。

如果底层调用可能永久阻塞,Do 等待者也可能一直等待;DoChan 可以让调用者有机会超时返回,但内部任务仍然存在。具体问题见 Go 并发诊断的超时包装。

5. 缓存回源​

5.1 两次检查与写入时机​

缓存与 singleflight 的职责不同:缓存复用已经得到的结果,singleflight 合并尚未完成的工作。一个有明确边界的读取流程是:

  1. 请求先查缓存,命中就直接返回。
  2. 未命中时,用完整业务 key 进入共享的 Group。
  3. 真正执行的函数再次查缓存,避免错过其他调用刚刚填入的结果。
  4. 仍未命中才查询数据源,按缓存策略处理空结果、错误和有效期。
  5. 需要缓存的结果在共享函数返回前完成写入,再交给等待者。

第二次检查不能省略为“反正只会有一个请求回源”。某个请求可能在外层查缓存时未命中,随后暂时没有执行;另一轮已经回源、写缓存并结束,等它进入 Group 时,原来的进行中记录已经不存在。没有内层检查就会再查一次数据源。

同理,如果共享函数先返回,由外层请求稍后写缓存,新的调用可能在缓存尚未填入、旧任务记录却已经移除的窗口再次回源。缓存写入失败时,要明确是否仍返回数据、怎样限制后续重试,不能把写入动作的存在当成必然成功。

5.2 穿透、击穿与容量​

热点 key 失效后,大量同键请求同时回源,属于 缓存击穿 要处理的情形。单进程同键合并能减少这一轮重复回源,但等待者仍会受这次回源延迟影响。

缓存穿透 关注的是查询不存在的数据。同一个不存在的 key 被并发查询时,singleflight 可以合并这一轮;任务结束后再查,仍会重新回源。若请求不断变化成不同的不存在 key,更不会自动合并。是否缓存空结果、使用过滤结构、验证参数或限制请求量,要分别判断。

大量不同 key 同时未命中时,每个 key 都可以拥有自己的工作。Group 不会自动限制后端查询总量,也不会限制等待列表大小;需要配合总体并发限制、工作期限和容量保护。不要把“相同 key 只合并一轮”理解成“后端最多只收到一个请求”。

6. Forget 与跨进程协调​

6.1 忘记登记不等于停止执行​

Forget(key) 删除任务表中的当前登记,让之后的调用不再加入旧工作。旧函数仍会运行,旧等待者仍然等待旧结果。

时刻操作结果归属
1A 启动 key 的任务,尚未完成已加入 A 的调用者等待 A
2调用 Forget(key)A 仍在运行,但不再是表中的可加入任务
3B 用同一个 key 发起调用B 启动新工作,可与 A 重叠
4A 完成,B 尚未完成A 只通知自己的等待者;新调用仍应加入 B

v0.10.0 清理时会检查任务表是否仍指向自己,避免 A 完成时把 B 的登记删除。但这只保护内部任务表,不保护业务数据:A 如果更晚写回过期结果,仍可能覆盖 B 的新结果。需要在缓存或数据源写入处使用适合业务的版本校验等机制。

因此不能把 Forget 当作强制取消、缓存失效操作或超时重试的无成本按钮。它允许新工作绕过旧工作,代价可能是额外回源和新旧结果竞争。

6.2 跨进程共享的额外职责​

一个进程内的两个不同 Group 就可以分别执行同一个 key,更不用说两台服务器。若要跨进程减少同一 key 的重复回源,需要共享的协调与结果存储,不能把本地 Group 放进服务里就认为已经覆盖集群。

以共享缓存配合租约为例,设计需要逐项回答:

  • 怎样原子决定当前由谁执行,租约多长、能否续租、失败后谁可以接管。
  • 没取得执行权的请求到哪里读取结果,怎样等待、超时或降级,不能只让它们一直抢锁。
  • 执行者成功后怎样写入结果并释放自己的租约;通知丢失时,等待者是否仍能通过结果存储恢复。
  • 旧执行者因暂停而超过租约、后来又恢复时,怎样避免它覆盖新执行者的结果。
  • 数据源有副作用时,怎样保证业务幂等,以及是否需要存储侧接受的 fencing 或版本校验。

租约过期允许别人接管,并不保证旧任务已经停止;锁的这些边界见 Redis 分布式锁。对于只读回源,允许少量重复计算可能是可以接受的取舍;对于扣款、下单等操作,不能把请求合并当成持久的幂等保证。

7. 常见问题​

7.1 等待者传入不同的函数,会各自执行吗?​

不会。只要加入同一轮相同 key 的任务,后来者的函数就不会执行,它拿到的是已有函数的结果。函数中的请求专属副作用也会被跳过,因此每个请求都必须执行的鉴权、审计或响应处理不能随意放进被合并的函数。还要保证同一 key 的返回类型约定一致,避免调用者按自己的函数预期错误地断言结果类型。

7.2 panic 和 Goexit 会变成普通 error 吗?​

7.3 可以用它保证操作只执行一次吗?​

8 · 面试题卡片​

本页题目与参考回答

8.1 singleflight 如何合并请求?如果自行实现,需要哪些状态?​

考察重点:key 的范围、进行中任务表、结果等待与清理。

相关内容:合并范围与结果、进行中任务的协调。

参考回答

同一个 Group 用 key 查找正在执行的任务,后来者等待并共享已有结果;没有任务时才登记并执行新函数。 基本状态包括受锁保护的任务表,以及每次调用的完成通知、值和错误。

查表和登记要原子协调,执行耗时函数时不应一直持有任务表锁。完成后通知等待者并清理属于自己的记录,异常退出也要有明确处理。它保留的是进行中的任务,完成后的新请求仍会重新执行。

8.2 singleflight 用于缓存回源时,能解决哪些问题,还有什么限制?​

考察重点:缓存复用、同键合并、二次检查与不存在数据的处理。

相关内容:缓存回源。

参考回答

它能合并单个 Group 内同键且执行重叠的回源,减少这一轮的重复查询。 外层先查缓存,共享函数内再次检查,仍未命中才查数据源,并在返回前完成约定的缓存写入。

热点失效时这能缓解回源压力;不存在的数据即使这一轮被合并,下一轮仍可能回源,不同 key 也不会合并。空结果缓存、过滤、总体限流和跨实例协调需要另外处理,不能把所有缓存故障归给一个工具解决。

8.3 singleflight 中的函数很慢,其他调用者会一直等吗?怎样处理超时?​

考察重点:同步等待、调用者取消、共享工作的执行期限。

相关内容:等待取消与工作期限。

参考回答

Do 会等待本次工作结束,没有内置的 context 等待取消;使用 DoChan 可以让调用者 select 结果与自己的取消信号。 但调用者超时返回不等于共享函数已经停止。

共享函数应有明确的工作期限,并把取消传给真正支持它的操作。直接使用第一个请求的 context,可能在它取消时影响其他等待者;若由服务管理共享工作的生命周期,也要接受无人等待时仍可能继续执行的代价。

8.4 singleflight 是单机还是分布式的?跨进程合并需要补什么?​

题型:综合题

考察重点:本地状态范围、租约、结果共享、故障接管与旧执行者写入。

相关内容:跨进程共享的额外职责、Redis 分布式锁。

参考回答

标准 Group 只在自己的进程内实例中合并,跨进程需要共享协调和结果存储。 可以讨论共享缓存与租约方案:取得执行权的一方回源并写结果,其他请求等待或读取结果,同时保留超时与降级路径。

还必须处理执行者崩溃、租约过期、通知丢失和旧任务恢复写回。租约过期不代表旧任务已停止,需要合适的版本或 fencing 约束;有副作用的业务另需幂等保证,不能仅凭加锁就承诺只执行一次。

8.5 Shared 为 true,是否说明本次调用没有执行函数?​

考察重点:结果共享标志与实际执行身份的区别。

相关内容:一次执行与多个结果接收者。

参考回答

不能这样判断。Shared 表示结果被多个调用者共享,最初执行函数的调用者也可能得到 true。 后来者加入同一轮执行时,已有函数的结果会被共同使用。

需要统计真实回源次数,应在实际函数体内记录。也不要根据 Shared 决定某个只允许实际执行者完成的副作用,它并不是执行者身份标志。

8.6 Forget 会停止旧任务并让旧等待者改等新结果吗?​

考察重点:登记移除、任务生命周期、新旧结果与清理边界。

相关内容:忘记登记不等于停止执行。

参考回答

不会。Forget 只移除当前 key 的登记,让后续调用可以启动新工作;旧任务继续执行,旧等待者仍接收旧结果。 因而同一个 key 可能出现新旧任务同时运行。

该版本完成清理时会确认映射仍属于自己,避免旧任务删掉新登记。但旧任务写回过期业务结果的问题仍需应用处理,不能把内部记录保护误认为缓存版本保护。

9. 参考资料​

来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明