本文转载自微信公众号“Golang梦工厂”,作者AsongGo。转载本文请联系Golang梦工厂公众号。前言大家好,我是asong。今天想和大家分享一下singleflight库。Singleflight只有100多行,但它可以防止缓存崩溃。注:本文基于https://pkg.go.dev/golang.org/x/sync/singleflight进行分析。Cachebreakdown什么是cachebreakdown通常在高并发系统中,会有大量的请求同时查询一个key。如果此时hotkey刚好失效,就会向数据库发送大量请求。这种现象就是缓存崩溃。缓存击穿和缓存雪崩有点相似,但也有一点区别。缓存雪崩是由于大面积缓存失效导致DB崩溃。并发和高并发就是关注这一点。如果此时key失效,持续并发会突破缓存,直接向数据库请求,就像完好无损的水桶被切了一个洞。某时刻数据库请求量过大,压力骤增!如何解决?来个简单粗暴的,直接让热点数据永不过期,定时刷新数据。不过这个设置需要区分场景,比如某宝的首页就可以这样做。方法2为了避免缓存被击穿,我们可以在第一个查询数据库的请求上加一个互斥锁,其余的查询请求都会被阻塞,直到锁被释放,后续线程进来查找如果已经有缓存了,直接去缓存保护数据库。但也因为会阻塞其他线程,这时候系统吞吐量会下降。需要结合实际业务考虑是否这样做。方法三方法三是singleflight的设计思路。也使用了互斥锁,但是相比方式2,加锁粒度会更细一些。这里简单总结一下singleflight的设计原理,后面会分析源码。singleflightd的设计思路是将一组相同的请求合并为一个请求,使用map存储,只有一个请求会到达mysql,使用sync.waitgroup包进行同步,所有请求返回相同的结果。截图2021-07-14pm8.30.56迫不及待欣赏源码了,我们直奔主题,来看看singleflight是怎么设计的。数据结构singleflight的结构定义如下:typeGroupstruct{musync.Mutex//互斥锁保证并发安全mmap[string]*call//存储同一个请求,key为同一个请求,value保存调用信息。}group结构比较简单,只有两个字段,m是一个map,key是同一个请求的标识,value是用来保存调用信息的,这个map是懒加载的,其实调用的时候会初始化用来;mu是一个mutex,用来保证m的并发安全。m存储调用信息,单独封装一个结构体:typecallstruct{wgsync.WaitGroup//存储返回值,只在wgdone之前写入一次valinterface{}//存储返回的错误信息errerror//标识是否调用了Forgot方法forgottenbool//统计同一个请求的次数,在wgdone前写上dupsint//使用DoChan方法,使用通道通知chans[]chan<-Result}//使用typeResultstruct{Valinterface{}//将返回值存入Dochan方法Errerror//存储返回的错误信息Sharedbool//表示结果是否为共享结果}Do方法//输入参数:key:标识同一个请求,fn:要执行的函数//返回值:v:返回结果err:执行函数错误信息shard:是否为共享结果func(g*Group)Do(keystring,fnfunc()(interface{},error))(vinterface{},errerror,sharedbool){//code块锁g.mu。Lock()//map用于懒加载ifg.m==nil{//map初始化g.m=make(map[string]*call)}//判断是否有相同请求ifc,ok:=g.m[key];ok{//等量请求+1c.dups++//解锁即可,等待执行结果即可,不会有写操作g.mu.Unlock()//已有请求正在执行,等待即可foritOkc.wg.Wait()//区分panic错误和runtime错误ife,ok:=c.err.(*panicError);ok{panic(e)}elseifc.err==errGoexit{runtime.Goexit()}returnc.val,c.err,true}//之前没有这个请求,需要一个新的指针类型c:=new(call)//sync.waitgroup用法,只有一个请求在运行,其他请求都在等待,所以只需要add(1)c.wg.Add(1)//m赋值g.m[key]=c//没有写操作,直接解锁g.mu.Unlock()//唯一请求执行函数g.doCall(c,key,fn)returnc.val,c.err,c.dups>0}这里唯一的问题是区分恐慌和运行时错误。这与下面的docall方法有关。看完docall你就知道为什么了docall//doCallhandlesthesinglecallforakey.func(g*Group)doCall(c*call,keystring,fnfunc()(interface{},error)){//标识是否正常返回normalReturn:=false//标识是否发生panicrecovered:=falsedeferfunc(){//用这个判断是否是运行时导致直接退出if!normalReturn&&!recovered{//返回运行时错误信息c.err=errGoexit}c.wg.Done()g.mu.Lock()deferg.mu.Unlock()//防止重复删除keyif!c.forgotten{delete(g.m,key)}//检查是否出现panic错误ife,ok:=c.err.(*panicError);ok{//如果调用了dochan方法,为了避免通道死锁,应该直接抛出panic,不可恢复,否则会隐藏错误iflen(c.chans)>0{gopanic(e)//打开一个写成panicselect{}//保留这个goroutine,这样panic就可以写入crashdump}else{panic(e)}}elseifc.err==errGoexit{//当运行时error不需要做什么,已经退出}else{//如果返回norm盟友,直接将数据写入通道即可。for_,ch:=rangec.chans{ch<-Result{c.val,c.err,c.dups>0}}}}()//使用匿名函数目的是恢复panic,返回信息给upperlayerfunc(){deferfunc(){if!normalReturn{//发生panic,我们恢复它,然后将错误信息返回给上层ifr:=recover();r!=nil{c.err=newPanicError(r)}}}()//执行函数c.val,c.err=fn()//fn没有发生panicnormalReturn=true}()//判断执行函数是否发生panicif!normalReturn{recovered=true}}这里简单说明一下为什么要区分panic和runtimeerrors。如果不加区分的调用出现panic,但是锁没有释放,会导致后续所有使用同一个key的调用都死锁。具体可以查看这个issue:https://github.com/golang/go/issues/33519Dochan和Forget方法//异步返回//输入参数:key:标识同一个请求,fn:要执行的函数//输出参数:<-chanchannelfunc(g*Group)DoChan(keystring,fnfunc()等待接收results)(interface{},error))<-chanResult{//初始化channelch:=make(chanResult,1)g.mu.Lock()//延迟加载ifg.m==nil{g.m=make(map[string]*call)}//判断是否有相同请求ifc,ok:=g.m[key];ok{//相同请求数+1c.dups++//添加等待chanc.chans=append(c.chans,ch)g.mu.Unlock()返回}c:=&call{chans:[]chan<-Result{ch}}c.wg.Add(1)g.m[key]=cg.mu.Unlock()//openone写成调用gog.doCall(c,key,fn)//返回本通道等待数据接收返回}//松开一个键,下一次调用不会阻塞等待func(g*Group)忘记(密钥串){g。mu.Lock()ifc,ok:=g.m[key];ok{c.forgotten=true}delete(g.m,key)g.mu.Unlock()}注意事项因为使用singleflight需要自己写执行函数,所以如果我们写的执行函数一直循环下去,就会导致我们整个程序处于循环状态,累积越来越多的请求,所以在使用的时候,还是需要注意一下,比如这个例子:result,err,_:=d.singleGroup.Do(key,func()(interface{},error){for{//TODO}}但是一般不会出现这个问题,我们在日常开发中使用context来控制timeout.总结一下,这篇文章就到这里了,因为最近项目中也用到了singleflight库,所以看了一下源码实现,真的很神奇,这么短的代码实现了这么重要的功能,为什么不能'没想到……所以还是要多看源码库,真的可以学到很多东西,真的应验了那句话:知道的越多,不知道的就越多!
