问题描述
某服务上线后,其中一个http接口A连续多天出现了慢请求告警。通过查看日志(因为当时还没有引入调用链),判断是调用一个内部接口B时慢了,但是请求A接口 的慢请求数量远大于B接口的慢请求数量。经过和提供接口B的同事核对,以及增加详细日志,发现原因是我在调用接口时使用了singleflight,导致一个慢请求变 成多个慢请求。
下面,我们来使用代码模拟一下这个bug:
go
package conscurrentdemo
import (
"errors"
"fmt"
"log"
"math/rand"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
)
import "golang.org/x/sync/singleflight"
type UserInfo struct {
ID int `json:"id"`
Name string `json:"name"`
Mobile string `json:"mobile"`
Email string `json:"email"`
}
var sg singleflight.Group
var sleepCounter uint64 // 休眠计数器
// QueryUserInfo 查询用户信息
func QueryUserInfo(id int) (result UserInfo, err error) {
defer func() {
log.Printf("QueryUserInfo id:%d result:%v err:%v\n", id, result, err)
}()
// query api .....
// 随机20%的概率休眠200ms
if rand.Int31n(10000)%5 == 0 {
time.Sleep(time.Millisecond * 200)
atomic.AddUint64(&sleepCounter, 1)
}
result = UserInfo{
ID: id,
Name: "张三",
Mobile: "14899990001",
Email: "xx@qq.com",
}
return
}
func GetUserInfoSingleFlight(id int) (UserInfo, error) {
var result UserInfo
v, err, _ := sg.Do("user_query_"+strconv.Itoa(id), func() (interface{}, error) {
return QueryUserInfo(id)
})
if err != nil {
return result, err
}
var ok bool
if result, ok = v.(UserInfo); !ok {
return result, errors.New("")
}
return result, nil
}
func TestSingleFlight(t *testing.T) {
var wg sync.WaitGroup
wg.Add(100)
// 计数器
var counter uint64
for i := 0; i < 100; i++ {
go func(id int) {
defer wg.Done()
t0 := time.Now()
defer func() {
// 统计耗费的时间>100ms的次数
if time.Since(t0) > (time.Millisecond * 100) {
atomic.AddUint64(&counter, 1)
}
}()
info, err := GetUserInfoSingleFlight(id)
if err != nil {
log.Printf("query user info failed id:%d err:%v info:%v\n", id, err, info)
}
}((i)%2 + 1)
}
wg.Wait()
fmt.Println("休眠次数:", sleepCounter)
fmt.Println("耗时>100ms的请求个数:", counter)
}
运行结果: 我们可以看到,耗时请求次数远远大于休眠次数。
- 由于引入了随机因子,所以产生的慢请求数量也可能是0。
- 休眠200ms,统计的是>100ms的次数-故意的。
下面,我们来分析一下singleflight的源码。
SingleFlight原理分析
我们来看一下singleflight的Do方法的源码:
go
// Do executes and returns the results of the given function, making
// sure that only one execution is in-flight for a given key at a
// time. If a duplicate comes in, the duplicate caller waits for the
// original to complete and receives the same results.
// The return value shared indicates whether v was given to multiple callers.
// Do执行输入的方法并返回结果,并保证key相同时,在同一时刻只执行一次。在方法执行时,如果有重复的调用,调用者会等待。
// 返回的shared变量标识是否是多个调用者共用结果。
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()
if e, ok := c.err.(*panicError); ok {
panic(e)
} else if c.err == errGoexit {
runtime.Goexit()
}
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
}
这个方法的核心,是WaitGroup,这个WaitGroup只有一个任务,有n个waiter。同一时刻,key相同时,如果有正在执行的调用,那么新的调用/请求都会等待。 这最终导致了一个慢请求变成多个慢请求。
结尾
这个问题出现在几年前,刚从php转go语言时。当时对并发编程不够理解,所以使用singleflight的方法不正确。singleflight是防缓存击穿利器,但是不是用来 防止缓存击穿,而是直接在调用接口时使用时,会导致慢请求增多,扩大请求的长尾效应,还不如直接调用接口。当时的解决方法是:去掉singleflight,直接调用B接口。修改之后,慢请 求数量大大减少。
为了提高接口性能,后来又采用了其他措施来提高接口性能:
- 增加本地缓存,本地缓存失效时调用接口B。
- 订阅相关变更消息,通过redis的pub/sub机制来更新本地缓存。
注:此文原载于本人个人网站,链接地址。
本文由mdnice多平台发布