动手写分布式缓存 - GeeCache第六天 防止缓存击穿
源代码/数据集已上传到 Github - 7days-golang
本文是7天用Go从零实现分布式缓存GeeCache的第六篇。
- 缓存雪崩、缓存击穿与缓存穿透的概念简介。
- 使用 singleflight 防止缓存击穿,实现与测试。代码约70行
1 缓存雪崩、缓存击穿与缓存穿透
GeeCache 第五天 提到了缓存雪崩和缓存击穿,在这里做下总结:
缓存雪崩:缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。缓存雪崩通常因为缓存服务器宕机、缓存的 key 设置了相同的过期时间等引起。
缓存击穿:一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到 DB ,造成瞬时DB请求量大、压力骤增。
缓存穿透:查询一个不存在的数据,因为不存在则不会写到缓存中,所以每次都会去请求 DB,如果瞬间流量过大,穿透到 DB,导致宕机。
2 singleflight 的实现
还记得 GeeCache 第五天 最后的测试结果吗?
1 | 2020/02/16 21:17:45 [Server http://localhost:8003] Pick peer http://localhost:8001 |
我们并发了 N 个请求 ?key=Tom
,8003 节点向 8001 同时发起了 N 次请求。假设对数据库的访问没有做任何限制的,很可能向数据库也发起 N 次请求,容易导致缓存击穿和穿透。即使对数据库做了防护,HTTP 请求是非常耗费资源的操作,针对相同的 key,8003 节点向 8001 发起三次请求也是没有必要的。那这种情况下,我们如何做到只向远端节点发起一次请求呢?
geecache 实现了一个名为 singleflight 的 package 来解决这个问题。
day6-single-flight/geecache/singleflight/singleflight.go - github
首先创建 call
和 Group
类型。
1 | package singleflight |
call
代表正在进行中,或已经结束的请求。使用sync.WaitGroup
锁避免重入。Group
是 singleflight 的主数据结构,管理不同 key 的请求(call)。
实现 Do
方法
1 | func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { |
- Do 方法,接收 2 个参数,第一个参数是
key
,第二个参数是一个函数fn
。Do 的作用就是,针对相同的 key,无论 Do 被调用多少次,函数fn
都只会被调用一次,等待 fn 调用结束了,返回返回值或错误。
g.mu
是保护 Group 的成员变量 m
不被并发读写而加上的锁。为了便于理解 Do
函数,我们将 g.mu
暂时去掉。并且把 g.m
延迟初始化的部分去掉,延迟初始化的目的很简单,提高内存使用效率。
剩下的逻辑就很清晰了:
1 | func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) { |
并发协程之间不需要消息传递,非常适合 sync.WaitGroup
。
- wg.Add(1) 锁加1。
- wg.Wait() 阻塞,直到锁被释放。
- wg.Done() 锁减1。
3 singleflight 的使用
day6-single-flight/geecache/geecache.go - github
1 | type Group struct { |
- 修改
geecache.go
中的Group
,添加成员变量 loader,并更新构建函数NewGroup
。 - 修改
load
函数,将原来的 load 的逻辑,使用g.loader.Do
包裹起来即可,这样确保了并发场景下针对相同的 key,load
过程只会调用一次。
4 测试
执行 run.sh
就可以看到效果了。
1 | $ ./run.sh |
可以看到,向 API 发起了三次并发请求,但8003 只向 8001 发起了一次请求,就搞定了。
如果并发度不够高,可能仍会看到向 8001 请求三次的场景。这种情况下三次请求是串行执行的,并没有触发 singleflight
的锁机制工作,可以加大并发数量再测试。即,将 run.sh
中的 curl
命令复制 N 次。
附 推荐
last updated at 2023-11-15