Go语言 singleflight 防缓存击穿

Go语言使用 singleflight 防缓存击穿

1
2
3
4
5
1、缓存击穿
2、Go语言的 singleflight 介绍
3、singleflight 实例
4、singleflight 源码分析
5、小结

1、缓存击穿

缓存击穿是指在高并发的情况下,某个热点的key突然过期,导致大量的请求直接访问数据库,造成数据库的压力过大,甚至宕机的现象;

常见的解决缓存击穿方案:

设置热点数据永不过期:对于一些确定的热点数据,可以将其设置为永不过期,这样就可以确保不会因为缓存失效而导致请求直接访问到数据库;

使用互斥锁:为了防止缓存失效时所有请求同时访问到数据库,可以采用锁机制确保仅有一个请求数据库并更新缓存,而其他请求则在缓存更新后再进行访问;

监控狗程序:后台监控缓存的使用情况,当缓存即将过期时,异步更新缓存,延长过期时间;

2、Go语言的 singleflight 介绍

通俗的来说就是 singleflight 将相同的并发请求合并成一个请求;进而减少对下层服务的并发请求压力;
通常用于解决防止 “缓存击穿” 的应用场景;

在构建高性能的服务时,缓存是优化数据库压力和提高响应速度的关键技术;使用缓存也会带来一些问题,其中就包括缓存击穿,它不仅会导致数据库压力剧增,引起数据库性能的下降,严重时甚至会击垮数据库,导致数据库不可用;
在 Go 语言中,golang.org/x/sync/singleflight 包提供了一种机制,确保对于任何特定 key 的并发请求在同一时刻只执行一次;这个机制有效地防止了缓存击穿问题;
本文将深入探讨 Go 语言中 singleflight 包的使用;从缓存击穿问题的基础知识开始,进而详细介绍 singleflight 包的使用,展示如何利用它来避免缓存击穿;

3、singleflight 实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// @package    : singleflight
// @file : singleflight_demo.go
// @author : hqd8080
// @contact : hanquanding@163.com
// @time : 2025/01/02
// @description: singleflight

package main

import (
"context"
"fmt"
"golang.org/x/sync/singleflight"
"testing"
"time"
)

func TestSingleflightDemo(t *testing.T) {
var group singleflight.Group

// 模拟 10 个并发的请求、只有一个请求能请求到 "模拟耗时操作"
for i := 0; i < 10; i++ {
go func(i int) {
key := "demo"
item := i
val, err, _ := group.Do(key, func() (interface{}, error) {
// 模拟耗时操作
time.Sleep(time.Second * 2)
return fmt.Sprintf("resultl_%d", item), nil
})

if err != nil {
fmt.Println("group do err:", err)
return
}
fmt.Println("value:", val)
}(i)
}
time.Sleep(time.Second * 5)
fmt.Println("all task finished")
}

结合 context 超时控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// @package    : singleflight
// @file : singleflight_demo.go
// @author : hqd8080
// @contact : hanquanding@163.com
// @time : 2025/01/02
// @description: singleflight

package main

import (
"context"
"fmt"
"golang.org/x/sync/singleflight"
"testing"
"time"
)

func TestSingleflightTimeout(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*3)
go doTask(ctx)

time.Sleep(time.Second * 2)
cancel() // 2秒后超时
}

func doTask(ctx context.Context) {
var g singleflight.Group
key := "demo"

// 使用 DoChan 结合 select 做超时控制
result := g.DoChan(key, func() (interface{}, error) {
time.Sleep(time.Second * 5)
return "result", nil
})

select {
case r := <-result:
fmt.Println("result:", r.Val)
case <-ctx.Done():
fmt.Println("done")
return
}
}

4、singleflight 源码分析

golang.org/x/sync/singleflight singleflight 的结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
type call struct {
wg sync.WaitGroup

// 这些字段在 WaitGroup 结束前写入一次;
// 只有在 WaitGroup 结束后才会被读取;
val interface{}
err error

// 这些字段在 WaitGroup 结束前使用 singleflight 互斥锁进行读写;
// 在 WaitGroup 结束后读取但不写入;
dups int
chans []chan<- Result
}


// Group 代表分成多个工作组,形成一个命名空间,在这个命名空间中,各工作单元可以重复执行;
type Group struct {
mu sync.Mutex // 互斥锁
m map[string]*call // 懒加载
}

// Result 保存 Do 方法的结果,以便在通道上传递,做异步处理;
type Result struct {
Val interface{}
Err error
Shared bool
}

// Do 执行函数:对同一个 key 多次调用的时候,在第一次调用没有执行完的时候, 只会执行一次 fn,其他的调用会阻塞住等待这次调用返回, shared 表示 fn 的结果是否被共享;
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
}

// DoChan DoChan 和 Do 类似,只是 DoChan 返回一个 channel,也就是同步与异步的区别;
func (g *Group) DoChan(key string, fn func() (interface{}, error)) <-chan Result {
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
}
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
}

// Forget 用于通知 Group 删除某个 key 这样后面继续这个 key 的调用的时候就不会在阻塞等待了;
func (g *Group) Forget(key string) {
g.mu.Lock()
delete(g.m, key)
g.mu.Unlock()
}

5、小结

本文首先介绍了缓存击穿的概念、以及常见的缓存击穿解决方案、接着简单介绍singleflight包的实现原理;
再通过两个实例来展示singleflight包的使用方式;

重点介绍了singleflight 的源码剖析,该包的主要作用是:用于防止重复的请求,它确保给定的 key,函数在同一时间内只执行一次,多个请求共享同一结果;singleflight 能实现这种效果,关键点在于

1、将多个相同请求合并成一个请求,确保函数只执行一次singleflight 为了解决这个问题,引入了互斥锁 sync.Mutexmap;
2、结果共享机制singleflight通过阻塞式和非阻塞式两种方式,实现了结果的共享;