26

集中式内存缓存Guava Cache - 简书

 4 years ago
source link: https://www.jianshu.com/p/64b0df87e51b?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

集中式内存缓存Guava Cache

22016.09.08 09:47:08字数 3,279阅读 18,313

缓存的主要作用是暂时在内存中保存业务系统的数据处理结果,并且等待下次访问使用。在日长开发有很多场合,有一些数据量不是很大,不会经常改动,并且访问非常频繁。但是由于受限于硬盘IO的性能或者远程网络等原因获取可能非常的费时。会导致我们的程序非常缓慢,这在某些业务上是不能忍的!而缓存正是解决这类问题的神器!

webp

当然也并不是说你用了缓存你的系统就一定会变快,建议在用之前看一下使用缓存的9大误区(上) 使用缓存的9大误区(下)

缓存在很多系统和架构中都用广泛的应用,例如:

  • CPU缓存
  • 操作系统缓存
  • HTTP缓存
  • 数据库缓存
  • 静态文件缓存
  • 分布式缓存

可以说在计算机和网络领域,缓存是无处不在的。可以这么说,只要有硬件性能不对等,涉及到网络传输的地方都会有缓存的身影。

缓存总体可分为两种 集中式缓存 和 分布式缓存

“集中式缓存"与"分布式缓存"的区别其实就在于“集中”与"非集中"的概念,其对象可能是服务器、内存条、硬盘等。比如:

1.服务器版本:
  • 缓存集中在一台服务器上,为集中式缓存。
  • 缓存分散在不同的服务器上,为分布式缓存。
2.内存条版本:
  • 缓存集中在一台服务器的一条内存条上,为集中式缓存。
  • 缓存分散在一台服务器的不同内存条上,为分布式缓存。
3.硬盘版本:
  • 缓存集中在一台服务器的一个硬盘上,为集中式缓存。
  • 缓存分散在一台服务器的不同硬盘上,为分布式缓存。

想了解分布式缓存可以看一下浅谈分布式缓存那些事儿

这是几个当前比较流行的java 分布式缓存框架5个强大的Java分布式缓存框架推荐

而我们今天要讲的是集中式内存缓存guava cache,这是当前我们项目正在用的缓存工具,研究一下感觉还蛮好用的。当然也有很多其他工具,还是看个人喜欢。oschina上面也有很多类似开源的java缓存框架

Guava Cache与ConcurrentMap很相似,但也不完全一样。最基本的区别是ConcurrentMap会一直保存所有添加的元素,直到显式地移除。相对地,Guava Cache为了限制内存占用,通常都设定为自动回收元素。在某些场景下,尽管LoadingCache 不回收元素,它也是很有用的,因为它会自动加载缓存。

guava cache 加载缓存主要有两种方式:

  1. cacheLoader
  2. callable callback

cacheLoader

创建自己的CacheLoader通常只需要简单地实现V load(K key) throws Exception方法.

cacheLoader方式实现实例:

LoadingCache<Key, Value> cache = CacheBuilder.newBuilder()
       .build(
           new CacheLoader<Key, Value>() {
             public Value load(Key key) throws AnyException {
               return createValue(key);
             }
           });
...
try {
  return cache.get(key);
} catch (ExecutionException e) {
  throw new OtherException(e.getCause());
} 

从LoadingCache查询的正规方式是使用get(K)方法。这个方法要么返回已经缓存的值,要么使用CacheLoader向缓存原子地加载新值(通过load(String key) 方法加载)。由于CacheLoader可能抛出异常,LoadingCache.get(K)也声明抛出ExecutionException异常。如果你定义的CacheLoader没有声明任何检查型异常,则可以通过getUnchecked(K)查找缓存;但必须注意,一旦CacheLoader声明了检查型异常,就不可以调用getUnchecked(K)

Callable

这种方式不需要在创建的时候指定load方法,但是需要在get的时候实现一个Callable匿名内部类。

Callable方式实现实例:

Cache<Key, Value> cache = CacheBuilder.newBuilder()
    .build(); // look Ma, no CacheLoader
...
try {
  // If the key wasn't in the "easy to compute" group, we need to
  // do things the hard way.
  cache.get(key, new Callable<Value>() {
    @Override
    public Value call() throws AnyException {
      return doThingsTheHardWay(key);
    }
  });
} catch (ExecutionException e) {
  throw new OtherException(e.getCause());
} 

而如果加上现在java8里面的Lambda表达式会看起来舒服很多

   try {
      cache.get(key,()->{
        return null;
      });
    } catch (ExecutionException e) {
      e.printStackTrace();
    }   

所有类型的Guava Cache,不管有没有自动加载功能,都支持get(K, Callable<V>)方法。这个方法返回缓存中相应的值,或者用给定的Callable运算并把结果加入到缓存中。在整个加载方法完成前,缓存项相关的可观察状态都不会更改。这个方法简便地实现了模式"如果有缓存则返回;否则运算、缓存、然后返回"。

当然除了上面那种被动的加载,它还提供了主动加载的方法cache.put(key, value),这会直接覆盖掉给定键之前映射的值。使用Cache.asMap()视图提供的任何方法也能修改缓存。但请注意,asMap视图的任何方法都不能保证缓存项被原子地加载到缓存中。进一步说,asMap视图的原子运算在Guava Cache的原子加载范畴之外,所以相比于Cache.asMap().putIfAbsent(K,V)Cache.get(K, Callable<V>) 应该总是优先使用。

上面有提到 Guava Cache与ConcurrentMap 不一样的地方在于 guava cache可以自动回收元素,这在某种情况下可以更好优化资源被浪费的情况。

基于容量的回收

当缓存设置CacheBuilder.maximumSize(size)。这个size是指具体缓存项目的数量而不是内存的大小。而且并不是说数量大于size才会回收,而是接近size就回收。

  • expireAfterAccess(long, TimeUnit):缓存项在给定时间内没有被读/写访问,则回收。请注意这种缓存的回收顺序和基于大小回收一样。
  • expireAfterWrite(long, TimeUnit):缓存项在给定时间内没有被写访问(创建或覆盖),则回 收。如果认为缓存数据总是在固定时候后变得陈旧不可用,这种回收方式是可取的。

guava cache 还提供一个Ticker方法来设置缓存失效的具体时间精度为纳秒级。

基于引用的回收

通过使用弱引用的键、或弱引用的值、或软引用的值,Guava Cache可以把缓存设置为允许垃圾回收:

  • CacheBuilder.weakKeys():使用弱引用存储键。当键没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(==),使用弱引用键的缓存用==而不是equals比较键。
  • CacheBuilder.weakValues():使用弱引用存储值。当值没有其它(强或软)引用时,缓存项可以被垃圾回收。因为垃圾回收仅依赖恒等式(==),使用弱引用值的缓存用==而不是equals比较值。
  • CacheBuilder.softValues():使用软引用存储值。软引用只有在响应内存需要时,才按照全局最近最少使用的顺序回收。考虑到使用软引用的性能影响,我们通常建议使用更有性能预测性的缓存大小限定(见上文,基于容量回收)。使用软引用值的缓存同样用==而不是equals比较值。

任何时候,你都可以显式地清除缓存项,而不是等到它被回收:

  • 个别清除:Cache.invalidate(key)
  • 批量清除:Cache.invalidateAll(keys)
  • 清除所有缓存项:Cache.invalidateAll()

这里说一个小技巧,由于guava cache是存在就取不存在就加载的机制,我们可以对缓存数据有修改的地方显示的把它清除掉,然后再有任务去取的时候就会去数据源重新加载,这样就可以最大程度上保证获取缓存的数据跟数据源是一致的。

移除监听器

不要被名字所迷惑,这里指的是移除缓存的时候所触发的监听器。

请注意,RemovalListener抛出的任何异常都会在记录到日志后被丢弃[swallowed]。

   LoadingCache<K , V> cache = CacheBuilder
      .newBuilder()
      .removalListener(new RemovalListener<K, V>(){
         @Override
        public void onRemoval(RemovalNotification<K, V> notification) {
          System.out.println(notification.getKey()+"被移除");
        }
      })

Lambda的写法:

    LoadingCache<K , V> cache = CacheBuilder
       .newBuilder()
       .removalListener((notification)->{
         System.out.println(notification.getKey()+"已移除");
       })

警告:默认情况下,监听器方法是在移除缓存时同步调用的。因为缓存的维护和请求响应通常是同时进行的,代价高昂的监听器方法在同步模式下会拖慢正常的缓存请求。在这种情况下,你可以使用RemovalListeners.asynchronous(RemovalListener, Executor)把监听器装饰为异步操作。

这里提一下guava cache的自动回收,并不是缓存项过期起马上清理掉,而是在读或写的时候做少量的维护工作,这样做的原因在于:如果要自动地持续清理缓存,就必须有一个线程,这个线程会和用户操作竞争共享锁。此外,某些环境下线程创建可能受限制,这样CacheBuilder就不可用了。

相反,我们把选择权交到你手里。如果你的缓存是高吞吐的,那就无需担心缓存的维护和清理等工作。如果你的缓存只会偶尔有写操作,而你又不想清理工作阻碍了读操作,那么可以创建自己的维护线程,以固定的时间间隔调用Cache.cleanUp()ScheduledExecutorService可以帮助你很好地实现这样的定时调度。

guava cache 除了回收还提供一种刷新机制LoadingCache.refresh(K),他们的的区别在于,guava cache 在刷新时,其他线程可以继续获取它的旧值。这在某些情况是非常友好的。而回收的话就必须等新值加载完成以后才能继续读取。而且刷新是可以异步进行的。

如果刷新过程抛出异常,缓存将保留旧值,而异常会在记录到日志后被丢弃[swallowed]。
重载CacheLoader.reload(K, V)可以扩展刷新时的行为,这个方法允许开发者在计算新值时使用旧的值。

  //有些键不需要刷新,并且我们希望刷新是异步完成的
LoadingCache<Key, Value> graphs = CacheBuilder.newBuilder()
      .maximumSize(1000)
      .refreshAfterWrite(1, TimeUnit.MINUTES)
      .build(
          new CacheLoader<Key, Value>() {
            public Graph load(Key key) { // no checked exception
              return getValue(key);
            }

            public ListenableFuture<Value> reload(final Key key, Value value) {
              if (neverNeedsRefresh(key)) {
                return Futures.immediateFuture(value);
              } else {
                // asynchronous!
                ListenableFutureTask<Value> task = ListenableFutureTask.create(new Callable<Value>() {
                  public Graph call() {
                    return getValue(key);
                  }
                });
                executor.execute(task);
                return task;
              }
            }
          });
       

CacheBuilder.refreshAfterWrite(long, TimeUnit)可以为缓存增加自动定时刷新功能。和expireAfterWrite相反,refreshAfterWrite通过定时刷新可以让缓存项保持可用,但请注意:缓存项只有在被检索时才会真正刷新(如果CacheLoader.refresh实现为异步,那么检索不会被刷新拖慢)。因此,如果你在缓存上同时声明expireAfterWriterefreshAfterWrite,缓存并不会因为刷新盲目地定时重置,如果缓存项没有被检索,那刷新就不会真的发生,缓存项在过期时间后也变得可以回收。

asMap视图

asMap视图提供了缓存的ConcurrentMap形式,但asMap视图与缓存的交互需要注意:

  • cache.asMap()包含当前所有加载到缓存的项。因此相应地,cache.asMap().keySet()包含当前所有已加载键;
  • asMap().get(key)实质上等同于cache.getIfPresent(key),而且不会引起缓存项的加载。这和Map的语义约定一致。
  • 所有读写操作都会重置相关缓存项的访问时间,包括Cache.asMap().get(Object)方法和Cache.asMap().put(K, V)方法,但不包括Cache.asMap().containsKey(Object)方法,也不包括在Cache.asMap()的集合视图上的操作。比如,遍历Cache.asMap().entrySet()不会重置缓存项的读取时间。

guava cache为我们实现统计功能,这在其它缓存工具里面还是很少有的。

  • CacheBuilder.recordStats()用来开启Guava Cache的统计功能。统计打开后, Cache.stats()方法会返回CacheStats对象以提供如下统计信息:

  • hitRate():缓存命中率;

  • averageLoadPenalty():加载新值的平均时间,单位为纳秒;

  • evictionCount():缓存项被回收的总数,不包括显式清除。
    此外,还有其他很多统计信息。这些统计信息对于调整缓存设置是至关重要的,在性能要求高的应用中我们建议密切关注这些数据, 这里我们就不一一介绍了。

缓存虽然是个好东西,但是一定不能滥用,一定要根据自己系统的需求来妥善抉择。
当然 guava 除了cache这块还有很多其它非常有用的工具。

本文参考:https://github.com/google/guava/wiki/CachesExplained


作者信息
本文系MaxLeap团队_Service&Infra成员:贾威威 【原创】


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK