如果 1000 个请求同时查询同一个课程,而这个课程的缓存刚好过期,会发生什么?
最直接的结果是:这 1000 个请求都会发现 Redis 没有数据,然后一起去查 MySQL。
缓存本来是为了保护数据库,但在热点 key 失效的一瞬间,数据库反而可能承受更集中的压力。
这也是我在做选课系统查询接口时需要处理的问题。
数据库相比 Redis,更受连接池、索引、事务和磁盘 IO 等资源限制,所以一般会在数据库前加一层缓存。读操作先读缓存,缓存中不存在再查数据库,并构建缓存。
但新添加了一层中间件,就要考虑这层带来的问题。
为什么查询接口需要缓存#
在选课系统里,课程查询接口属于典型的高频读接口。
如果每次查询都直接访问 MySQL,请求量一上来,数据库连接池、索引查询和磁盘 IO 都会被持续消耗。Redis 缓存的作用,就是把大部分读请求拦在数据库前面。
正常情况下,请求链路大概是:
查询请求
↓
读取 Redis 缓存
↓
缓存命中,直接返回如果缓存没有命中,再进入 MySQL 查询,并把结果重新写回 Redis:
查询请求
↓
读取 Redis 缓存
↓
缓存未命中
↓
查询 MySQL
↓
重建 Redis 缓存
↓
返回结果这个流程看起来很自然,但放到高并发场景里,就会遇到缓存击穿和缓存穿透的问题。
热点 key 过期后,数据库会发生什么#
首先要考虑的,就是热点 key 过期。
一个查询请求查热点 key,发现缓存未命中,会继续查数据库,拿到结果后还要重新构建缓存。这就是缓存击穿的问题。
重新构建缓存后,后来的请求可以命中缓存。如果只是一个请求,这没有什么问题。
但如果是高并发场景下,1000 个请求并发打入服务端,全部都查同一个 key,还没等缓存重建完成,请求就全部打入数据库。
这时候缓存本来是保护数据库的,结果在热点 key 过期的一瞬间,数据库反而被打了一波集中流量。
分布式锁能解决击穿,但有额外开销#
常见的通用解决方法是,用分布式锁保证短时间内只有一个请求能够参与重新构建缓存,其他请求阻塞等待。
这确实能解决问题。
但每个请求获取 Redis 分布式锁,本身也是一次网络请求的往返。在高并发场景下,这也是一笔不小的开销。
其实想想,我们真正需要的逻辑很简单:
同一时刻,同一个 key,只让一个请求真正去查数据库,其余请求等待并共享结果。Go 扩展库 golang.org/x/sync/singleflight 就是为了这个场景设计的。
singleflight 真正解决的是什么问题#
singleflight 解决的是同一个 Go 进程内,相同 key 的重复函数调用合并问题。
它不是缓存,也不是分布式锁。
它做的事情更像是:
第一个请求:我去查数据库
后续相同 key 的请求:我等第一个请求的结果
第一个请求完成后:大家共享同一个结果需要注意的是,singleflight 只在当前 Go 进程内生效。如果服务部署了多个实例,每个实例内部仍然可能各自有一个请求去查询数据库。
Group 和 call:singleflight 如何记录正在执行的查询#
singleflight 在这个场景里最重要的是 Group 结构体和它的 Do 方法。
type Group struct {
mu sync.Mutex
m map[string]*call
}可以看到,Group 里有一个 Mutex 和一个 map。
因为后续多个请求会同时访问这个 map,而 Go 的 map 不是并发安全的,所以这里需要加锁。
map 的 key 就是 Do 方法传入的 key。每条请求会根据查询的 key 来区分自己是不是同一批请求。
再看 call:
type call struct {
wg sync.WaitGroup
val interface{}
err error
dups int
chans []chan<- Result
}关于 call,这里只需要先抓住两个字段:
wg:让后来的请求等待第一个请求完成。val:保存第一个请求查询到的结果,后续请求共享这个结果。
Do 方法:第一个请求查库,其他请求等待结果#
Do 方法的大致源码如下:
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
}一个请求调用 Do(),必须先上锁,因为后续要访问 map。
抢到锁后,它会查询自己要读取的 key,通过 ok 这个标志位判断 key 是否已经存在。
如果 key 不存在,说明当前请求是第一个尝试构建这个 key 的请求。
这个请求会创建一个 call,调用 wg.Add(1),把 call 放入 map 后释放锁。随后这个请求会自己同步执行 fn 函数,也就是查询数据库并重建缓存。
查询完成后,结果会写入 call.val,错误会写入 call.err。最后调用 wg.Done(),所有阻塞在 wg.Wait() 的请求都会停止阻塞并被唤醒。
如果 key 已经存在,说明这个 key 已经有其他请求在查询了。
本请求不再重复查数据库,而是调用 wg.Wait() 阻塞等待。当第一个请求查询结束后,这些阻塞的请求全部被唤醒,直接获取 call.val 里的结果。
这样就把高并发请求合并成一次缓存重建。其他请求只需要等待结果,而不是一起打到数据库。
singleflight 只能缓解部分缓存穿透#
singleflight 在缓存问题中不只是能优化缓存击穿,也可以缓解一部分缓存穿透问题。
比如大量请求同时查询同一个不存在的 key,缓存未命中后,如果没有 singleflight,这些请求会一起打到数据库。
加上 singleflight 后,同一个 key 只会有一个请求查数据库,其他请求等待并共享结果。
但它解决不了“很多不同非法 key”同时访问的问题。
因为不同 key 会进入不同的 singleflight 调用,所以还需要布隆过滤器和缓存空值。
这里也可以顺便区分一下:
- 穿透:缓存和数据库中都没有数据,请求一直打到数据库。
- 击穿:缓存没有数据,但数据库有数据,通常发生在热点 key 过期时。
缓存空值:防止同一个非法 key 反复查库#
缓存穿透的常见解决方案是缓存一个空值,并设置短 TTL。
比如用户查询一个不存在的课程 ID,第一次请求会查数据库。数据库发现没有这条记录后,不是直接结束,而是往 Redis 写一个短时间的空值。
后续如果还有请求查询同一个不存在的 key,就可以直接命中这个空值,不需要反复查数据库。
但单靠缓存空值还不够。
缓存空值只能解决大量请求查询同一个不存在 key 的问题。如果攻击者不断构造不同的非法 key,流量还是会打入数据库。
布隆过滤器:提前拦截大量非法 key#
为了解决恶意查询不同非法 key 的问题,我在项目中引入了布隆过滤器中间件。
布隆过滤器本质上是一个 bitmap。
每个 key 会通过多个不同的 hash 函数映射到 bitmap 的多个位置,并把这些位置标记为 1。
提前将所有合法 key 添加到布隆过滤器中,后续请求携带 key 过来时,也会进行同样的 hash 运算。
如果多个 hash 对应的 bitmap 位置都为 1,说明这个 key 可能存在。
如果任意一个位置为 0,说明这个 key 一定不存在。
这里说的是“可能存在”,不是“一定存在”,是因为不同 key 可能出现 hash 冲突,也就是多个 key 映射到了相同的位置。
但如果某个 hash 对应的位置为 0,就说明这个 key 一定没有被加入过布隆过滤器,可以直接拦截。
虽然布隆过滤器可能出现误判,但它能拦截绝大部分非法 key 的请求。
少数误判请求即使进入下一层查询缓存,也还有缓存空值这个策略兜底。
所以布隆过滤器是为了缓解恶意请求大量不同非法 key,缓存空值是为了解决大量请求反复查询同一个不存在 key。
我的查询链路总结#
最终,我的查询链路大概是:
查询请求
↓
布隆过滤器拦截明显非法 key
↓
查询 Redis 缓存
↓
缓存命中,直接返回
↓
缓存未命中
↓
singleflight 合并同 key 的数据库查询
↓
查询 MySQL
↓
数据库存在:写入正常缓存
↓
数据库不存在:写入短 TTL 空值
↓
返回结果这条链路里,每一层解决的问题都不一样。
Redis 缓存减少正常查询对 MySQL 的访问。
singleflight 合并同一个 key 的并发查询,缓解热点 key 过期时的缓存击穿。
缓存空值防止同一个不存在 key 反复查库。
布隆过滤器提前拦截大量明显非法 key。
所以查询接口的缓存防护,不是单独依赖某一个方案,而是把几种手段组合起来,让请求尽量在进入数据库之前就被处理掉。


