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。
一项正常工作的处理过程可以分成四步:
- 持锁查找 key。如果已经有
call,登记后来者,然后离开任务表的临界区,等待已有结果。 - 如果没有,就建立新
call并放入表中。放入表后释放锁,再执行用户函数。 - 用户函数返回后保存结果和错误,通知同步等待者完成,并向登记的结果 channel 分发结果。
- 清理任务表中仍指向这次
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 合并尚未完成的工作。一个有明确边界的读取流程是:
- 请求先查缓存,命中就直接返回。
- 未命中时,用完整业务 key 进入共享的
Group。 - 真正执行的函数再次查缓存,避免错过其他调用刚刚填入的结果。
- 仍未命中才查询数据源,按缓存策略处理空结果、错误和有效期。
- 需要缓存的结果在共享函数返回前完成写入,再交给等待者。
第二次检查不能省略为“反正只会有一个请求回源”。某个请求可能在外层查缓存时未命中,随后暂时没有执行;另一轮已经回源、写缓存并结束,等它进入 Group 时,原来的进行中记录已经不存在。没有内层检查就会再查一次数据源。
同理,如果共享函数先返回,由外层请求稍后写缓存,新的调用可能在缓存尚未填入、旧任务记录却已经移除的窗口再次回源。缓存写入失败时,要明确是否仍返回数据、怎样限制后续重试,不能把写入动作的存在当成必然成功。
5.2 穿透、击穿与容量
热点 key 失效后,大量同键请求同时回源,属于 缓存击穿 要处理的情形。单进程同键合并能减少这一轮重复回源,但等待者仍会受这次回源延迟影响。
缓存穿透 关注的是查询不存在的数据。同一个不存在的 key 被并发查询时,singleflight 可以合并这一轮;任务结束后再查,仍会重新回源。若请求不断变化成不同的不存在 key,更不会自动合并。是否缓存空结果、使用过滤结构、验证参数或限制请求量,要分别判断。
大量不同 key 同时未命中时,每个 key 都可以拥有自己的工作。Group 不会自动限制后端查询总量,也不会限制等待列表大小;需要配合总体并发限制、工作期限和容量保护。不要把“相同 key 只合并一轮”理解成“后端最多只收到一个请求”。
6. Forget 与跨进程协调
6.1 忘记登记不等于停止执行
Forget(key) 删除任务表中的当前登记,让之后的调用不再加入旧工作。旧函数仍会运行,旧等待者仍然等待旧结果。
| 时刻 | 操作 | 结果归属 |
|---|---|---|
| 1 | A 启动 key 的任务,尚未完成 | 已加入 A 的调用者等待 A |
| 2 | 调用 Forget(key) | A 仍在运行,但不再是表中的可加入任务 |
| 3 | B 用同一个 key 发起调用 | B 启动新工作,可与 A 重叠 |
| 4 | A 完成,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 吗?
不会。在 v0.10.0 中,Do 对执行函数的 panic 作特殊处理后继续传播 panic;存在 DoChan 等待者时,panic 不会作为一个普通 Result.Err 安全送达,可能使进程退出。等待方在自己的 goroutine 里写 recover,不能捕获另一个 goroutine 的 panic。
runtime.Goexit 也不是普通错误返回。执行函数调用它时,该版本不会按正常完成路径向 DoChan 发送结果,等待方不能指望收到一个表示 Goexit 的 Err。业务失败应显式返回错误,不能把这些控制流当成错误处理协议。
7.3 可以用它保证操作只执行一次吗?
只能在约定的进行中合并范围内减少重复执行。任务完成后的新调用、Forget 后的新调用、其他 Group 或其他进程,都可能再次执行。进程崩溃与业务提交之间也没有持久记录来协调,因此它不能保证跨请求、跨故障的只执行一次。
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. 参考资料
- golang.org/x/sync v0.10.0:singleflight 实现与接口说明
- golang.org/x/sync v0.10.0:singleflight 测试
- Go 官方博客:Go Concurrency Patterns: Context
来源:Coding 101 原文 · 免费阅读,引用请保留出处。内容使用说明