前言
在构建高性能的服务时,缓存是优化数据库压力和提高响应速度的关键技术。使用缓存也会带来一些问题,其中就包括 缓存击穿,它不仅会导致数据库压力剧增,引起数据库性能的下降,严重时甚至会击垮数据库,导致数据库不可用。
在 Go
语言中,golang.org/x/sync/singleflight
包提供了一种机制,确保对于任何特定 key
的并发请求在同一时刻只执行一次。这个机制有效地防止了缓存击穿问题。
本文将深入探讨 Go
语言中 singleflight
包的使用。从缓存击穿问题的基础知识开始,进而详细介绍 singleflight
包的使用,展示如何利用它来避免缓存击穿。
准备好了吗?准备一杯你最喜欢的咖啡或茶,随着本文一探究竟吧。
缓存击穿
缓存击穿 是指在高并发的情况下,某个热点的 key
突然过期,导致大量的请求直接访问数据库,造成数据库的压力过大,甚至宕机的现象。
缓存击穿流程图.png
常见的解决方案:
- 设置热点数据永不过期:对于一些确定的热点数据,可以将其设置为 永不过期,这样就可以确保不会因为缓存失效而导致请求直接访问数据库。
- 设置互斥锁:为了防止缓存失效时所有请求同时查询数据库,可以采用锁机制确保仅有一个请求查询数据库并更新缓存,而其他请求则在缓存更新后再进行访问。
- 提前更新:后台监控缓存的使用情况,当缓存即将过期时,异步更新缓存,延长过期时间。
singleflight 包
Package singleflight provides a duplicate function call suppression mechanism.
这段英文来自官方文档的介绍,直译过来的意思是:singleflight
包提供了一种“重复函数调用抑制机制”。
换句话说,当多个 goroutine
同时尝试调用同一个函数(基于某个给定的 key
)时,singleflight
会确保该函数只会被第一个到达的 goroutine
调用,其他 goroutine
会等待这次调用的结果,然后共享这个结果,而不是同时发起多个调用。
一句话概括就是 singleflight
将多个请求合并成一个请求,多个请求共享同一个结果。
组成部分
Group
:这是 singleflight
包的核心结构体。它管理着所有的请求,确保同一时刻,对同一资源的请求只会被执行一次。Group
对象不需要显式创建,直接声明后即可使用。
Do
方法:Group
结构体提供了 Do
方法,这是实现合并请求的主要方法,该方法接收两个参数:一个是字符串 key
(用于标识请求资源),另一个是函数 fn
,用来执行实际的任务。在调用 Do
方法时,如果已经有一个相同 key
的请求正在执行,那么 Do
方法会等待这个请求完成并共享结果,否则执行 fn
函数,然后返回结果。
Do
方法有三个返回值,前两个返回值是 fn
函数的返回值,类型分别为 interface{}
和 error
,最后一个返回值是一个 bool
类型,表示 Do
方法的返回结果是否被多个调用共享。
DoChan
:该方法与 Do
方法类似,但它返回的是一个通道,通道在操作完成时接收到结果。返回值是通道,意味着我们能以非阻塞的方式等待结果。
Forget
:该方法用于从 Group
中删除一个 key
以及相关的请求记录,确保下次用同一 key
调用 Do
时,将立即执行新请求,而不是复用之前的结果。
Result
:这是 DoChan
方法返回结果时所使用的结构体类型,用于封装请求的结果。这个结构体包含三个字段,具体如下:
Val
(interface{}
类型):请求返回的结果。Err
(error
类型):请求过程中发生的错误信息。Shared
(bool
类型):表示这个结果是否被当前请求以外的其他请求共享。
安装
通过以下命令,在 go
应用中安装 singleflight
依赖:
go get golang.org/x/sync/singleflight
使用示例
// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/singleflight/usage/main.go package main import ( "errors" "fmt" "golang.org/x/sync/singleflight" "sync" ) var errRedisKeyNotFound = errors.New("redis: key not found") func fetchDataFromCache() (any, error) { fmt.Println("fetch data from cache") returnnil, errRedisKeyNotFound } func fetchDataFromDataBase() (any, error) { fmt.Println("fetch data from database") return"程序员陈明勇", nil } func fetchData() (any, error) { cache, err := fetchDataFromCache() if err != nil && errors.Is(err, errRedisKeyNotFound) { fmt.Println(errRedisKeyNotFound.Error()) return fetchDataFromDataBase() } return cache, err } func main() { var ( sg singleflight.Group wg sync.WaitGroup ) forrange5 { wg.Add(1) gofunc() { defer wg.Done() v, err, shared := sg.Do("key", fetchData) if err != nil { panic(err) } fmt.Printf("v: %v, shared: %v\n", v, shared) }() } wg.Wait() }
singleflight.png
这段代码模拟了一个典型的并发访问场景:从缓存获取数据,若缓存未命中,则从数据库检索。在此过程中,singleflight
库起到了至关重要的作用。它确保在多个并发请求尝试同时获取相同数据时,实际的获取操作(不论是访问缓存还是查询数据库)只会执行一次。这样不仅减轻了数据库的压力,还有效防止了高并发环境下可能发生的缓存击穿问题。
代码运行结果如下所示:
fetch data from cache
redis: key not found
fetch data from database
v: 程序员陈明勇, shared: true
v: 程序员陈明勇, shared: true
v: 程序员陈明勇, shared: true
v: 程序员陈明勇, shared: true
v: 程序员陈明勇, shared: true
根据运行结果可知,当 5 个 goroutine
并发获取相同数据时,数据获取操作实际上只由一个goroutine
执行了一次。此外,由于所有返回的 shared
值均为 true
,这表明返回的结果被其他 4 个goroutine
共享。
最佳实践
key 的设计
在生成 key
的时候,我们应该保证它的唯一性与一致性。
- 唯一性:确保传递给
Do
方法的key
具有唯一性,以便Group
区分不同请求。推荐使用结构化的命名方式来保证key
的唯一性,例如,可以遵循类似{类型}):{标识}
的规范来构建key
。以获取用户信息为例,相应的key
可以是user:1234
,其中user
标识数据类型,而1234
则是具体的用户标识。 - 一致性:对于相同的请求,无论何时调用,生成的
key
应该保持一致,以便Group
正确地合并相同的请求,防止非预期的错误。
超时控制
在调用 Group.Do
方法时,第一个到达的 goroutine
可以成功执行 fn
函数,而其他随后到达的 goroutine
将进入阻塞状态。如果阻塞状态持续过长,可能需要采取降级策略以保证系统的响应性,这时候,我们可以利用 Group.DoChan
方法和结合 select
语句实现超时控制。
以下是一个实现超时控制的简单示例:
// https://github.com/chenmingyong0423/blog/blob/master/tutorial-code/go/singleflight/timeout_control/main.go package main import ( "fmt" "golang.org/x/sync/singleflight" "time" ) func main() { var sg singleflight.Group doChan := sg.DoChan("key", func() (interface{}, error) { time.Sleep(4 * time.Second) return"程序员陈明勇", nil }) select { case <-doChan: fmt.Println("done") case <-time.After(2 * time.Second): fmt.Println("timeout") // 采用其他降级策略 } }
小结
本文首先介绍了 缓存击穿 的含义及其常见的解决方案。
然后深入探讨了 singleflight
包,从基础概念、组成部分到具体的安装和使用示例。
接着通过模拟一个典型的并发访问场景来演示如何利用 singleflight
来防止在高并发场景下可能发生的缓存击穿问题。
最后,探讨在实践中设计 key
和控制请求超时的最佳策略,以便更好地理解和应用 singleflight
,从而优化并发处理逻辑。