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 结合 context 和 select 进行超时控制。

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,可以显著提高系统的并发性能。

打 赏