- 作者:老汪软件技巧
- 发表时间:2024-11-12 00:03
- 浏览量:
缓存击穿
缓存在某个时间点过期的时候,恰好在这个时间点对这个Key有大量的并发请求过来,这些请求发现缓存过期一般都会从后端DB加载数据并回设到缓存,这个时候大并发的请求可能会瞬间把后端DB压垮。
为什么我们需要 SingleFlight(使用场景)?
一般情况下我们在写对外的服务的时候都会有一层 cache 作为缓存,用来减少底层数据库的压力,但是在遇到例如 redis 抖动或者其他情况可能会导致大量的 cache miss 出现。
如下图所示,可能存在来自桌面端和移动端的用户有 1000 的并发请求,他们都访问的获取文章列表的接口,获取前 20 条信息,如果这个时候我们服务直接去访问 redis 出现 cache miss 那么我们就会去请求 1000 次数据库,这时可能会给数据库带来较大的压力(这里的 1000 只是一个例子,实际上可能远大于这个值)导致我们的服务异常或者超时。
这时候就可以使用 singleflight 库了,这个库主要是用幂等思路解决缓存击穿,就是将一组相同的请求合并成一个请求,实际上只会去请求一次,然后对所有的请求返回相同的结果。
如下图所示,使用 singleflight 之后,我们在一个请求的时间周期内实际上只会向底层的数据库发起一次请求大大减少对数据库的压力。
singleflight的使用demo
通过 getData(key) 获取数据, 逻辑是
先尝试从cache中获取如果cache中不存在就从db中获取
我们模拟了10个并发请求,来同时调用 getData 函数,执行结果如下:
package main
import (
"errors"
"log"
"sync"
"golang.org/x/sync/singleflight"
)
var errorNotExist = errors.New("not exist")
func main() {
var wg sync.WaitGroup
wg.Add(10)
//模拟10个并发
for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
data, err := getData("key")
if err != nil {
log.Print(err)
return
}
log.Println(data)
}()
}
wg.Wait()
}
//获取数据
func getData(key string) (string, error) {
data, err := getDataFromCache(key)
if err == errorNotExist {
//模拟从db中获取数据
data, err = getDataFromDB(key)
if err != nil {
log.Println(err)
return "", err
}
//TOOD: set cache
} else if err != nil {
return "", err
}
return data, nil
}
//模拟从cache中获取值,cache中无该值
func getDataFromCache(key string) (string, error) {
return "", errorNotExist
}
//模拟从数据库中获取值
func getDataFromDB(key string) (string, error) {
log.Printf("get %s from database", key)
return "data", nil
}
执行结果:
可以看得到10个请求都是走的db,因为cache中不存在该值,当我们利用上 singlefligth 包, getData 改动一下:
package main
import (
"errors"
"log"
"sync"
)
var gsf singleflight.Group
var errorNotExist = errors.New("not exist")
func main() {
var wg sync.WaitGroup
wg.Add(10)
//模拟10个并发
for i := 0; i < 10; i++ {
go func() {
defer wg.Done()
data, err := getData("key")
if err != nil {
log.Print(err)
return
}
log.Println(data)
}()
}
wg.Wait()
}
//获取数据
func getData(key string) (string, error) {
data, err := getDataFromCache(key)
if err == errorNotExist {
//模拟从db中获取数据
v, err, _ := gsf.Do(key, func() (interface{}, error) {
return getDataFromDB(key)
//set cache
})
if err != nil {
log.Println(err)
return "", err
}
//TOOD: set cache
data = v.(string)
} else if err != nil {
return "", err
}
return data, nil
}
// 模拟从cache中获取值,cache中无该值
func getDataFromCache(key string) (string, error) {
return "", errorNotExist
}
// 模拟从数据库中获取值
func getDataFromDB(key string) (string, error) {
log.Printf("get %s from database", key)
return "data", nil
}
执行结果如下:
以看得到只有一个请求进入的db,其他的请求也正常返回了值,从而保护了后端DB。
使用singleflight就够了吗?
通过上面的分析我们知道,面对多次相同的请求,我们可以使用singleflight来合并请求,最终只有一个请求生效,所有相同的请求都是返回的这次请求的结果,但是主要到这里的请求只是单个请求,也就是说并没有批量请求,而在现实中,我们却有很多批量请求的场景,比如获取商品列表,订单列表等等这样的批量查询请求。假设我们有三次批量请求,第一次查询id为1,2,3的商品,第二次查询id为1,3,4的商品,第三次查询id为1,4,5的商品,假设用singleflight,则会认为他们不是相同请求,所以请求不会合并,那么这样id为1的请求就会请求3次,所以针对这样的多个key的处理singleflight并非合适。
优化方案
在go 官方源码的基础改造出适用于批量查询的 multisingleflight,代码仓库/Percygu/go_…
multisingleflight在项目中的使用
multisingleflight作为项目难点应用:
凡是go语言项目,涉及到用到缓存的地方都可以在代码层面用multisingleflight来兜底
比如瑞吉外卖的商家列表查询,订单查询,卖家查询等地方
难点
考虑到查询db耗时较大,性能不好,是有意对热点商品做一次redis缓存,但是但是在遇到例如 redis 抖动或者其他情况可能会导致大量的 cache miss 出现,这个时候大并发的请求可能会瞬间把后端DB压垮
解决方案
采用go语言提供的singleflight 库,在代码层面做一次请求优化,利用singleflight 的幂等思想,就是将一组相同的请求合并成一个请求,实际上只会去请求一次,然后对所有的请求返回相同的结果,这样来减轻db的访问压力,避免瞬间瞬间把后端DB压垮
问题
go语言官方提供的singleflight 只能针对单个key的请求做到有效的访问合并,针对多个key查询,其中有部分重复key的话,不能有效的降低请求的次数
优化方案
在go 官方源码的基础改造出适用于批量查询的 multisingleflight