本文转载自微信公众号《Golang梦工厂》,作者AsongGo。转载本文请联系Golang梦工厂公众号。前言大家好,我是asong。最近想写一个localcache来练练手。工作这么久,看到很多同事实现的本地缓存。他们都有自己的长处。我也在思考如何实现一个高性能的本地缓存。理解实现本地缓存的一个版本,欢迎大家提出宝贵意见,我会根据意见不断完善。本文主要介绍设计本地缓存时应该考虑的问题。后续文章是为了实现。欢迎关注本系列。为什么要有本地缓存??随着互联网的普及,用户量和访问量都在增加,这就要求我们的应用支持更多的并发,比如某宝的首页流量,大量用户进入首页同时,对于我们应用服务器和数据库服务器造成的计算也是巨大的。数据库访问本身占用数据库连接,造成巨大的网络开销。面对如此高的并发,数据库就会面临瓶颈。这时候就要考虑加缓存了。缓存分为分布式缓存和本地缓存。在大多数场景下,我们可以使用分布式缓存来满足需求。分布式缓存的访问速度也很快,但是数据需要跨网络传输。在面对首页高并发的情况下,对性能的要求非常高,一点性能优化的空间都不能放过。这时候我们可以选择使用本地缓存来提升性能。本地缓存不需要通过网络传输,应用和缓存都在同一个进程中,请求速度快,适用于首页等数据更新频率不高的业务场景。综上所述,我们使用本地缓存后的系统架构是这样的:本地缓存虽然带来了性能优化,但也有一些缺点。缓存与应用耦合,多个应用不能直接共享缓存。应用程序或集群的每个节点都需要维护自己独立的缓存,这是一种内存浪费。使用缓存的是我们的程序员。我们需要根据数据类型和业务场景,准确地确定使用哪种类型的缓存,以及如何使用。使用这种缓存能够以最小的成本和最快的效率达到最优的目的。思考:如何实现一个高性能的本地缓存数据结构第一步是考虑数据应该如何存储;数据查找效率一定要高,首先我们想到哈希表,哈希表查找效率高,时间复杂度为O(1),可以满足我们的需求;确定用什么结构来存储,然后就要考虑用什么类型的存储,因为不同的业务场景使用不同的数据类型,为了通用,在java中我们可以使用泛型,Go语言中暂时没有泛型,我们可以改用接口类型,把解析出来的数据留给程序员去断言,这样增强了可扩展性,也增加了一些风险。总结:数据结构:哈希表;key:字符串类型;值:接口类型;并发安全的本地缓存应用肯定会面临并发读写的场景,这是必须要考虑的并发安全问题。因为我们选择了hash结构,Go语言主要提供了两种hash,一种是非线程安全的map,一种是线程安全的sync.map。为了方便,我们可以直接选择sync.map,或者考虑使用map+sync.RWMutex的组合,自己实现线程安全。网上有人比较这两种方法。当读操作远多于写操作时,使用sync.map的性能远高于map+sync.RWMutex的组合。本地缓存中的读操作远高于写操作,但是我们的本地缓存不仅支持在存储数据时使用锁,而且在执行过期、清空等操作时也需要加锁,所以使用map的方法+sync.RWMutex比较Flexible,所以这里选择这种方式保证并发安全。高性能的并发访问锁可以保证数据读写的安全,但是会增加锁的竞争。本地缓存本来就是为了提升性能而设计的,不能作为性能瓶颈。因此,我们需要优化锁竞争。对于本地缓存的应用场景,我们可以根据key进行分桶处理,减少锁竞争。我们的key都是string类型,所以我们可以使用djb2hash算法将key打散分桶,然后对每个桶加锁,也就是锁精化,减少竞争。对象上限由于本地缓存是存储在内存中的,内??存是有限的,我们不能无限存储,所以我们可以根据自己的具体应用场景来指定缓存对象的个数和预估上限。默认情况下,我们选择的缓存数量为1024。淘汰策略因为我们会设置缓存对象的数量,当触发上限时,我们可以使用淘汰策略来淘汰它们。常见的缓存淘汰算法有:LFULFU(LeastFrequentlyUsed),这是最近不太常用的一种算法,根据数据的历史访问频率来淘汰数据,这个算法的核心思想是,最近不常使用的,大概率不会再使用,使用频率最低的数据会被替换掉。存在的问题:有些数据在短时间内被频繁访问,之后很长一段时间都不会再被访问,因为之前的访问频率急剧上升,所以之后短时间内不会被淘汰,占据队列最前面的位置会导致更频繁使用的块更容易被清除,刚进入缓存的新数据也可能被快速删除。LRULRU(LeastRecentlyUser)是最近最少使用算法,根据数据的历史访问记录剔除数据。这个算法的核心思想是最近使用过的数据有很大概率会再次被使用,而一段时间没有被使用过的数据有很大概率不会再被使用,并且有最长时间未访问的数据替换问题:当客户端访问大量历史数据时,缓存中的数据可能会被历史数据替换,降低缓存命中率。FIFOFIFO(FirstinFirstout)是一种先进先出的算法。这个算法的核心思想是最近刚访问过,以后访问的可能性比较大。先进入缓存的数据先被淘汰。存在问题:该算法采用绝对公平的方式进行数据替换,容易出现页面错误。TwoQueuesTwoQueues是FIFO+LRU的组合。其核心思想是第一次访问数据时将数据缓存在FIFO队列中,第二次访问数据时将数据从FIFO队列中移到LRU队列中。两个队列都以自己的方式逐出数据。存在问题:该算法与LRU-2一致,适应性较差。LRU存储的数据需要大量访问才能清除历史记录。ARUARU(AdaptiveReplacementCache)是一种自适应缓存替换算法。它是LFU和LRU算法的结合。其核心思想是根据被淘汰数据的访问情况,增加对应的LRU或LFU链表的大小。ARU主要包括四个Linkedlist,LRU和LRUGhost,LFU和LFUGhost,Ghost链表是对应淘汰的数据记录的链表,不记录数据,只记录ID等信息。截图2021-12-046.52.05pm当数据被访问时,它会被添加到LRU队列中。如果再次访问该数据,则同时放入LFU链表;如果在LRU队列中淘汰数据,那么数据会进入LRUGhost队列,如果后面再次访问数据,增加LRU队列的大小,同时减少LFU队列的大小.有个问题:因为要维护四个队列,所以会占用较多的内存空间。每种算法都有自己的特点。结合我们本地缓存的场景,选择ARU算法作为缓存淘汰策略是一个不错的选择。LRU和LFU的大小可以动态调整以适应当前最佳缓存命中。.除了使用缓存淘汰策略清除数据外,还可以加一个过期时间进行双重保证,防止不常访问的数据一直占用内存。有两种方式:数据过期直接删除,数据过期不删除。两种异步更新数据的方式各有优缺点。异步更新数据需要选择特定的业务场景。为了迎合大部分业务,我们采用数据过期直接删除的方式。第一种方法比较友好。这里我们在获取数据的时候使用懒加载来判断数据是否过期,设置定时任务每天定时删除过期数据。缓存监控很多人也忽略了缓存的监控。基本上,如果写入后没有报错,就会认为已经生效了。这使得无法感知缓存是否在工作。因此,监控缓存的各项指标也很重要。通过使用不同的索引数据,我们可以优化缓存的参数,从而达到缓存优化的目的。如果是企业应用,我们可以使用Prometheus进行监控和上报。我们可以简单的写一个小组件自测,定时打印缓存数量,缓存命中率等指标。GC调优对于大量使用本地缓存的应用程序,由于缓存消除,GC问题一定很常见。如果GC很多,STW时间长,肯定会影响服务的可用性;对于此事,一般是个案分析。本地缓存上线后,记得经常查看GC监控。缓存穿透在使用缓存的时候一定要考虑缓存穿透的问题,但是这个一般不会在本地缓存中实现,基本都是交给用户去实现。当在缓存中找不到该元素时,它会在缓存键上设置锁;其他线程会等待这个元素被填充,而不是去访问数据库(外部使用singleflight将其包裹起来)。参考文章https://zhuanlan.zhihu.com/p/352910565https://cloud.tencent.com/developer/article/1676115https://tech.meituan.com/2017/03/17/cache-about.htmlhttps://www.cnblogs.com/vancasola/p/9951686.html总结真正设计出一个高性能的本地缓存并不容易。由于本人也是孤陋寡闻,本文的设计思路也是个人的实践想法。欢迎大家提出宝贵意见,让我们一起做一个真正的高性能本地缓存。下一篇我会分享一个自己写的本地缓存,敬请期待!!!【小编推荐】如何在Kubernetes上部署MySQL数据库鸿蒙轻量级数据库DatabaseHelper基本用法与技巧Oracle数据库巡查命令手册鸿蒙轻量级数据库DatabaseHelper基本用法移动云文档数据库:企业高效数据管理利器!
