专业的JAVA编程教程与资源

网站首页 > java教程 正文

「每日分享」性能优化之本地缓存利器-guava cache

temp10 2024-10-25 16:59:13 java教程 14 ℃ 0 评论

点击上方"java全栈技术"关注,每天学习一个java知识点

在系统中,一些访问量大但是数据量小、与业务无关的缓存适合采用本地缓存。为什么不采用分布式缓存呢?分布式集群缓存的构建、维护成本较高,不太适合做紧急的项目。而本地缓存访问速度快,使用方便,劣势是数据更新的一致性难以保证,使用范围有所限制。Guava Cache是本地缓存的不二之选,今天我们就来一探究竟。下面是本文的目录大纲:

「每日分享」性能优化之本地缓存利器-guava cache

  1. guava cache的优点和使用场景,用来判断业务中是否适合使用此缓存
  2. 介绍常用的方法,并给出示例,作为使用的参考
  3. 深入解读源码。

guava简介

guava cache是一个本地缓存。有以下优点:

  • 很好的封装了get、put操作,能够集成数据源。
  • 一般我们在业务中操作缓存,都会操作缓存和数据源两部分。如:put数据时,先插入DB,再删除原来的缓存;ge数据时,先查缓存,命中则返回,没有命中时,需要查询DB,再把查询结果放入缓存中。 guava cache封装了这么多步骤,只需要调用一次get/put方法即可。
  • 线程安全的缓存,与ConcurrentMap相似,但前者增加了更多的元素失效策略,后者只能显示的移除元素。
  • Guava Cache提供了三种基本的缓存回收方式:基于容量回收、定时回收和基于引用回收。定时回收有两种:按照写入时间,最早写入的最先回收;按照访问时间,最早访问的最早回收。
  • 监控缓存加载/命中情况。

常用方法

  • V getIfPresent(Object key) 获取缓存中key对应的value,如果缓存没命中,返回null。return value if cached, otherwise return null.
  • V get(K key) throws ExecutionException 获取key对应的value,若缓存中没有,则调用LocalCache的load方法,从数据源中加载,并缓存。 return value if cached, otherwise load, cache and return.
  • void put(K key, V value) if cached, return; otherwise create, cache , and return.
  • void invalidate(Object key); 删除缓存
  • void invalidateAll(); 清楚所有的缓存,相当远map的clear操作。
  • long size(); 获取缓存中元素的大概个数。为什么是大概呢?元素失效之时,并不会实时的更新size,所以这里的size可能会包含失效元素。
  • CacheStats stats(); 缓存的状态数据,包括(未)命中个数,加载成功/失败个数,总共加载时间,删除个数等。
  • ConcurrentMap<K, V> asMap(); 将缓存存储到一个线程安全的map中。

批量操作就是循环调用上面对应的方法,如:

  • ImmutableMap<K, V> getAllPresent(Iterable<?> keys);
  • void putAll(Map<? extends K,? extends V> m);
  • void invalidateAll(Iterable<?> keys);

在GuavaCache中缓存的容器被定义为接口Cache<K, V>的实现类,这些实现类都是线程安全的,因此通常定义为一个单例。下面是官方给的demo:

回收策略

常用定时回收,下面是三种基于时间的清理或刷新缓存数据的方式:

expireAfterAccess: 当缓存项在指定的时间段内没有被读或写就会被回收。

expireAfterWrite:当缓存项在指定的时间段内没有更新就会被回收。

refreshAfterWrite:当缓存项上一次更新操作之后的多久会被刷新。

考虑到时效性,我们可以使用expireAfterWrite,使每次更新之后的指定时间让缓存失效,然后重新加载缓存。guava cache会严格限制只有1个加载操作,这样会很好地防止缓存失效的瞬间大量请求穿透到后端引起雪崩效应。

然而,通过分析源码,guava cache在限制只有1个加载操作时进行加锁,其他请求必须阻塞等待这个加载操作完成;而且,在加载完成之后,其他请求的线程会逐一获得锁,去判断是否已被加载完成,每个线程必须轮流地走一个“”获得锁,获得值,释放锁“”的过程,这样性能会有一些损耗。这里由于我们计划本地缓存1秒,所以频繁的过期和加载,锁等待等过程会让性能有较大的损耗。

因此我们考虑使用refreshAfterWrite。refreshAfterWrite的特点是,在refresh的过程中,严格限制只有1个重新加载操作,而其他查询先返回旧值,这样有效地可以减少等待和锁争用,所以refreshAfterWrite会比expireAfterWrite性能好。但是它也有一个缺点,因为到达指定时间后,它不能严格保证所有的查询都获取到新值。了解过guava cache的定时失效(或刷新)原来的同学都知道,guava cache并没使用额外的线程去做定时清理和加载的功能,而是依赖于查询请求。在查询的时候去比对上次更新的时间,如超过指定时间则进行加载或刷新。所以,如果使用refreshAfterWrite,在吞吐量很低的情况下,如很长一段时间内没有查询之后,发生的查询有可能会得到一个旧值(这个旧值可能来自于很长时间之前),这将会引发问题。

可以看出refreshAfterWrite和expireAfterWrite两种方式各有优缺点。

guava cache源码解析

示例代码:

先了解一些主要类和接口:

  • CacheBuilder:类,缓存构建器。构建缓存的入口,指定缓存配置参数并初始化本地缓存。
  • CacheBuilder在build方法中,会把前面设置的参数,全部传递给LocalCache,它自己实际不参与任何计算。这种初始化参数的方法值得借鉴,代码简洁易读。
  • CacheLoader:抽象类。用于从数据源加载数据,定义load、reload、loadAll等操作。
  • Cache:接口,定义get、put、invalidate等操作,这里只有缓存增删改的操作,没有数据加载的操作。
  • AbstractCache:抽象类,实现Cache接口。其中批量操作都是循环执行单次行为,而单次行为都没有具体定义。
  • LoadingCache:接口,继承自Cache。定义get、getUnchecked、getAll等操作,这些操作都会从数据源load数据。
  • AbstractLoadingCache:抽象类,继承自AbstractCache,实现LoadingCache接口。
  • LocalCache:类。整个guava cache的核心类,包含了guava cache的数据结构以及基本的缓存的操作方法。
  • LocalManualCache:LocalCache内部静态类,实现Cache接口。其内部的增删改缓存操作全部调用成员变量localCache(LocalCache类型)的相应方法。
  • LocalLoadingCache:LocalCache内部静态类,继承自LocalManualCache类,实现LoadingCache接口。其所有操作也是调用成员变量localCache(LocalCache类型)的相应方法。

综上,guava cache的核心操作,都在LocalCache中实现。

其他:

  • CacheStats:缓存加载/命中统计信息。

在看具体的代码之前,先来简单了解一下LocalCache的数据结构。

LocalCache的数据结构如下所示:

LocalCache的数据结构与ConcurrentHashMap很相似,都由多个segment组成,且各segment相对独立,互不影响,所以能支持并行操作。每个segment由一个table和若干队列组成。缓存数据存储在table中,其类型为AtomicReferenceArray<ReferenceEntry<K, V>>,即一个数组,数组中每个元素是一个链表。两个队列分别是writeQueue和accessQueue,用来存储写入的数据和最近访问的数据,当数据过期,需要刷新整体缓存(见上述示例最后一次cache.getIfPresent("key5"))时,遍历队列,如果数据过期,则从table中删除。segment中还有基于引用场景的其他队列,这里先不做讨论。

CacheBuilder

CacheBuilder是缓存配置和构建入口,先看一些属性。CacheBuilder的设置操作都是为这些属性赋值。

CacheBuilder构建缓存有两个方法:

LocalCache

LocalCache是guava cache的核心类。LocalCache的构造函数在上面已经分析过,接着看下核心方法。

对于get(key, loader)方法流程:

  • 对key做hash,找到存储的segment及数组table上的位置;
  • 链表上查找entry,如果entry不为空,且value没有过期,则返回value,并刷新entry。
  • 若链表上找不到entry,或者value已经过期,则调用lockedGetOrLoad。
  • 锁住整个segment,遍历entry可能在的链表,查看数据是否存在是否过期,若存在则返回。若过期则删除(table,各种queue)。若不存在,则新建一个entry插入table。放开整个segment的锁。
  • 锁住entry,调用loader的reload方法,从数据源加载数据,然后调用storeLoadedValue更新缓存。
  • storeLoadedValue时,锁住整个segment,将value设置到entry中,并设置相关数据(入写入/访问队列,加载/命中数据等)。

getAll(keys)方法:

  • 循环调用get方法,从缓存中获取key对应的value。没有命中的记录下来。
  • 如果有没有命中的key,调用loadAll(keys,loader)方法加载数据。
  • 将加载的数据依次缓存,调用segment的put(K key, int hash, V value, boolean onlyIfAbsent)方法。
  • put时,锁住整个segment,将数据插入链表,更新统计数据。

put(key,value)方法:

  • 对key做hash,找到segment的位置和table上的位置;
  • 锁住整个segment,将数据插入链表,更新统计数据。

putAll(map) 循环调用put方法。

putIfAbsent(key, value) 缓存中,键值对不存在的时候才插入。

实践

guava cache是将数据源中的数据缓存在本地,那如果我们想把远端数据源中的数据缓存在远端分布式缓存(如redis),可以怎么来使用guava cache的方式进行封装呢?

可以仿照guava写一个简单的缓存,定义如下:

CacheBuilder类 : 配置缓存参数,构建缓存。同上面所讲。

Cache接口:定义增删查接口。

MyCache类:实现Cache接口,put -> 存入DB,更新缓存; get -> 查询缓存,存在即返回;若不存在,查询DB,更新缓存,返回。

CacheLoader类:供MyCache调用,get和getAll时提供单次查DB和批量查DB。

本文暂时没有评论,来添加一个吧(●'◡'●)

欢迎 发表评论:

最近发表
标签列表