bigywater.tech
Go · 79

singleflight:合并重复请求,和缓存击穿

热点缓存过期的瞬间,成百上千个请求同时打到后端,这是缓存击穿。singleflight 把同一个键的并发请求合并成一次。这篇实测它能省多少,以及错误共享、ctx 借用、panic 扩散这几个容易忽略的行为。

100 → 1
100 个并发请求同一个键,加载函数的调用次数
200 → 1
缓存刚过期时 200 个陆续到达的请求,打到后端的次数
ctx 借用
第二个调用方被第一个调用方的超时连累

合并的是“正在进行”的调用

实测 · 本机执行输出
  100 个并发请求同一个键,加载函数耗时 100 ms:不合并 → 加载函数被调用 100 次,总用时 102 ms
  100 个并发请求同一个键,加载函数耗时 100 ms:singleflight → 加载函数被调用 1 次,总用时 102 ms,shared=true 的调用方 100 个
  100 个并发请求,分布在 4 个不同的键上:加载函数被调用 4 次
  同一个键顺序调用 3 次(每次等上一次结束):加载函数被调用 3 次(singleflight 不是缓存)

同一个键的并发调用合并成一次:100 个请求加载函数只调用 1 次,而不同键各自合并(4 个键 4 次)。它不是缓存:同一个键顺序调用 3 次就执行 3 次,所以缓存仍要自己做,singleflight 只负责缓存失效时的那一下。

缓存击穿的对照

实测 · 本机执行输出
  缓存刚过期,200 个请求在 50 ms 内陆续到达,后端每次 100 ms:不合并 → 打到后端 200 次,总用时 150 ms
  缓存刚过期,200 个请求在 50 ms 内陆续到达,后端每次 100 ms:用 singleflight → 打到后端 1 次,总用时 101 ms

热点键刚过期,200 个请求在 50 ms 内陆续到达,后端每次 100 ms:不合并打后端 200 次,用 singleflight 只打 1 次,总用时也从 150 ms 降到 101 ms。

共享的不只是结果

实测 · 本机执行输出
  三个请求先后到达(0/10/20 ms),第一个的加载函数返回错误:三个调用方得到的错误 = 后端失败 / 后端失败 / 后端失败
  第一个调用方的 ctx 20 ms 后超时,第二个调用方的 ctx 不会超时,加载函数用的是第一个调用方的 ctx:第一个得到 <nil>/context deadline exceeded;第二个得到 <nil>/context deadline exceeded
  DoChan + select:调用方自己在 31 ms 超时离开,后台的加载仍会继续

错误会被所有等待者共享,所以后到的请求也得到一次失败。更隐蔽的是 ctx:加载函数用了第一个调用方的 ctx,它 20 ms 超时,第二个调用方尽管自己没超时也得到 context deadline exceeded。解决办法是加载函数用不会被某个调用方取消的 ctx,并用 DoChan 让每个调用方自己 select 超时。

panic 会扩散

实测 · 本机执行输出
  加载函数 panic:三个调用方的结果 = [recover 到 panic(*singleflight.panicError) recover 到 panic(*singleflight.panicError) recover 到 panic(*singleflight.panicError)]

加载函数 panic 时,每个调用方都收到包装后的 *singleflight.panicError 并 panic。第一个调用方 recover 了,其余的也要。

源码注释

实测 · 本机执行输出
  // Do executes and returns the results of the given function, making
  // sure that only one execution is in-flight for a given key at a
  // time. If a duplicate comes in, the duplicate caller waits for the
  // original to complete and receives the same results.
  // The return value shared indicates whether v was given to multiple callers.
  // Forget tells the singleflight to forget about a key. Future calls
  // to Do for this key will call the function rather than waiting for
  // an earlier call to complete.

Do 保证同一个键同一时刻只有一个在飞行中的执行;shared 告诉你结果有没有给多个调用方;Forget 让下一次调用重新执行而不是等旧的。

分步演示

singleflight
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。singleflight 来自 golang.org/x/sync v0.24.0(本机代理下载);每个实验是一个小程序,加载函数用 time.Sleep 模拟后端;第二节的请求到达间隔 250 µs;源码注释摘自 singleflight.go
    • 没有验证:Forget 在实际场景中的用法、DoChan 与 Forget 联用、singleflight 的内存占用、大量不同键时的开销、其它版本的行为(panic 的包装方式可能随版本变化,没有对比)、与带 TTL 的缓存库组合时的细节
    京ICP备2026067502号-1京公网安备11010502063487号