防止缓存击穿库:singleflight 源码分析
singleflight通常被用来做防止缓存击穿,代码位置在https://github.com/golang/groupcache/tree/master/singleflight,在详细介绍代码内容之前,我先区分下雪崩、穿透和击穿:
雪崩
雪崩就是指缓存中大批量热点数据同时过期或缓存机器意外发生了全盘宕机后系统涌入大量查询请求,因为大部分数据在Redis层已经失效,请求渗透到数据库层,大批量请求犹如洪水一般涌入,引起数据库压力造成查询堵塞甚至宕机。
解决办法:
将缓存失效时间分散开,比如每个key的过期时间是随机,防止同一时间大量数据过期现象发生,这样不会出现同一时间全部请求都落在数据库层,如果缓存数据库是分布式部署,将热点数据均匀分布在不同Redis和数据库中,有效分担压力,别一个人扛。
简单粗暴,让Redis数据永不过期(如果业务准许,比如不用更新的名单类)。当然,如果业务数据准许的情况下可以,比如中奖名单用户,每期用户开奖后,名单不可能会变了,无需更新。
事前:redis 高可用,主从+哨兵,redis cluster,避免全盘崩溃。- 事中:本地 ehcache 缓存 + hystrix 限流&降级,避免 MySQL 被打死。- 事后:redis 持久化,一旦重启,自动从磁盘上加载数据,快速恢复缓存数据。
缓存穿透
缓存穿透是指段时间涌入大量请求,缓存中查不到,每次你去数据库里查,也查不到。(数据库 id 是从 1 开始的,结果黑客发过来的请求 id 全部都是负数。)这样的话,缓存中不会有,请求每次都“视缓存于无物”,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接把数据库给打死。
解决方式很简单,每次系统 A 从数据库中只要没查到,就写一个空值到缓存里去,比如 set -999 UNKNOWN。然后设置一个过期时间,这样的话,下次有相同的 key 来访问的时候,在缓存失效之前,都可以直接从缓存中取数据。
缓存击穿
缓存击穿,某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在失效的瞬间,大量的请求就击穿了缓存,直接请求数据库,就像是在一道屏障上凿开了一个洞。
方法一:我们简单粗暴点,直接让热点数据永远不过期,定时任务定期去刷新数据就可以了。不过这样设置需要区分场景,比如某宝首页可以这么做。
方法二:为了避免出现缓存击穿的情况,我们可以在第一个请求去查询数据库的时候对他加一个互斥锁,其余的查询请求都会被阻塞住,直到锁被释放,后面的线程进来发现已经有缓存了,就直接走缓存,从而保护数据库。但是也是由于它会阻塞其他的线程,此时系统吞吐量会下降。需要结合实际的业务去考虑是否要这么做。
方法三:就是singleflight的设计思路,也会使用互斥锁,但是相对于方法二的加锁粒度会更细
singleflight 源码分析
说完了singleflight的应用场景,下面详细分析下singleflight的源码,源码非常简洁,目录下就包含了两个文件singleflight.go 和对应的测试的测试文件singleflight_test.go
源码中就定义了两个结构体和一个方法
// call is an in-flight or completed Do call
type call struct {
wg sync.WaitGroup
val interface{}
err error
}
// Group represents a class of work and forms a namespace in which
// units of work can be executed with duplicate suppression.
type Group struct {
mu sync.Mutex // protects m
m map[string]*call // lazily initialized
}
通过call的waitGroup来阻塞相同key的请求,实现了一个指允许一个请求到后端,通过Group的m来实现相同key的数据共享,大家取同一份结果,下面看下Do函数的具体实现:
// Do executes and returns the results of the given function, making
// sure that only one execution is in-flight for a given key at a
// time. If a duplicate comes in, the duplicate caller waits for the
// original to complete and receives the same results.
//同一个对象多次同时多次调用这个逻辑的时候,可以使用其中的一个去执行
func (g *Group) Do(key string, fn func()(interface{},error)) (interface{}, error ){
g.mu.Lock() //加锁保护存放key的map,因为要并发执行
if g.m == nil { //lazing make 方式建立
g.m = make(map[string]*call)
}
if c, ok := g.m[key]; ok { //如果map中已经存在对这个key的处理那就等着吧
g.mu.Unlock() //解锁,对map的操作已经完毕
c.wg.Wait()
return c.val,c.err //map中只有一份key,所以只有一个c
}
c := new(call) //创建一个工作单元,只负责处理一种key
c.wg.Add(1)
g.m[key] = c //将key注册到map中
g.mu.Unlock() //map的操做完成,解锁
c.val, c.err = fn()//第一个注册者去执行
c.wg.Done()
g.mu.Lock()
delete(g.m,key) //对map进行操作,需要枷锁
g.mu.Unlock()
return c.val, c.err //给第一个注册者返回结果
}
执行过程如下:
1,对于相同key的请求,大家抢锁,只有第一个请求可以获得锁;
2,然后查询map发现没有数据,创建一个call,waitGroup加1,写入map,然后释放锁;做到了锁的粒度最小化。
3,其他获得锁的请求,从map中取到call,由于函数fn还没有执行完毕,所以waitGroup还在等待状态,后面获得锁的请求都在等待这个waitGroup;
4,当函数执行完毕以后,获得了数据,调用wg.Done()通知所有等待的请求获取数据,实现了大家共享一份数据;
5,然后加锁做清理工作,清理掉map里存储的数据。
推荐阅读