一.前言

1.1 为什么需要Singleflight?

很多程序员可能还是第一次听说,本人第一次听说这个的时候以为翻译过来就是程序设计中被称为的是 "单例模式"。 google之后二者天壤之别。

一般情况下我们在写一写对外的服务的时候都会有一层 cache 作为缓存,用来减少底层数据库的压力,但是在遇到例如 redis 抖动或者其他情况可能会导致大量的 cache miss 出现。

1.2 使用场景

如下图所示,可能存在来自桌面端和移动端的用户有 1000 的并发请求,他们都访问的获取文章列表的接口,获取前 20 条信息,如果这个时候我们服务直接去访问 redis 出现 cache miss 那么我们就会去请求 1000 次数据库,这时可能会给数据库带来较大的压力(这里的 1000 只是一个例子,实际上可能远大于这个值)导致我们的服务异常或者超时。

这时候就可以使用singleflight 库了,直译过来就是 单飞.

这个库的主要作用就是: 将一组相同的请求合并成一个请求,实际上只会去请求一次,然后对所有的请求返回相同的结果。 如下图所示

二. slingleFligh 库(使用教程)

2.1 函数签名

主要是一个Group结构体, 结构体包含三个方法,相见代码注释

type Group
// Do 执行函数, 对同一个 key 多次调用的时候,在第一次调用没有执行完的时候
// 只会执行一次 fn 其他的调用会阻塞住等待这次调用返回
// v, err 是传入的 fn 的返回值
// shared 表示是否真正执行了 fn 返回的结果,还是返回的共享的结果
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) // DoChan 和 Do 类似,只是 DoChan 返回一个 channel,也就是同步与异步的区别
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result // Forget 用于通知 Group 删除某个 key 这样后面继续这个 key 的调用的时候就不会在阻塞等待了
func (g *Group) Forget(key string)

2.2 使用示例

先使用一个普通的例子,这时一个获取文章详情的函数,我们在函数里面使用一个 count 模拟不同并发下的耗时的不同,并发越多请求耗时越多。

func getArticle(id int) (article string, err error) {
// 假设这里会对数据库进行调用, 模拟不同并发下耗时不同
// 使用原子操作保证并发安全
atomic.AddInt32(&count, 1)
time.Sleep(time.Duration(count) * time.Millisecond) return fmt.Sprintf("article: %d", id), nil
}

使用 singlefight 的时候就只需要 new(singleflight.Group) 然后调用以下相对应的Do方法

func singleflightGetArticle(sg *singleflight.Group, id int) (string, error) {

	v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
return getArticle(id)
}) return v.(string), err
}

3. 测试

写一个简单的测试代码,模拟启动1000个goroutine 去并发调用这两个方法

package main

import (
"fmt"
"sync"
"sync/atomic"
"time" "golang.org/x/sync/singleflight"
) var count int32 func getArticle(id int) (article string, err error) {
// 假设这里会对数据库进行调用, 模拟不同并发下耗时不同
atomic.AddInt32(&count, 1)
time.Sleep(time.Duration(count) * time.Millisecond) return fmt.Sprintf("article: %d", id), nil
} func singleFlightGetArticle(sg *singleflight.Group, id int) (string, error) {
v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
return getArticle(id)
}) return v.(string), err
} func main() {
// 使用一个定时器,
time.AfterFunc(1*time.Second, func() {
atomic.AddInt32(&count, -count)
}) var (
wg sync.WaitGroup
now = time.Now()
n = 1000
sg = &singleflight.Group{}
) for i := 0; i < n; i++ {
wg.Add(1)
go func() {
res, _ := singleFlightGetArticle(sg, 1)
// res, _ := getArticle(1)
if res != "article: 1" {
panic("err")
}
wg.Done()
}()
} wg.Wait()
fmt.Printf("同时发起 %d 次请求,耗时: %s", n, time.Since(now))
}

调用 getArticle 方法的耗时,花费了 1s 多

go run demo.go
同时发起 1000 次请求,耗时: 1.0157721s

切换到singleflight方法测试发现花费20ms

go run demo.go
同时发起 1000 次请求,耗时: 21.1962ms

测试发现,使用singleflight这种方式的确在这种场景下能减少执行执行,提高效率。

3.实现原理

这是非内置库,仓库golang.org/x/sync/singleflight 源码解析如下

Group

是一个结构体

type Group struct {
mu sync.Mutex // protects m
m map[string]*call // lazily initialized
}

Group 结构体由一个互斥锁和一个 map 组成,可以看到注释 map 是懒加载的,所以 Group 只要声明就可以使用,不用进行额外的初始化零值就可以直接使用。call 保存了当前调用对应的信息,map 的键就是我们调用 Do 方法传入的 key

call 也是一个结构体,结构体如下

type call struct {
wg sync.WaitGroup // 函数的返回值,在 wg 返回前只会写入一次
val interface{}
err error // 使用调用了 Forgot 方法
forgotten bool // 统计调用次数以及返回的 channel
dups int
chans []chan<- Result
}

Do

Do方法用来执行传入函数

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)
} // 会先去看 key 是否已经存在
if c, ok := g.m[key]; ok {
// 如果存在就会解锁
c.dups++
g.mu.Unlock() // 然后等待 WaitGroup 执行完毕,只要一执行完,所有的 wait 都会被唤醒
c.wg.Wait() // 这里区分 panic 错误和 runtime 的错误,避免出现死锁,后面可以看到为什么这么做
if e, ok := c.err.(*panicError); ok {
panic(e)
} else if c.err == errGoexit {
runtime.Goexit()
}
return c.val, c.err, true
} // 如果我们没有找到这个 key 就 new call
c := new(call) // 然后调用 waitgroup 这里只有第一次调用会 add 1,其他的都会调用 wait 阻塞掉
// 所以这要这次调用返回,所有阻塞的调用都会被唤醒
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock() // 然后我们调用 doCall 去执行
g.doCall(c, key, fn)
return c.val, c.err, c.dups > 0
}

doCall

这个方法种有个技巧值得学习,使用了两个 defer 巧妙的将 runtime 的错误和我们传入 function 的 panic 区别开来避免了由于传入的 function panic 导致的死锁。

func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
normalReturn := false
recovered := false // 第一个 defer 检查 runtime 错误
defer func() {
...
}() // 使用一个匿名函数来执行
func() {
defer func() {
if !normalReturn {
// 如果 panic 了我们就 recover 掉,然后 new 一个 panic 的错误
// 后面在上层重新 panic
if r := recover(); r != nil {
c.err = newPanicError(r)
}
}
}() c.val, c.err = fn() // 如果 fn 没有 panic 就会执行到这一步,如果 panic 了就不会执行到这一步
// 所以可以通过这个变量来判断是否 panic 了
normalReturn = true
}() // 如果 normalReturn 为 false 就表示,我们的 fn panic 了
// 如果执行到了这一步,也说明我们的 fn recover 住了,不是直接 runtime exit
if !normalReturn {
recovered = true
}
}

第一个defer函数如下

defer func() {
// 如果既没有正常执行完毕,又没有 recover 那就说明需要直接退出了
if !normalReturn && !recovered {
c.err = errGoexit
} c.wg.Done()
g.mu.Lock()
defer g.mu.Unlock() // 如果已经 forgot 过了,就不要重复删除这个 key 了
if !c.forgotten {
delete(g.m, key)
} if e, ok := c.err.(*panicError); ok {
// 如果返回的是 panic 错误,为了避免 channel 死锁,我们需要确保这个 panic 无法被恢复
if len(c.chans) > 0 {
go panic(e)
select {} // Keep this goroutine around so that it will appear in the crash dump.
} else {
panic(e)
}
} else if c.err == errGoexit {
// 已经准备退出了,也就不用做其他操作了
} else {
// 正常情况下向 channel 写入数据
for _, ch := range c.chans {
ch <- Result{c.val, c.err, c.dups > 0}
}
}
}()

DoChan

Do chan 和 Do 类似,其实就是一个是同步等待,一个是异步返回,主要实现上就是,如果调用 DoChan 会给 call.chans 添加一个 channel 这样等第一次调用执行完毕之后就会循环向这些 channel 写入数据

func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
ch := make(chan Result, 1)
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok {
c.dups++
c.chans = append(c.chans, ch)
g.mu.Unlock()
return ch
}
c := &call{chans: []chan<- Result{ch}}
c.wg.Add(1)
g.m[key] = c
g.mu.Unlock() go g.doCall(c, key, fn) return ch
}

Forget

forget 用于手动释放某个 key 下次调用就不会阻塞等待了

func (g *Group) Forget(key string) {
g.mu.Lock()
if c, ok := g.m[key]; ok {
c.forgotten = true
}
delete(g.m, key)
g.mu.Unlock()
}

三.注意事项

3.1 一个阻塞,全员等待

使用 singleflight 我们比较常见的是直接使用 Do 方法,但是这个极端情况下会导致整个程序 hang 住,如果我们的代码出点问题,有一个调用 hang 住了,那么会导致所有的请求都 hang 住

还是之前的例子, 加入一个 select 模拟阻塞

package main

import (
"fmt"
"sync"
"sync/atomic"
"time" "golang.org/x/sync/singleflight"
) var count2 int32 func getArticle2(id int) (article string, err error) {
// 假设这里会对数据库进行调用, 模拟不同并发下耗时不同
atomic.AddInt32(&count2, 1)
time.Sleep(time.Duration(count2) * time.Millisecond) return fmt.Sprintf("article: %d", id), nil
} func singleFlightGetArticle2(sg *singleflight.Group, id int) (string, error) {
v, err, _ := sg.Do(fmt.Sprintf("%d", id), func() (interface{}, error) {
// 模拟阻塞
select {}
return getArticle2(id)
}) return v.(string), err
} func main() {
// 使用一个定时器,
time.AfterFunc(1*time.Second, func() {
atomic.AddInt32(&count2, -count2)
}) var (
wg sync.WaitGroup
now = time.Now()
n = 1000
sg = &singleflight.Group{}
) for i := 0; i < n; i++ {
wg.Add(1)
go func() {
res, _ := singleFlightGetArticle2(sg, 1)
if res != "article: 1" {
panic("err")
}
wg.Done()
}()
} wg.Wait()
fmt.Printf("同时发起 %d 次请求,耗时: %s", n, time.Since(now))
}

执行就会发现死锁

fatal error: all goroutines are asleep - deadlock!

goroutine 1 [select (no cases)]:

这时候DoChan就能派上用场了,结合 select 做超时控制

func singleflightGetArticle(ctx context.Context, sg *singleflight.Group, id int) (string, error) {
result := sg.DoChan(fmt.Sprintf("%d", id), func() (interface{}, error) {
// 模拟出现问题,hang 住
select {}
return getArticle(id)
}) select {
case r := <-result:
return r.Val.(string), r.Err
case <-ctx.Done():
return "", ctx.Err()
}
}

调用的时候传入一个含 超时的 context 即可

func main() {
// 使用一个定时器,
time.AfterFunc(1*time.Second, func() {
atomic.AddInt32(&count2, -count2)
}) var (
wg sync.WaitGroup
now = time.Now()
n = 1000
sg = &singleflight.Group{}
// 超时控制
ctx, _ = context.WithTimeout(context.Background(), 1*time.Second)
) for i := 0; i < n; i++ {
wg.Add(1)
go func() {
res, _ := singleFlightGetArticle2(ctx, sg, 1)
if res != "article: 1" {
panic("err")
}
wg.Done()
}() } wg.Wait()
fmt.Printf("同时发起 %d 次请求,耗时: %s", n, time.Since(now))
}

执行时就会返回超时错误.

❯ go run demo2.go
panic: context deadline exceeded

3.2 一个出错,全部出错

本身不是什么问题,因为singleflight就是这么设计的。 但是实际使用的时候,可能并不是想要这样的效果。 比如 如果一次调用要1s. 我们的数据库请求或者下游服务服务可以支撑10rps(即每秒可以支持10次的请求)的请求的时候会导致我们的错误阈值提高。 因为我们可以1s尝试10次,但是用了singleflight之后只能尝试一次。只要出错这段时间内的所有请求都会收到影响。 那这种情况该如何解决呢?

我们可以启动一个Goroutine定时forget一下,相当于将rps 从1rps提高到10rps

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

四. 使用场景

如开头场景所讲, singleflige 可以有效解决在使用 Redis 对数据库中的数据进行缓存,发生缓存击穿时,大量的流量都会打到数据库上进而影响服务的尾延时。

使用singleflight能有效解决这个问题,限制对同一个键值对的多次重复请求,减少对下游的瞬时流量

通过一段代码可以查看如何解决这种缓存击穿的问题


type service struct {
requestGroup singleflight.Group
} func (s *service) handleRequest(ctx context.Context, request Request) (Response, error) {
// request.Hash就是传入的建,请求的哈希在业务上一般表示相同的请求,所以上述代码使用它作为请求的键
v, err, _ := requestGroup.Do(request.Hash(), func() (interface{}, error) {
rows, err := // select * from tables
if err != nil {
return nil, err
}
return rows, nil
})
if err != nil {
return nil, err
}
return Response{
rows: rows,
}, nil
}

五. 总结

golang/sync/singleflight.Group.Dogolang/sync/singleflight.Group.DoChan 分别提供了同步和异步的调用方式,这让我们使用起来也更加灵活。

当我们需要减少对下游的相同请求时,可以使用 golang/sync/singleflight.Group 来增加吞吐量和服务质量,不过在使用的过程中我们也需要注意以下的几个问题:

  • golang/sync/singleflight.Group.Dogolang/sync/singleflight.Group.DoChan 一个用于同步阻塞调用传入的函数,一个用于异步调用传入的参数并通过 Channel 接收函数的返回值;
  • golang/sync/singleflight.Group.Forget 可以通知 golang/sync/singleflight.Group 在持有的映射表中删除某个键,接下来对该键的调用就不会等待前面的函数返回了;
  • 一旦调用的函数返回了错误,所有在等待的 Goroutine 也都会接收到同样的错误;

六.参考

  1. https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-sync-primitives/#singleflight
  2. https://lailin.xyz/post/go-training-week5-singleflight.html
  3. https://pkg.go.dev/golang.org/x/sync/singleflight

十一. Go并发编程--singleflight的更多相关文章

  1. C++ 并发编程指南(收藏笔记)

    git地址: https://github.com/forhappy/Cplusplus-Concurrency-In-Practice https://github.com/forhappy/Cpl ...

  2. Python并发编程之实战异步IO框架:asyncio 下篇(十一)

    大家好,并发编程 进入第十一章. 前面两节,我们讲了协程中的单任务和多任务 这节我们将通过一个小实战,来对这些内容进行巩固. 在实战中,将会用到以下知识点: 多线程的基本使用 Queue消息队列的使用 ...

  3. java并发编程笔记(十一)——高并发处理思路和手段

    java并发编程笔记(十一)--高并发处理思路和手段 扩容 垂直扩容(纵向扩展):提高系统部件能力 水平扩容(横向扩容):增加更多系统成员来实现 缓存 缓存特征 命中率:命中数/(命中数+没有命中数) ...

  4. 并发编程从零开始(十一)-Atomic类

    并发编程从零开始(十一)-Atomic类 7 Atomic类 7.1 AtomicInteger和AtomicLong 如下面代码所示,对于一个整数的加减操作,要保证线程安全,需要加锁,也就是加syn ...

  5. Java并发编程(十一)-- Java中的锁详解

    上一章我们已经简要的介绍了Java中的一些锁,本章我们就详细的来说说这些锁. synchronized锁 synchronized锁是什么? synchronized是Java的一个关键字,它能够将代 ...

  6. Java并发编程原理与实战三十一:Future&FutureTask 浅析

    一.Futrue模式有什么用?------>正所谓技术来源与生活,这里举个栗子.在家里,我们都有煮菜的经验.(如果没有的话,你们还怎样来泡女朋友呢?你懂得).现在女票要你煮四菜一汤,这汤是鸡汤, ...

  7. Java并发编程原理与实战二十一:线程通信wait&notify&join

    wait和notify wait和notify可以实现线程之间的通信,当一个线程执行不满足条件时可以调用wait方法将线程置为等待状态,当另一个线程执行到等待线程可以执行的条件时,调用notify可以 ...

  8. Java并发编程原理与实战十一:锁重入&自旋锁&死锁

    一.锁重入 package com.roocon.thread.t6; public class Demo { /* 当第一个线程A拿到当前实例锁后,进入a方法,那么,线程A还能拿到被当前实例所加锁的 ...

  9. cocos2d-x游戏引擎核心之十一——并发编程(消息通知中心)

    [续] cocos2d-x游戏引擎核心之八——多线程 这里介绍cocos2d-x的一种消息/数据传递方式,内置的观察者模式,也称消息通知中心,CCNotificationCenter. 虽然引擎没有为 ...

随机推荐

  1. 学大数据一定要会Java开发吗?

    Java是目前使用广泛的编程语言之一,具有的众多特性,特别适合作为大数据应用的开发语言.Java语言功能强大和简单易用,不仅吸收了C++语言的各种优点还摒弃了C++里难以理解的多继承.指针等概念. J ...

  2. javascript-vue介绍

    vue.js是一个用于创建web交互页面的库 从技术角度讲,vue专注于MVVM模型的viewModel层,它通过双向数据绑定把view层和model层连接起来,实际DOM封装和输出格式都被抽象为Di ...

  3. 表单编辑时el-form的validate方法执行无效,阻塞代码运行 - Element UI踩坑记录

    今天在用element-ui写管理后台需求时,遇到一个奇怪的问题 一个正常带校验的表单,在新增列表数据时表单校验功能正常: 但是在新增之后再去编辑数据时,表单校验却失效了,甚至阻塞了后续的代码执行,控 ...

  4. IE浏览器——网络集合代理无法启动

    用管理员身份运行cmd然后输入 sc config diagnosticshub.standardcollector.service start=demand

  5. Python matplotlib pylot和pylab的区别

    matplotlib是Python中强大的绘图库. matplotlib下pyplot和pylab均可以绘图. 具体来说两者的区别 pyplot 方便快速绘制matplotlib通过pyplot模块提 ...

  6. linked-list-cycle-ii leetcode C++

    Given a linked list, return the node where the cycle begins. If there is no cycle, returnnull. Follo ...

  7. hdu 5018 Revenge of GCD

    题意: 给你两个数:X和Y  .输出它们的第K大公约数.若不存在输出 -1 数据范围: 1 <= X, Y, K <= 1 000 000 000 000 思路: 它俩的公约数一定是gcd ...

  8. Django项目部署到阿里云服务器无法发送邮件,发送邮件超时问题解决

    前言 今天, 博主将写了好久的一个Django小项目尝试在阿里云部署上线,由于项目中需要发送邮件,博主调用了163邮箱接口,在本地测试邮件功能正常,可是上线后,邮件功能一直超时崩溃.在百度一番查资料后 ...

  9. Sentinel-Go 源码系列(二)|初始化流程和责任链设计模式

    上节中我们知道了 Sentinel-Go 大概能做什么事情,最简单的例子如何跑起来 其实我早就写好了本系列的第二篇,但迟迟没有发布,感觉光初始化流程显得有些单一,于是又补充了责任链模式,二合一,内容显 ...

  10. HTML基本使用

    HTML初识 (Hyper Text Markup Language): 超文本标记语言 「HTML骨架格式」 <!-- 页面中最大的标签 根标签 --> <html> < ...