面试中经常会问一些关于缓存的问题。我虽然知道这些问题是存在的,网上也会有很多整理好的解决这类问题的答案,但是对这种问题的时候还是没有实际的感受的。最近在了解缓存的处理方式的时候,晓得了一个库groupcache
,就看了下源码,打算了解下处理方式。其中有一个模块 singleflight
,加上注释 65 行代码,其实现的功能就比较厉害了,解决的就是缓存击穿的问题。
缓存击穿
在高并发的情况下,大量的请求同时查询同一个key时,此时这个key正好失效了,就会导致同一时间,这些请求都会去查询数据库,容易把数据库整崩溃,这样的现象我们称为缓存击穿。
实例
先来看看一个简单的 demo,看下实现的效果。
package main
import (
"fmt"
"sync"
"time"
"github.com/golang/groupcache/singleflight"
)
func search() (interface{}, error) {
fmt.Println("start searching")
time.Sleep(time.Millisecond * 200)
return 1000, nil
}
func main() {
g := singleflight.Group{}
wg := sync.WaitGroup{}
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
res, err := g.Do("multi search", search)
fmt.Println(res, err)
wg.Done()
}()
}
wg.Wait()
}
函数search
假装是一个耗时的数据库查询,需要在 200ms 后才会返回数据。假如这个时候有 1000 个查询过来,如果都进行search
操作,则会对数据库造成比较大的压力。但是,上述代码运行的结果中只有打印了一行start searching
,也就是说 1000 个查询中,只有1个进行了实际的操作。牛逼吧!!!
源码
源码中涉及的结构体只有两个,一个是调用的实体,通过val
和err
保存函数返回的结果,通过wg
来表示函数运行的状态(函数是否调用完成)。
type call struct {
wg sync.WaitGroup
val interface{}
err error
}
另外一个是调用实体的字 hash 表,里面有一个互斥锁,来对m
的存取进行并发的控制。
type Group struct {
mu sync.Mutex // protects m
m map[string]*call // lazily initialized
}
在实际进行查询的时候,就使用了如下函数Do
。
func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
g.mu.Lock()
if g.m == nil {
g.m = make(map[string]*call)
}
// key 是否已经存在调用,如果存在调用,等待返回结果
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()
// 调用的结果存入 call 之中
c.val, c.err = fn()
c.wg.Done()
// 在函数执行过程,过来的重复查询,都会在处于c.wg.Wait()的状态,直到c.wg.Done()调用完成
// 删除的时候要使用互斥锁
g.mu.Lock()
delete(g.m, key)
g.mu.Unlock()
return c.val, c.err
}
简单的来说,就是一个key
对应一个调用的过程。在一个key
第一次调用的时候,会把key
放到Group.m
中去,这样后面同样的key
来的时候,就会发现key
已经存在,然后通过call.wg
等待执行的结果。
第一次感觉到代码的力量。尽然用如此简单的方式,解决了实际中出现的问题。