目录
前言 :
为什么要使用缓存
在系统中,有些数据,数据量小,但是访问十分频繁(例如国家标准行政区域数据),针对这种场景,需要将数据搞到应用的本地缓存中,以提升系统的访问效率,减少无谓的数据库访问(数据库访问占用数据库连接,同时网络消耗比较大),但是有一点需要注意,就是缓存的占用空间以及缓存的失效策略。
为什么是本地缓存
采用本地缓存的数据,大多是与业务无关的小数据缓存,没有必要搞分布式的集群缓存,如果是涉及到订单和商品这类重要业务的数据,才会直接走DB进行请求, 再加上分布式缓存的构建,集群维护成本比较高,所以分布式集群缓存不太适合紧急的业务项目。
GuvaCache介绍
Cache在软件开发中具有广泛的用途。
在多线程高并发场景中往往是离不开cache的,需要根据不同的应用场景来需要选择不同的cache,比如分布式缓存如Redis、memcached,还有本地(进程内)缓存如ehcache、GuavaCache。Guava Cache是一个全内存的本地缓存实现,它提供了线程安全的实现机制。整体上来说Guava cache 是本地缓存的不二之选,简单易用,性能好
Guava Cache与ConcurrentMap很相似,但也不完全一样。
最基本的区别是ConcurrentMap会一直保存所有添加的元素,直到显式地移除。而Guava Cache为了限制内存的占用,通常都是设定为自动回收元素。在某些场景下,尽管LoadingCahe不回收元素,但是它还是很有用的,因为它会自动加载缓存。
这里面需要注意的就是 GuavaCash的缓存过期删除时机 , 下面说删除策略时会提及
GuvaCache适用场景
- 你愿意消耗一部分内存来提升速度;
- 你已经预料某些值会被多次调用;
- 缓存数据不会超过内存总量;
一 、引入Maven
<!-- https://mvnrepository.com/artifact/com.google.guava/guava -->
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1.1-jre</version>
</dependency>
二 、 快速开始
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import com.google.common.cache.RemovalListener;
import com.google.common.cache.RemovalNotification;
public class GuavaCacheController {
// 定义一个属性 LoadingCache 用来存放缓存数据
private LoadingCache<Integer, String> cache;
public GuavaCacheController() {
// 构建LoadingCache
cache = CacheBuilder.newBuilder()
.maximumSize(128) // 指定缓存所能够存储的最大记录数量
.expireAfterWrite(1, TimeUnit.MINUTES) // 指定对象被写入到缓存后多久过期
.build(new CacheLoader<Integer, String>() { // 构建
@Override
public String load(Integer integer) {
// getString
return getString(integer);
}
});
}
private List<String> getListString() throws ExecutionException {
List<String> list = new ArrayList<>();
// 循环调用五次 用来增加调用时间 增多测试数据
for (int i = 1; i <= 5; i++) {
String string;
// 通过cache.get 来查询数据 , 查询后的数据进行缓存
String s = cache.get(i);
list.add(s);
}
return list;
}
private String getString(int num) {
try {
System.out.println(" he is coming ~ " + "[ " + num + " ]");
// 线程休眠来模拟service调用时间
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 若本次调用该方法传进来的 key 缓存中没有的话 , 那么之前构建的cache则会将本次调用结果记录至缓存
// 想看底层具体如何更新缓存的话 可以在 return 上打断点 , 监视后面的动作 com.google.common.cache.LocalCache 该类便是
return "this:{ " + num + " }";
}
}
三、构建细节介绍:
3.1 、缓存构建器CacheBuilder
使用CacheBuilder可以构建出两种类型的cache,LoadingCache和Cache:它们如下特点(非必选,可配置)
- 自动装载实体到内存
- LRU替换策略(默认,不可修改)
(LRU全称是Least Recently Used,即最近最久未使用的意思 , LRU算法的设计原则是:如果一个数据在最近一段时间没有被访问到,那么在将来它被访问的可能性也很小。也就是说,当限定的空间已存满数据时,应当把最久没有被访问到的数据淘汰。)
- 基于时间的失效策略,可按最新访问或者最近写入计算时间
- key自动包裹(wrapped)在弱引用
- value自动包裹在弱引用或者软引用
- 换出或删除数据时有通知
- 访问缓存统计
3.2 、构建参数详解
属性 | 含义 | 备注/注意事项 |
initialCapacity(10) | 设置cache的初始大小为10 | |
maximumSize(128) | 构建缓存对象时指定缓存所能够存储的最大记录数量(条数). | 当 警告:在缓存项的数目达到限定值之前,缓存就可能进行回收操作——通常来说,这种情况发生在缓存项的数目逼近限定值时 |
expireAfterWrite(1, TimeUnit.MINUTES) | 指定对象被写入到缓存后1分钟过期 | |
expireAfterAccess(1, TimeUnit.MINUTES) | 定对象超过1分钟没有被访问后过期。 | 定时回收周期性地在写操作中执行,偶尔在读操作中执行。 |
weakValues() | 指定 | 当没有其他强引用指向key和value时,key和value对象就会被垃圾回收器回收。 考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定 maximumSize |
weakKeys() | 指定 | |
recordStats() | 开启统计信息开关 | 可以对 |
concurrencyLevel(5) | 设置并发度,即可以同时写缓存的线程数 |
|
removalListener(removalListener) | 声明一个监听器 | 缓存项被移除时,RemovalListener会获取移除通知[RemovalNotification],其中包含移除原因[RemovalCause]、键和值 |
.refreshAfterWrite(1, TimeUnit.MINUTES) | 当缓存项上一次更新操作之后的多久会被刷新 |
弱引用 : 例如 : 当给
value
引用赋值一个新的对象之后,就不再有任何一个强引用指向原对象。System.gc()
触发垃圾回收后,原对象就被清除了Object value = new Object(); cache.put("key1",value); value = new Object();//原对象不再有强引用
声明一个监听器: 缓存项被移除时,RemovalListener会获取移除通知 , 当有记录被删除时可以感知到这个事件
public GuavaCacheController() { RemovalListener<Integer, String> removalListener = new RemovalListener<Integer, String>() { @Override public void onRemoval(RemovalNotification<Integer, String> notification) { System.out.println("removal key : " + notification.getKey() + " , value : " + notification.getValue()); } }; // 构建LoadingCache cache = CacheBuilder.newBuilder() .maximumSize(128) .expireAfterWrite(1, TimeUnit.MINUTES) .removalListener(removalListener) .build(new CacheLoader<Integer, String>() { @Override public String load(Integer integer) { return getString(integer); } }); }
四、缓存使用细节介绍:
4.1 、缓存数据
4.1.1、自动加载
LoadingCache
是Cache
的子接口,相比较于Cache
,当从LoadingCache
中读取一个指定key的记录时,如果该记录不存在,则LoadingCache
可以自动执行加载数据到缓存的操作。与构建
Cache
类型的对象类似,LoadingCache
类型的对象也是通过CacheBuilder
进行构建,不同的是,在调用CacheBuilder
的build
方法时,必须传递一个CacheLoader
类型的参数,CacheLoader
的load
方法需要我们提供实现。当调用LoadingCache
的get
方法时,如果缓存不存在对应key的记录,则CacheLoader
中的load
方法会被自动调用从外存加载数据,load
方法的返回值会作为key对应的value存储到LoadingCache
中,并从get
方法返回。
LoadingCache<Integer, String> cache = CacheBuilder.newBuilder()
.maximumSize(128)
.expireAfterWrite(1, TimeUnit.MINUTES)
.removalListener(removalListener)
.build(new CacheLoader<Integer, String>() {
@Override
public String load(Integer integer) {
return getString(integer);
}
});
4.1.2、显示插入
使用cache.put(key, value)方法可以直接向缓存中插入值,这会直接覆盖掉给定键之前映射的值。
使用Cache.asMap()视图提供的任何方法也能修改缓存。但请注意,asMap视图的任何方法都不能保证缓存项被原子地加载到缓存中。进一步说,asMap视图的原子运算在Guava Cache的原子加载范畴之外,所以相比于
Cache.asMap().putIfAbsent(K,V),Cache.get(K, Callable<V>)
应该总是优先使用。
cache.put(1,"111");
4.2、清除数据
可以调用
Cache
的invalidateAll
或invalidate
方法显示删除Cache
中的记录。invalidate
方法一次只能删除Cache
中一个记录,接收的参数是要删除记录的key。invalidateAll
方法可以批量删除Cache
中的记录,当没有传任何参数时,invalidateAll
方法将清除Cache
中的全部记录。invalidateAll
也可以接收一个Iterable
类型的参数,参数中包含要删除记录的所有key值。
// 删除单个
cache.invalidate(key);
// 批量删除
cache.invalidateAll(List<String>)
// 清空缓存
Cache.invalidateAll()
cache.cleanUp()
4.2.1 清除时机
使用CacheBuilder构建的缓存不会"自动"执行清理和回收工作,也不会在某个缓存项过期后马上清理,也没有诸如此类的清理机制。相反,它会在写操作时顺带做少量的维护工作,或者偶尔在读操作时做——如果写操作实在太少的话。
这样做的原因在于:如果要自动地持续清理缓存,就必须有一个线程,这个线程会和用户操作竞争共享锁。此外,某些环境下线程创建可能受限制,这样CacheBuilder就不可用了。
相反,我们把选择权交到你手里。如果你的缓存是高吞吐的,那就无需担心缓存的维护和清理等工作。如果你的 缓存只会偶尔有写操作,而你又不想清理工作阻碍了读操作,那么可以创建自己的维护线程,以固定的时间间隔调用Cache.cleanUp()。ScheduledExecutorService可以帮助你很好地实现这样的定时调度。
4.3 、统计信息
// 要想查看统计信息的前提是 构建的时候开启了统计信息开关
.recordStats() //开启统计信息开关
// 获取统计信息
cache.stats()
统计属性 | 含义 |
hitRate() | 缓存命中率 |
averageLoadPenalty() | 加载新值的平均时间,单位为纳秒 |
evictionCount() | 缓存项被回收的总数,不包括显式清除 |
五、总结
GuavaCache毕竟是一款面向本地缓存的,轻量级的Cache,适合缓存少量数据。如果你想缓存上千万数据,可以为每个key设置不同的存活时间,并且高性能,那并不适合使用GuavaCach
5.2 refresh和expire刷新机制
考虑到时效性,我们可以使用expireAfterWrite,使每次更新之后的指定时间让缓存失效,然后重新加载缓存。guava cache会严格限制只有1个加载操作,这样会很好地防止缓存失效的瞬间大量请求穿透到后端引起雪崩效应。
然而,通过分析源码,guava cache在限制只有1个加载操作时进行加锁,其他请求必须阻塞等待这个加载操作完成;而且,在加载完成之后,其他请求的线程会逐一获得锁,去判断是否已被加载完成,每个线程必须轮流地走一个“”获得锁,获得值,释放锁“”的过程,这样性能会有一些损耗。这里由于我们计划本地缓存1秒,所以频繁的过期和加载,锁等待等过程会让性能有较大的损耗。因此我们考虑使用refreshAfterWrite。refreshAfterWrite的特点是,在refresh的过程中,严格限制只有1个重新加载操作,而其他查询先返回旧值,这样有效地可以减少等待和锁争用,所以refreshAfterWrite会比expireAfterWrite性能好。但是它也有一个缺点,因为到达指定时间后,它不能严格保证所有的查询都获取到新值。了解过guava cache的定时失效(或刷新)原来的同学都知道,guava cache并没使用额外的线程去做定时清理和加载的功能,而是依赖于查询请求。在查询的时候去比对上次更新的时间,如超过指定时间则进行加载或刷新。所以,如果使用refreshAfterWrite,在吞吐量很低的情况下,如很长一段时间内没有查询之后,发生的查询有可能会得到一个旧值(这个旧值可能来自于很长时间之前),这将会引发问题。