groupcache 第一弹 singleflight

2,357 阅读3分钟

面试中经常会问一些关于缓存的问题。我虽然知道这些问题是存在的,网上也会有很多整理好的解决这类问题的答案,但是对这种问题的时候还是没有实际的感受的。最近在了解缓存的处理方式的时候,晓得了一个库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个进行了实际的操作。牛逼吧!!!

源码

源码中涉及的结构体只有两个,一个是调用的实体,通过valerr保存函数返回的结果,通过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等待执行的结果。

第一次感觉到代码的力量。尽然用如此简单的方式,解决了实际中出现的问题。