防缓存击穿神器: 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
}
相关推荐
哎呦没30 分钟前
大学生就业招聘:Spring Boot系统的架构分析
java·spring boot·后端
_.Switch1 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
杨哥带你写代码2 小时前
足球青训俱乐部管理:Spring Boot技术驱动
java·spring boot·后端
AskHarries3 小时前
读《show your work》的一点感悟
后端
A尘埃3 小时前
SpringBoot的数据访问
java·spring boot·后端
yang-23073 小时前
端口冲突的解决方案以及SpringBoot自动检测可用端口demo
java·spring boot·后端
Marst Code3 小时前
(Django)初步使用
后端·python·django
代码之光_19803 小时前
SpringBoot校园资料分享平台:设计与实现
java·spring boot·后端
编程老船长3 小时前
第26章 Java操作Mongodb实现数据持久化
数据库·后端·mongodb
IT果果日记4 小时前
DataX+Crontab实现多任务顺序定时同步
后端