前言
在我们日常开发中,如果某些数据会频繁的进行读取,并且很少会做修改,我们一般会对这些数据进行缓存,来提高读取的速度。
使用缓存之所以能提高性能,是因为读取的速度比从磁盘或数据库中读取的速度更高。
但是使用缓存并不是全部都是好处,因为存放缓存数据的空间一般都是CPU内存,或者Redis等,它们的空间更宝贵,是典型的空间换时间,所以需要放到缓存中的数据应该是真正需要缓存的。
JVM缓存
在最开始,没有其他第三方缓存方案时,如果我们需要将某个数据进行缓存,一般会通过一个Map结构来存放,使用缓存的唯一标识作为Map的key,要缓存的数据作为Map的value。
这种方式本质上是使用JVM的堆内存做为缓存空间。使用这种方式的优点就是简单,只需要按照Map的API进行操作。
但是也存在一些问题:
只能显式的对缓存数据进行写入和清除;
没有现成的淘汰策略,如果要实现淘汰策略则需要自己实现;
清除数据时没有回调机制;
Guava Cache、EhCache
因为JVM缓存存在的一系列问题,所以出现了一些专门作为JVM缓存的工具,如EhCache,Guava Cache。
这些工具可以解决使用Map结构作为缓存的一些问题。如数据淘汰策略,清除回调等。
当然,因为需要具备这些功能,则引入一些依赖变得必要,需要额外增加一些维护和系统的消耗。
分布式缓存
不管是使用Map结构的JVM缓存,还是使用Guava Cache、EhCache等第三方工具,本质上都是使用JVM的堆内存作为缓存空间。
这样的方式只能满足在单节点使用,但是在分布式场景中,则需要每个节点都要有单独的空间进行数据缓存,并且在缓存数据需要更新时要对每个节点进行更新,在一些大厂的系统中,往往一个系统会有成百上千个节点,如果使用JVM缓存,在数据需要更新时,逐个节点更新,运维小哥哥估计又要爆粗口了,这样的方式显然是不可接受的。
于是就出现了分布式缓存中间件,如Redis、Memcached,在分布式环境下可以共享内存。
Guava Cache的使用
假设一个场景,比如我们有一个产品要销售,而产品的基本信息如名称,售价,描述等信息需要频繁进行查询,如果每次查询时都从数据库中获取则会大大降低效率。我们使用Guava Cache进行缓存。
class Product {
// 产品编码
private String code;
// 产品名称
private String name;
// 产品价格
private BigDecimal price;
// 产品描述
private String desc;
// 省略构造方法
// 省略getter,setter
}
基本使用
按照面向对象的思想,针对产品Product的缓存也会有一个具体的对象存在,所以需要先创建出一个Product的缓存对象。
public static LoadingCache<String, Product> buildCache() {
CacheBuilder<Object, Object> builder = CacheBuilder.newBuilder();
return builder.build(new CacheLoader<String, Product>() {
/**
* 当未命中缓存数据时,调用load方法获取
*/
@Override
public Product load(String key) throws Exception {
System.out.println("从数据库查询产品");
return new Product(key, "产品".concat(key), new BigDecimal(10), "自定义一个产品");
}
});
}
先通过CacheBuilder.newBuilder()
方法创建出一个可以创建缓存对象的构建器;
使用builder.build()
方法则可以创建出一个缓存对象LoadingCache
;
在build()
方法中需要传入一个CacheLoader
对象,用于加载缓存数据;
有了这个LoadingCache
对象之后,我们就可以从缓存对象中获取我们需要的数据了。
public static void main(String[] args) throws ExecutionException {
LoadingCache<String, Product> productCache = buildCache();
Product product1 = productCache.get("1001");
System.out.println(product1);
Product product2 = productCache.get("1001");
System.out.println(product2);
}
执行结果:
从执行结果我们可以看出,我们从缓存中第一次获取数据时会执行load()
方法,所以打印了“从数据库查询产品”。
当我们第二次从缓存中获取数据时则并没有打印,说明没有执行load()
方法,这代表命中了Guava Cache中的缓存数据。
当然,到这里还并没有体现出Guava Cache比使用Map结构的JVM缓存更优秀的地方,是因为我的代码中还没有使用到。
过期淘汰策略
Guava Cache提供了多种淘汰缓存数据的方式,可以通过CacheBuilder
在build()
时同时指定。
public static LoadingCache<String, Product> buildCache() {
CacheBuilder<Object, Object> builder = CacheBuilder.newBuilder();
return builder
// 2秒未写入则淘汰
.expireAfterWrite(2, TimeUnit.SECONDS)
// 3秒未访问则淘汰
.expireAfterAccess(3,TimeUnit.SECONDS)
// 4秒未写入则刷新
.refreshAfterWrite(4,TimeUnit.SECONDS)
.build(new CacheLoader<String, Product>() {
@Override
public Product load(String key) throws Exception {
System.out.println("从数据库查询产品");
return new Product(key, "产品".concat(key), new BigDecimal(10), "自定义一个产品");
}
});
}
expireAfterWrite:当缓存项指定时间未更新则淘汰;
expireAfterAccess:当缓存项指定时间未访问则淘汰;
refreshAfterWrite:当缓存项上次被更新后多久会被刷新。
refreshAfterWrite
这个策略比较特殊,当第一个请求执行load()
把数据加载进缓存后,如果指定的过期时间是3秒,则在这3秒内获取缓存项都会从缓存中获取,在3秒后如果没有访问,缓存数据不会被淘汰;当有新的请求访问时,才会执行load()
方法重新加载,这个过程只会阻塞当前请求的线程,如果在加载过程中有其他线程也访问同一个key,则会立即返回原来的缓存数据,不会阻塞。
refreshAfterWrite
的机制会导致如果有很长一段时间没有访问,突然有多个线程访问时,会拿到旧值,这个旧值可能是很久以前的。
所以,一般都会使用expireAfterAccess
和refreshAfterWrite
搭配使用。
缓存清除
在实际场景中,在创建Cache对象时虽然指定了淘汰策略,某些情况下可能仍需要手动进行缓存的清除,来达到刷新缓存的目的。
Cache.invalidate(key)
:按照key单个清除;
Cache.invalidateAll(keys)
:按照keys批量清除;
Cache.invalidateAll()
:清除所有缓存项;
清除回调
文章开始介绍JVM缓存时有说过,Guava Cache支持清除回调,可以在缓存被清除时执行一个回调方法,便于我们做一些分析和统计。
那么如果使用呢?
public static LoadingCache<String, Product> buildCache() {
CacheBuilder<Object, Object> builder = CacheBuilder.newBuilder();
return builder
.expireAfterAccess(3, TimeUnit.SECONDS)
// 注册一个缓存被清除时的监听器
.removalListener(removalNotification -> {
System.out.printf("产品:%s被清除,移除原因:%s \n", removalNotification.getKey(), removalNotification.getCause());
})
.build(new CacheLoader<String, Product>() {
@Override
public Product load(String key) throws Exception {
System.out.println("从数据库查询产品");
return new Product(key, "产品".concat(key), new BigDecimal(10), "自定义一个产品");
}
});
}
由上面代码可以看出,只需要在CacheBuilder
上注册一个监听器,便可以在缓存被清除时执行监听器中的代码。
需要注意的是,只支持注册一个监听器,如果重复注册,则会抛出在注册时抛出异常。
上面代码中的这种注册方式的监听器在缓存被移除时,监听器的执行和缓存清除的线程是同步执行的,也就是说,如果清除缓存中的代码运行时间较长,则对应的invalidate
方法执行时间也会变长。
public static LoadingCache<String, Product> buildCache() {
CacheBuilder<Object, Object> builder = CacheBuilder.newBuilder();
return builder
.expireAfterAccess(3, TimeUnit.SECONDS)
// 注册异步监听
.removalListener(RemovalListeners.asynchronous(
removalNotification ->
System.out.printf("产品:%s被清除,移除原因:%s \n", removalNotification.getKey(), removalNotification.getCause()),
Executors.newSingleThreadExecutor()
)).build(new CacheLoader<String, Product>() {
@Override
public Product load(String key) throws Exception {
System.out.println("从数据库查询产品");
return new Product(key, "产品".concat(key), new BigDecimal(10), "自定义一个产品");
}
});
}
解决这个问题则可以通过RemovalListeners.asynchronous(Listener,Executor)
注册一个异步事件监听器,不过需要给这个异步事件监听指定一个线程池。
如果在监听器代码执行过程中抛出异常,不管是同步监听器还是异步监听器,都不会影响监听器的运行,Guava会将异常丢弃。
以上是本文分享所有内容,希望对你有所帮助吧~