在开发业务代码过程中,我们经常会使用缓存DB数据的方式来加速查询,这样的架构会有一个问题,我们常常需要解决缓存穿透、缓存雪崩和缓存击穿问题。缓存击穿问题是指,在平常高并发的系统中,大量的请求同时查询一个 key 时,如果这个 key 正好过期失效了,就会导致大量的请求都打到数据库上,造成数据库压力很大,这就是缓存击穿。如果能够将一段时间的相同的N个请求合成一个,那么穿透到数据库的压力是不是就从N变成了1?
SingleFlight
就是这样的一个并发原语。本文介绍了SingleFlight
, 的使用以及它的基本原理,还有一些使用上的细节。
This article was first published in the Medium MPP plan. If you are a Medium user, please follow me on Medium. Thank you very much.
Figure 1:使用 SingleFlight
合并相同的请求。
SingleFlight 使用
Go 官方将 singleflight ,放在了golang.org/x
中,说明他可能还会有一些变化。它提供了重复函数调用抑制机制,使用它可以避免同时进行相同的函数调用。第一个调用未完成时后续的重复调用会等待,当第一个调用完成时则会与它们分享结果,这样以来虽然只执行了一次函数调用但是所有调用都拿到了最终的调用结果。
list1: 使用 SingleFlight
合并DB请求
从结果可以看出,我们我们模拟了5个请求cache miss 的情况,但是只有一个请求调用了DB,五个请求都拿到了相同的结果。这对于后端服务来说,是很可观的优化。他避免了数据库被缓存穿透。
在go 源码中,同样能看到 SingleFlight
的使用,比如/src/net/lookup.go#L165/src/cmd/go/internal/vcs/vcs.go#L1385,缓存库 groupcache 也是使用SingleFlight
类似的实现来防止缓存穿透。
singleflight 分析
singleflight
包中定义了一个名为Group
的结构体类型,它表示一类工作,并形成一个命名空间,在这个命名空间中,可以使用重复抑制来执行工作单元。
SingleFlight 的数据结构是 Group,它提供了三个方法。
- Do:这个方法执行一个函数,并返回函数执行的结果。你需要提供一个
key
,对于同一个key
,在同一时间只有一个在执行,同一个key
并发的请求会等待。第一个执行的请求返回的结果,就是它的返回结果。函数 fn 是一个无参的函数,返回一个结果或者 error,而 Do 方法会返回函数执行的结果或者是 error,shared 会指示 v 是否返回给多个请求。 - DoChan:类似 Do 方法,只不过是返回一个 chan,等
fn
函数执行完,产生了结果以后,就能从这个 chan 中接收这个结果。 - Forget:告诉 Group 忘记这个 key。这样一来,之后这个 key 请求会执行
fn
,而不是等待前一个未完成的fn
函数的结果。
使用细节
请求阻塞
singleflight
内部使用 waitGroup 来让同一个 key 的除了第一个请求的后续所有请求都阻塞。直到第一个请求执行 fn 返回后,其他请求才会返回。
这意味着,如果 fn 执行需要很长时间,那么后面的所有请求都会被一直阻塞。
这时候我们可以使用 DoChan 结合 ctx + select 做超时控制
Forget
singleflight 的实现为,如果第一个请求失败了,那么后续所有等待的请求都会返回同一个 error。
实际上可以根据DB使用情况定时 forget 一下 key,让更多的请求能有机会走到后续逻辑。
|
|
比如1秒内有100个请求过来,正常是第一个请求能执行 GetUserFromDB
,后续99个都会阻塞。
增加这个 Forget 之后,每 100ms 就能有一个请求执行 GetUserFromDB
,相当于是多了几次尝试的机会,相对的也给DB造成了更大的压力,需要根据具体场景进去取舍
。