Go语言之防缓存击穿利器 Singleflight

1. 缓存击穿

在日常开发中,为了提升性能和减轻数据库的压力,通常会对热点数据进行缓存。例如,使用 Redis 缓存用户请求的数据,如果缓存中有数据则直接返回,否则查询数据库并将结果写入缓存。

但是,如果缓存失效了,在查询数据库和将数据再次写入缓存的过程中,其他请求也会出现缓存未命中的情况,导致大量请求直接打到数据库,给数据库造成极大压力,甚至可能导致数据库崩溃。

这种情况被称为缓存击穿。

2. Singleflight

为了防止缓存击穿,传统的解决方案是加锁。然而,加锁的方法较重且逻辑复杂。相比之下,Singleflight 是一个轻量级的解决方案。

下面是一个示例:

package main

import (
	"fmt"
	"sync"
	"time"

	"golang.org/x/sync/singleflight"
)

var g singleflight.Group

func main() {
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			val, err, _ := g.Do("key", func() (interface{}, error) {
				fmt.Println("query db")
				time.Sleep(100 * time.Millisecond) // 模拟查询数据库
				return time.Now().UnixNano(), nil
			})
			if err != nil {
				fmt.Println(err)
				return
			}
			fmt.Println(val)
		}()
	}
	wg.Wait()
}

输出结果:

query db
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100
2024/07/17 11:04:13 1626491053454483100

可以看到,10 个请求都获取到了结果,并且只有一个请求查询了数据库,大大减轻了数据库的压力。

3. 源码分析

Singleflight 的实现非常简单,除去注释大概只有 100 来行代码,但功能强大,值得学习。

Group

type Group struct {
	mu sync.Mutex       // 保护 map 的互斥锁
	m  map[string]*call // 懒加载的 map
}

Group 结构体包含一个互斥锁和一个 map。由于 map 是懒加载的,Group 只需声明即可使用,不需要额外的初始化。

Call

type call struct {
	wg sync.WaitGroup
	val interface{} // 函数返回值
	err error       // 函数返回的错误

	forgotten bool   // 是否调用了 forget 方法
	dups      int    // 记录 key 被共享的次数
	chans     []chan<- Result // 用于异步返回结果的通道
}

call 保存了当前调用的相关信息,map 的键就是调用 Do 方法传入的 key。

Do

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()
		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
}

Do 方法首先尝试获取锁,如果 key 存在,则等待第一个请求执行完成,并返回结果。如果 key 不存在,则新建一个 call 并执行 doCall

doCall

func (g *Group) doCall(c *call, key string, fn func() (interface{}, error)) {
	defer func() {
		c.wg.Done()
		g.mu.Lock()
		defer g.mu.Unlock()
		if !c.forgotten {
			delete(g.m, key)
		}
	}()

	c.val, c.err = fn()
}

doCall 方法执行传入的函数 fn,并将结果保存在 call 中。函数执行完毕后,调用 wg.Done() 解除阻塞,并删除 map 中的 key。

4. 注意事项

阻塞

Singleflight 内部使用 WaitGroup 来让同一 key 的后续请求阻塞,直到第一个请求完成。如果 fn 执行时间较长,后续所有请求都会被阻塞。此时可以使用 DoChan 结合 contextselect 进行超时控制。

func loadChan(ctx context.Context, key string) (string, error) {
	data, err := loadFromCache(key)
	if err != nil && err == ErrCacheMiss {
		result := g.DoChan(key, func() (interface{}, error) {
			data, err := loadFromDB(key)
			if err != nil {
				return nil, err
			}
			setCache(key, data)
			return data, nil
		})
		select {
		case r := <-result:
			return r.Val.(string), r.Err
		case <-ctx.Done():
			return "", ctx.Err()
		}
	}
	return data, nil
}

请求失败

如果第一个请求失败,后续所有等待的请求都会返回同一个错误。可以根据下游的承载能力定时调用 Forget 方法,让更多请求有机会执行 fn

go func() {
	time.Sleep(100 * time.Millisecond)
	g.Forget(key)
}()

例如,1 秒内有 100 个请求,正常情况下,只有第一个请求会执行 queryDB,后续 99 个请求都会阻塞。通过定期调用 Forget 方法,每 100 毫秒就会有一个请求执行 queryDB,相当于增加了几次尝试机会,但也会给数据库带来更大压力,需要根据具体场景进行权衡。

结论

Singleflight 是 Go 语言中一个防止缓存击穿的轻量级解决方案,通过共享相同 key 的请求,避免了缓存失效时大量请求同时打到数据库,保护了数据库的性能和稳定性。通过合理使用 Singleflight,可以显著提高系统的并发性能。

打 赏