Golang并发工具Singleflight


Posted in Golang onMay 06, 2022

前言

前段时间在一个项目里使用到了分布式锁进行共享资源的访问限制,后来了解到Golang里还能够使用singleflight对共享资源的访问做限制,于是利用空余时间了解,将知识沉淀下来,并做分享

文章尽量用通俗的语言表达自己的理解,从入门demo开始,结合源码分析singleflight的重点方法,最后分享singleflight的实际使用方式与需要注意的“坑“。

定义

按照官方文档的定义,singleflight 提供了一个重复的函数调用抑制机制

Package singleflight provides a duplicate function call suppression

用途

通俗的来说就是 singleflight将相同的并发请求合并成一个请求,进而减少对下层服务的压力,通常用于解决缓存击穿的问题

  • 缓存击穿是指: 在高并发的场景中,大量的request同时请求查询一个共享资源(例如Redis缓存的key) ,如果这个共享资源正好过期失效了,就会导致大量相同的request都打到Redis下游的数据库,导致数据库的负载上升。

Golang并发工具Singleflight

简单Demo

var (
	sfKey1 = "key1"
	wg     *sync.WaitGroup
	sf     singleflight.Group
	nums   = 10
)

func getValueService(key string) { //service
   var val string
   wg = &sync.WaitGroup{}
   wg.Add(nums)
   for idx := 0; idx < nums; idx++ { // 模拟多协程同时请求
      go func(idx int) { // 注意for的一个小坑
         defer wg.Done()
         value, _ := getAndSetCacheNoChan(idx, key) //简化代码,不处理error
         log.Printf("request %v get value: %v", idx, value)
         val = value
      }(idx)
   }
   wg.Wait()
   log.Println("val: ", val)
   return
}

// getValueBySingleflight 使用singleflight取cacheKey对应的value值
func getValueBySingleflight(idx int, cacheKey string) (string, error) {
   log.Printf("idx %v into-cache...", idx)
   // 调用singleflight的Do()方法
   value, _, _ := sf.Do(cacheKey, func() (ret interface{}, err error) {
      log.Printf("idx %v is-setting-cache", idx)
      // 休眠0.1s以捕获并发的相同请求
      time.Sleep(100 * time.Millisecond)
      log.Printf("idx %v set-cache-success!", idx)
      return "myValue", nil
   })
   return value.(string), nil
}

看看实际效果

Golang并发工具Singleflight

  • 由结果图可以看到,索引=8的协程第一个进入了Do()方法,其他协程则阻塞住,等到idx=8的协程拿到执行结果后,协程以乱序的形式返回执行结果。
  • 相同key的情况下,singleflight将我们的多个请求合并成1个请求。由1个请求去执行对共享资源的操作。

源码分析

结构

type (
   Group struct { // singleflight实体
      mu sync.Mutex       // 互斥锁
      m  map[string]*call // 懒加载
   }

   call struct {
      wg sync.WaitGroup
      // 存储 调用singleflight.Do()方法返回的结果
      val interface{}
      err error

      // 调用singleflight.Forget(key)时将对应的key从Group.m中删除
      forgotten bool

      // 通俗的理解成singleflight合并的并发请求数
      dups  int
      // 存储 调用singleflight.DoChan()方法返回的结果
      chans []chan<- Result
   }
   
   Result struct {
      Val    interface{}
      Err    error
      Shared bool
   }
)

对外暴露的方法

func Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool)   

func DoChan(key string, fn func() (interface{}, error)) <-chan Result) 

// 将key从Group.m中删除
func Forget(key string)

DoChan()和Do()最大的区别是DoChan()属于异步调用,返回一个channel,解决同步调用时的阻塞问题

重点方法分析

Do

func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) {
   g.mu.Lock() // 加互斥锁
   if g.m == nil { // 懒加载map
      g.m = make(map[string]*call)
   }
   if c, ok := g.m[key]; ok { // 检查相同的请求已经是否进入过singleflight
      c.dups++
      g.mu.Unlock()
      c.wg.Wait() // 调用waitGroup的wait()方法阻塞住本次调用,等待第一个进入singleflight的请求执行完毕拿到结果,将本次请求唤醒.

      if e, ok := c.err.(*panicError); ok { //如果调用完成,发生error ,将error上抛
         panic(e)
      } else if c.err == errGoexit {
         runtime.Goexit()
      }
      // 返回调用结果
      return c.val, c.err, true
   }
   c := new(call) // 相同的请求第一次进入singleflight
   c.wg.Add(1)
   g.m[key] = c // new一个call实体,放入singleflight.call这个map
   g.mu.Unlock()

   g.doCall(c, key, fn) //实际执行的函数
   return c.val, c.err, c.dups > 0
}

流程图

Golang并发工具Singleflight

由源码可以分析出,最后实际执行我们业务逻辑的函数其实是放到了doCall() 里,我们稍后分析这个函数

Forget

再简单看看Forget()函数,很短.

func (g *Group) Forget(key string) {
   g.mu.Lock()
   if c, ok := g.m[key]; ok {
      c.forgotten = true // key的forgotten标志位记为true
   }
   delete(g.m, key)  // Group.m中删除对应的key
   g.mu.Unlock()
}

doCall

func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
   normalReturn := false
   recovered := false

    //使用双重defer来区分error的类型: panic && runtime.error
   defer func() { 
      if !normalReturn && !recovered {
        // fn()发生了panic且fn()中的panic没有被recover掉
        // errGoexit连接runtime.Goexit错误
         c.err = errGoexit 
      }

      c.wg.Done()
      g.mu.Lock()
      defer g.mu.Unlock()
      if !c.forgotten { // 检查key是否调用了Forget()
         delete(g.m, key)
      }

      if e, ok := c.err.(*panicError); ok {
         // 如果返回的是 panic 错误,为了避免channel被永久阻塞,我们需要确保这个panic无法被recover
         if len(c.chans) > 0 {
            go panic(e)  // panic无法被恢复
            select {} // 阻塞本goroutinue.
         } else {
            panic(e)
         }
      } else {
         // 将结果正常地返回
         for _, ch := range c.chans {
            ch <- Result{c.val, c.err, c.dups > 0}
         }
      }
   }()

   func() {
      defer func() {
         if !normalReturn {
            // 表示fn()发生了panic()
            // 此时与panic相关的堆栈已经被丢弃(调用的fn()) ,无法通过堆栈跟踪去确定error类型
            if r := recover(); r != nil {
               c.err = newPanicError(r) //new一个新的自定义panic err,往第一个defer抛
            }
         }
      }()
     // 执行我们实际的业务逻辑,并将业务方法的返回值赋给singleflight.call
      c.val, c.err = fn()的val和err属性
      // 如果fn()发生panic,normalReturn无法被赋值为true,而是进入doCall()的第二个defer()
      normalReturn = true
   }()
   // 如果normalResult为false时,表示fn()发生了panic
   // 但是执行到了这一步,表示fn()中的panic被recover了
   if !normalReturn {
      recovered = true // recovered标志位置为true
   }
}

由以上分析可以得出几个重要的结论

  • singleflight主要使用sync.Mutex和sync.WaitGroup进行并发控制.

  • 对于key相同的请求, singleflight只会处理的一个进入的请求,后续的请求都会使用waitGroup.wait()将请求阻塞

  • 使用双重defer()区分了panic和runtime.Goexit错误,如果返回的是一个panic错误,group.c.chans会发生阻塞,那么需要抛出这个panic且确保其无法被recover

实际使用

分享一段实际项目中使用singleflight结合本地缓存的代码模版

func (s Service) getDataBySingleFlight(ctx  context.Context) (entity.List, error) {
    // 1. 从localCache查
    resData, err := local_cache.Get(ctx, key)
    if err != nil {
       log.Fatalln()
       return resData, err
    }
    if resData != nil {
       return resData, nil
    }
    // 2. localCache无数据,从redis查
    resData, err = srv.rdsRepo.Get()
    if err != nil && err != redis.Nil {
       // redis错误
       log.Fatalln()
       return resData, err
    } else if redis.Nil == err {
           // redis无数据 ,查db
           resData, err, _ = singleFlight.Do(key, func() (interface{}, error) {
           // 构建db查询条件
          searchConn := entity.SearchInfo{}
           //  建议休眠0.1s 捕获0.1s内的重复请求
          time.Sleep(100 * time.Millisecond)
           // 4. 查db
          data, err := srv.dBRepo.GetByConn(ctx, searchConn)
          if err != nil {
             log.Fatalln()
             return data, err
          }
           // 5. 回写localCache && redisCache
          err = local_cache.Set(ctx, data)
          if err != nil {
             log.Fatalln()
          }
          err = srv.rdsRepo.Set(ctx, data)
          if err != nil {
             log.Fatalln()
          }
      // 返回db数据,回写cache的error不上抛
      return data, nil
   })
   return resData, err
}
return resData, nil

弊端与解决方案

singleflight当然不是解决问题的银弹,在使用的过程中有一些“坑”需要我们注意

  • Do()方法是一个同步调用的方法,无法处理下游服务调用的超时情况

解决方案:

使用singleflight的doChan()方法,在service层使用 channel+select 做超时控制.

func enterGetAndSetCacheWithChan(ctx context.Context, key string) (str string, err error) {
   tag := "enterGetAndSetCacheWithChan"
   sonCtx, _ := context.WithTimeout(ctx, 2 * time.Second)
   val := ""
   nums := 10 //协程数
   wg = &sync.WaitGroup{}
   wg.Add(nums)
   for idx := 0; idx < nums; idx++ {
      go func() {
         defer wg.Done()
         val, err = getAndSetCacheWithChan(sonCtx, idx, key)
         if err != nil {
            log.Printf("err:[%+v]", err)
            return
         }
         str = val
      }()
   }
   wg.Wait()
   log.Printf("tag:[%s] val:[%s]", tag, val)
   return
}

func getAndSetCacheWithChan(ctx context.Context, idx int, cacheKey string) (string, error) {
   tag := "getAndSetCacheWithChan"
   log.Printf("tag: %s ;idx %d into-cache...", tag, idx)
   ch := sf.DoChan(cacheKey, func() (ret interface{}, err error) { // do的入参key,可以直接使用缓存的key,这样同一个缓存,只有一个协程会去读DB
      log.Printf("idx %v is-setting-cache", idx)
      time.Sleep(100 * time.Millisecond)
      log.Printf("idx %v set-cache-success!", idx)
      return "myValue", nil
   })
   for { // 选择 context + select 超时控制
      select {
      case <-ctx.Done():
         return "", errors.New("ctx-timeout") // 根据业务逻辑选择上抛 error
      case data, _ := <-ch:
         return data.Val.(string), nil
      default:
      }
   }
}
  • 如果第一个请求失败了,那么所有等待的请求都会返回同一个error

解决方案

根据实际情况,结合下游服务调用耗时与下游实际能支持的QPS等数据,对key做定时Forget()。

go func() {
       time.Sleep(100 * time.Millisecond)
       g.Forget(key)
   }()

参考文章

singleflight双重defer: developer.51cto.com/article/652…

到此这篇关于Golang并发工具Singleflight的文章就介绍到这了!


Tags in this post...

Golang 相关文章推荐
win10下go mod配置方式
Apr 25 Golang
Golang 正则匹配效率详解
Apr 25 Golang
golang中实现给gif、png、jpeg图片添加文字水印
Apr 26 Golang
golang 实现Location跳转方式
May 02 Golang
完美解决golang go get私有仓库的问题
May 05 Golang
goland 设置project gopath的操作
May 06 Golang
Go语言基础知识点介绍
Jul 04 Golang
golang fmt格式“占位符”的实例用法详解
Jul 04 Golang
go goroutine 怎样进行错误处理
Jul 16 Golang
Golang使用Panic与Recover进行错误捕获
Mar 22 Golang
Golang入门之计时器
May 04 Golang
Go语言编译原理之源码调试
Aug 05 Golang
深入理解 Golang 的字符串
May 04 #Golang
Golang入门之计时器
May 04 #Golang
Golang 入门 之url 包
May 04 #Golang
Golang解析JSON对象
Apr 30 #Golang
Golang 并发编程 SingleFlight模式
Golang 实现 WebSockets 之创建 WebSockets
Apr 24 #Golang
Golang 实现WebSockets
You might like
PHP代码保护--Zend Guard的使用详解
2013/06/03 PHP
实现获取http内容的php函数分享
2014/02/16 PHP
用PHP写的一个冒泡排序法的函数简单实例
2016/05/26 PHP
PHP的中使用非缓冲模式查询数据库的方法
2017/02/05 PHP
使用Codeigniter重写insert的方法(推荐)
2017/03/23 PHP
在Laravel5中正确设置文件权限的方法
2019/05/22 PHP
使用Modello编写JavaScript类
2006/12/22 Javascript
判断对象是否Window的实现代码
2012/01/10 Javascript
关于scrollLeft,scrollTop的浏览器兼容性测试
2013/03/19 Javascript
Js中的onblur和onfocus事件应用介绍
2013/08/27 Javascript
Javascript removeChild()删除节点及删除子节点的方法
2015/12/27 Javascript
基于jQuery的ajax方法封装
2016/07/14 Javascript
关于Vue组件库开发详析
2018/07/01 Javascript
微信小程序基于Taro的分享图片功能实践详解
2019/07/12 Javascript
Vue 解决路由过渡动画抖动问题(实例详解)
2020/01/05 Javascript
js实现简单进度条效果
2020/03/25 Javascript
详解JavaScript的this指向和绑定
2020/09/08 Javascript
[56:42]完美世界DOTA2联赛循环赛 Matador vs Forest 第二场 11.06
2020/11/06 DOTA
Pyhton中单行和多行注释的使用方法及规范
2016/10/11 Python
python利用标准库如何获取本地IP示例详解
2017/11/01 Python
详解Python在使用JSON时需要注意的编码问题
2019/12/06 Python
python 安装库几种方法之cmd,anaconda,pycharm详解
2020/04/08 Python
Python排序函数的使用方法详解
2020/12/11 Python
5个你不知道的HTML5的接口介绍
2013/08/07 HTML / CSS
HTML5所有标签汇总及标签意义解释
2015/03/12 HTML / CSS
SNIDEL官网:日本VIVI杂志人气少女第一品牌
2020/03/12 全球购物
房地产员工找工作的自我评价
2013/11/15 职场文书
酒店总经理工作职责
2013/12/13 职场文书
法定代表人授权委托书
2014/04/04 职场文书
导师就业推荐信范文
2014/05/22 职场文书
2014小学二年级班主任工作总结
2014/12/05 职场文书
行政主管岗位职责
2015/02/03 职场文书
农村老人去世追悼词
2015/06/23 职场文书
排球赛新闻稿
2015/07/17 职场文书
Vue和Flask通信的实现
2021/05/19 Vue.js
新手入门Mysql--sql执行过程
2021/06/20 MySQL