GroupCache学习笔记汇总-Go语言开发

简介
groupcache是memcached的Go语言版本。
与Redis等其他常用cache实现不同,groupcache并不运行在单独的server上,而是作为library和app运行在同一进程中。所以groupcache既是server也是client。

  • 分布式缓存库
  • 数据无版本概念, 也就是一个key对应的value是不变的,并没有update
  • 节点之间可互访,自动复制热点数据

在API上,groupcache最大的特点是不提供update/delete/TTL等常见操作,其唯一更改cache的方式是refill。比如:

var testGroup = groupcache.NewGroup("test", 64<<20, groupcache.GetterFunc(
        func(ctx groupcache.Context, key string, dest groupcache.Sink) error {
            value := readFromDB(key)
            dest.SetBytes(value)
            return nil
        }))

用户只能通过在callback中写入groupcache.Sink来更新cache。而这个callback只有在cache miss的时候才会被调用。所以cache一旦被写入便无法更新。

上面这个例子中返回的testGroup也被直接用来读取cache:

var data []byte
err := testGroup.Get(ctx, "test-key",
    groupcache.AllocatingByteSliceSink(&data))
process(data)

也就是说cache的写入和读取都是通过一个struct来完成。这使得groupcahe可以更有效率的控制refill逻辑。比如,有N个请求读取同一个group中的同一个key。这N个请求都会被转发到同一台机器上,由一个groupcache.Group处理。这样只有第一个请求需要去数据库读取数据以refill cache,其余N-1个请求只需等待第一个请求从数据库中读取数据即可。因为同一个key的请求都由一个groupcache.Group处理,上述实现变的非常简单,只需要一个map来记录所有in-flight request和一个WaitGroup来控制线程等待即可(代码详见groupcache/singleflight)。

代码概览
groupcache最大的特点就是轻量。整个repo一共3000多行Go代码,而且只依赖于标准库。所以其逻辑非常简单清晰:
在这里插入图片描述
整个repo的核心部分就是groupcacache.Group这一个struct负责了cache的本地查询和peer查询。当cache在本地和peer都不存在时,它便会call上面介绍的callback来进行refill。注意图中提到的hot cache,其用意是,即使根据sharding某个key并不应该缓存在本地,如果其被访问的足够频繁便将其ke缓存在本地的hot cache中。现在这个逻辑并没有被实现,代码只是将一些随机的key存储在了hot cache中。

当某个key在本地找不到时,groupcacache会根据sharding向peer发送HTTP request。groupcahe.HTTPPool的一大责任就是负责处理这些HTTP request,并调取groupcacache.Group的API来获取HTTP request请求的缓存值,并将结果返回。

groupcahe.HTTPPool使用groupcacache.Group的方法和普通用户是一样的,peer之间交流的数据也都用了protobuf定义。所以我们很容易通过实现不同的HTTPPool(以及groupcache.ProtoGetter)来使groupcache支持不同的protocol。
注意:

  1. 不需要对服务器进行单独的设置,这将大幅度减少部署和配置的工作量。groupcache 既是客户端库也是服务器库,并连接到自己的 peer 上。
  2. 具有缓存过滤机制。众所周知,在 memcached 出现“Sorry,cache miss(缓存丢失)”时,经常会因为不受控制用户数量的请求而导致数据库(或者其它组件)产生“惊群效应(thundering herd)”;groupcache 会协调缓存填充,只会将重复调用中的一个放于缓存,而处理结果将发送给所有相同的调用者。
  3. 不支持多个版本的值。如果“foo”键对应的值是“bar”,那么键“foo”的值永远都是“bar”。这里既没有缓存的有效期,也没有明确的缓存回收机制,因此同样也没有 CAS 或者 Increment/Decrement。(set了什么值就算以后改了这个值也不改变)
  4. 基于上一点的改变,groupcache 就具备了自动备份“超热”项进行多重处理,这就避免了 memcached 中对某些键值过量访问而造成所在机器 CPU 或者 NIC 过载。
以上是GroupCache的简要介绍 接下来是源码分析

首先,groupchace源码中至少包含了以下知识点,比如rpc是什么,golang中如何使用rpc;protobuf怎么用,ring hash(一致性哈希)算法原理,lru算法原理等,singleflight是一种编程技巧。

  1. rpc
  2. protobuf
  3. ring hash
  4. lru
  5. singleflight

首先看rpc

什么是RPC:
远程过程调用(Remote Procedure Call,缩写为 RPC)是一个计算机通信协议。 该协议允许运行于一台计算机的程序调用另一台计算机的子程序,而程序员无需额外地为这个交互作用编程。 如果涉及的软件采用面向对象编程,那么远程过程调用亦可称作远程调用或远程方法调用。维基百科:远程过程调用

用通俗易懂的语言描述就是:RPC允许跨机器、跨语言调用计算机程序方法。打个比方,我用go语言写了个获取用户信息的方法getUserInfo,并把go程序部署在阿里云服务器上面,现在我有一个部署在腾讯云上面的php项目,需要调用golang的getUserInfo方法获取用户信息,php跨机器调用go方法的过程就是RPC调用。

golang中如何实现RPC

在golang中实现RPC非常简单,有封装好的官方库和一些第三方库提供支持。Go RPC可以利用tcp或http来传递数据,可以对要传递的数据使用多种类型的编解码方式。golang官方的net/rpc库使用encoding/gob进行编解码,支持tcp或http数据传输方式,由于其他语言不支持gob编解码方式,所以使用net/rpc库实现的RPC方法没办法进行跨语言调用。

golang官方还提供了net/rpc/jsonrpc库实现RPC方法,JSON RPC采用JSON进行数据编解码,因而支持跨语言调用。但目前的jsonrpc库是基于tcp协议实现的,暂时不支持使用http进行数据传输。

除了golang官方提供的rpc库,还有许多第三方库为在golang中实现RPC提供支持,大部分第三方rpc库的实现都是使用protobuf进行数据编解码,根据protobuf声明文件自动生成rpc方法定义与服务注册代码,在golang中可以很方便的进行rpc服务调用。

net/rpc库
下面的例子演示一下如何使用golang官方的net/rpc库实现RPC方法,使用http作为RPC的载体,通过net/http包监听客户端连接请求。

package main

import (
    "errors"
    "fmt"
    "log"
    "net"
    "net/http"
    "net/rpc"
    "os"
)

// 算数运算结构体
type Arith struct {
}

// 算数运算请求结构体
type ArithRequest struct {
    A int
    B int
}

// 算数运算响应结构体
type ArithResponse struct {
    Pro int // 乘积
    Quo int // 商
    Rem int // 余数
}

// 乘法运算方法
func (this *Arith) Multiply(req ArithRequest, res *ArithResponse) error {
    res.Pro = req.A * req.B
    return nil
}

// 除法运算方法
func (this *Arith) Divide(req ArithRequest, res *ArithResponse) error {
    if req.B == 0 {
        return errors.New("divide by zero")
    }
    res.Quo = req.A / req.B
    res.Rem = req.A % req.B
    return nil
}

func main() {
    rpc.Register(new(Arith)) // 注册rpc服务
    rpc.HandleHTTP()         // 采用http协议作为rpc载体

    lis, err := net.Listen("tcp", "127.0.0.1:8095")
    if err != nil {
        log.Fatalln("fatal error: ", err)
    }

    fmt.Fprintf(os.Stdout, "%s", "start connection")

    http.Serve(lis, nil)
}

上述服务端程序运行后,将会监听本地的8095端口,我们可以实现一个客户端程序,连接服务端并实现RPC方法调用。

package main

import (
    "fmt"
    "log"
    "net/rpc"
)

// 算数运算请求结构体
type ArithRequest struct {
    A int
    B int
}

// 算数运算响应结构体
type ArithResponse struct {
    Pro int // 乘积
    Quo int // 商
    Rem int // 余数
}

func main() {
    conn, err := rpc.DialHTTP("tcp", "127.0.0.1:8095")
    if err != nil {
        log.Fatalln("dailing error: ", err)
    }

    req := ArithRequest{9, 2}
    var res ArithResponse

    err = conn.Call("Arith.Multiply", req, &res) // 乘法运算
    if err != nil {
        log.Fatalln("arith error: ", err)
    }
    fmt.Printf("%d * %d = %d\n", req.A, req.B, res.Pro)

    err = conn.Call("Arith.Divide", req, &res)
    if err != nil {
        log.Fatalln("arith error: ", err)
    }
    fmt.Printf("%d / %d, quo is %d, rem is %d\n", req.A, req.B, res.Quo, res.Rem)
}

net/rpc/jsonrpc库
上面的例子我们演示了使用net/rpc实现RPC的过程,但是没办法在其他语言中调用上面例子实现的RPC方法。所以接下来的例子我们演示一下使用net/rpc/jsonrpc库实现RPC方法,此方式实现的RPC方法支持跨语言调用。

$GOPATH/src/test/rpc/jsonrpc_server.go

package main

import (
“errors”
“fmt”
“log”
“net”
“net/rpc”
“net/rpc/jsonrpc”
“os”
)

// 算数运算结构体
type Arith struct {
}

// 算数运算请求结构体
type ArithRequest struct {
A int
B int
}

// 算数运算响应结构体
type ArithResponse struct {
Pro int // 乘积
Quo int // 商
Rem int // 余数
}

// 乘法运算方法
func (this *Arith) Multiply(req ArithRequest, res *ArithResponse) error {
res.Pro = req.A * req.B
return nil
}

// 除法运算方法
func (this *Arith) Divide(req ArithRequest, res *ArithResponse) error {
if req.B == 0 {
return errors.New(“divide by zero”)
}
res.Quo = req.A / req.B
res.Rem = req.A % req.B
return nil
}

func main() {
rpc.Register(new(Arith)) // 注册rpc服务

lis, err := net.Listen("tcp", "127.0.0.1:8096")
if err != nil {
    log.Fatalln("fatal error: ", err)
}

fmt.Fprintf(os.Stdout, "%s", "start connection")

for {
    conn, err := lis.Accept() // 接收客户端连接请求
    if err != nil {
        continue
    }

    go func(conn net.Conn) { // 并发处理客户端请求
        fmt.Fprintf(os.Stdout, "%s", "new client in coming\n")
        jsonrpc.ServeConn(conn)
    }(conn)
}

}
上述服务端程序启动后,将会监听本地的8096端口,并处理客户端的tcp连接请求。我们可以用golang实现一个客户端程序连接上述服务端并进行RPC调用。

$GOPATH/src/test/rpc/jsonrpc_client.go

package main

import (
“fmt”
“log”
“net/rpc/jsonrpc”
)

// 算数运算请求结构体
type ArithRequest struct {
A int
B int
}

// 算数运算响应结构体
type ArithResponse struct {
Pro int // 乘积
Quo int // 商
Rem int // 余数
}

func main() {
conn, err := jsonrpc.Dial(“tcp”, “127.0.0.1:8096”)
if err != nil {
log.Fatalln("dailing error: ", err)
}

req := ArithRequest{9, 2}
var res ArithResponse

err = conn.Call("Arith.Multiply", req, &res) // 乘法运算
if err != nil {
    log.Fatalln("arith error: ", err)
}
fmt.Printf("%d * %d = %d\n", req.A, req.B, res.Pro)

err = conn.Call("Arith.Divide", req, &res)
if err != nil {
    log.Fatalln("arith error: ", err)
}
fmt.Printf("%d / %d, quo is %d, rem is %d\n", req.A, req.B, res.Quo, res.Rem)

}

protorpc库

为了实现跨语言调用,在golang中实现RPC方法的时候我们应该选择一种跨语言的数据编解码方式,比如JSON,上述的jsonrpc可以满足此要求,但是也存在一些缺点,比如不支持http传输,数据编解码性能不高等。于是呢,一些第三方rpc库都选择采用protobuf进行数据编解码,并提供一些服务注册代码自动生成功能。下面的例子我们使用protobuf来定义RPC方法及其请求响应参数,并使用第三方的protorpc库来生成RPC服务注册代码。

首先,需要安装protobuf及protoc可执行命令(protobuf所需要的编辑器和插件)

然后,我们编写一个proto文件,定义要实现的RPC方法及其相关参数。

syntax = "proto3";
package pb;

// 算术运算请求结构
message ArithRequest {
    int32 a = 1;
    int32 b = 2;
}

// 算术运算响应结构
message ArithResponse {
    int32 pro = 1;  // 乘积
    int32 quo = 2;  // 商
    int32 rem = 3;  // 余数
}

// rpc方法
service ArithService {
    rpc multiply (ArithRequest) returns (ArithResponse);    // 乘法运算方法
    rpc divide (ArithRequest) returns (ArithResponse);      // 除法运算方法
}

接下来我们需要根据上述定义的arith.proto文件生成RPC服务代码。
1.要先安装protorpc库
2.然后使用protoc工具生成代码
执行protoc命令后,在与arith.proto文件同级的目录下生成了一个arith.pb.go文件,里面包含了RPC方法定义和服务注册的代码。(这里的1.2步骤是执行的命令根据每个人的文件位置可能有所不同,所以我不给出案例,可以去protobuf的官网里看看再做)

基于生成的arith.pb.go代码我们来实现一个rpc服务端

package main

import (
    "errors"
    "test/rpc/pb"
)

// 算术运算结构体
type Arith struct {
}

// 乘法运算方法
func (this *Arith) Multiply(req *pb.ArithRequest, res *pb.ArithResponse) error {
    res.Pro = req.GetA() * req.GetB()
    return nil
}

// 除法运算方法
func (this *Arith) Divide(req *pb.ArithRequest, res *pb.ArithResponse) error {
    if req.GetB() == 0 {
        return errors.New("divide by zero")
    }
    res.Quo = req.GetA() / req.GetB()
    res.Rem = req.GetA() % req.GetB()
    return nil
}

func main() {
    pb.ListenAndServeArithService("tcp", "127.0.0.1:8097", new(Arith))
}

运行上述程序,将会监听本地的8097端口并接收客户端的tcp连接。

基于ariti.pb.go再来实现一个客户端程序。

package main

import (
    "fmt"
    "log"
    "test/rpc/pb"
)

func main() {
    conn, err := pb.DialArithService("tcp", "127.0.0.1:8097")
    if err != nil {
        log.Fatalln("dailing error: ", err)
    }
    defer conn.Close()

    req := &pb.ArithRequest{9, 2}

    res, err := conn.Multiply(req)
    if err != nil {
        log.Fatalln("arith error: ", err)
    }
    fmt.Printf("%d * %d = %d\n", req.GetA(), req.GetB(), res.GetPro())

    res, err = conn.Divide(req)
    if err != nil {
        log.Fatalln("arith error ", err)
    }
    fmt.Printf("%d / %d, quo is %d, rem is %d\n", req.A, req.B, res.Quo, res.Rem)
}
protobuf

这个就去官网看就可以或者找个演示视频看一遍就会用了。
我这里说一些我的通俗理解:
首先它是一个协议,类似于json,xml。
这里就把protobuf和json对比帮助理解一下
区别:
json可读性非常好,但是有很多的{}这个括号在传输的时候也是要传输过去的,这就造成了数据量大传输托慢。而protobuf则不同,它是使用之前写好.proto文件,文件里写好都有哪些方法,哪些结构体,然后使用protobuf的编辑器编译生成一个.pb.go的文件,这个协议文件里就写好了有哪些结构体,
当程序中利用protobuf去序列化/反序列化的时候,就会根据这个协议文件生成二进制数据,而不是像json的那种可读形式,但是这种方法无疑降低了数据量,传输起来也就更快。

LRU

这里建议看hashicorp公司推出的lru算法,他们基于简单的lru又封装出了三个“升级”版的LRU,分别为加了锁的LRU、ARC、2Q。
在github上的源码:
https://github.com/hashicorp/golang-lru
我这里分别介绍下他们的思想,深层次还是要靠自己看源码去理解。
LRU:
实现思想:把常用的数据放到顶层,不常用的就向下挤,当缓存满了以后就把下面最不常用的数据提出。
这里需要提两个方法:

  1. Get
  2. Peek

这两个方法都是获取值,get获取值以后会把数据移动到顶层,代表它常用,Peek获取值后不更新运输位置。

ARC
实现思想:把有效的缓存队列分成了两个T1,T2,
T1用于保存最近使用的条目,
T2用于保存最常用的条目
同时各自都有一个淘汰队列,分别命名为B1,B2
核心结构:

  • T1 :保存最近使用的条目
  • T2 :保存常用的条目(至少被引用两次的)
  • B1 :保存从T1中淘汰的条目
  • B2 :保存从T2中淘汰的条目
    也就是说:
    执行一次Add(“a”)则保存在T1,第二次执行或者访问这个“a”则把a保存在T2里
    注意被T1,T2淘汰过的数据在添加则直接保存在T2里。

2Q
有两个常量:缓存比率(相对最近一次访问的条目),重影比率

  • recent 保存最近的条目
  • frequest 保存最常用的条目
  • recentEvict 保存最近剔除的条目
ring hash(一致性哈希)

https://blog.csdn.net/kefengwang/article/details/81628977
解释的很详细

singleflight

防止缓存击穿利器
给缓存加一个过期时间,下次未命中缓存时再去从数据源获取结果写入新的缓存,这个是后端开发人员再熟悉不过的基操。
但是需要注意的是缓存击穿问题。

防缓存击穿的方式有很多种,比如通过计划任务来跟新缓存使得从前端过来的所有请求都是从缓存读取等等。groupCache的源码,里面有一个很有意思的库,叫singleFlight, 因为groupCache从节点上获取缓存如果未命中,则会去其他节点寻找,其他节点还没有的话再从数据源获取,所以这个步骤对于防击穿非常有必要。singleFlight使得groupCache在多个并发请求对一个失效的key进行源数据获取时,只让其中一个得到执行,其余阻塞等待到执行的那个请求完成后,将结果传递给阻塞的其他请求达到防止击穿的效果。
SingleFlight 源码剖析
很简短:

package singlefilght

import "sync"

type Group struct {
    mu sync.Mutex
    m map[string]*Call // 对于每一个需要获取的key有一个对应的call
}

// call代表需要被执行的函数
type Call struct {
    wg sync.WaitGroup // 用于阻塞这个调用call的其他请求
    val interface{} // 函数执行后的结果
    err error         // 函数执行后的error
}

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
    }

    // 初始化一个call,往map中写后就解
    c := new(Call)
    c.wg.Add(1)
    g.m[key] = c
    g.mu.Unlock()
    
  // 执行获取key的函数,并将结果赋值给这个Call
    c.val, c.err = fn()
    c.wg.Done()
    
    // 重新上锁删除key
    g.mu.Lock()
    delete(g.m, key)
    g.mu.Unlock()

    return c.val, c.err

}

就这么100行不到的代码就能解决缓存击穿的问题。
至此Groupcache的大概知识点就概括完了。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值