缓存是几乎所有程序或产品的必需品,我们在每个角落都能看到它,比如网页、数据库、应用程序等。 它的意义在于它所带来的效率。这就像在你忙于燃烧卡路里的问题时,把你最喜欢的零食放在桌子上,让你快速拿一下。 熟悉和可快速构建一个本地kv缓存对日常开发很重要,所以我将从头开始实现一个健全且可用的本地kv缓存。KVCache的基本元素 首先是实现键值缓存时应考虑的因素:贮存 一般最常用的数据结构是map〔string〕Element{},其中string为key,其中element包含value信息。元素 最简单的元素至少应该包含值和过期时间。并且值类型通常是interface{},可以根据场景换成string、int或者其他特定类型。并发 缓存必须考虑并发访问,除非它是特定于线程的映射。和其他高级语言一样,Go也提供了一个线程安全的映射(sync。map)。 当然,将map与sync。RWMutex组合起来可以实现相同的功能,提供更大的灵活性并帮助更好地理解Go。容量 如果要在KubernetesPod中运行,则需要提前设置缓存大小。 否则,它可能会消耗过多的内存,并导致整个pod因内存限制而被淘汰。删除过期的 及时释放过期的key可以提高缓存利用率,节省开销,提升性能。GC调优 它与Go相关,减少了GC对缓存的影响。而sync。Pool可以用来进一步优化。API设计 Cache有Put、Get、Remove和Flush4个基本功能,并且开放给更多的方法以获得更好的支持。缓存实现 考虑到这些元素,我们现在要设计缓存。 首先是两个简单的步骤。1)定义所有类型和功能。typeCachestruct{defaultExpirationtime。Durationelementsmap〔string〕Elemcapacityint64sizeint64locksync。RWMutexpoolsync。Poolcleanercleaner}typeElemstruct{KstringNeededforthesync。PoolVinterface{}Expirationint64LastHitint64}typecleanerstruct{Intervaltime。Durationstopchanbool}func(cCache)Get(kstring)(vinterface{},errerror){returnnil,nil}func(cCache)Put(kstring,vinterface{})error{returnnil,nil}func(cCache)Remove(kstring)(isFoundbool,errerror){returnfalse,nil}func(cCache)Flush()error{returnnil}Usedincleaningjobfunc(cCache)RemoveExpired(){}Runcleaningjobfunc(clcleaner)Run(cCache){}定义默认参数 在New函数中使用它们。const(DEFAULTEXPIRATION10time。MinuteDEFAULTCLEANDURATION10time。MinuteDEFAULTCAP1024)funcNewCache()(Cache,error){returnnewCache(DEFAULTCAP,DEFAULTEXPIRATION,DEFAULTCLEANDURATION)}funcnewCache(capint64,expirationtime。Duration,cleandurationtime。Duration)(Cache,error){returnCache{defaultExpiration:expiration,elements:make(map〔string〕Elem,cap),capacity:cap,lock:new(sync。RWMutex),cleaner:cleaner{Interval:cleanduration,stop:make(chanbool),},pool:sync。Pool{New:func()interface{}{returnEntry{}},},},nil} 然后是put方法。在为map设置key和val之前,需要做一些额外的工作。获取并设置密钥的过期时间。获取和设置密钥的最后访问时间。 在缓存已满,但无法删除过期key为新key腾出空间的场景下,我们需要引入一些额外的淘汰策略。 LRU,最近最少用户,就是我们在这种情况下应用的:最近没有被访问过的键将被淘汰。但是,需要注意的是,它对历史数据并不友好。 还有一些其他的淘汰政策,例如, LFU,最不常用的,淘汰了低频率访问的键。潜在的问题是,如果密钥在短时间内被频繁访问,它们将在缓存中保留很长时间,尽管以后不会访问。 FIFO,先进先出,最新的保留。其缺点与LFU相同。 ARC,自适应替换缓存。作为LRU的高级版本,它通过与LFU和LRU集成,4个队列,消耗更多内存来实现更高性能的缓存。 此外,还有其他算法,如双队列、MRU等。 最后,我们来看看sync。Pool。只有在立即使用存储的对象时才需要选择加入功能。否则,池中的对象将在Get中起作用之前被频繁替换。但这是我们未来需要改进缓存的功能。 一步一步,Put方法就搞定了。func(cCache)Put(kstring,vinterface{})error{expire:time。Now()。Add(DEFAULTEXPIRATION)。UnixNano()lastHit:time。Now()。UnixNano()ifc。size1c。capacity{LRUkicksiniferr:c。removeLeastVisited();err!nil{returnerr}}c。lock。Lock()deferc。lock。Unlock()ifele,ok:c。elements〔k〕;ok{ele。Vvele。Expirationexpireele。LastHitlastHitreturnnil}ele:Elem{V:v,Expiration:expire,LastHit:lastHit,}c。pool。Put(ele)c。elements〔k〕elec。sizec。size1returnnil} 然后你会发现Getthefunction已经在你的口袋里了:只需从pool和map中获取结果,并更新最后访问时间和过期时间。func(cCache)Get(kstring)(vinterface{},errerror){ele:c。pool。Get()ifitem,ok:ele。(Elem);ok{ifitem。Kk{returnitem。V,nil}}expire:time。Now()。Add(DEFAULTEXPIRATION)。UnixNano()lastHit:time。Now()。UnixNano()c。lock。RLock()deferc。lock。RUnlock()ifele,ok:c。elements〔k〕;ok{ele。Expirationexpireele。LastHitlastHitreturnele。V,nil}returnnil,nil} 接下来要做的就是构建一个自动清理器,通过启动一个goroutine定期清理过期时间小于当前时间的键。Usedincleaningjobfunc(cCache)RemoveExpired(){now:time。Now()。UnixNano()c。lock。Lock()deferc。lock。Unlock()fork,v:rangec。elements{ifv。Expiration0nowv。Expiration{,c。Remove(k)}}}Runcleaningjobfunc(clcleaner)Run(cCache){ticker:time。NewTicker(cl。Interval)for{select{caseticker。C:c。RemoveExpired()casecl。stop:ticker。Stop()return}}}funcstopJanitor(cCache){c。cleaner。stoptrue} 同时,将以下两行添加到前面的New方法中。goc。cleaner。Run(c)runtime。SetFinalizer(c,stopCleaner) 至此,缓存功能基本完成,下面我们测试下性能。性能比较 迫不及待的想把我们自己搭建的缓存和Github上那些完美的缓存做个性能对比,我以gocache和hashicorplrucache为例,写了一个benchmark测试,对比一下访问效率。packagegolanglocalcacheimport(fmttestingtimehashicorpgithub。comhashicorpgolanglrugocachegithub。compatrickmngocache)funcBenchmarkGoCache(btesting。B){c:gocache。New(1time。Minute,5time。Minute)b。Run(Put,func(btesting。B){fori:0;ib。N;i{c。Add(toKey(i),toKey(i),gocache。DefaultExpiration)}})b。Run(Get,func(btesting。B){fori:0;ib。N;i{value,found:c。Get(toKey(i))iffound{value}}})}funcBenchmarkCache(btesting。B){c,:NewCache()b。Run(Put,func(btesting。B){fori:0;ib。N;i{c。Put(toKey(i),toKey(i))}})b。Run(Get,func(btesting。B){fori:0;ib。N;i{value,:c。Get(toKey(i))ifvalue!nil{value}}})}funcBenchmarkHashicorpLRU(btesting。B){c:cache2go。Cache(test)c,:hashicorp。New(1024)b。Run(Put,func(btesting。B){fori:0;ib。N;i{c。Add(toKey(i),toKey(i))}})b。Run(Get,func(btesting。B){fori:0;ib。N;i{value,err:c。Get(toKey(i))iferrtrue{value}}})}functoKey(iint)string{returnfmt。Sprintf(item:d,i)} 结果符合我的预期。我们的缓存比那些成熟的开源缓存慢。但是,当它只花费我们20分钟时,我们还能期待什么。 结果给了我一个提示,hashicorpcache这么快一定是有原因的,以后我们可以在单独讲一下!改进方法 我们的缓存速度较慢,但我们可以做一些事情来加快速度。那怎么办? 最重要的因素是并发和缓存大小,两者相互影响:并发越大,元素越多,内存占用越大,缓存越慢。因此,减少并发是第一要务。 这里我们需要一种着色方法,将一个缓存映射拆分为多个,以降低并发可能性并缩小每个缓存的大小。毫无疑问,散列最常用于阴影,因为哈希结果具有高离散率,即高随机性。 Hash避免产生过多的内存分配,缓解垃圾回收带来的压力。哈希算法非常高效。 可以很容易地得出结论,哈希方法的速度决定了着色算法的效率,因为密钥是通过hash(key)分配给不同的缓存的。 那么问题就在于选择哪种算法。MD5和SHA256是最常见的哈希算法,FNV和DJB2各有优势。如果您在这些选项上苦苦挣扎,请进行基准比较。 此外,添加更多方法,如直接访问字符串和直接存储int,或优化sync。Pool使用也是改进缓存的方法。结束 互联网世界有很多的开源缓存,这使得我们免于自己编写,而且效率更高。但是,当你更了解其中实现原理的时候,开发起来会更加的高效。