在日常开发中,为了提升性能和减轻数据库的压力,通常会对热点数据进行缓存。例如,使用 Redis 缓存用户请求的数据,如果缓存中有数据则直接返回,否则查询数据库并将结果写入缓存。
但是,如果缓存失效了,在查询数据库和将数据再次写入缓存的过程中,其他请求也会出现缓存未命中的情况,导致大量请求直接打到数据库,给数据库造成极大压力,甚至可能导致数据库崩溃。
这种情况被称为缓存击穿。
为了防止缓存击穿,传统的解决方案是加锁。然而,加锁的方法较重且逻辑复杂。相比之下,Singleflight 是一个轻量级的解决方案。
下面是一个示例:
package main
import (
"fmt"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
var g singleflight.Group
func main() {
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
val, err, _ := g.Do("key", func() (interface{}, error) {
fmt.Println("query db")
time.Sleep(100 * time.Millisecond) // 模拟查询数据库
return time.Now().UnixNano(), nil
})
if err != nil {
fmt.Println(err)
return
}
fmt.Println(val)
}()
}
wg.Wait()
}
输出结果:
query db
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
可以看到,10 个请求都获取到了结果,并且只有一个请求查询了数据库,大大减轻了数据库的压力。
Singleflight 的实现非常简单,除去注释大概只有 100 来行代码,但功能强大,值得学习。
type Group struct {
mu sync.Mutex // 保护 map 的互斥锁
m map[string]*call // 懒加载的 map
}
Group
结构体包含一个互斥锁和一个 map。由于 map 是懒加载的,Group
只需声明即可使用,不需要额外的初始化。
type call struct {
wg sync.WaitGroup
val interface{} // 函数返回值
err error // 函数返回的错误
forgotten bool // 是否调用了 forget 方法
dups int // 记录 key 被共享的次数
chans []chan<- Result // 用于异步返回结果的通道
}
call
保存了当前调用的相关信息,map 的键就是调用 Do
方法传入的 key。
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok {
c.dups++
g.mu.Unlock()
c.wg.Wait()
return c.val, c.err, true
}
c := new(call)
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock()
g.doCall(c, key, fn)
return c.val, c.err, c.dups > 0
}
Do
方法首先尝试获取锁,如果 key 存在,则等待第一个请求执行完成,并返回结果。如果 key 不存在,则新建一个 call
并执行 doCall
。
func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
defer func() {
c.wg.Done()
g.mu.Lock()
defer g.mu.Unlock()
if !c.forgotten {
delete(g.m, key)
}
}()
c.val, c.err = fn()
}
doCall
方法执行传入的函数 fn
,并将结果保存在 call
中。函数执行完毕后,调用 wg.Done()
解除阻塞,并删除 map 中的 key。
Singleflight 内部使用 WaitGroup
来让同一 key 的后续请求阻塞,直到第一个请求完成。如果 fn
执行时间较长,后续所有请求都会被阻塞。此时可以使用 DoChan
结合 context
和 select
进行超时控制。
func loadChan(ctx context.Context, key string) (string, error) {
data, err := loadFromCache(key)
if err != nil && err == ErrCacheMiss {
result := g.DoChan(key, func() (interface{}, error) {
data, err := loadFromDB(key)
if err != nil {
return nil, err
}
setCache(key, data)
return data, nil
})
select {
case r := <-result:
return r.Val.(string), r.Err
case <-ctx.Done():
return "", ctx.Err()
}
}
return data, nil
}
如果第一个请求失败,后续所有等待的请求都会返回同一个错误。可以根据下游的承载能力定时调用 Forget
方法,让更多请求有机会执行 fn
。
go func() {
time.Sleep(100 * time.Millisecond)
g.Forget(key)
}()
例如,1 秒内有 100 个请求,正常情况下,只有第一个请求会执行 queryDB
,后续 99 个请求都会阻塞。通过定期调用 Forget
方法,每 100 毫秒就会有一个请求执行 queryDB
,相当于增加了几次尝试机会,但也会给数据库带来更大压力,需要根据具体场景进行权衡。
Singleflight 是 Go 语言中一个防止缓存击穿的轻量级解决方案,通过共享相同 key 的请求,避免了缓存失效时大量请求同时打到数据库,保护了数据库的性能和稳定性。通过合理使用 Singleflight,可以显著提高系统的并发性能。
如果您喜欢我的文章,请点击下面按钮随意打赏,您的支持是我最大的动力。
最新评论