Go 语言编写的缓存及缓存过滤库:groupcache

GROUPCACHE简单介绍

groupcache是使用Go语言编写的缓存及缓存过滤库,作为memcached许多场景下的替代版本。对比原始memcached


首先,groupcache与memcached的相似之处:通过key分片,并且通过key来查询响应的peer。

其次,groupcache与memcached的不同之处:

1.不需要对服务器进行单独的设置,这将大幅度减少部署和配置的工作量。groupcache既是客户端库也是服务器库,并连接到自己的peer上。

2.具有缓存过滤机制。众所周知,在memcached出现“Sorry,cachemiss(缓存丢失)”时,经常会因为不受控制用户数量的请求而导致数据库(或者其它组件)产生“惊群效应(thunderingherd)”;groupcache会协调缓存填充,只会将重复调用中的一个放于缓存,而处理结果将发送给所有相同的调用者。

3.不支持多个版本的值。如果“foo”键对应的值是“bar”,那么键“foo”的值永远都是“bar”。这里既没有缓存的有效期,也没有明确的缓存回收机制,因此同样也没有CAS或者Increment/Decrement。

4.基于上一点的改变,groupcache就具备了自动备份“超热”项进行多重处理,这就避免了memcached中对某些键值过量访问而造成所在机器CPU或者NIC过载。


me:="http://10.0.0.1"
peers:=groupcache.NewHTTPPool(me)
//Wheneverpeerschange:
peers.Set("http://10.0.0.1","http://10.0.0.2","http://10.0.0.3")
varthumbNails=groupcache.NewGroup("thumbnail",64<<20,groupcache.GetterFunc(
func(ctxgroupcache.Context,keystring,destgroupcache.Sink)error{
fileName:=key
dest.SetBytes(generateThumbnail(fileName))
returnnil
}))

vardata[]byte
err:=thumbNails.Get(ctx,"big-file.jpg",
groupcache.AllocatingByteSliceSink(&data))
http.ServeContent(w,r,"big-file-thumb.jpg",modTime,bytes.NewReader(data))


GROUPCACHE简单评论

groupcache是BradFitzpatrick最新的作品(之前最有名的两个作品是memcached与OpenID1),目标在于取代一部分memcached的功能。
以官方的说明是:

groupcacheisacachingandcache-fillinglibrary,intendedasareplacementformemcachedinmanycases.

另外一篇介绍文是「PlayingWithGroupcache」。

跟memcached差异最大的地方在于「没有更改与删除的功能」,一旦写进去后就不会变动。在放弃update/delete的特性后,换来的是:

Cluster的能力。
处理热点的能力。
以往在memcachedserver之间是没有交集的,在groupcache则是cluster起来。另外以前在memcached会因为同时存取同一个key而造成singleCPUoverloading的问题,在groupcache则透过auto-mirror机制解决。

不过还是得想一下要怎么用,毕竟没有update/delete功能…

GroupCache简单使用

packagemain

import(
"fmt"
groupcache"github.com/golang/groupcache"
"log"
"net/http"
"os"
"strings"
)

funcmain(){
//Usage:./test_groupcacheport
me:=":"+os.Args[1]
peers:=groupcache.NewHTTPPool("http://localhost"+me)
peers.Set("http://localhost:8081","http://localhost:8082","http://localhost:8083")

helloworld:=groupcache.NewGroup("helloworld",1024*1024*1024*16,groupcache.GetterFunc(
func(ctxgroupcache.Context,keystring,destgroupcache.Sink)error{
log.Println(me)
dest.SetString(me)
returnnil
}))

fmt.Println("GroupName:",helloworld.Name())
http.HandleFunc("/xbox/",func(whttp.ResponseWriter,r*http.Request){
parts:=strings.SplitN(r.URL.Path[len("/xbox/"):],"/",1)
iflen(parts)!=1{
http.Error(w,"BadRequest",http.StatusBadRequest)
return
}
vardata[]byte
helloworld.Get(nil,parts[0],groupcache.AllocatingByteSliceSink(&data))
w.Write(data)
log.Println("Gets:",helloworld.Stats.Gets.String())
log.Println("Load:",helloworld.Stats.Loads.String())
log.Println("LocalLoad:",helloworld.Stats.LocalLoads.String())
log.Println("PeerError:",helloworld.Stats.PeerErrors.String())
log.Println("PeerLoad:",helloworld.Stats.PeerLoads.String())
})

http.ListenAndServe(me,nil)
}

GroupCache源码分析

概述

GitHub:https://github.com/golang/groupcache.git
memcached作者BradFitzpatrick用Go语言实现
分布式缓存库
数据无版本概念,也就是一个key对应的value是不变的,并没有update
节点之间可互访,自动复制热点数据
实现
LRU

typeKeyinterface{}

typeentrystruct{
keyKey
valueinterface{}
}

typeCacheStruct{
MaxEntriesint
OnEvictedfunc(keyKey,valueinterface{})
ll*list.List
cachemap[interface{}]*list.Element
}
不懂Go语言或者对Go语言不熟悉的同学,可以和我一起来顺带学习Go语言首先是Go语言将变量的类型放置于变量名之后上面的key变量,
它的类型是Key,然后需要说明的是interface{},在Go中它可以指向任意对象,也就任意对象都是先这个接口,你可以把它看成Java/C#中的
Object,C/C++中的void*,然后我们来看Cache,其一个字段MaxEntires表示Cache中最大可以有的KV数,OnEvicted是一个回调函数,
在触发淘汰时调用,ll是一个链表,cache是一个map,key为interface{},value为list.Element其指向的类型是entry类型,通过数据
结构其实我们已经能够猜出LRU的实现了,没有错实现就是最基本的,Get一次放到list头,每次Add的时候判断是否满,满则淘汰掉list尾的数据

//创建一个Cache
funcNew(maxEntriesint)*Cache
//向Cache中插入一个KV
func(c*Cache)Add(keyKey,valueinterface{})
//从Cache中获取一个key对应的value
func(c*Cache)Get(keyKey)(valueinterface{},okbool)
//从Cache中删除一个key
func(c*Cache)Remove(keyKey)
//从Cache中删除最久未被访问的数据
func(c*Cache)RemoveOldest()
//获取当前Cache中的元素个数
func(c*Cache)Len()
这里我们看到了Go中如何实现一个类的成员方法,在func之后加类,这种写法和Go语言很意思的一个东西有关系,在Go中接口的实现并不是和Java中那样
子,而是只要某个类只要实现了某个接口的所有方法,即可认为该类实现了该接口,类似的比如说在java中有2个接口名字不同,即使方法相同也是不一样
的,而Go里面认为这是一样的。另外Go中开头字母大写的变量名,类名,方法名表示对外可知,小写开头的表示对外不暴露。另外类实这种代码
ele.Value.(*entry).value,其中(*entry)表示将Value转成*entry类型访问

Singleflight

这个包主要实现了一个可合并操作的接口,代码也没几行

//回调函数接口
typecallstruct{
//可以将其认为这是类似java的CountdownLatch的东西
wgsync.WaitGroup
//回调函数
valinterface{}
//error
errerror
}

//注意这个Group是singleflight下的
typeGroupstruct{
//保护m的锁
musync.Mutex//protectsm
//key/call映射表
mmap[string]*call//lazilyinitialized
}
另外有一点要说明的是Go使用开头字母大小写判断是否外部可见,大写外部可见,小写外部不可见,比如上面的Group在外部实用singleflght包
是可以访问到的,而call不能,真不理解为啥不用个export啥的关键字,唉

//这个就是这个包的主要接口,用于向其他节点发送查询请求时,合并相同key的请求,减少热点可能带来的麻烦
//比如说我请求key="123"的数据,在没有返回的时候又有很多相同key的请求,而此时后面的没有必要发,只要
//等待第一次返回的结果即可
func(g*Group)Do(keystring,fnfunc()(interface{},error))(interface{},error){
//首先获取group锁
g.mu.Lock()
//映射表不存在就创建1个
ifg.m==nil{
g.m=make(map[string]*call)
}
//判断要查询某个key的请求是否已经在处理了
ifc,ok:=g.m[key];ok{
//已经在处理了就释放group锁并wait在wg上,我们看到wg加1是在某个时间段内第一次请求时加的
//并且在完成fn操作返回后会wgdone,那时wait等待就会返回,直接返回第一次请求的结果
g.mu.Unlock()
c.wg.Wait()
returnc.val,c.err
}
//创建回调,wg加1,把回调存到m中表示已经有在请求了,释放锁
c:=new(call)
c.wg.Add(1)
g.m[key]=c
g.mu.Unlock()

//执行fn,释放wg
c.val,c.err=fn()
c.wg.Done()

//加锁将请求从m中删除,表示请求已经做好了
g.mu.Lock()
delete(g.m,key)
g.mu.Unlock()

returnc.val,c.err
}
ByteView

一个不可变byte视图,其实就包装了一下byte数组和string,一个为null,就用另外一个

typeByteViewstruct{
//Ifbisnon-nil,bisused,elsesisused.
b[]byte
sstring
}
提供的接口也很简单:

//返回长度
func(vByteView)Len()int
//按[]byte返回一个拷贝
func(vByteView)ByteSlice()[]byte
//按string返回一个拷贝
func(vByteView)String()string{
//返回第i个byte
func(vByteView)At(iint)byte
//返回ByteView的某个片断,不拷贝
func(vByteView)Slice(from,toint)ByteView
//返回ByteView的从某个位置开始的片断,不拷贝
func(vByteView)SliceFrom(fromint)ByteView
//将ByteView按[]byte拷贝出来
func(vByteView)Copy(dest[]byte)int
//判断2个ByteView是否相等
func(vByteView)Equal(b2ByteView)bool
//判断ByteView是否和string相等
func(vByteView)EqualString(sstring)bool
//判断ByteView是否和[]byte相等
func(vByteView)EqualBytes(b2[]byte)bool
//对ByteView创建一个io.ReadSeeker
func(vByteView)Reader()io.ReadSeeker
//读取从off开始的后面的数据,其实下面调用的SliceFrom,这是封装成了io.Reader的一个ReadAt方法的形式
func(vByteView)ReadAt(p[]byte,offint64)(nint,errerror){
Sink

不太好表达这个是较个啥,举个例子,比如说我调用一个方法返回需要是一个[]byte,而方法的实现我并不知道,可以它内部产生的是一个string,
方法也不知道调用的它的实需要是啥,于是我们就可以在调用方这边使用这边使用继承了Sink的类,而方法内部只要调用Sink的方法进行传值

typeSinkinterface{
//SetStringsetsthevaluetos.
SetString(sstring)error

//SetBytessetsthevaluetothecontentsofv.
//Thecallerretainsownershipofv.
SetBytes(v[]byte)error

//SetProtosetsthevaluetotheencodedversionofm.
//Thecallerretainsownershipofm.
SetProto(mproto.Message)error

//viewreturnsafrozenviewofthebytesforcaching.
view()(ByteView,error)
}

//使用ByteView设置Sink,间接调用SetString,SetBytes等
funcsetSinkView(sSink,vByteView)error
这里要说明一点,Go语言比较有意思的是,类是否实现了某个接口是外挂式的,也就是不要去再类定义的时候写类似implenents之类的东西,只要类方法
中包括了所有接口的方法,就说明这个类是实现了某个接口

这里实现Sink的有5个类:

stringSink

返回值是string的Sink

typestringSinkstruct{
sp*string
vByteView
}

//创建一个stringSink,传入的是一个string的指针
funcStringSink(sp*string)Sink{
return&stringSink{sp:sp}
}
byteViewSink

返回值是byteView的Sink

typebyteViewSinkstruct{
dst*ByteView
}

//创建一个byteViewSink,传入的是一个ByteView指针
funcByteViewSink(dst*ByteView)Sink{
ifdst==nil{
panic("nildst")
}
return&byteViewSink{dst:dst}
}
protoSink

返回值是一个protobuffermessage

typeprotoSinkstruct{
dstproto.Message//authorativevalue
typstring

vByteView//encoded
}

//创建一个protoSink,传入的是一个proto.Messages
funcProtoSink(mproto.Message)Sink{
return&protoSink{
dst:m,
}
}
allocBytesSink

返回值是[]byte的Sink,每次Set都会重新分配内存

typeallocBytesSinkstruct{
dst*[]byte
vByteView
}

//创建一个allocBytesSink,传入的是一个数组分片指针
funcAllocatingByteSliceSink(dst*[]byte)Sink{
return&allocBytesSink{dst:dst}
}
truncBytesSink

返回值是一个定长的[]byte的Sink,超过长度的会被截断,服用传入的空间

typetruncBytesSinkstruct{
dst*[]byte
vByteView
}

//创建一个truncBytesSink,传入的实一个数组分片指针
funcTruncatingByteSliceSink(dst*[]byte)Sink{
return&truncBytesSink{dst:dst}
}
另外这些Sink类,都是包外不可见的,但是创建函数和Sink接口可见的,这样子在对于使用上来说,只有接口操作不会有另外的东西,对外简单清晰

HTTPPool

一个HTTPPool提供了groupcache节点之间的方式以及节点的选择的实现,但是其对于groupcache.Group是透明的,Group使用的HTTPPool
实现的1个接口PeerPicker以及httpGetter实现的接口ProtoGetter

//groupcache提供了一个节点互相访问访问的类
typehttpGetterstruct{
transportfunc(Context)http.RoundTripper
baseURLstring
}

//协议为GEThttp://example.com/groupname/key
//response见groupcache.proto,含有2个可选项分别为[]byte和double
//实现默认使用go自带的net/http包直接发送请求
func(h*httpGetter)Get(contextContext,in*pb.GetRequest,out*pb.GetResponse)error
HttpPool实现比较简单都是直接使用Go内部自带的一些包

//创建一个HttpPool,只能被使用一次,主要是注册PeerPicker,以及初始化http服务
funcNewHTTPPool(selfstring)*HTTPPool

//设置groupcache集群的节点列表
func(p*HTTPPool)Set(peers...string)

//提供按key选取节点,按key作hash,但是这段代码在OS为32bit是存在bug,如果算出来的hashcode正好是-1*2^31时
//会导致outofrange,为啥会有这个bug看看代码你就会发现了,作者忘了-1*2^31<=int32<=1*2^31-1
func(p*HTTPPool)PickPeer(keystring)(ProtoGetter,bool)

//http服务处理函数,主要是按http://example.com/groupname/key解析请求,调用group.Get,按协议返回请求
func(p*HTTPPool)ServeHTTP(whttp.ResponseWriter,r*http.Request)
Cache

Cache的实现很简单基本可以认为就是直接在LRU上面包了一层,加上了统计信息,锁,以及大小限制

typecachestruct{
musync.RWMutex
nbytesint64//ofallkeysandvalues
lru*lru.Cache
nhit,ngetint64
nevictint64//numberofevictions
}

//返回统计信息,加读锁
func(c*cache)stats()CacheStats
//加入一个kv,如果cache的大小超过nbytes了,就淘汰
func(c*cache)add(keystring,valueByteView)
//根据key返回value
func(c*cache)get(keystring)(valueByteView,okbool)
//删除最久没被访问的数据
func(c*cache)removeOldest()
//获取当前cache的大小
func(c*cache)bytes()int64
//获取当前cache中kv的个数,内部会加读锁
func(c*cache)items()int64
//获取当前cache中kv的个数,函数名中的Locked的意思是调用方已经加锁,比如上面的stats方法
func(c*cache)itemsLocked()int64
Group

该类是GroupCache的核心类,所有的其他包都是为该类服务的

typeGroupstruct{
//group名,可以理解为namespace的名字,group其实就是一个namespace
namestring
//由调用方传入的回调,用于groupcache访问后端数据
//typeGetterinterface{
//Get(ctxContext,keystring,destSink)error
//}
getterGetter
//通过该对象可以达到只调用一次
peersOncesync.Once
//用于访问groupcache中其他节点的接口,比如上面的HTTPPool实现了该接口
peersPeerPicker
//cache的总大小
cacheBytesint64

//从本节点指向向后端获取到的数据存在在cache中
mainCachecache

//从groupcache中其他节点上获取到的数据存在在该cache中,因为是用其他节点获取到的,也就是说这个数据存在多份,也就是所谓的hot
hotCachecache
//用于向groupcache中其他节点访问时合并请求
loadGroupsingleflight.Group

//统计信息
StatsStats
}
由上面的结构体我们可以看出来,groupcache支持namespace概念,不同的namespace有自己的配额以及cache,不同group之间cache是独立的
也就是不能存在某个group的行为影响到另外一个namespace的情况groupcache的每个节点的cache分为2层,由本节点直接访问后端的
存在maincache,其他存在在hotcache

//根据name从全局变量groups(一个string/group的map)查找对于group
funcGetGroup(namestring)*Group
//创建一个新的group,如果已经存在该name的group将会抛出一个异常,go里面叫panic
funcNewGroup(namestring,cacheBytesint64,getterGetter)*Group
//注册一个创建新group的钩子,比如将group打印出来等,全局只能注册一次,多次注册会触发panic
funcRegisterNewGroupHook(fnfunc(*Group))
//注册一个服务,该服务在NewGroup开始时被调用,并且只被调用一次
funcRegisterServerStart(fnfunc())
下面重点介绍Get方法

func(g*Group)Get(ctxContext,keystring,destSink)error
整个groupcache核心方法就这么一个,我们来看一下该方法是怎么运作的

func(g*Group)Get(ctxContext,keystring,destSink)error{
//初始化peers,全局初始化一次
g.peersOnce.Do(g.initPeers)
//统计信息gets增1
g.Stats.Gets.Add(1)
ifdest==nil{
returnerrors.New("groupcache:nildestSink")
}
//查找本地是否存在该key,先查maincache,再查hotcache
value,cacheHit:=g.lookupCache(key)

//如果查到九hit增1,并返回
ifcacheHit{
g.Stats.CacheHits.Add(1)
returnsetSinkView(dest,value)
}

//加载该key到本地cache中,其中如果是本地直接请求后端得到的数据,并且是同一个时间段里第一个,
//就不需要重新setSinkView了,在load中已经设置过了,destPopulated这个参数以来底的实现
destPopulated:=false
value,destPopulated,err:=g.load(ctx,key,dest)
iferr!=nil{
returnerr
}
ifdestPopulated{
returnnil
}
returnsetSinkView(dest,value)
}

func(g*Group)load(ctxContext,keystring,destSink)(valueByteView,destPopulatedbool,errerror){
//统计loads增1
g.Stats.Loads.Add(1)
//使用singleflight来达到合并请求
viewi,err:=g.loadGroup.Do(key,func()(interface{},error){
//统计信息真正发送请求的次数增1
g.Stats.LoadsDeduped.Add(1)
varvalueByteView
varerrerror
//选取向哪个节点发送请求,比如HTTPPool中的PickPeer实现
ifpeer,ok:=g.peers.PickPeer(key);ok{
//从groupcache中其他节点获取数据,并将数据存入hotcache
value,err=g.getFromPeer(ctx,peer,key)
iferr==nil{
g.Stats.PeerLoads.Add(1)
returnvalue,nil
}
g.Stats.PeerErrors.Add(1)
}
//如果选取的节点就是本节点或从其他节点获取失败,则由本节点去获取数据,也就是调用getter接口的Get方法
//另外我们看到这里dest已经被赋值了,所以有destPopulated来表示已经赋值过不需要再赋值
value,err=g.getLocally(ctx,key,dest)
iferr!=nil{
g.Stats.LocalLoadErrs.Add(1)
returnnil,err
}
g.Stats.LocalLoads.Add(1)
destPopulated=true
//将获取的数据放入maincache
g.populateCache(key,value,&g.mainCache)
returnvalue,nil
})

//如果成功则返回
iferr==nil{
value=viewi.(ByteView)
}
return
}
这里需要说明的是A向groupcache中其他节点B发送请求,此时B是调用Get方法,然后如果本地不存在则也会走load,但是不同的是
PickPeer会发现是本身节点(HTTPPOOL的实现),然后就会走getLocally,会将数据在B的maincache中填充一份,也就是说如果
是向groupcache中其他节点发请求的,会一下子在groupcache集群内存2分数据,一份在B的maincache里,一份在A的hotcache中,
这也就达到了自动复制,越是热点的数据越是在集群内份数多,也就达到了解决热点数据的问题

总结
按Groupcache项目页面上的宣称的替代Memcached的大多数应用场景表示不大现实,GroupCache在我看来基本上只能适用于静态数据,比如下载服务中的文件缓存等
代码比较简单,以业务为目标值得学习
学习go语言不错的材料(PS:我也是刚学习go,边看代码边查语法,感觉一下子能明白不少)

郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。