背景
说到缓存都离不开 3 个问题,国内总结出了 3 个比较典型的问题 缓存穿透、缓存击穿、缓存雪崩。
缓存穿透
缓存和数据库中都没有的数据,而用户不断发起请求,如发起为 id 为“-1”的数据或 id 为特别大不存在的数据。这时的用户很可能是攻击者,攻击会导致数据库压力过大
缓存击穿
缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力
缓存雪崩
缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力过大甚至 Down 机。和缓存击穿不同的是, 缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库
SingleFlight
SingleFlight 用于防止缓存击穿非常有效,其来源于Golang 准官方库 SingleFlight
它主要提供了 3 个 API
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
func (g *Group) Forget(key string)
举个例子:
func (d *Dao) SFGetTopicByRoom(ctx context.Context, roomId int64) (res topicsModel.Topics, err error) {
key := getTopicRoomCacheKey(roomId)
// 定义调用下游的方法
fn := func(c context.Context, rid int64) func() (interface{}, error) {
return func() (resp interface{}, err error) {
return d.GetTopicByRoom(c, rid)
}
}
// 使用singleflight处理请求
v, err, _ := d.singleFlight.Do(key, fn(ctx, roomId))
if err != nil {
return
}
res, _ = v.(*topicsModel.Topics)
return
}
当该接口发生高并发时,其中只有 1 个请求可以通过 fn 方法去调用下游服务获取数据,其余的请求都将等待这一个请求返回,然后白嫖它的结果。 比如当前存在 1000 个并发,只有第一个请求会真正地去调用下游,后来的 999 个请求发现已经有人去下游拉数据了,那么它们就会阻塞等待,直到第一个请求成功返回,把结果直接分发给他们。这样虽然接口层面上发送了 1000 次并发请求,但实际上下游只被调用了 1 次,极大减少了下游的压力。
当该接口发生高并发时,其中只有 1 个请求可以通过 fn 方法去调用下游服务获取数据,其余的请求都将等待这一个请求返回,然后白嫖它的结果。 比如当前存在 1000 个并发,只有第一个请求会真正地去调用下游,后来的 999 个请求发现已经有人去下游拉数据了,那么它们就会阻塞等待,直到第一个请求成功返回,把结果直接分发给他们。这样虽然接口层面上发送了 1000 次并发请求,但实际上下游只被调用了 1 次,极大减少了下游的压力。
虽然很好用,但是还是有几个需要注意的问题:
-
1、协程暴涨:每一个请求都将分配一个协程,如果并发上万就意味着同时产生上万个协程
-
2、内存暴涨:原因差不多
-
3、超时时可能会全军覆没:因为 singleflight 是通过阻塞读实现,第一个请求超时或者失败,意味着后面排队嗷嗷待哺的请求都将失败,产生大量错误
-
4、后续请求耗时增加:调度等待
这些问题产生的原因可以归结为 singleflight 的两个特征:
阻塞读:缺少超时控制,难以快速失败 单并发:控制了并发量,但牺牲了成功率 一般可以通过一些技巧进行规避:
1、解决阻塞读问题,通过 channel 实现超时控制, singleflight 提供了 DoChan()可用于替换普通的 Do()
func doSomeThings(key string) {
ch := g.DoChan(key, func() (interface{}, error) {
ret, err := find(context.Background(), key)
return ret, err
})
// 设定超时时间
timeout := time.After(500 * time.Millisecond)
var ret singleflight.Result
select {
case <-timeout: // 超时处理
return errors.New("超时了!!!!")
case ret = <-ch: // 请求成功处理
fmt.Printf("请求成功!")
}
}
2、解决单并发问题,原版的方法过于激进,假设我们有 1000 次并发,那其实我们可以允许其中的 100 次进入下游,以此保证在不对下游造成过大压力的同时 保证一定的请求饱和度,降低全军覆没的概率 具体可以通过 Forget()方法进行分批 singleflight
// 每 10 毫秒删除一次内存缓存
// 相当于按照 100 毫秒为分界,每个 100 毫秒时间片内的并发共享同一个下游请求
// 假如 1s 内有 1000 个并发,平均每 100ms 内有 100 次(这 1s 内的 1000 个并发被切成 10 组),那么相当于一共向下游分别真实请求了 10 次
// 这样的话 即使某一组出了问题,也不至于波及全部
func doSomeThings(key string) {
v, _, shared := g.Do(key, func() (interface{}, error) {
go func() {
time.Sleep(100 * time.Millisecond)
// 清理内存缓存
g.Forget(key)
}()
ret, err := find(context.Background(), key)
return ret, err
})
}