热点缓存过期的瞬间,成百上千个请求同时打到后端,这是缓存击穿。singleflight 把同一个键的并发请求合并成一次。这篇实测它能省多少,以及错误共享、ctx 借用、panic 扩散这几个容易忽略的行为。
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:三个调用方的结果 = [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 让下一次调用重新执行而不是等旧的。