详细介绍一下,手写本地缓存实战1——各个击破

张强律师 法律头条 2022-11-13 21:02:07

导读:详细介绍一下,手写本地缓存实战1-各个击破大家好。又见面了。本文是作者作为掘金科技社区签约作者输出的缓存专栏连载内容,将通过一系列专题讲解缓存的方方面面。如果有兴趣,请

详细介绍一下,手写本地缓存实战1-各个击破

大家好。又见面了。

本文是作者作为掘金科技社区签约作者输出的缓存专栏连载内容,将通过一系列专题讲解缓存的方方面面。如果有兴趣,请关注获取后续更新。

通过前两个专栏“对缓存原理的深刻理解和实用设计”,我们介绍了缓存

整体架构

设计规格

,并且还阐述了缓存的共性

典型问题

使用策略

。作为本系列的第三篇文章,我们将在下一个项目中一起讨论local 缓存的各种使用场景和实现策略——通过本文介绍的local 缓存的几种实现策略和关键特性的支持,实现local 缓存的使用和构造要点,这也将作为我们下一篇文章介绍的手写local /[/k2/。

local 缓存的渐进历史

本质上,

缓存

它实际上是数据的集合。

(甚至有的时候,这个集合中只有

任何人

一些缓存计数器等元素)。说白了就是一个容器。

而已。在各个编程语言中,容器类的对象类型都有很多不同种类,这就需要开发人员根据业务场景不同的诉求,选择不同的缓存承载容器,并进行二次加工封装以契合自己的意愿。

让我们看看在实现local 缓存时可能涉及到的一些常见的选择类型。

小众魅力——最简单的系列缓存

目录

或数组

是缓存相对简单的形式,常用于一些黑白名单。

类数据的缓存,业务层面上用于判断某个值是否存在于集合中,然后作出对应的业务处理。

例如,有这样一个场景:

在一个论坛系统中,管理员会把一些违规的用户拉入黑名单,禁止他们发帖。这些列入黑名单的用户ID列表存储在数据库中的一个独立表中。当用户发帖时,后台需要判断用户是否在禁词黑名单中。如果是,则禁止其过账操作。

因为黑名单ID的数量不会很大,为了避免用户每次发帖都要查询一次DB,可以选择黑名单

用户标识

加载到内存中进行缓存,然后在每次发帖时判断是否在黑名单中。实现,您可以简单地使用列表来携带:

公共类user black List cache { private List & lt;字符串& gt黑名单;public boolean in blacklist(String userId){ return black list . contains(userId);} public void addinto black list(String userId){ black list . add(userId);} public void removefromleball(String userId){ black list . remove(userId);}}作为基础

目录

实现缓存的黑名单,提供三个API方法:

接口名称

函数声明

黑名单中

确定用户是否被列入黑名单。

addinto黑名单

将用户添加到黑名单。

移除黑名单

从黑名单中删除用户

或者是listarray的形式,因为数据结构比较简单,没有冗余数据,所以缓存存储时占用的内存比较经济。当然,由于列表和数组的数据结构特点,在实现条件查询操作时,

时间复杂度

会比较高,所以使用场景比较有限。

所有生物-正则键值对缓存

比较

目录

对于这种线性集合容器,在实际项目中,更多的场景会选择使用一些key-value。

格式的映射集(如HashMap

)作为容器——这也是most 缓存最基本的数据结构形式。在业务中,可以将查询条件作为键值,然后将实际内容存储为值,可以实现单个数据条件的高效查询匹配。

或者上面发帖论坛系统的一个场景:

用户登录论坛系统查看帖子列表时,接口需要调用后端提供的帖子列表查询请求。在帖子列表中,会显示每个帖子的发帖人信息。

由于一个帖子的发帖人只存储UID信息,所以需要给界面的是这个UID对应的用户的简要信息,比如头像、昵称、注册年份等。,所以在返回帖子列表之前,需要根据UID查询相应的用户信息,最后返回到前端。

按照上面的要求,如果每次查询完帖子列表后,在DB中逐个查询每个帖子对应的用户信息,必然会导致每个列表接口多次调用DB查询用户。所以如果我们把用户的简要信息映射到缓存,然后每次根据UID直接从缓存查询,这样可以大大简化每次查询操作与DB的交互次数。

使用

散列表

为了构建缓存,我们可以使用UID作为键,UserInfo

作为值,代码如下:

公共类UserCache { private Map & lt字符串,UserInfo & gtuserCache = new HashMap & lt& gt();public void putUser(UserInfo user){ user cache . put(user . getuid(),user);} public UserInfo getUser(String uid){ if(!user cache . contains key(uid)){ throw new runtime exception(& # 34;找不到用户& # 34;);}返回user cache . get(uid);} public void remove user(String uid){ user cache . remove(uid);} public boolean has user(String uid){ return user cache . contains key(uid);}}为了满足业务场景的需要,上面代码实现的缓存提供了几个外部功能接口:

接口名称

函数声明

putUser

将指定的用户信息存储在缓存中

getUser

根据UID获取相应的用户信息数据

移除用户

删除指定UID对应的user 缓存数据

hasUser

判断指定用户是否存在

使用

散列表

Build 缓存,就可以轻松实现O(1)

复杂的数据操作,执行性能可以得到有效保证。这就是为什么HashMap

之所以广泛应用于缓存场景。

容量限制-缓存支持数据删除和容量限制

用类推的方法

散列表

缓存数据的结构是一种简单的缓存实现策略,可以解决很多查询场景的实际需求,但是在使用中,慢慢会出现一些问题。

在线问题定位过程中,经常会遇到一些内存溢出问题,而这些问题很大一部分是由于容器类的无约束使用造成的。

因此,在许多情况下,为了可靠性或业务本身的需求,将需要缓存。

散列表

有一个最大容量限制。

,如支持

LRU战略

,以确保最多只有缓存指定的数据量。

比如上一节,为了提高根据UID查询用户信息的效率,决定将用户信息缓存存储在内存中。但是这样:

论坛的用户数量一直在增加,这样加载到内存中的用户数据量会越来越大,内存占用也会越来越大

无限的

增加,遇到用户井喷式增长,很容易填满内存,造成内存溢出。

问题;

论坛里的用户,其实很多用户注册后都是僵尸,或者近几年没用过系统。这些数据被加载到内存中,业务几乎不会使用它们。

徒然占据记忆

仅仅...

在这种情况下,会涉及到上一篇文章中提到的a 缓存的基本特性——

缓存消除机制

!也就是支持

热点数据

而不是存储全部数据。我们可以修改上一节中实现的缓存,以支持限制缓存的最大数量。如果超过这个数字,最不常用的数据将根据LRU策略被删除。

我们可以根据

LinkedHashMap

去实现。例如,我们可以首先实现一个LRU支持的缓存容器LruHashMap。

,代码示例如下:

公共类LruHashMap & ltk,V & gt扩展LinkedHashMap & ltk,V & gt{ private static final long serial version uid = 1287190405215174569 l;private int maxEntriespublic LruHashMap(int maxEntries,boolean accessOrder) { super(16,0.75f,accessOrder);this . max entries = max entries;}/* * *自定义数据消除的触发条件,每次put操作都会调用该方法来确定*/protected boolean removeeldestentry(map . entry

maxEntities

:此缓存容器中允许存储的最大记录数。

附属订单

:决定数据消除的排序策略。介绍

真实的

表示基于LRU策略排序,false

这意味着根据数据写入顺序进行排序。向缓存写入新数据时,会判断缓存容器中的数据量是否超过

maxEntities

,如果超过,将基于accessOrder

指定的排序规则对数据进行排序,然后删除第一个元素并移出要插入新数据的位置。

我们使用这个改进的缓存容器来重写以前的缓存实现:

公共类UserCache { private Map & lt字符串,UserInfo & gtuserCache = new LruHashMap & lt& gt(10000,真);public void putUser(UserInfo user){ user cache . put(user . getuid(),user);} public userinfo getuser(string uid){//因为是热点缓存而且不是全量,所以缓存中没有数据,所以尽量查询DB加载到内存中(展示代码,忽略异常判断逻辑)如果(!user cache . contains key(uid)){ UserInfo user = query fromdb(uid);putUser(用户);返回用户;}返回user cache . get(uid);}}与直接使用HashMap构建的缓存相比,修改后的缓存增加了一个基于

LRU战略

消除数据的能力可以限制缓存的最大记录数,既可以满足业务对缓存数据的使用要求,又可以避免缓存因调用者原因无限增长带来的内存溢出风险,还可以减少无用冷数据对内存数据的占用和浪费。

线程并发场景

为了减少内存浪费和防止内存溢出,我们基于

LinkedHashMap

定制支持LRU的战略

的容量限制器缓存具有更高的可靠性。但是,as 缓存,很多情况下,流程中的整个系统需要全局共享,这必然会涉及到并发场景。

下去调用缓存。

而且我们之前实现的策略都是非线程安全的,适合本地缓存或者单线程的场景。当使用多线程时,我们需要将前面的实现转换成线程安全缓存容器。

对于不需要消除策略的简单场景,我们可以使用

并发哈希表

替换HashMap作为缓存的容器存储对象,以获得线程安全性。

但是,对于我们基于LinkedHashMap实现的容量有限缓存的容器,要让它支持线程安全,我们可以用最简单最残酷的方式来实现——基于

同步锁

。比如下面的实现就是在原来LruHashMap的基础上嵌套一层保护壳,实现线程安全访问:

public class ConcurrentLruHashMap & lt;k,V & gt{ private LruHashMap & ltk,V & gtlruHashMappublic ConcurrentLruHashMap(int max entities){ lruHashMap = new lruHashMap & lt;& gt(max entities);} public synchronized V get(Object key){ return lruhashmap . get(key);}公共同步void put(K key,V value) { lruHashMap.put(key,value);}私有静态类LruHashMap & ltk,V & gt扩展LinkedHashMap & ltk,V & gt{ private int maxEntitiespublic LruHashMap(int max entities){ super(16,0.75f,true);this . max entities = max entities;} @ Override protected boolean removeEldestEntry(Map。Entry & ltk,V & gt老大){ return size()& gt;maxEntities}}}为了尽量减少锁对缓存操作性能的影响,我们还可以优化同步锁的策略,比如基于

分段锁

降低同步锁的粒度,减少锁竞争,提高性能。

此外,

谷歌番石榴

开源库中还有一个ConcurrentLinkedHashMap。

,它还支持LRU策略,并在确保线程安全的情况下优化了锁定机制。如果项目中有必要,还可以考虑直接引入相应的开源库来使用。

歌曲结束-TTL缓存到期机制

使用缓存时,经常需要设置缓存记录的有效期,支持删除过期的缓存数据。为了实现这种能力,需要确定两种处理策略:

如何存储每条数据的到期时间?

使用什么机制删除过期数据?

让我们聊一聊。

到期时间存储和设置

由于支持设置到期时间,因此有必要在每条记录中存储到期时间。对于传统

键值

类型缓存架构,我们可以评估其价值

对该结构进行了扩展,封装了一层public 缓存 object shell来存储缓存管理所需的一些信息。例如,下面的实现:

@Datapublic类CacheItem & ltV & gt{私V值;私人长篇expirate;//后续还有其他扩展,在此补充。。。}其中

价值

用于存储real 缓存数据,而其他辅助参数可以与该值一起存储,例如用于记录到期时间的expireAt。

参数。

对于到期时间的设置,一般有两种时间表达形式:

使用

绝对矩

,比如2022年10月13日12: 00: 00到期。

使用

时间跨度

,例如指定5分钟的过期时间。

对于用户来说,很明显

类型2

表单设置更方便,更符合业务的实际使用场景。对于缓存实现来说,显然使用的是第一种。

方式,管理每条数据是否过期会更可行(如果使用时间间隔,需要额外存储时间间隔的计时起点,或者一直扣除剩余时间,比较麻烦)。作为对策,我们可以在设置缓存的到期时间时进行转换,将调用者设置的到期时间间隔转换为实际存储的绝对时刻,以满足两者的需求。

结合以上结论,我们可以编写以下代码:

公共类DemoCache & ltk,V & gt{私人地图& ltk,CacheItem & ltV & gt& gtcache = new HashMap & lt& gt();/* * *分别设置一个密钥对应的过期时间* @param key唯一标识* @param timeIntvl过期时间* @param timeUnit时间单位*/public void expire after (k key,inttimeintvl,timeUnit时间单位){ cache item < V & gtitem = cache . get(key);if(item = = null){ return;} long expi reat = system . current time millis()+time unit . tomi llis(time intvl);item . setexpireat(expire at);}/* * *写入指定到期时间的缓存信息* @param key唯一标识* @param value 缓存实际值* @param timeIntvl到期时间* @param timeUnit时间单位*/ public void put(K key,V value,int timeIntvl,time unit time unit){ long expire at = system . current time millis()+time unit . tomillis(time intvl);CacheItem & ltV & gtitem =新的CacheItem & lt& gt();item.setValue(值);item . setexpireat(expire at);cache.put(key,item);}//省略其他方法}在上面的代码中,支持设置不同的时间单位,比如

第二

、分钟

、小时

、日

等等。,这样可以节省业务端自行转换的操作时间长度。此外,还提供了两种设置超时的方法:

接口独立地指定一些数据的过期时间。

或者写更新缓存时直接设置对应的到期时间

在最终存储中,调用者设置的超时信息也被转换成缓存中的绝对时间戳值,以便直接使用后续的缓存到期判断和数据清理。

在调用具体服务时,可以根据不同场景灵活设置到期时间。例如,当我们登录时,会生成一个令牌,我们可以缓存令牌信息,使其在一定时间内保持有效:

rlogin(字符串标记,用户user) {//...省略业务逻辑细节//将新创建的帖子添加到缓存,缓存30分钟cache.put (token,user,30,time unit . minutes);}对于一个已有的记录,我们也可以单独设置,常用于

缓存续订

在场景中。比如上面提到的令牌信息,登录成功后30分钟内会缓存。这个时候,我们希望用户如果一直在操作,不要让自己的令牌失效。否则用户在使用过程中会被要求重新登录,这种体验会很差。我们可以这样做:

postinfo After auth(string token){//每次使用后,30分钟后将过期时间重置为cache.expire after (token,30,time unit . minutes);}这样,只要用户登录并不断做操作,令牌就永远不会过期,直到用户无所事事30分钟,令牌才会从缓存中删除。

过期数据删除机制

在上一节中,我们确定了缓存到期时间的存储策略,也给出了调用方设置缓存时间的操作界面。这里还有一个关键问题需要解决:如何让设置了失效时间的数据在失效时变得不可用?给出如下

三种

处理的方法。

定期清洁

这是最容易想到的实现。我们可以有一个。

定时任务

,定期扫描所有记录,确定是否过期,过期则删除相应的记录。因为涉及到多线程处理缓存的数据,为了并发安全起见,我们的缓存可以采用一些线程安全的容器(比如前面提到的ConcurrentHashMap

)来实现,如下:

公共类DemoCache & ltk,V & gt{私人地图& ltk,CacheItem & ltV & gt& gtcache = new ConcurrentHashMap & lt& gt();public demo cache(){ timelycleanexireditems();} private void timelycleaneexpireditems(){ new Timer()。schedule(new TimerTask(){ @ Override public void run(){ cache . entry set()。remove if(entry-& gt;entry.getValue()。has expired());} },1000L,1000 l * 10);}//省略其他方法}这样我们就可以根据缓存的总数据量和缓存对数据到期时间的精度要求来设定合理的定时执行策略。例如,我们设置每个

10s

执行过期数据清理任务。然后,当一个任务逾期时,最差的情况可能要逾期10s才能删除(所以在逾期时间的精度控制上会有一定的误差范围)。

另外,为了尽可能保证控制精度,我们需要尽可能缩短定时清洗间隔。但是,当缓存有大量数据时,频繁的全量扫描操作也会对

系统性能

造成一定的影响。

懒惰删除

这是处理数据过期的另一种策略,与定期清理的激进策略相反。

懒惰删除

不会主动判断缓存是否无效,会在使用的时候判断。每次读取缓存时,先判断对应的记录是否过期。如果已经过期,直接删除,通知调用方没有这个缓存数据。

代码如下:

公共类DemoCache & ltk,V & gt{私人地图& ltk,CacheItem & ltV & gt& gtcache = new HashMap & lt& gt();/* * *从缓存* @ param key缓存key * @ return缓存value */public V get(k key){ cache item < V & gt;item = cache . get(key);if(item = = null){ return null;}//如果已经过期,则删除并返回null If(item . has expired()){ cache . remove(key);返回null} return item . getvalue();}//省略其他方法}与常规清理机制相比,基于懒惰删除的策略在代码实现上不需要额外的独立清理服务,并且可以保证数据一旦过期就不可用。但是懒删除也有一个很大的问题,就是依靠外部调用来触发自己的数据清洗机制。

不可控因素太多了

如果一个记录已经过期,但是没有查询它的请求,那么过期的记录将总是驻留在缓存中,导致内存浪费。

两者的结合

如前所述,无论是主动定时清理策略还是平躺的懒人删除都不是完美的解决方案:

定期清理可以确保删除内存中所有过期的数据,但是

频繁执行可能会造成性能影响。

并且到期时间存在准确性问题。

惯性删除可以确保精确控制到期时间并解决性能问题,但是

很容易将过期的数据留在内存中。

无法删除的问题

而以上两种方案的优缺点正好可以互相弥补,可以一起使用取长补短。

看看下面的实现:

公共类DemoCache & ltk,V & gt{私人地图& ltk,CacheItem & ltV & gt& gtcache = new ConcurrentHashMap & lt& gt();public demo cache(){ timelycleanexireditems();} private void timelycleaneexpireditems(){ new Timer()。schedule(new TimerTask(){ @ Override public void run(){ cache . entry set()。remove if(entry-& gt;entry.getValue()。has expired());} },1000L,1000 l * 60 * 60 * 24);} public V get(K key){ CacheItem & lt;V & gtitem = cache . get(key);if(item = = null){ return null;}//如果已经过期,则删除并返回null If(item . has expired()){ cache . remove(key);返回null} return item . getvalue();}}在上面的实现中:

1.使用

懒惰删除

策略,每次使用时判断是否过期并执行删除策略,保证过期的数据不会被继续使用。

与一个人合作

低频任务

作为兜底(比如24小时执行一次),用于清理已过期但是始终未被访问到的缓存数据,保证已过期数据不会长久残留内存中。由于执行频率较低,也不会对性能造成太大影响。

作为备份(例如每24小时一次),用于清理已经过期但从未被访问过的缓存数据,以保证过期数据不会长时间留在内存中。由于执行频率较低,不会对性能造成太大影响。

回顾

回顾下面这篇文章的内容,作为手写local 缓存框架的开胃菜,我们首先介绍了一些项目中local 缓存实现的几种情况,连同一些如

淘汰策略

,过期了。

平等能力发展的第一次体验。

这些内容在项目开发中出现的频率非常高,几乎任何有一定规模的项目都会或多或少用到。我们称之为手写本地缓存”

前菜

“,因为这些都是一些零散的独立场景应对策略,就像一个一个的。

游击队

,分散开来,各自撑起自己的小根据地。在这个内容的基础上,我们接下来的文章将会谈到如何在游击经验的基础上建立正规军——建立一个通用的、系统的完整的local 缓存框架。

那么,关于本文前面提到的各种“游击队”local 缓存,你们有没有在编码中涉及过?你是怎么做到的?欢迎在评论区交流,期待和朋友们一起学习,一起成长。

附加备注

:

本文属于“深入理解缓存原理与实用设计”系列专栏之一。本专栏围绕缓存这一宏大命题,全面系统地分析缓存的各种实现策略和原理,以及缓存的各种用法和各种解题策略,共同探讨缓存设计的哲理。

如果你有兴趣,也请关注这个专栏。

我开明,讲技术,不只是技术~

如果你觉得有用,请

喜欢+关注

让我感受到你的支持。也可以关注我的微信官方账号【建筑启蒙】更多及时更新。

期待和大家一起探讨,一起成长为更好的自己。

总结:以上内容是对detailed 介绍一下、手写local 缓存实战1的详细介绍——各个击破,文章内容部分转载自网络,希望对你了解黑名单有所帮助和价值。

版权声明

本站搜集来源于网络,如侵犯到任何版权问题,请立即告知本站,本站将及时予与删除并致以最深的歉意。