目录
一、背景
用户在一次请求路径中,其实会经过很多缓存节点,如浏览器缓存,CDN节点缓存,网关代理缓存,以及在各业务系统内常用的本地缓存,分布式缓存等。而缓存也作为高并发系统三大保护利器之一(缓存,限流,降级),可以很大程度提升系统访问速度,增加系统吞吐量和并发用户数,可谓是抗高并发流量的银弹。
开发中面对不同的业务场景有不同的缓存,常用的有本地缓存和分布式缓存,对于单机的、数据量不大的数据,用HashMap就能实现一个简单的缓存,也可以借助Guava cache等来实现。对于分布式缓存,常用的如美团的squirrel(redis),celler(tair)。这里不讲这些缓存介质他们的原理和特性。主要是探讨下平时在项目使用缓存过程中特别是高并发场景下需要考虑的一些问题以及其应对的套路。
二、缓存更新的常见模式
在应用中,缓存挡在数据库的前面抵挡了大量的数据查询,直接减轻了数据库的压力,对于缓存更新通常有以下几种模式。
- Cache Aside Pattern
- Read/Write Through Pattern
- Write Behind Caching Pattern
Cache Aside Pattern模式
这种模式通常是平时应用最广泛的一种模式,没有单独的缓存维护组件,缓存和db的读写操作由应用方负责,对于读写请求分别为
请求读:先读缓存,若命中则返回。若没有命中,从数据库中查询数据写入缓存并返回
请求写:先更新数据库,然后将缓存中的数据失效掉(注意是失效而不是更新)
通常在应用中,写缓存和写入数据库是两个独立的事务,选择先更新缓存还是先更新数据库在高并发的情况下,都有可能会产生数据不一致,如以下情况,注:抛开因为如写数据库失败或写缓存失败造成不一致的因素。
Read/Write Through Pattern模式
Cache Aside Pattern模式中由应用方维护数据库和缓存的读写,导致应用方数据库和缓存的维护设计侵入代码,数据层的耦合增大,代码复杂性增加。
而Read/Write Through Pattern模式弥补了这一问题,调用方无需管理缓存和数据库调用,通过在设计中多抽象出一层缓存管理组件来负责和缓存和数据库读写维护,并且缓存和数据库的读写维护是同步的。调用方直接和缓存管理组件打交道,缓存和数据库对调用方是透明的视为一个整体。通过分离出缓存管理组件,解耦业务代码。
Write Behind Caching Pattern模式
Write Behind模式和Write Through模式整个架构是一样的,最核心的一点在于write through在缓存数据库中的更新是同步的,而Write Behind是异步的。
每次的请求写都是直接更新缓存然后就成功返回,并没有同步把数据更新到数据库。而把更新到数据库的过程称为flush,触发flush的条件可自定义,如定时或达到一定容量阈值时进行flush操作。并且可以实现批量写,合并写等策略,也有效减少了更新数据的频率,这种模式最大的好处就是读写响应非常快,吞吐量也会明显提升,因为都是跟cache交互。当然这种模式也有其他的问题。例如:数据不是强一致性的,因为选择了把最新的数据放在缓存里,如果缓存在flush到数据库之前宕机了就会丢失数据,另外实现也是最复杂的。
几种模式的优缺点
模式 | 优点 | 缺点 |
---|---|---|
Cache Aside | 1.实现简单 | 1.需要调用方维护缓存和db的更新逻辑2.代码侵入大 |
Read/Write Through | 1.引入缓存管理组件,缓存和数据库的维护对应用方式透明的2.应用代码入侵小,逻辑更清晰 | 1.引入缓存管理组件,实现更复杂 |
Write Behind Caching | 1.读写直接和缓存打交道,异步批量更新数据库,性能最好2.缓存和数据库对应用方透明 | 1.实现最复杂2.数据丢失的风险3.一致性最弱 |
三、缓存一致性
一致性问题
由于引入缓存,数据就分散在两处不同数据源,并且现在的缓存组件通常都是分布式缓存,请求缓存加上网络IO过程还是比较耗时,如果包含在数据库的事务控制内,会增加事务控制粒度和事务释放的耗时,造成大量的数据库连接挂起,严重的降低系统性能,甚至会因为数据库连接数过多,导致系统崩溃。因此缓存和数据库的更新通常是两个事务,缓存和数据库的一致性问题其实又回到了老生常谈的分布式一致性问题上面来了。造成这种问题的原因通常有以下两个层面。
1.业务层面
业务层面主要是选择缓存更新模式的不同造成的不一致,例如上述的Cache Aside Pattern 里不管是先更新db还是先删除或更新cache,在高并发的情况下都有可能造成不一致的情况,只是不同的更新方式造成不一致的概率不一样,尽可能的选择造成不一致概率最小的更新模式。
2.系统层面
系统层面也就是分布式一致性中单个节点系统问题导致失败造成的不一致,在这里就是如缓存服务的机器宕机,网络异常造成的更新失败等。
解决方案
强一致性
1.采用强一致性协议
2.并行请求转为串行化
但是2种方式都将严重降低系统的吞吐量,这里不做讨论。
最终一致性
在绝大部分场景中,特别是互联场景下,大多是保证最终一致性。
- 重试机制
- 应用更新数据库,若这一步就失败,那么更新事务失败回退。
- 应用更新缓存失败,将失败的数据写入mq
- 消费mq得到失败的数据,重试删除缓存
整个过程考虑了数据库写入成功,缓存因系统故障等写入失败,导致数据库和缓存此时数据不一致,将失败的数据写入mq,监听mq重试删除缓存来到达最终一致性。缺点是整个重试写入的维护都在业务代码中,代码侵入性比较高。因此可以考虑一下方式引入databus,订阅数据更新binlog,解耦缓存更新过程。
- 重试+binlog
- 应用更新数据库,binlog日志同步databus。
- 缓存管理组件订阅binlog,并删除缓存,失败则将缓存key写入mq。
- 缓存管理组件订阅mq,重试删除缓存。
通过引入databus和缓存管理组件,将缓存更新的维护和业务代码解耦。
另一个原因是,现在的数据库通常是主从架构来提升整体的查询qps,因数据库主从同步的延迟,删除缓存后,如果此时从数据库还未同步完成,新来的请求发现缓存失效了,从从库里查询了已经过期的数据放到缓存中,也会造成数据的不一致。而通过订阅binlog的同步的延迟性,使删除缓存的时序延后,进一步降低不一致的几率。
总结
缓存能带来高性能的一个很重要原因就是牺牲掉了强一致性,数据更新会有延迟,就会有分布式事务的问题。对于一致性通常会采用最终一致性,而设置缓存过期时间也是最终一致性的一种思想。过期时间设置太短,会造成缓存过多回溯到db,设置太长,又会使脏数据长时间停留在cache中,增加了不一致的时间,以及缓存了过多的冷数据也会浪费缓存的内存资源。因此在平时的设计中运用缓存就会面临这些问题,高性能和一致性就是系统要做trade-off的地方,也是我们要考虑是否要用缓存的因素。
四、缓存常见问题场景
通常使用缓存时,缓存充当了前置查询,当缓存查询未命中时,请求将回溯到后端db。因此缓存减轻了高并发场景下的查询压力,但高并发场景下也带来了缓存访问时的一些风险,主要是缓存失效,增加回溯率。常见的几种常见是缓存穿透,缓存雪崩,热key,大key等问题。
缓存穿透
问题
正常情况下,如果缓存设计比较合理的情况下,通常是能够命中缓存的,减少请求回溯到数据库层。但如果大量的非法请求都去查询压根数据库中根本就不存在的数据,也就是缓存和数据库都查询不到这条数据,但是请求每次都会打到数据库上面去,缓存就形同虚设,缓存命中率为0,这种情况我们称之为缓存穿透。
每次请求都穿过缓存到达数据库层,压力直接打到数据库上,试想一下,如果有人恶意对你的系统进行攻击,拿大量不存在的key去请求接口,导致大量请求穿过缓存到达数据库,增加数据库压力甚至宕机。
解决方案
1.业务非法参数校验
在上层业务上做非法参数校验,尽量避免非法参数的请求case打到cache层。
2.缓存空对象
因为每次查缓存都不存在,然后回溯到db去查询也不存在。因此可以把这种不存在的key也缓存起来,设置标识空的标识值,如“##”,那么就无法穿透到db层,但是要记到设置过期时间。这种方式的好处在于实现简单,但是会占用缓存空间,如果空数据的命中率不高,而且遇到的比较多非法请求时,会增加缓存空间的压力。
public Object getCache(final String key) {
Object value = redis.get(key);
if (value != null) {
if (value.equals("##")) {
return null;
}
return value;
}
Object valueFromDb = getValueFromDb(key);
if (value == null) {
valueFromDb = "##"; //"##"缓存标识为空
}
redis.set(key, valueFromDb, t);
return valueFromDb;
}
3.布隆过滤器
缓存穿透是每次查询都要经过缓存,查询未命中回溯到数据库中,如果我们用一种存储结构存储所有数据,在查询缓存之前提前过滤要查询的数据是否一定不存在,如果存在就不用再去查缓存了,也就避免了缓存穿透的问题,当然这对过滤器的存储结构要求就比较高了。如果不考虑内存容量问题,hashmap是最简单的一种过滤器,把所有数据存在map中,通过get(key)是否存在进行过滤,当然我们这里用hashmap是不现实的,因为不满足内存容量要求,而布隆过滤器就是这样一种用比较少的内存存储大量数据映射,能满足提前过滤要查询的数据在系统中是否一定不存在的要求。
下面简单介绍下布隆过滤器,借助bitset的存储特性和一组Hash算法构成,是一种空间效率极高的概率型算法和数据结构,主要用来判断一个元素是否在集合中存在。初始时bit数组每一位为0,每个输入值通过一组hash算法,得到一组bit数组的下标,并把该bit位标识为1。如果一个要验证的值通过这组hash函数得到的下标有不为1的情况,那么可以肯定这个值肯定不存在。
写入过程(如有3个hash函数):
- 初始时bit数组每一位都初始标记为0。
- "user_10"这个值经过3个hash函数计算后,得到的bit数组下标分别为[3,6,12],并把对应的bit数组标识为1。
- 同理值为"user_20"经过hash计算得出的下标为[6,10,15],分别标识为1。注意在标记数值位6时已经被标记为1,则保持为1。
查询一个原始值在布隆过滤器中是否存在的过程如下:
- 待查询的值"user_30" 经过同样的hash计算后得出的bit下标为[3,6,13],可以看出13位为0,则"user_30"一定不存在。
- 待查询的值"user_20" 经过同样的hash计算后得出的bit下标为[6,10,10],可以看出都匹配为1,得出在布隆过滤器中这个值一定存在的结论?
- 其实不一定,因为有hash冲突的存在,多个值hash的bit位重复的情况都标识为1,假如不存在的值"user_404"值hash得出下标为[3,6,10],因为hash冲突得出都为1,其实这个值并不存在,因此,“布隆过滤器能确定一个值一定不存在,但是不能确定一个值一定存在”。但我们缓存穿透要利用的就是前一句"布隆过滤器能确定一个值一定不存在",因此不存在计算误差。
4.使用布隆过滤器解决缓存穿透
了解布隆过滤器原理后,再回到用布隆过滤器解决缓存穿透问题就很简单了,在缓存前加一层布隆过滤器,利用布隆过滤器bitset存储结构存储数据库中所有值,查询缓存前,先查询布隆过滤器,若一定不存在就返回,不用再回溯流量到缓存服务,过程如下:
private final BloomFilter<String> bloomFilter =
BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), 1024 * 1024 * 32);
//查询布隆过滤器中是否存在
public boolean contains(String cacheKey) {
if (StringUtils.isEmpty(cacheKey)) {
return true;
}
boolean exists = bloomFilter.mightContain(cacheKey);
if (!exists) {
bloomFilter.put(cacheKey);
}
return exists;
}
//模拟初始化布隆过滤器,从db填充所有数据
public void initBF() {
int offset = 0;
int limit = 200;
while (true) {
List<String> dataFromDb = listFromDb(offset, limit);
if (CollectionUtils.isEmpty(dataFromDb)) {
break;
}
for (String s : dataFromDb) {
bloomFilter.put(s);
}
offset += limit;
}
}
方案对比
方案 | 使用场景 | 使用成本 |
---|---|---|
缓存空对象 | 1. 空数据量不大2. 数据频繁变化实时性高 | 1.代码维护简单2.需要过多的缓存空间3. 数据不一致 |
过滤器 | 1.数据量比较大2. 数据命中不高3. 数据相对固定实时性低 | 1.代码维护复杂2.缓存空间占用少 |
缓存雪崩
原因
缓存层挡在db层前面,抗住了非常多的流量,在分布式系统中,“everything will fails”,缓存作为一种资源,当cache crash后,流量集中涌入下层数据库,称之为缓存雪崩。
造成这种问题通常有2种原因
- 业务层面:大量的缓存key同时失效,失效请求全部回源到数据库,造成数据库压力过大崩溃。
- 系统层面:缓存服务宕机。
解决方案
1.分散过期时间
业务层面的原因,主要是缓存key过期时间一致,造成同一时间,大量缓存key同时失效。针对这种问题的解决方案,主要是防止缓存在同一时间一期过期,如在设置的过期时间的基础上增加1-5分钟的随机值,使缓存失效时间比较均匀
2.提前演练压测
提前做好系统的演练压测,发现性能瓶颈,预估合适的系统存储和计算容量。
3.cache高可用+后端数据库限流
- 缓存作为一种系统资源,且通常充当关键路径关键资源,应尽可能提升缓存的可用性,如redis的sentinel和cluster机制等
- 后端数据库限流(Hystrix),缓存层宕机,流量集中打到数据库,会再次让db崩溃。为保护这种情况下的db,在db层加入限流(Hystrix)
缓存击穿
原因
缓存系统中会有部分热点数据,查询量很大,并且通常缓存会设有过期时间,在这种情况下缓存击穿是指当某一个热点key失效的时候,很多请求这一时间都查不到缓存,然后全部请求并发打到了数据库去查询数据构建缓存,造成数据库压力非常大甚至宕机,并且全部请求去数据库查询构建缓存是没必要的,只需要一次查询去构建缓存就行了。
解决方案
1.互斥锁:
因为是同一时间很多请求并发的访问数据库,把这个动作设置一个分布式锁,只有一个请求能去db访问,其他请求重试等待,解决了全部请求全部查询数据库的问题。这种方案相当于把数据库的访问压力转到了分布式锁的压力上来,有一定的弊端,但是最简单实用,如果查询数据库的耗时比较长,过多的读请求线程堵塞,存在将机器内存打满的风险。
public Object getCache(final String key) {
Object value = redis.get(key);
//缓存值过期
if (value == null) {
//加mutexKey的互斥锁
if (redis.setnx(mutexKey, 1, time)) {
value = db.get(key);
redis.set(key, value, time);
redis.delete(mutexKey);
} else {
sleep(500);
//重试
return get(key);
}
}
return value;
}
2.软过期+互斥锁:
软过期指对缓存的值里存储逻辑过期时间t1,这个时间比实际要过期的时间t2小(t1<t2),业务取值时候,校验t1是否过期,在发现了数据逻辑时间过期的时候,也是引入一把互斥锁,首先将t1时间延长t1=t1+t并设置到缓存中去,接着去db查询新数据,其他线程这时看到延长了的过期时间,就会继续使用旧数据,等线程获取最新数据后再更新缓存。
这种方案相比第一种进一步减少了读请求线程阻塞的时间,第一种方案阻塞时间block time从数据库查询并设置到缓存中的整个时间段。第二种方案阻塞时间block time:t1=t1+t并设置到缓存中的时间段。
public Object getCache(final String key) {
Object value = redis.get(key);
if (value != null) {
//检验缓存里的逻辑过期时间
if (value.getTimeout() <= currentTimeMillis()) {
if (redis.setnx(mutexKey,time)) {
//立即延长逻辑过期时间,减少阻塞时间
value.setTimeout(value.getTimeout() + t1);
redis.set(key, value, time);
value = db.get(key);
//获取最新db数据,并重新设置新的逻辑过期时间,覆盖旧数据
value.setTimeout(value.getTimeout() + t2);
redis.set(key, value, time);
redis.delete(mutexKey);
} else {
sleep(500);
get(key);
}
}
} else {
//缓存不存在的情况和上面一样
if (redis.setnx(mutexKey,time)) {
value = db.get(key);
redis.set(key, value,time1);
redis.delete(mutexKey);
} else {
sleep(500);
get(key);
}
}
return value;
}
3.静态数据:lazy expiration
这里静态数据的含义是指redis不set expire过期时间,对redis来说认为数据是不过期的是静态的
但实际和上面的软过期是一样的,通过value里设置逻辑过期时间,再拿到值判断值过期之后,后台新起异步线程更新缓存,这种方式性能最好
public Object getCache(String key) {
Object value = redis.get(key);
if (value.getTimeout() <= System.currentTimeMillis()) {
// 另起一条线程异步更新缓存
executorService.execute(new Runnable() {
public void run() {
if (redis.setnx(mutexKey, "1")) {
redis.expire(mutexKey, 3 * 60);
String dbValue = db.get(key);
redis.set(key, dbValue);
redis.delete(mutexKey);
}
}
});
}
return value;
}
以下是各种方案的优缺点对比:
方法 | 优点 | 缺点 |
---|---|---|
互斥锁 | 1.简单易用2.一致性保证 | 1.存在线程阻塞的风险2.数据库访问的压力转到分布式锁上来 |
软过期+互斥锁 | 1.相比互斥锁方案,降低线程阻塞的时间 | 1.代码更复杂2.逻辑过期时间会占用一定的内容空间 |
静态数据 | 1.数据不过期,异步构建性能最好2.基本杜绝热点key重建问题 | 1.不能保证一致性2.代码复杂性增加3.逻辑过期时间会占用一定的内容空间 |
热点key问题
原因
用户的消费速度远远大于生产速度,例如电商平台上线某个热门促销商品,微博大量转发的热门新闻等,这些数据往往查询量非常大。其实缓存击穿也是一种热点key问题,但是这里要讨论的方面不一样,缓存击穿主要侧重的是热key失效后大量并发查询涌向数据库照成的压力,而这里的热key侧重的是热key的访问压力已经大到超过redis性能极限,相对于缓存击穿的热key,这里也可叫巨热数据。
分布式缓存组件,通常会进行分片切分,例如squirrel的cluster机制,查询某个key,会通过key的hash值计算出对应的slot,路由到某个分片的所属机器上。热key出现时,所有热点访问的请求都会路由到同一个redis server,该节点的负载严重加剧,并且这种现象通常不是马上加机器就能解决,因为同一个请求key还是会落到同一个新机器上,瓶颈依然存在。并且如果这个key还是大key ,甚至可能达到物理网卡极限,服务被打垮宕机,造成雪崩,成为系统瓶颈和风险。因此热点key会有以下问题。
- 流量集中,达到物理网卡上限。
- 请求过多,缓存分片服务被打垮。
- 缓存分片打垮,重建再次被打垮,引起业务雪崩。
解决方案
通常是在客户端和缓存服务端进行改造优化。
1.多级缓存
- 在客户端加入本地缓存,如guava-cache或ehcache,热点缓存直接命中本地缓存,根本上减少了热点请求缓存服务。这种方案的问题是容量有限,对业务有入侵,可以对redis sdk进行改造,集成本地缓存功能,对业务无感知。
- 如果缓存集群为代理模式,可以在代理接单添加本地缓存,利用代理节点可以水平扩容的特点,解决容量有限的问题,当然性能也要比客户端本地缓存差一些,因为“缓存离用户越近,性能越好”。代理模式如下图:
2.多副本:
当发现某个热key的时候,增加热key所在节点的从副本,这种情况对读多写少的情况比较有效。但是也增加了多副本同步不一致的风险。
3.迁移热key:
当发现某个slot里热key的时候,将该slot的单独迁移到新的节点,和集群其他节点隔离,避免影响集群节点其他业务。
热key发现
热key的解决方案可以在客户端加本地缓存,热key备份,以及迁移热key节点,根据应用情况选择不同的方案,但如果热key已经出现的时候,没有及时发现和处理,再去处理就为时已晚,因此通常如何提前发现热key并即使处理热key非常重要。
点击展开内容
解决方案
1.人为预测
这种方案其实也是有一定可行性的。比如,电商预告要在第二天中午12点开放某商品的促销,对比这个商品历史促销的访问量,预测能达到热点访问量,从而提前加载热点缓存。
2.客户端
客户端是距离key"最近"的地方,redis的命令每次都是从客户端触发的,可以在客户端的代码处进行统计计数。
但是这种方案也存在一些问题。无法预知key的个数,存在内存泄漏的风险;只能解决当前客户端的热点key,无法实现规模化的运维统计。
3.机器层面
站在机器的角度,可以通过对机器上所有Redis端口的TCP数据包进行抓取完成热点key的统计。此种方法对于redis客户端和服务端来说毫无侵入,但是依然存在问题:需要一定开发成本,由于是以机器为单位进行统计,想要了解集群维度热点key,后期还是需要汇总统计。
4.服务端monitor
redis的monitor可以统计出一段时间的所有命令,美团的squirrel也是用的这种方式,通过monitor qps最高的节点,利用正则表达式解析出热key,对热key所在的slot进行迁移。这种方式的好处是简单易用,缺点也比较明显,monitor命令执行期间会降低redis性能。
5.热点发现系统 可以建立一套热点发现系统,通过对实时请求上报计算,提前发现热key的产生。当计算监控到产生了热点key,将热key推送到客户端,客户端建立本地缓存。
大key问题
问题
大key就是value存储值比较大,squirrel的定义为
- string类型value > 10K大
- string类型value > 100K超大
- set、list、hash、zset等集合数据类型中的元素个数 > 1000大****
- set、list、hash、zset等集合数据类型中的元素个数 > 10000超大****
大key会有以下问题:
- 响应超时:由于redis是单线程的,大key会导致在get的时候堵住redis服务器的输出缓冲区,导致服务的超时。集合的删除时间复杂度为O(n),大集合删除的时候会严重阻塞进程,造成应用崩溃
- 数据倾斜:大key会导致集群不同节点间的数据倾斜,有的节点使用的容量较多,有的节点很空闲。
解决方案
1.单key存储value很大
可以把value对象拆成多份,使用multiGet,这样做的意义在于减少操作在一个节点的压力,分散到多个节点。
使用hash,每个filed存储对象的各属性。
2.集合存储了过多的的值
类似于场景一种的第一个做法,可以将这些元素分拆。
以hash为例,原先的正常存取流程是 hget(hashKey, field) ; hset(hashKey, field, value)
现在,固定一个桶的数量,比如 10000, 每次存取的时候,先在本地计算field的hash值,模除 10000, 确定了该field落在哪个key上。
newHashKey = hashKey + ( hash(field) % 10000); hset (newHashKey, field, value) ; hget(newHashKey, field)
3.压缩value
五、本地缓存
使用数组实现本地缓存
使用数组作为缓存是最节约内存空间的,插入50w条kv对数据占用10MB+的内存空间,但是当我们根据poiId查询缓存的时候,需要遍历整个数组才能找到对应的poiId,效率无法接受,可以通过将数组的空间开到Max_PoiId,使用poiId作为数组下标寻址,可以在O(1)的实现命中缓存位置,但是poiId最大值接近5亿,预开5亿个数组空间是不现实的,所以这种方法也是不可行的。
使用HashMap实现本地缓存
使用HashMap的方式可以解决数组中无法在常量时间获取缓存数据的问题,并且插入50w条数据,整个缓存数据结构所占空间为30MB左右,同样也是可以接受的。但是使用hashMap的方式从缓存功能的角度是缺失的,比如缓存空间占满后的替换策略,查询/放置的超时时间等等。如果使用这种方式,这些功能需要自己二次开发,成本较大。且缓存大小受堆内存大小的限制,缓存时间收gc时间的影响。
Guava Cache
Guava是Google团队开源的一款 Java 核心增强库,包含集合、并发原语、缓存、IO、反射等工具箱,性能和稳定性上都有保障,应用十分广泛。Guava Cache支持很多特性:
- 支持最大容量限制
- 支持两种过期删除策略(插入时间和访问时间)
- 支持简单的统计功能
- 基于LRU算法实现
Guava Cache的架构设计灵感来源于ConcurrentHashMap,我们前面也提到过,简单场景下可以自行编码通过hashmap来做少量数据的缓存,但是,如果结果可能随时间改变或者是希望存储的数据空间可控的话,自己实现这种数据结构还是有必要的。
Guava Cache继承了ConcurrentHashMap的思路,使用多个segments方式的细粒度锁,在保证线程安全的同时,支持高并发场景需求。Cache类似于Map,它是存储键值对的集合,不同的是它还需要处理evict、expire、dynamic load等算法逻辑,需要一些额外信息来实现这些操作。对此,根据面向对象思想,需要做方法与数据的关联封装。如图5所示cache的内存数据模型,可以看到,使用ReferenceEntry接口来封装一个键值对,而用ValueReference来封装Value值,之所以用Reference命令,是因为Cache要支持WeakReference Key和SoftReference、WeakReference value。
总体来看,Guava Cache基于ConcurrentHashMap的优秀设计借鉴,在高并发场景支持和线程安全上都有相应的改进策略,使用Reference引用命令,提升高并发下的数据访问速度并保持了GC的可回收,有效节省空间;同时,write链和access链的设计,能更灵活、高效的实现多种类型的缓存清理策略,包括基于容量的清理、基于时间的清理、基于引用的清理等;编程式的build生成器管理,让使用者有更多的自由度,能够根据不同场景设置合适的模式。
Caffinie
****Caffeine是一种高性能,近似最优命中的本地缓存,简单点说类似于自己实现的ConcurrentHashMap,是使用Java8重写的基于Guava和ConcurrentLinkedHashMap的本地缓存,既然是重写,性能吊打Guava,且在Spring Boot 2.0(spring 5)中已取代Guava,那么就没有理由怀疑Caffeine的性能了。Guava Cache 的功能的确是很强大,满足了绝大多数人的需求,但是其本质上还是使用 LRU 淘汰算法对ConcurrentHashMap的一层封装,所以在与其他较为优良的淘汰算法的对比中就相形见绌了。而 Caffeine Cache 实现了 W-TinyLFU(LFU+LRU 算法的变种)。
Ehcache
Ehcache是现在最流行的纯Java开源缓存框架,配置简单、结构清晰、功能强大,是一个非常轻量级的缓存实现,我们常用的Hibernate里面就集成了相关缓存功能。存取速度相较于Caffeine稍慢,但是Ehcache缓存组件支持Heap-OffHeap-Disk的层级结构如下图所示,缓存可以分层存在于堆内存、堆外内存和磁盘上,并且我们可以单独指定通过哪种方式实现缓存。这就为我们提供了一个更为可行的实现方案-只使用磁盘空间来实现本地缓存。这样,就不存在占用过多堆内存空间而导致服务full gc等的不稳定因素了。
测试结果来自基准测试,我们可以看到Caffeine的在速度上的优势是非常明显的,但是至于它为什么可以这么快,这里我就不做详细介绍了,大家感兴趣的话可以自行查阅资料(Caffeine高性能设计剖析)