防缓存击穿神器: 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
}
相关推荐
Chenyiax28 分钟前
从 Chat 到 Responses:OpenAI API 抽象为什么变了?
后端
MariaH29 分钟前
Koa和Express的区别
后端
MariaH35 分钟前
Koa框架的使用
后端
luckdewei2 小时前
那个用 passlib 做认证的新同事,上线第一天就把用户密码写进了日志
后端
ping某3 小时前
为什么 Nginx 明明监听了 80,转发后端时却用了 4xxxx 端口?
后端·nginx
JustHappy3 小时前
我汇总了身边朋友的经历才发现,其实第一份实习是最难找的......
前端·后端·面试
uhakadotcom3 小时前
在python 的 工程化架构中 ,什么是 薄包装器层?
后端·面试·github
用户1474853079747 小时前
CodeX使用Skill生成游戏美术和音乐资源,一分钟入门
后端
Melody1238 小时前
用 abort 中断 AI 流式请求,我之前做错了
后端
onething3658 小时前
Spring Boot + Spring AI 从入门到实战:7天转型计划 Day 5 —— SSE 流式输出 + 打字机效果
人工智能·后端·全栈