当前位置: 首页 > 科技观察

动手实现一个Localcache-Implementation

时间:2023-03-16 18:24:46 科技观察

前言大家好,我是asong。经过前面两篇文章的介绍,我们已经基本了解了如何设计本地缓存。本文是本系列的完结篇。自己动手实现一个本地缓存,然后详细听我说!!!本文代码已经上传到github:https://github.com/asong2020/go-localcache现在这个版本算是1.0了,后续会继续优化迭代。第一步:抽象接口第一步非常重要。基于面向接口编程的原则,我们首先将要暴露给用户的方法进行抽象,为用户提供简单易懂的方法。因此,我抽象出来的结果如下://ICacheabstractinterfacetypeICacheinterface{//Setvalueusedefaultexpiretime.defaultdoesnotexpire.Set(keystring,value[]byte)error//Getvalueiffindit.ifvaluealreadyexpirewilldelete.Get(keystring)([]byte,error)//SetWithTimesetvaluewithexpiretimeSetWithTime(keystring,value[]byte,expiredtime.Duration)error//DeletemanualremovesthekeyDelete(keystring)error//LencomputesnumberofentriesincacheLen()int//Capacityreturnsamountofbytesstoreinthecache.Capacity()int//Closeisusedtosignalashutdownofthecachewhenyouaredonewithit.//Thisallowsthecleaninggoroutinestoexitandensuresreferencesarenot//kepttothecachepreventingGCoftheentirecache.Close()error//Statsreturnscache'sstatisticsStats()Stats//GetKeyHitreturnskeyhitGetKeyHit(keystring)int64}Set(keystring,value[]byte):使用该方法存储的数据使用默认的过期时间。如果不开启清除过期异步任务,则永不过期,否则默认过期时间为10分钟。Get(keystring)([]byte,error):根据key获取对象内容。如果数据过期,将在此步骤中删除。SetWithTime(keystring,value[]byte,expiredtime.Duration):使用自定义的过期时间存储对象Delete(keystring)error:根据key删除对应的缓存数据Len()int:获取缓存对象的个数Capacity()int:获取当前缓存容量Close()error:关闭缓存Stats()Stats:缓存监控数据GetKeyHit(keystring)int64:获取key的命中率数据Step2:定义缓存对象第一个stepisabstracted接口,下面就要定义一个缓存对象实例实现接口,先看定义结构:typecachestruct{//hashFuncrepresentsusedhashfunchashFuncHashFunc//bucketCountrepresentsthenumberofsegmentswithinacacheinstance.valuemustbeapoweroftwo.bucketCountuint64//bucketMaskisbitwiseANDappliedtothehashValtofindthesegmentid.bucketMaskuint64//segmentisshardsegments[]*segment//segmentlocklocks[]sync.RWMutex//closecacheclosechanstruct{}}hashFunc:用于分片的hash函数,用户可以自己定义,实现HashFunc接口即可,使用fnv算法默认情况下。bucketCount:分片数量,必须为偶数。默认分片数为256。bucketMask:因为分片数为偶数,所以在可以分片的情况下可以使用位运算代替取余来提高性能效率。hashValue%bucketCount==hashValue&bucketCount-1.segments:Segment对象,每个segment的对象结构后面会介绍。locks:每个分片的读写锁close:缓存对象关闭时通知其他goroutine挂起接下来我们来写缓存对象的构造函数://NewCacheconstructorcacheinstancefuncNewCache(opts...Opt)(ICache,error){options:=&options{hashFunc:NewDefaultHashFunc(),bucketCount:defaultBucketCount,maxBytes:defaultMaxBytes,cleanTime:defaultCleanTIme,statsEnabled:defaultStatsEnabled,cleanupEnabled:defaultCleanupEnabled,}for_,each:=rangeopts{each(bucket.options)}if!isPowerOf返回nil,errShardCount}ifoptions.maxBytes<=0{returnnil,ErrBytes}segments:=make([]*segment,options.bucketCount)locks:=make([]sync.RWMutex,options.bucketCount)maxSegmentBytes:=(options.maxBytes+options.bucketCount-1)/options.bucketCountforindex:=rangesegments{segments[index]=newSegment(maxSegmentBytes,options.statsEnabled)}c:=&cache{hashFunc:options.hashFunc,bucketCount:options.bucketCount,bucketMask:options.bucketCount-1,segments:segments,locks:locks,close:make(chanstruct{}),}ifoptions.cleanupEnabled{goc.cleanup(options.cleanTime)}returnc,nil}这里,为了更好的扩展,我们使用Options编程方式,我们的构造函数主要做了三件事:前置参数检查,对于从外部传入的参数,我们仍然需要做基本的验证Fragment缓存对象的对象初始化和构造这里,在构造缓存对象时,我们需要先计算每个分片的容量。默认情况下,整个本地缓存是256M的数据,然后平均分成各个区域。用户可以选择要缓存的数据的大小。Step3:定义分片结构每个分片结构如下:typesegmentstruct{hashmapmap[uint64]uint32entriesbuffer.IBufferclockclockevictList*list.ListstatsIStats}hashmp:存储keyentries对应的存储索引:存储key/value的底层结构,我们在第四步中介绍过,也是代码的核心部分。clock:定义时间方法evicList:这里用一个队列来记录旧索引,当容量不足时删除(临时解决,当前存储结构不适合使用LRU淘汰算法)stats:缓存的监控数据.接下来我们看一下每个切片的构造函数:"toobigbytes=%d;shouldbesmallerthan%d",bytes,maxSegmentSize))}capacity:=(bytes+segmentSize-1)/segmentSizeentries:=buffer.NewBuffer(int(capacity))entries.Reset()return&segment{entries:条目,hashmap:make(map[uint64]uint32),clock:&systemClock{},evictList:list.New(),stats:newStats(statsEnabled),}}这里主要需要注意的是:我们需要根据缓存数据每个区域的大小用于计算容量,对应上面的缓存对象初始化步骤。第四步:定义缓存结构缓存对象构建完成,接下来就是本地缓存的核心:定义缓存结构。Bigcache、fastcache、freecache都是使用字节数组而不是map来存储缓存数据,从而减轻GC压力,所以我们也可以借鉴他们的思路,继续使用字节数组。这里我们使用二维字节切片来存储缓存数据key/value;画个图说明一下:使用二维数组存储数据相比bigcache的优势在于可以直接根据索引删除对应的数据。虽然会有虫洞的问题,但是我们可以记录虫洞的索引,不断地填充。.各个缓存的封装结构如下:基本思路清楚了,再来看看我们对存储层的封装:缓冲区满后记录idleindex//避免array"wormhole"availableSpacemap[int]struct{}//placeholderrecordtheindexthatbufferhasstored.placeholdermap[int]struct{}}array[][]byte:存储二维切片cacheobjectcapacity:缓存结构体的最大容量index:索引,记录缓存的位置indexcount:记录缓存可用的个数Space:记录“虫洞”,当缓存对象被删除时,记录缓存的索引免费位置,方便容量满后使用“虫洞”。占位符:记录缓存对象的索引,迭代清除过期缓存即可使用。向缓冲区写入数据的过程(代码就不贴了):第五步:改进向缓冲区写入数据的方法上面我们定义了所有需要的结构体,接下来就是填充我们的写入缓冲区方法:func(c*cache)Set(keystring,value[]byte)error{hashKey:=c.hashFunc.Sum64(key)bucketIndex:=hashKey&c.bucketMaskc.locks[bucketIndex].Lock()deferc.locks[bucketIndex].解锁()err:=c.segments[bucketIndex].set(key,hashKey,value,defaultExpireTime)returnerr}func(s*segment)set(keystring,hashKeyuint64,value[]byte,expireTimetime.Duration)error{ifexpireTime<=0{returnErrExpireTimeInvalid}expireAt:=uint64(s.clock.Epoch(expireTime))ifpreviousIndex,ok:=s.hashmap[hashKey];ok{iferr:=s.entries.Remove(int(previousIndex));错误!=nil{returnerr}delete(s.hashmap,hashKey)}entry:=wrapEntry(expireAt,key,hashKey,value)for{index,err:=s.entries.Push(entry)iferr==nil{s.hashmap[hashKey]=uint32(index)s.evictList.PushFront(index)returnnil}ele:=s.evictList.Back()iferr:=s.entries.Remove(ele.Value.(int));err!=nil{returnerr}s.evictList.Remove(ele)}}过程分析如下:根据key计算hash值,然后根据得到对应的分片位置片段的数量。如果当前缓存中存在相同的key,则先删除,再重新插入。会刷新过期时间包存储结构,根据过期时间戳、key长度、hash大小、缓存对象将数据存入缓存。如果缓存失败,请删除最旧的数据并重试。第六步:改进slave缓存读取数据的方法第一步是根据key计算hash值,然后根据分片数量获取对应的分片位置:func(c*cache)Get(keystring)([]byte,error){hashKey:=c.hashFunc.Sum64(key)bucketIndex:=hashKey&c.bucketMaskc.locks[bucketIndex].RLock()deferc.locks[hashKey&c.bucketMask].RUnlock()入口,err:=c.segments[bucketIndex].get(key,hashKey)iferr!=nil{returnil,err}returnentry,nil}第二步执行分片方法获取缓存数据:首先根据hash值判断缓存中是否存在该key,如果不存在,则返回的key没有找到,从缓存中读取数据获取缓存中的key判断是否发生hash冲突判断缓存对象是否过期,过期后删除缓存数据(可以根据返回当前过期数据到业务优化的需要)在每条记录缓存中监控数据stats.miss()returnnil,ErrEntryNotFound}entry,err:=s.entries.Get(int(index))iferr!=nil{s.stats.miss()returnnil,err}ifentry==nil{s.stats.小姐()返回尼l,ErrEntryNotFound}ifentryKey:=readKeyFromEntry(entry);key!=entryKey{s.stats.collision()returnnil,ErrEntryNotFound}returnentry,nil}func(s*segment)get(keystring,hashKeyuint64)([]byte,error){currentTimestamp:=s.clock.TimeStamp()entry,err:=s.getWarpEntry(key,hashKey)iferr!=nil{returnnil,err}res:=readEntry(entry)expireAt:=int64(readExpireAtFromEntry(entry))ifcurrentTimestamp-expireAt>=0{_=s.entries.Remove(int(s.hashmap[hashKey]))delete(s.hashmap,hashKey)returnnil,ErrEntryNotFound}s.stats.hit(键)returnres,nil}第七步:来个测试用例体验先来个简单的测试用例来测试:func(h*cacheTestSuite)TestSetAndGet(){cache,err:=NewCache()assert.Equal(h.T(),nil,err)key:="asong"value:=[]byte("公众号:GolangDreamWorks")err=cache.Set(key,value)assert.Equal(h.T(),nil,err)res,err:=cache.Get(key)assert.Equal(h.T(),nil,err)assert.Equal(h.T(),value,res)h.T().Logf("getvalueis%s",string(res))}运行结果:===RUNTestCacheTestSuite===RUNTestCacheTestSuite/TestSetAndGetcache_test.go:33:getvalueis公众号:GolangDreamWorks---PASS:TestCacheTestSuite(0.00s)---PASS:TestCacheTestSuite/TestSetAndGet(0.00s)PASS是done,基本功能都搞定了,剩下的就是跑benchmark,优化,迭代(文中不再赘述,大家可以关注github仓库最新动态)参考文章https://github.com/allegro/bigcachehttps://github.com/VictoriaMetrics/fastcachehttps://github.com/coocood/freecachehttps://github.com/patrickmn/go-cache总结实现文章到这里就结束了,但是这个项目的编码还没有结束。我会在这个版本的基础上继续迭代优化。这种本地缓存的优点:实现简单,用户容易理解。使用二维切片作为存储结构,避免了无法删除底层数据的弊端,也在一定程度上避免了“虫洞”问题。测试用例齐全,适合作为小白的入门项目。待优化点:未能使用高效的缓存淘汰算法可能导致热点数据被频繁删除优化处理方式根据业务场景(具体业务场景)进行优化迭代点:增加异步加载缓存功能...(思考)的本文代码已上传至github:https://github.com/asong2020/go-好了localcache,本文到此结束,我是asong,下期见。