前言

在我们日常开发中,如果某些数据会频繁的进行读取,并且很少会做修改,我们一般会对这些数据进行缓存,来提高读取的速度。

使用缓存之所以能提高性能,是因为读取的速度比从磁盘或数据库中读取的速度更高。

但是使用缓存并不是全部都是好处,因为存放缓存数据的空间一般都是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);
}

执行结果:

Guava Cache缓存实践_缓存

从执行结果我们可以看出,我们从缓存中第一次获取数据时会执行​​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会将异常丢弃。


以上是本文分享所有内容,希望对你有所帮助吧~