简易实现 Go 的缓存框架(下) | 青训营

40 阅读12分钟

书接上回:juejin.cn/post/726964…

三、HTTP服务

分布式缓存需要实现节点间通信,下面我们就来为单机节点搭建HTTP Server,以下是geecache/http.go的代码:

const defaultBasePath = "/_geecache/"

type HTTPPool struct {
	self     string
	basePath string
}

func c(self string) *HTTPPool {
	return &HTTPPool{
		self:     self,
		basePath: defaultBasePath,
	}
}

代码创建了结构体 HTTPPool,作为承载节点间HTTP通信的核心数据结构,self用来记录自己的地址,包括主机名/IP 和端口,basePath作为节点间通讯地址的前缀,默认是 /_geecache/,下面实现最核心的ServeHTTP()

func (p *HTTPPool) Log(format string, v ...interface{}) {
	log.Printf("[Server %s] %s", p.self, fmt.Sprintf(format, v))
}

func (p *HTTPPool) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	if !strings.HasPrefix(req.URL.Path, p.basePath) {
		fmt.Printf("HTTPPool serving unexpected path: " + req.URL.Path)
	}
	p.Log(req.Method, req.URL.Path)
	parts := strings.SplitN(req.URL.Path[len(p.basePath):], "/", 2)
	if len(parts) != 2 {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	groupName := parts[0]
	key := parts[1]
	group := GetGroup(groupName)
	if group == nil {
		http.Error(w, "no such group:"+groupName, http.StatusNotFound)
		return
	}
	view, err := group.Get(key)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/octet-stream")
	w.Write(view.ByteSlice())
}

这段代码的逻辑不算复杂,首先检查请求路径的URL是否以basePath为前缀,接着在Path中从basePath的结尾开始,以"/"开始切割,例如basePath为"/cache/",而URL路径为"/cache/user/key",则parts中的数据就是["user","key"]。如果成功切出来两段内容,那么第一段就是groupName,第二段为key,利用groupName获取到group,再利用key获取到group中的view写入HTTP响应中,并设置Content-Type头字段为"application/octet-stream",表示该响应的内容是字节流。

接着main.go进行测试!

var db = map[string]string{
	"Tom":  "630",
	"Jack": "589",
	"Sam":  "567",
}

func main() {
	geecache.NewGroup("scores", 2<<10, geecache.GetterFunc(
		func(key string) ([]byte, error) {
			log.Printf("[SlowDB] search key", key)
			if v, ok := db[key]; ok {
				return []byte(v), nil
			}
			return nil, fmt.Errorf("%s not exist", key)
		}))
	addr := "localhost:9999"
	peers := geecache.NewHTTPPool(addr)
	log.Println("geecache is running at", addr)
	log.Fatal(http.ListenAndServe(addr, peers))
}

四、一致性哈希

为了实现分布式缓存,一致性哈希是我们必须要考虑的问题,因此首先讲解这个部分。

1.不太健全的哈希

假设这样一个场景,我们现在拥有10个节点,请求发送给节点X,失败,因为它没有存储此缓存值,于是向其他节点寻求帮助,由该节点从数据源获取数据。节点X随机寻求了节点A的帮助,由A去获取此缓存值,此次请求结束。 不健全的哈希 但是问题来了,同样的请求再一次发送给了节点X,如何能让节点X有记忆一般的,再次去找节点A要数据,而不是向其他节点要呢?

针对这个需求,我们可以设计如下算法:

将请求中key的每个字符取ASCII码,将其相加后除以10取余数,这样就一定会得到一个小于10的数,余数为几,就去找几号节点要数据,这样对于固定的key,被请求的节点总会去找一个固定的节点寻求帮助,形成了类似"记忆"的逻辑。

可此时由于财务亏损,节点的数量由10缩减到了8,本来除以10取余数的操作,要变成除以8取余数,也就是以前10个节点时形成的"记忆"全部失效,缓存全部失效,严重时可能会造成瞬时DB请求量大、压力骤增,导致缓存雪崩的情况。

那要如何解决呢?这就要采用一致性哈希算法了。

2.一致性哈希算法

上述不太健全的算法对于key的处理是:取key中每个字符ASCII码的和,除以节点的总数量,得到余数。一致性哈希算法对于key的处理是,将其映射到一个2^32的空间中,并将这些数字首尾相连形成环,如图所示: 一致性哈希算法 将节点放到环上,对key的请求进行映射后也放到环上,顺时针寻找到的第一个节点,就是应选取的节点。例如对key2的请求要寻找节点A,对key1的请求要寻找节点B,对key4的请求要寻找节点C,对key3的请求要寻找节点A。

目前我们实现了节点的"记忆"功能,即节点接收到请求后,会对于相同的key,会固定的向一个节点寻求帮助。

现在由于财务亏损,我们关掉了节点B,这时也仅仅只影响到了对key1的请求,它由对应节点B变成了对应节点C,其他节点没有任何改变,不用去重新"记忆",避免了缓存雪崩的情况。

业务做大了,赚到钱了,要在这个基础上新加一个节点D,如图所示: 一致性哈希增加节点 那么也仅仅只有对key4的请求由节点C改变到节点D,其他的节点依旧没有任何影响,这便是一致性哈希算法。当然,它也有一些缺陷:

设想这样一个场景,目前有三个节点,经过映射后全部处于圆的上半部分,那么下半圆的所有key都只会对应上半圆中最左边的节点,导致它压力过大,其他节点空闲,导致缓存在负载不均衡。

解决这个办法也很简单,可以引入虚拟节点,即一个真实节点对应多个虚拟节点。例如节点A对应虚拟节点A1、A2、A3,其余节点也做同样的操作,这样就相当于扩充了节点的数量,解决了数据倾斜的问题,而且也只需要创建一个map维护真实节点和虚拟节点之间的映射即可。

3.实现一致性哈希算法

我们在geecache/consistenthash/consistenthash.go中实现一致性哈希算法:

type Hash func(data []byte) uint32

type Map struct {
	hash     Hash
	replicas int
	keys     []int
	hashMap  map[int]string
}

func New(replicas int, fn Hash) *Map {
	m := &Map{
		hash:     fn,
		replicas: replicas,
		hashMap:  make(map[int]string),
	}
	if m.hash == nil {
		m.hash = crc32.ChecksumIEEE
	}
	return m
}

下面听俺逐个解释:

  • 函数Hash:用来将数据映射到圆环上,如果不自定义,默认采用 crc32.ChecksumIEEE 算法;
  • 结构体Map,主要包括四个字段:

​ hash:表示哈希函数,即上面介绍的。

​ replicas:表示虚拟节点的个数。

​ keys:存储所有虚拟节点的编号,类型为 []int,是一个有序的整数数组。

​ hashMap:存储所有虚拟节点到真实节点的映射关系,其中 int 表示虚拟节点的编号,string 表示真实节点的地址。

接下来实现添加真实节点的Add方法:

func (m *Map) Add(keys ...string) {
	for _, key := range keys {
		for i := 0; i < m.replicas; i++ {
			hash := int(m.hash([]byte(strconv.Itoa(i) + key)))
			m.keys = append(m.keys, hash)
			m.hashMap[hash] = key
		}
	}
	sort.Ints(m.keys)
}

此方法允许传入多个真实节点的名称,并对每一个真实节点key,创建m.replicas个虚拟节点,并将虚拟节点的名字设置为编号+key取哈希后的值,接着添加到圆环中,并把真实节点和虚拟节点的映射关系添加到m.hashMap中。最后,对环上的节点进行哈希值排序。

下面实现节点选择的Get方法:

func (m *Map) Get(key string) string {
	if len(m.keys) == 0 {
		return ""
	}
	hash := int(m.hash([]byte(key)))
	idx := sort.Search(len(m.keys), func(i int) bool {
		return m.keys[i] >= hash
	})
	return m.hashMap[m.keys[idx%len(m.keys)]]
}

该方法首先计算出传入 key 的哈希值,也就是将 key 转成字节数组,再通过哈希函数Hash计算出哈希值;接着通过sort.Search()方法在有序的m.keys列表中查找到接近且大于等于该哈希值的第一个虚拟节点的下表,即顺时针后找到的第一个虚拟节点,这里使用了匿名函数func(i int) bool {}实现二分查找算法;最后通过m.hashMap映射该虚拟节点下表对应的真实节点,如果 idx == len(m.keys),说明应选择 m.keys[0],因为 m.keys 是一个环状结构,所以用取余数的方式来处理这种情况。

至此,我们实现了整个的一致性哈希算法!

五、分布式节点

先回顾一下之前画的流程: (1)和(3)已经实现,还剩(2)需要补充,让我们把(2)细化一些: 远程节点交互流程

1.抽象PeerPicker

geecache/peers.go编写如下代码:

type PeerPicker interface {
	PickPeer(key string) (peer PeerGetter, ok bool)
}

type PeerGetter interface {
	Get(group string, key string) ([]byte, error)
}

PeerPicker接口定义了一个用于选择节点的方法PickPeer(),它是一个抽象的概念,可以是一个HTTP服务的实现,也可以是一个节点间通过 P2P 协议通信的实现,只要符合定义的方法并实现了对应的接口即可。

PeerGetter接口定义了一个用于获取缓存数据的方法Get(),它同样是一个抽象的概念,具体的实现可以是从本地内存或磁盘中获取缓存数据,也可以是从其他节点获取远程缓存数据,只要符合定义的方法并实现了对应的接口即可。

将这两个接口独立出来实现了缓存系统的高可扩展性和异构性。可以通过PeerPicker接口选择对应数据所在的节点,再通过PeerGetter接口从对应的节点中获取数据,从而有效地实现了数据的分布式存储和读取。

2.节点选择与 HTTP 客户端

上文编写的geecache/http.go只实现了服务端功能,通信不仅需要服务端还需要客户端,因此,接下来要为 HTTPPool 实现客户端的功能:

type httpGetter struct {
	baseURL string
}

func (h *httpGetter) Get(group string, key string) ([]byte, error) {
	u := fmt.Sprintf(
		"%v%v/%v",
		h.baseURL,
		url.QueryEscape(group),
		url.QueryEscape(key),
	)
	res, err := http.Get(u)
	if err != nil {
		return nil, err
	}
	defer res.Body.Close()
	if res.StatusCode != http.StatusOK {
		return nil, fmt.Errorf("server returned: %v", res.Status)
	}
	bytes, err := ioutil.ReadAll(res.Body)
	if err != nil {
		return nil, fmt.Errorf("reading response body: %v", err)
	}
	return bytes, nil
}
var _ PeerGetter = (*httpGetter)(nil)

这段代码定义了一个名为httpGetter的结构体和它的方法Get(),并实现了PeerGetter接口。详细解释如下:

定义了 httpGetter 结构体,它有一个字符串类型的字段 baseURL,表示请求的基本 URL 地址。

Get()方法使用 http 协议从远程节点获取缓存数据,接收两个参数group和key,表示数据所属的分组和数据的键值。它根据baseURL、group、key等信息构造出一个完整的URL地址u,然后通过http.Get()方法发起一个GET请求,并从响应中读取数据。

如果链接成功,返回的响应码为http.StatusOK(即 200),则通过ioutil.ReadAll()方法读取响应体中的所有数据,最后返回读取到的字节数组和一个nil错误。

如果链接发生错误,或者响应状态码表明请求失败,则返回一个自定义的错误信息,表示读取失败或服务端返回错误。

最后一行代码是 var _ PeerGetter = (*httpGetter)(nil),它的作用是将httpGetter类型转换为PeerGetter接口类型。这么做的原因是PeerGetter是一个接口类型,无法直接实例化,因此我们需要向这个接口类型注册并实现相应的方法,最终得到一个完整的实例。在这里我们通过将httpGetter转换为PeerGetter接口类型,实现了httpGetterPeerGetter接口中的注册。

然后为为HTTPPool添加节点选择的功能:

const (
	defaultBasePath = "/_geecache/"
	defaultReplicas = 50
)

type HTTPPool struct {
	self        string
	basePath    string
	mu          sync.Mutex
	peers       *consistenthash.Map
	httpGetters map[string]*httpGetter
}

新增字段peers,类型是一致性哈希算法的 Map,用来根据具体的 key 选择节点

新增字段httpGetters,映射远程节点与对应的httpGetter。每一个远程节点对应一个httpGetter,因为httpGetter与远程节点的地址 baseURL有关。

然后实现PeerPicker接口:

func (p *HTTPPool) Set(peers ...string) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.peers = consistenthash.New(defaultReplicas, nil)
	p.peers.Add(peers...)
	p.httpGetters = make(map[string]*httpGetter, len(peers))
	for _, peer := range peers {
		p.httpGetters[peer] = &httpGetter{baseURL: peer + p.basePath}
	}
}

func (p *HTTPPool) PickPeer(key string) (PeerGetter, bool) {
	p.mu.Lock()
	defer p.mu.Unlock()
	if peer := p.peers.Get(key); peer != "" && peer != p.self {
		p.Log("Pick peer %s", peer)
		return p.httpGetters[peer], true
	}
	return nil, false
}

var _ PeerPicker = (*HTTPPool)(nil)

Set()方法将收到的节点传进一致性哈希算法,进行实例化;

PickPeer()方法对一致性哈希的Get()方法进一步封装,从节点哈希表中获取到该key所属的节点peer,返回节点对应的HTTP客户端。

至此,HTTPPool既具备了提供 HTTP 服务的能力,也具备了根据具体的 key,创建 HTTP 客户端从远程节点获取缓存值的能力。

接着,将上述新增的功能集成在主流程geecache/geecache.go中:

type Group struct {
	name      string
	getter    Getter
	mainCache cache
	peers     PeerPicker
}

func (g *Group) RegisterPeers(peers PeerPicker) {
	if g.peers != nil {
		panic("RegisterPeerPicker called more than once")
	}
	g.peers = peers
}

func (g *Group) load(key string) (value ByteView, err error) {
	if g.peers != nil {
		if peer, ok := g.peers.PickPeer(key); ok {
			if value, err = g.getFromPeer(peer, key); err == nil {
				return value, nil
			}
			log.Println("[GeeCache] Failed to get from peer", err)
		}
	}

	return g.getLocally(key)
}

func (g *Group) getFromPeer(peer PeerGetter, key string) (ByteView, error) {
	bytes, err := peer.Get(g.name, key)
	if err != nil {
		return ByteView{}, err
	}
	return ByteView{b: bytes}, nil
}

RegisterPeers() 方法将实现了PeerPicker接口的HTTPPool注入到Group中;

getFromPeer() 方法实现了PeerGetter接口的httpGetter从访问远程节点,获取缓存值;

load() 方法,使用 PickPeer() 方法选择节点,若非本机节点,则调用 getFromPeer() 从远程获取。若是本机节点或失败,则回退到 getLocally()

最后测试,编写main.go

var db = map[string]string{
	"Tom":  "630",
	"Jack": "589",
	"Sam":  "567",
}

func createGroup() *geecache.Group {
	return geecache.NewGroup("scores", 2<<10, geecache.GetterFunc(
		func(key string) ([]byte, error) {
			log.Println("[SlowDB] search key", key)
			if v, ok := db[key]; ok {
				return []byte(v), nil
			}
			return nil, fmt.Errorf("%s not exist", key)
		}))
}

func startCacheServer(addr string, addrs []string, gee *geecache.Group) {
	peers := geecache.NewHTTPPool(addr)
	peers.Set(addrs...)
	gee.RegisterPeers(peers)
	log.Println("geecache is running at", addr)
	log.Fatal(http.ListenAndServe(addr[7:], peers))
}

func startAPIServer(apiAddr string, gee *geecache.Group) {
	http.Handle("/api", http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) {
			key := r.URL.Query().Get("key")
			view, err := gee.Get(key)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			w.Header().Set("Content-Type", "application/octet-stream")
			w.Write(view.ByteSlice())

		}))
	log.Println("fontend server is running at", apiAddr)
	log.Fatal(http.ListenAndServe(apiAddr[7:], nil))

}

func main() {
	var port int
	var api bool
	flag.IntVar(&port, "port", 8001, "Geecache server port")
	flag.BoolVar(&api, "api", false, "Start a api server?")
	flag.Parse()

	apiAddr := "http://localhost:9999"
	addrMap := map[int]string{
		8001: "http://localhost:8001",
		8002: "http://localhost:8002",
		8003: "http://localhost:8003",
	}

	var addrs []string
	for _, v := range addrMap {
		addrs = append(addrs, v)
	}

	gee := createGroup()
	if api {
		go startAPIServer(apiAddr, gee)
	}
	startCacheServer(addrMap[port], []string(addrs), gee)
}

在这里稍微发点牢骚,其实从二、单机并发缓存开始,我对整个项目的理解就没那么透彻了,属于在一知半解的状态下,代码逻辑吧也能缕缕顺,但是自己敲不出来,也想不出来人家为什么这么写。而这个五、分布式节点,我就更搞不懂了,理解就更少了,但是看来看去好像就是不停的对最底层的数据结构进行封装,然后再用一个东西封装以下已经封装好的底层数据结构,这么做肯定有它的意义,但是我真的说不出来,靠自己啃貌似是不行了,应该需要一个牛逼的大佬点拨我那么一下。容我那么小emo一下,铁子们,属实太废物了。

六、防止缓存击穿

1.解释概念

首先对缓存雪崩、缓存击穿、缓存穿透的概念做个解释:

缓存雪崩:缓存在同一时刻全部失效,造成瞬时DB请求量大、压力骤增,引起雪崩。缓存雪崩通常因为缓存服务器宕机、缓存的 key 设置了相同的过期时间等引起。

缓存击穿:一个存在的key,在缓存过期的一刻,同时有大量的请求,这些请求都会击穿到 DB ,造成瞬时DB请求量大、压力骤增。

缓存穿透:查询一个不存在的数据,因为不存在则不会写到缓存中,所以每次都会去请求 DB,如果瞬间流量过大,穿透到 DB,导致宕机。

2.具体实现

设想这样一种情况,我们运行了三个节点,分别占用8001、8002、8003端口,在查询某个key的时候,并发了三个请求指向8001,假如有10万个在并发请求该数据呢?那就会向 8001 同时发起10万次请求,如果8001又同时向数据库发起10万次查询请求,很容易导致缓存被击穿。那这种情况下,我们如何做到只向远端节点发起一次请求呢?

下面来编写geecache/singleflight/singleflight.go解决这个问题:

type call struct {
	wg  sync.WaitGroup
	val interface{}
	err error
}

type Group struct {
	mu sync.Mutex
	m  map[string]*call
}

call用于表示一次函数调用的结果,避免重复的函数调用,在需要时可以直接从缓存中读取数据,减少了函数调用的次数;

Group用于存储函数调用的缓存结果,管理不同 key 的请求(call)。

下面实现Do()方法:

func (g *Group) Do(key string, fn func() (interface{}, error)) (interface{}, error) {
    // 1. 加锁,保证并发安全
	g.mu.Lock()
    // 2. 如果 m 没有初始化,先初始化一下
	if g.m == nil {
		g.m = make(map[string]*call)
	}
    // 3. 判断 Key 是否存在于 m 中,如果存在直接返回对应项的 value
	if c, ok := g.m[key]; ok {
		g.mu.Unlock()
		c.wg.Wait()
		return c.val, c.err
	}
    // 4. Key 不存在则初始化一个新事件,并更新 m 中的映射关系
	c := new(call)
	c.wg.Add(1)
	g.m[key] = c
	g.mu.Unlock()
    // 计算函数 fn,并更新 call 结构体中保存的计算结果和错误信息
	c.val, c.err = fn()
    // 通知等待组,计算完成
	c.wg.Done()
	g.mu.Lock()
    // 5. 删除已缓存的key-value,在后续的调用中,从缓存中获取到的key将会是第一次添加key,以此保证对于一个key,只计算一次结果加入缓存
	delete(g.m, key)
	g.mu.Unlock()
    // 返回计算结果
	return c.val, c.err
}

个人觉得Do()方法比较复杂,让chatgpt给我讲了一下:对于每一个输入的 key 值,Group 会维护一个 call 结构体,其中储存了函数调用的结果。当调用 Group 的 Do 方法时,如果该 key 对应的结果已经被计算过并缓存了,则直接返回结果;如果没有被缓存,则调用传入的函数 fn 计算结果,并将计算的结果缓存下来,下一次再调用该函数时可以直接取出缓存的结果返回。

接下来在geecache/geecache.go中使用singleflight

type Group struct {
	name      string
	getter    Getter
	mainCache cache
	peers     PeerPicker
	loader *singleflight.Group
}

func NewGroup(name string, cacheBytes int64, getter Getter) *Group {
    // ...
	g := &Group{
        // ...
		loader:    &singleflight.Group{},
	}
	return g
}

func (g *Group) load(key string) (value ByteView, err error) {
	viewi, err := g.loader.Do(key, func() (interface{}, error) {
		if g.peers != nil {
			if peer, ok := g.peers.PickPeer(key); ok {
				if value, err = g.getFromPeer(peer, key); err == nil {
					return value, nil
				}
				log.Println("[GeeCache] Failed to get from peer", err)
			}
		}

		return g.getLocally(key)
	})

	if err == nil {
		return viewi.(ByteView), nil
	}
	return
}

首先在Group中添加刚刚定义的新字段,然后修改NewGroup方法,初始化时也初始化loader

接着对load()方法进行修改,用Do方法包裹起原来的逻辑,即实现了并发场景下对于相同的key,只调用一次。

到这里防止缓存击穿的部分就实现完毕了,我觉得自己越来越不行了,属实是一点都看不懂了,这可咋整啊,纯纯大废物一个。不过博客都写到这了,就硬着头皮收个尾吧,然后再回过头来钻研,也很希望能有大佬帮我顺顺,呜呜呜。

七、使用Protobuf通信

1.Protobuf

下面给一段关于Protobuf的官方说明:

Protobuf,全称为 Protocol Buffers,是谷歌公司开发的一种高效、灵活、可扩展的序列化数据结构的协议,常用于数据存储、通信协议等领域。它既可以用于语言之间的数据交换,也可以用于持久化数据。Protobuf 的设计目标是简单、高效、自描述,支持多种编程语言,如 C++, Java, Python 等。

  • 它的优点包括:与 XML 和 JSON 相比,序列化后的数据相对更小,传输效率更高
  • 无须 XML 或 JSON 解析器,因此解析速度更快,资源消耗更少
  • 支持向后和向前兼容,有利于协议的演化和扩展
  • 支持多种语言,且跨语言支持更出色

使用 Protobuf,首先需要编写 .proto 文件来定义数据结构,然后使用编译器将其编译成目标代码。编译器生成的目标代码包含了从原始数据结构到二进制数据的映射,以及从二进制数据到原始数据结构的映射。这种映射通过操作字节码进行操作,实现了数据的序列化和反序列化。

用我自己的理解来说,就是和JSON一样的东西,但是比JSON牛逼很多,用它就完了。

2.使用Protobuf通信

这一块的具体实现暂且搁置吧,毕竟功能已经全部实现完了,改用protobuf不是太着急的事情,我想先把前面的代码好好消化消化,然后可以专门写一篇关于protobuf的博客,再来把这个坑填上。