cache教程 6.解决缓存击穿的问题

1. 缓存雪崩、缓存击穿与缓存穿透

简单区分下:

缓存雪崩:

当某一个时刻出现大规模的缓存key失效的情况,那么就会导致大量的请求直接打在数据库上面,如果在高并发的情况下,可能瞬间就会导致数据库宕机。缓存雪崩通常因为缓存服务器宕机、缓存的 key 设置了相同的过期时间等引起。

缓存击穿:

是指一个存在的key非常热点,在不停的扛着大并发。用户集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库。

其和缓存雪崩区别,雪崩是大量key失效,而击穿是在某个存在的key失效瞬间的

缓存穿透:

查询一个不存在的key,因为不存在则不会写到缓存中,所以每次都会去请求 DB(DB中也可能不存在的),如果瞬间流量过大,穿透到 DB,导致宕机。

其和缓存击穿区别:缓存击穿查找的key是存在的,而穿透是该key是不存在的。

2 singleflight 的实现

在上一章节中,我们并发了 N 个请求 ?key=Tom,8003 节点向 8001 同时发起了 N 次请求。

假设在某个瞬间这个key失效了,假设对数据库的访问也没有做任何限制的,很可能向数据库也发起 N 次请求,容易导致缓存击穿。

即使对数据库做了防护,HTTP 请求是非常耗费资源的操作,针对相同的 key,8003 节点向 8001 发起三次请求也是没有必要的。那这种情况下,我们如何做到只向远端节点发起一次请求呢?

那容易想到一种做法,在多个请求并发访问同一个 key,过滤掉重复请求

做法:

  • 当一个请求到来时,需要记录
  • 在这个请求完成返回数据前,若有相同请求到达就排队等待, 直到正在处理的请求完成
  • 在排队等待的请求就可以直接获取到前面请求返回的结果

 请求A,请求B,请求C都是访问key-Tom。假设请求A先到,那B,C就要阻塞等待,等到A完成后才进行下一步操作。每一个请求都是一个协程,而协程又需要等待其他协程完成 之后才有下一步操作,那想到可以使用sync.WaitGroup。

请求对象call

需要记录请求,那需要定义好请求对象,创建请求结构体call,其内部有val返回结果,err错误信息,还有sync.WaitGroup类型变量wg,控制其他同样的请求的线程是否需要等待。

type call struct {
	wg  sync.WaitGroup // 控制线程是否等待
	val interface{}    // 请求返回结果
	err any            // 返回的错误信息
}

有了请求对象后,那需要记录,那存储到哪呢?还有这些都是同样的key的请求,那怎样通过key快速找到该请求呢?可以想到是存储到map中嘛。

存储请求的结构体Group

用map[string]*call 来存储, key 为查询缓存的 key, value 是本次请求的对象。

用了 map, 就需要考虑线程安全问题, 所以就必须考虑上锁。

type Group struct {
	mutex   sync.Mutex
	callMap map[string]*call
}

Group 是 singleflight 的主数据结构,管理不同 key 的请求(call)。 

有了这些结构后,那肯定是需要修改下访问缓存的方式的了。

Do 方法

其接收 2 个参数,第一个参数是 key,第二个参数是一个函数 fn。Do 的作用是,针对相同的 key,无论 Do 被调用多少次,函数 fn 都只会被调用一次,等待 fn 调用结束了,返回返回值或错误。

代码分析:

不是第一个访问该key的请求就进入第4代码,一直等待Wait()。

而第一个访问该key的请求就直接到第8行,进行加锁等等操作,执行回调函数fn,请求结束就Done()。

这时第4行的Wait()就不用等待了,那其他线程也就拿到了返回的结果。

这里的回调函数就是访问该key的操作函数,集成到geecache的Group中就清晰其如何使用了。

//为了简单易懂点,这里去掉了map的上锁那些操作。(实际实现需要对map加锁)
func (g *Group) Do(key string, fn func() (any, error)) (any, error) {
	if c, ok := g.callMap[key]; ok {
		c.wg.Wait()         //如果有请求正在进行中,那就等待
		return c.val, c.err //请求结束,返回结果
	}

	c := new(call)
	c.wg.Add(1)        //发起请求前加锁
	g.callMap[key] = c //添加到map中,表明该key已有对应的请求在处理

	c.val, c.err = fn() //调用fn,即是访问key的函数
	c.wg.Done()         //请求结束

	delete(g.callMap, key) //删除该key,表示该key当前没有请求在处理
	return c.val, c.err
}

下面是map加锁后的做法

g.callMap延迟初始化,其目的很简单,提高内存使用效率

func (g *Group) Do(key string, fn func() (any, error)) (any, error) {
	g.mutex.Lock()
	if g.callMap == nil {
		g.callMap = make(map[string]*call)
	}
	if c, ok := g.callMap[key]; ok {
		g.mutex.Unlock()
		c.wg.Wait()         //如果有请求正在进行中,那就等待
		return c.val, c.err //请求结束,返回结果
	}

	c := new(call)
	c.wg.Add(1)        //发起请求前加锁
	g.callMap[key] = c //添加到map中,表明该key已有对应的请求在处理
	g.mutex.Unlock()

	c.val, c.err = fn() //调用fn,即是访问key的函数
	c.wg.Done()         //请求结束

	g.mutex.Lock()
	delete(g.callMap, key) //删除该key,表示该key当前没有请求在处理
	g.mutex.Unlock()

	return c.val, c.err
}

3 singleflight 的使用

其实Go语言中已经实现了singleflight功能的,在internal/singleflight库中。原教程那时可能还没有这个库,但现在官方已有这个库了,我们还是再次实现该功能也不错的。

又是回到geecache.go文件中的load函数中,这里获取远程节点,访问远程节点。

func (g *Group) load(key string) (value ByteView, err error) {
	if g.peers != nil {
		if peer, ok := g.peers.PickPeer(key); ok {
			if value, err = g.getFromPeer(peer, key); err == nil {
				return value, nil
			}
		}
	}

	return g.getLocally(key)
}

要想可以使用,那Group结构体中需要添加singleflight.Group,并更新构建函数 NewGroup。

type Group struct {
	name      string
	mainCache cache
	getter    Getter
	peers *HTTPPool //添加了节点集合

	loader *singlefilght.Group // each key is only fetched once
}

func NewGroup(name string, cacheBytes int64, getter Getter) *Group {
    // ...
	g := &Group{
        // ...
		loader:    &singleflight.Group{},
	}
	return g
}

回到重要的load方法

  • 修改 load 函数,将原来的 load 的逻辑,使用 g.loader.Do 包裹起来即可,这样确保了并发场景下针对相同的 key,load 过程只会调用一次。
func (g *Group) load(key string) (value ByteView, err error) {
	viewi, err := g.loader.Do(key, func() (any, error) {
		if g.peers != nil {
			if peer, ok := g.peers.PickPeer(key); ok {
				if value, err = g.getFromPeer(peer, key); err == nil {
					return value, nil
				}
				log.Println("[GeeCache] Failed to get from peer", err)
			}
		}
		return g.getLocally(key)
	})

	if err == nil {
		return viewi.(ByteView), nil
	}
	return
}

 4.测试

使用上一章节的代码即可。

cache教程 6.解决缓存击穿的问题_第1张图片

完整代码:https://github.com/liwook/Go-projects/tree/main/go-cache/6-single-flight

你可能感兴趣的:(#,Go实现分布式缓存,缓存,go,缓存击穿)