防缓存击穿神器: singleflight

问题提出

一般业务场景下,为了保证系统的稳定性,一般在mysql前加一层redis缓存(设置一定的超时时间)。所以请求某个key的时候,如果命中redis缓存则直接读取缓存,如果不命中就访问mysql。当某个热 key 缓存失效时, 会有大量的请求直接请求mysql导致缓存击穿。
singleflight 便可以解决上述问题

本文研究的singleflight包路径位于 src/internal/singleflight/singleflight.go

Go 还有一种实现 golang.org/x/sync/sing...

核心结构

Package singleflight provides a duplicate function call suppression mechanism.

通过强制一个函数所有后续调用等待第一个调用完成,消除同时运行重复函数的低效性

  • call封装singleflight中的一次调用,其中

    • val fn 返回的数据
    • err fn 返回的err
    • shared 表明返回数据是调用 fn 得到的还是其他请求调用返回的
    • chans 配合DoChan使用
  • Group 封装基于key维度的 重复调用消除

go 复制代码
// call is an in-flight or completed singleflight.Do call
type call struct {
    wg sync.WaitGroup

    // These fields are written once before the WaitGroup is done
    // and are only read after the WaitGroup is done.
    val any
    err error

    // These fields are read and written with the singleflight
    // mutex held before the WaitGroup is done, and are read but
    // not written after the WaitGroup is done.
    dups  int
    chans []chan<- Result
}

// Group represents a class of work and forms a namespace in
// which units of work can be executed with duplicate suppression.
type Group struct {
    mu sync.Mutex       // protects m
    m  map[string]*call // lazily initialized
}

// Result holds the results of Do, so they can be passed
// on a channel.
type Result struct {
    Val    any
    Err    error
    Shared bool
}

Do

Do 执行并返回给定函数fn的结果,同一个key的调用,保证只有一个执行在进行中。如果存在重复调用,重复的调用者会等待原始执行完成并接收相同的结果

  • 基于该key 查询是否存在in-flight调用,如果存在则阻塞其等待完成
  • 反之,将该key存入m,同步调用doCall 执行传入的方法,方法执行完成删除对应的key
  • 如果Group中chan不为空,则将结果封装成Result发送到对应的通道(DoChan中 会使用)
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.
func (g *Group) Do(key string, fn func() (any, error)) (v any, 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()
        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
}

// doCall handles the single call for a key.
func (g *Group) doCall(c *call, key string, fn func() (any, error)) {
    c.val, c.err = fn()
    c.wg.Done()

    g.mu.Lock()
    delete(g.m, key)
 for _, ch := range c.chans { 
 ch <- Result{c.val, c.err, c.dups > 0 } 
 } 
    g.mu.Unlock()
}

DoChan

Do 请求是阻塞等待的,存在以下不足

  • 请求是阻塞的,没有超时控制,难以快速失败
  • 虽然抑制了重复调用的并发,但是成功率可能会因此下降

DoChan 可以解决这一问题,功能类似于Do方法

区别在于 该方法不会阻塞地等待结果的返回,而是直接返回一个通道

  • 基于该key 查询是否存在in-flight调用,如果存在则初始化长度为1的带缓冲通道ch,将该通道保存到call.chans中,当fn执行完成后会将结果发送到这些通道中
  • 异步执行doCall
  • 返回初始化的通道ch(等数据就绪了 数据会返回到对应通道中)
go 复制代码
// DoChan is like Do but returns a channel that will receive the
// results when they are ready. The second result is true if the function
// will eventually be called, false if it will not (because there is
// a pending request with this key).
func (g *Group) DoChan(key string, fn func() (any, error)) (<-chan Result, bool) {
    // 缓冲区长度为1 的通道 ==> 数据的发送不会阻塞
    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, false
    }
    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, true
}

使用场景伪代码

go 复制代码
func doLongTimeExec(ctx context.Context) error {
    g * singleflight.Group
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

    ch := g.DoChan("doLongTimeExec", func() (any, error) {
        // 模拟耗时操作
        time.Sleep(time.Second * 2)
        return nil, nil
    })

    select {
    case <-ctx.Done():
        return ctx.Err()
    case ret := <-ch:
        return ret.Err
    }
}

ForgetUnshared

想象一种场景,使用Do/DoChan虽然说降低了对下游的重复请求,但是单次请求失败/超时 产生的影响面被扩大了(因为并发场景下其他请求会复用这个结果) 这时候便可以通过异步调用 ForgetUnshared 改善这一情况

ForgetUnshared: 失效 key,后续对此 key 的调用将执行 fn,而不是等待前面的调用完成

  • 如果不存在该key的调用直接返回
  • 如果不存在其他请求在等该结果的返回,则删除对应的key(这样后续的key则会执行对应的方法fn,而不是等待前面的调用完成)
  • 如果存在其他请求在等该结果的返回,这时候不直接删除对应的call
go 复制代码
// ForgetUnshared tells the singleflight to forget about a key if it is not
// shared with any other goroutines. Future calls to Do for a forgotten key
// will call the function rather than waiting for an earlier call to complete.
// Returns whether the key was forgotten or unknown--that is, whether no
// other goroutines are waiting for the result.
func (g *Group) ForgetUnshared(key string) bool {
    g.mu.Lock()
    defer g.mu.Unlock()
    c, ok := g.m[key]
    if !ok {
        return true
    }
    if c.dups == 0 {
        delete(g.m, key)
        return true
    }
    return false
}
相关推荐
环境栈笔记13 分钟前
指纹浏览器 WebRTC 怎么设置?从 ICE 候选、代理出口到功能验证
前端·人工智能·后端·自动化
一只小猪妖1 小时前
分布式唯一 性ID生成方法
后端
小蒜学长1 小时前
微信小程序-高校自习室管理系统(代码+数据库+LW)
java·spring boot·后端·微信小程序·自习室管理
M1A11 小时前
告别代码风格之争:在 VS Code 中用 Black 一键格式化 Python
后端
IT_陈寒1 小时前
Python多进程共享变量这个坑,差点让我加班到凌晨
前端·人工智能·后端
撸串-研究生2 小时前
springboot福利院管理系统73979
java·spring boot·后端·福利院管理系统
xixiaoyunya2 小时前
别再写一堆 if-else 了:用策略模式重构你的业务代码
后端
tachibana22 小时前
Go 语言里的 Context 是什么?
开发语言·后端·golang·go·语言
鶴哥只手遮天3 小时前
Spring Framework 7.0.9 深度剖析-05-配置元数据解析:XML、注解与 JavaConfig
java·后端·开源
鶴哥只手遮天3 小时前
Spring Framework 7.0.9 深度剖析-16-异步与调度
java·后端·开源