在这里插入图片描述

一、前言

“缓存”详细大家都不陌生,在高并发系统设计中缓存是个必不可少的因素,它能够帮助按照一定规则保存处理好的解耦从而提高系统吞吐量和响应速度,我们常见的缓存组件包括Redis、ES等。但今天并不是来介绍缓存组件的,而是在实现过程中大家都会关注到的一个问题"缓存击穿",什么是缓存击穿呢?

缓存击穿:平常在高并发系统中,会出现大量的请求同时查询一个key的情况,假如此时这个热key刚好失效了,就会导致大量的请求都打到数据库上面去,这种现象就是缓存击穿。缓存击穿和缓存雪崩有点像,但是又有一点不一样,缓存雪崩是因为大面积的缓存失效,打崩了DB,而缓存击穿则是指一个key非常热点,在不停的扛着高并发,高并发集中对着这一个点进行访问,如果这个key在失效的瞬间,持续的并发到来就会穿破缓存,直接请求到数据库,就像一个完好无损的桶上凿开了一个洞,造成某一时刻数据库请求量过大,压力剧增!

一般解决“缓存击穿”无非是三种方式:不设过期时间、查库操作加锁、请求合并,今天要和大家分享的主要是第三周请求合并也就是singleflight

资料汇总:

二、使用singleflight

首先我们先来看一下go-zero和官方它们实现的singleflight使用上有没有什么差异

go-zero :

	sf := syncx.NewSingleFlight()
	count := 0
	fn := func() (interface{}, error) {
		time.Sleep(time.Second)
		count++
		return time.Now().UnixNano(), nil
	}

	for i := 0; i < 10; i++ {
		go func() {
			// 启用10个协程模拟获取缓存操作
			val, err := sf.Do("get_unix_nano", fn)
			if err != nil {
				fmt.Println(err)
			} else {
				fmt.Println(val)
			}
		}()
	}

	time.Sleep(time.Second * 2)
	fmt.Println("执行次数count:", count)

golang官方:

	sfg := singleflight.Group{}
	count = 0
	fn := func() (interface{}, error) {
		time.Sleep(time.Second)
		count++
		return time.Now().UnixNano(), nil
	}
	
	for i := 0; i < 10; i++ {
		go func() {
			// 启用10个协程模拟获取缓存操作
			val, err, _ := sfg.Do("get_unix_nano", fn)
			if err != nil {
				fmt.Println(err)
			} else {
				fmt.Println(val)
			}
		}()
	}

	time.Sleep(time.Second * 2)
	fmt.Println("执行次数count:", count)

输出如下,十次并发调用拿到的值是同一个并且count值也为1:

1662959934600515000
1662959934600515000
1662959934600515000
1662959934600515000
1662959934600515000
1662959934600515000
1662959934600515000
1662959934600515000
1662959934600515000
1662959934600515000
执行次数count: 1

在标准用法上基本一样,但是官方库会比go-zero多出一些功能:

  • DoChan 异步返回通过channel来接受结果
  • Forget 释放某个 key 下次调用就不会阻塞等待了

三、go官方包和go-zero实现的singleflight有什么区别?

从singleflight实现上来说它并不复杂,就是借助了更细颗粒度的互斥锁加上waitgroup来实现的,代码加起来也就100行,为什么go-zero在syncx包中还要在实现一个呢?官方库又是什么地方还有优化空间?

首先我们先看一下结构体的定义:

goalng官方包:
// 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
}

go-zero:
	flightGroup struct {
		calls map[string]*call
		lock  sync.Mutex
	}

结构体的定义上上一模一样,在关键实现上也是一样的方式,主要差异笔者认为是在以下部分:

golang官方库Do实现:

// 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() (interface{}, error)) (v interface{}, 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()

		if e, ok := c.err.(*panicError); ok {
			panic(e)
		} else if c.err == errGoexit {
			runtime.Goexit()
		}
		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
}

go-zero Do实现:

func (g *flightGroup) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
	c, done := g.createCall(key)
	if done {
		return c.val, c.err
	}

	g.makeCall(c, key, fn)
	return c.val, c.err
}

func (g *flightGroup) createCall(key string) (c *call, done bool) {
	g.lock.Lock()
	if c, ok := g.calls[key]; ok {
		g.lock.Unlock()
		c.wg.Wait()
		return c, true
	}

	c = new(call)
	c.wg.Add(1)
	g.calls[key] = c
	g.lock.Unlock()

	return c, false
}

官方库为了实现DoChan和Forget所以需要增加以下判断:

		if e, ok := c.err.(*panicError); ok {
			panic(e)
		} else if c.err == errGoexit {
			runtime.Goexit()
		}

一个库包里面又有panic又有runtime.Goexit()着实吓人,但是如果本身不太需要使用到DoChan和Forget的能力这样的实现就带来了不少负担,所以在go-zero里面做了减法从而使用上更简单更加没有顾虑,如果你不需要使用到DoChan和Forget的能力用go-zero提供的singleflight更加符合代码编写规约。