6. 防止缓存击穿
...大约 3 分钟
https://github.com/dreamjz/golang-notes/tree/main/books/7-days-golang/GeeCache/day6-single-flight
DAY6-SINGLE-FLIGHT
│  go.mod
│  go.work
│  main.go
│  run.sh
│  
└─geecache
    │  byteview.go
    │  cache.go
    │  consistenthash.go
    │  consistenthash_test.go
    │  geecache.go
    │  geecache_test.go
    │  go.mod
    │  http.go
    │  peers.go
    │
    ├─consistenthash
    │      consistenthash.go
    │      consistenthash_test.go
    │
    ├─lru
    │      lru.go
    │      lru_test.go
    │
    └─singleflight
            singleflight.go
1. 缓存雪崩、缓存击穿与缓存穿透
缓存雪崩:缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。缓存雪崩通常因为缓存服务器宕机、缓存的 key 设置了相同的过期时间等引起。
缓存击穿:一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到 DB ,造成瞬时DB请求量大、压力骤增。
缓存穿透:查询一个不存在的数据,因为不存在则不会写到缓存中,所以每次都会去请求 DB,如果瞬间流量过大,穿透到 DB,导致宕机。
2. Single Flight
在之前的测试中,若同时发起大量的请求,会可能导致两种情况:
- 若需本地载入 value,会同时发起大量的数据库调用,导致缓存击穿
 - 若需从其他节点载入,则会同时发起大量的 HTTP 请求,并且可能导致目标节点出现缓存击穿
 
实际上,发起的多次请求,因为需要的结果相同,只需要一个请求成功执行并返回结果即可。
2.1 实现
package singleflight
import "sync"
type call struct {
	wg  sync.WaitGroup
	val any
	err error
}
type Group struct {
	mu sync.Mutex
	m  map[string]*call
}
call:表示请求wg:并发计数器,用于确保请求只会被调用一次val:请求的结果err:请求的 error
Group:mu:互斥锁,保护m的读写m:存储不同的 key 对应的请求
func (g *Group) Do(key string, fn func() (any, error)) (any, error) {
	g.mu.Lock()
	if g.m == nil {
		g.m = make(map[string]*call) // 延迟加载
	}
	// 调用已存在
	if c, ok := g.m[key]; ok {
		g.mu.Unlock()
		// 等待调用结束
		c.wg.Wait()
		// 直接返回
		return c.val, c.err
	}
	// 调用不存在,创建新调用
	c := new(call)
	c.wg.Add(1)
	g.m[key] = c
	g.mu.Unlock()
	// 发起调用
	c.val, c.err = fn()
	c.wg.Done()
	// 调用结束,删除调用
	g.mu.Lock()
	delete(g.m, key)
	g.mu.Unlock()
	return c.val, c.err
}
- 若调用表不存在,则创建(延迟加载)
 - 若key对应的调用已存在,则等待调用完成并返回
 - 若不存在,则创建新的调用
 - 执行调用函数
fn - 调用结束,从调用表中删除
 - 返回结果
 
Do保证了任意多次相同的 key 对应的调用函数只会被执行一次,并返回相同的结果。
2.2 使用
type Group struct {
	...
	// use singleflight.Group to make sure that
	// each key is only fetched once
	loader *singleflight.Group
}
func NewGroup(name string, cacheBytes int64, getter Getter) *Group {
	...
	g := &Group{
		...
		loader:    &singleflight.Group{},
	}
	...
}
func (g *Group) load(key string) (ByteView, error) {
	view, err := g.loader.Do(key, func() (any, error) {
		if g.peers != nil {
			if peer, ok := g.peers.PickPeer(key); ok {
				val, err := g.getFromPeer(peer, key)
				if err == nil {
					return val, nil
				}
				log.Println("[GeeCache] Failed to get from peer", err)
			}
		}
		return g.getLocally(key)
	})
	if err != nil {
		return ByteView{}, err
	}
	return view.(ByteView), nil
}
load方法中,将原有的执行逻辑放入g.loader.Do中执行,保证每个请求只会被执行一次。
3. Demo 测试
发起 10 请求,可以看到请求实际只执行了 3 次。
2023/10/11 07:00:28 [Server http://localhost:8003] Pick peer http://localhost:8001
2023/10/11 07:00:28 [Server http://localhost:8001] GET /geecache/scores/Tom
2023/10/11 07:00:28 [SlowDB] search key Tom
0023/10/11 07:00:28 [Server http://localhost:8003] Pick peer http://localhost:8001
2023/10/11 07:00:28 [Server http://localhost:8001] GET /geecache/scores/Tom
2023/10/11 07:00:28 [GeeCache] hit
2023/10/11 07:00:28 [Server http://localhost:8003] Pick peer http://localhost:8001
2023/10/11 07:00:28 [Server http://localhost:8001] GET /geecache/scores/Tom
2023/10/11 07:00:28 [GeeCache] hit
...
Reference
 Powered by  Waline  v2.15.2
