guava cache 用法详解

1. 引言

在计算机领域的各个场景中,缓存都是一个非常常用的技术手段。通过高性能的缓存暂时存储重要的数据,可以有效提升整个系统的性能。

在 java 环境下,最常见的一种开源缓存框架要数 guava cache 了。简单的配置与优秀的性能,让它得到了广大 java 程序员的青睐。

2. guava cache 的使用

guava cache 的使用非常简单,下面是一个 sample:

LoadingCache<String, String> cache = CacheBuilder.newBuilder()
        .build(new CacheLoader<String, String>() {
             @Override
             public String load(String name) {
                 // 在 cache 中获取不到就会从 load() 方法中获取数据
                 return "world";
            }
        });
 cache.get("hello");

CacheBuilder 拥有诸如缓存容量、过期时间等一系列参数:

LoadingCache<Object, Object> userCache = CacheBuilder.newBuilder()
         // 基于容量回收。缓存的最大数量。超过就取MAXIMUM_CAPACITY = 1 << 30。依靠LRU队列recencyQueue来进行容量淘汰
        .maximumSize(1000)
         // 基于容量回收。但这是统计占用内存大小,maximumWeight与maximumSize不能同时使用。设置最大总权重
        .maximumWeight(1000)
         // 设置权重(可当成每个缓存占用的大小)
        .weigher((o, o2) -> 5)
         // 软弱引用(引用强度顺序:强软弱虚)
         // -- 弱引用key
        .weakKeys()
         // -- 弱引用value
        .weakValues()
         // -- 软引用value
        .softValues()
         // 过期失效回收
         // -- 没读写访问下,超过5秒会失效(非自动失效,需有任意getput方法才会扫描过期失效数据)
        .expireAfterAccess(5L, TimeUnit.SECONDS)
         // -- 没写访问下,超过5秒会失效(非自动失效,需有任意putget方法才会扫描过期失效数据)
        .expireAfterWrite(5L, TimeUnit.SECONDS)
         // 没写访问下,超过5秒会失效(非自动失效,需有任意putget方法才会扫描过期失效数据。但区别是会开一个异步线程进行刷新,刷新过程中访问返回旧数据)
        .refreshAfterWrite(5L, TimeUnit.SECONDS)
         // 移除监听事件
        .removalListener(removal -> {
             // 可做一些删除后动作,比如上报删除数据用于统计
             System.out.printf("触发删除动作,删除的key=%s%n", removal);
        })
         // 并行等级。决定segment数量的参数,concurrencyLevel与maxWeight共同决定
        .concurrencyLevel(16)
         // 开启缓存统计。比如命中次数、未命中次数等
        .recordStats()
         // 所有segment的初始总容量大小
        .initialCapacity(512)
         // 用于测试,可任意改变当前时间。参考:https://www.geek-share.com/detail/2689756248.html
        .ticker(new Ticker() {
             @Override
             public long read() {
                     return 0;
                    }
        })
        .build(new CacheLoader<Object, Object>() {
             @Override
             public Object load(Object name) {
                     // 在cache找不到就取数据
                     return String.format("重新load(%s):%s", System.currentTimeMillis(), name);
                    }
        });
 // 简单使用
 userCache.put("hello", "world");
 System.out.println(userCache.get("hello"));

当然,这不是本文我们讨论的重点,本文将为您介绍 guava cache 异步 reload 策略。

3. guava cache 异步刷新

3.1 基本介绍

在实际的生产环境中,我们可能面临诸如数据源获取耗时过长、数据源异常等各种问题,此时,无法获取到新的数据的情况下,我们往往希望仍旧能够返回缓存中旧的数据,即便旧的数据已经失效。

另一方面,当缓存数据失效时,主线程从数据源中获取数据的时间如果过长,就会阻塞主线程上任务的执行,这也是我们不希望看到的。

于是,guava cache 实现了异步刷新机制,解决了以下问题:

  1. 容错 — 数据源异常,仍然返回缓存中已失效的数据;
  2. 耗时 — 异步线程获取如果耗时超过预期,则主线程返回缓存中已失效的数据,避免阻塞。

3.2 使用方法

上面的示例中,我们在构建 guava cache 时,CacheBuilder 的 build 方法传入的参数是一个 CacheLoader 对象,并且实现了 load 方法。

要使用异步刷新机制,只要为这个传入的 CacheLoader 对象复写 reload 方法即可。

CacheLoader 默认的 reload 方法如下:

   public ListenableFuture<V> reload(K key, V oldValue) throws Exception {
     checkNotNull(key);
     checkNotNull(oldValue);
     return Futures.immediateFuture(load(key));
  }

可见,这个方法的返回值是一个 ListenableFuture 对象,默认的实现使用 Futures.immediateFuture 方法实现了在主线程中执行的策略。

我们只需要通过复写改变这个方法的默认行为即可。

5. 异步刷新基础实战

5.1 CacheLoader 对象的创建

首先我们创建一个 abstract 的 CacheLoader 对象,和默认方式类似 reload 方法返回异步封装的 load 方法:

    public abstract static class AsyncCacheLoader<K, V> extends CacheLoader<K, V> {
         private final ListeningExecutorService executorService =
                 MoreExecutors.listeningDecorator(Executors.newSingleThreadExecutor());

         protected ListeningExecutorService executorService() {
             return executorService;
        }

         @Override
         public ListenableFuture<V> reload(final K key, final V oldValue) {
             return executorService().submit(() -> {
                 try {
                     System.out.println("start to reload key: " + key);
                     return load(key);
                } catch (Exception ex) {
                     return oldValue;
                }
            });
        }
    }

5.2 创建 guava cache 对象

 LoadingCache<String, String> cache = CacheBuilder.newBuilder()
            .maximumSize(30)
            .refreshAfterWrite(1, TimeUnit.SECONDS)
            .build(new AsyncCacheLoader<String, String>() {
                 @Override
                 public String load(String key) throws Exception {
                     System.out.println("start to load key: " + key);
                     if (first) {
                         first = false;
                         return "world";
                    }
                     return "new_world";
                }
            });

load 方法很简单,first 是一个全局的 bool 值,初始状态下是 true,load 方法此时返回 world;当不是初始状态后,则返回字符串 new_world

5.3 main 函数执行

public static void main(String[] args) throws ExecutionException, InterruptedException {
         String value = cache.get("hello");
         System.out.println("first get: " + value);
         Thread.sleep(1200);
         value = cache.get("hello");
         System.out.println("second get: " + value);
    }

在代码中,我们通过 sleep 1.2 秒,模拟了缓存中数据失效的情况,当然,你也可以通过 tricker 来实现这一功能。

执行结果:

start to load key: hellofirst get: worldstart to reload key: hellostart to load key: hellosecond get: new_world

可以看到,当缓存中没有数据时,guava cache 通过 load 方法获取数据,而当缓存中存在数据但已失效后,guava cache 则改为通过 reload 方法获取数据。

6. 异步刷新实战进阶

那么,接下来我们要看看,如果数据源获取耗时过长,主线程是否会阻塞呢?

6.1 创建 guava cache 对象

基于上述实战代码,我们在 load 方法中增加 sleep 代码,来模拟数据源获取耗时的情况:

 LoadingCache<String, String> cache = CacheBuilder.newBuilder()
            .maximumSize(30)
            .refreshAfterWrite(1, TimeUnit.SECONDS)
            .build(new AsyncCacheLoader<String, String>() {
                 @Override
                 public String load(String key) throws Exception {
                     System.out.println("start to load key: " + key);
                     if (first) {
                         first = false;
                         return "world";
                    }
                     Thread.sleep(1000);
                     System.out.println("after load key: " + key);
                     return "new_world";
                }
            });

6.2 main 方法执行

  public static void main(String[] args) throws ExecutionException, InterruptedException {
         String value = cache.get("hello");
         System.out.println("first get: " + value);
         Thread.sleep(1200);
         value = cache.get("hello");
         System.out.println("second get: " + value);
         Thread.sleep(1200);
         value = cache.get("hello");
         System.out.println("third get: " + value);
    }

执行结果:

start to load key: hellofirst get: worldstart to reload key: hellostart to load key: hellosecond get: worldafter load key: hellothird get: new_world

可见,在第一次 reload 执行时,由于数据源获取时间过长,所以 guava cache 的主线程直接返回了缓存中已失效的数据 world,但数据获取线程并没有停止执行,于是,在第二次 reload 时,主线程返回了缓存中的数据 new_world。

7. 总结

guava cache 的异步 reload 策略可以有效实现容错、节约调用耗时的目的,但有一个致命的缺陷:主线程返回的数据有可能是已过期的。

通常,我们对于缓存中数据的实际失效时间并不敏感,在这样的情况下,即使 guava cache 返回了已失效数据,也并不会造成任何业务问题,而由此带来的性能提升与容错的好处是显而易见的。

但对于财务系统等实时性要求极高的系统,如何设置缓存,以及是否必须牺牲性能来换取数据的一致性就是一个十分值得斟酌的事情了。

那么,guava cache 是如何做到上述机制的呢?敬请期待下一篇文章,让我来深入 guava cache 的源码,详细为您解读。

发表评论

登录后才能评论
网站客服
网站客服
申请收录 侵权处理
分享本页
返回顶部