Go 中使用 singleflight 防止缓存击穿

一个典型的使用缓存的案例:

// GetConfig 获取 key 指定的配置项
func GetConfig(key string) (Config, bool) {
	cacheKey := "cfg:" + key
    
    // 如果缓存中查到则直接返回缓存数据
	if v, has := Cache().Get(cacheKey); has {
		if cfg, ok := v.(Config); ok {
			return cfg, true
		}
	}

    // 不在缓存中则从数据库中查找
	cfg := Config{}
	if err := DB().Where("`key`=?", key).First(&cfg).Error; err != nil {
		return cfg, false
	}

    // 将数据库中查到的值写入缓存, 300 秒过期
	Cache().Store(cacheKey, cfg, 300)
	return cfg, true
}

但这样存在一个问题: 如果 300 秒缓存过期时,有大量并发产生,将会导致这些并发在缓存中都找不到数据去查数据库的情况,从而发生所谓的缓存击穿。

我们可以使用 golang.org/x/sync/singleflight 来解决这个问题, singleflight 中主要是一个 Group 结构体,它包含以下三个函数:


// Do 执行并返回函数 fn 的结果,并确保在相同 key 在并发调用执行时,只会有一个调用被真正执行,
// 其它重复调用将会直接使用第一个执行的结果返回
// 返回的第 3 个参数 shared 表示返回的结果是直接执行得到的还是其它并发调用共享的。
func (g *Group) Do(key string, fn func() (interface{}, error)) (v interface{}, err error, shared bool) 

// DoChan  Do 的 channel 返回值形式(异步)
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result

// Forget 用于删除指定 key,删除后其它并发调用者将会被真正执行
func (g *Group) Forget(key string)

GetConfig() 重命名为 doGetConfig(),然后重新写一个 GetConfig() 函数:


var sg *singleflight.Group

func init() {
	sg = new(singleflight.Group)
}

// GetConfig 获取 key 指定的配置项
func GetConfig(key string) (Config, bool) {
	val, err, _ := sg.Do("getCfg:"+key, func() (interface{}, error) {
		cfg, _ := doGetConfig(key)
		return cfg, nil
	})
	if err != nil {
		// ...
	}
	cfg, ok := val.(Config)
	if !ok {
		// ...
	}
	return cfg, true
}