缓存应用之本地缓存-Guava Cache

缓存是高并发系统的三把利器之一(另两把是 限流、降级),可以说是必不可少的。缓存的主要目的是为了解决磁盘与内存速度差异问题,解决高并发下频繁访问数据库导致磁盘 I/O 压力和 CPU 负载过高问题。

这里所说的缓存是指业务系统的缓存,是将数据缓存在内存中,当下次有相同请求时就直接从内存中取数据返回。

缓存可以在服务端本地,也可以是远程独立的缓存系统,如 Redis,通常本地缓存和远程缓存配合使用。

本篇文章在 Spring 缓存体系上进行扩展和补充,更多可参考 Spring Boot 2实践系列(十):Spring 缓存体系Spring Boot 2实践系列(十一):Ehcache集成详解和使用Spring Boot 2实践系列(十二):Spring Data Redis 集成详解和使用官方:Spring Data Redis

Guava Cache

Guava Cache 简介

Guava 是 Google 开源的一组 Java 核心库,Guava Cache 是 Guava 中的一个模块。Guava 还包括新的集合类型(如 multimap 和 multiset),immutable 集合(不可变集合),图形库,函数类型以及用于并发,I / O ,哈希,基元,反射,字符串处理等基于 API 的实用程序,可以从源代码包路径 com.google.common 中看到各个模块。

关于各模块的具体使用,可参考 GitHub > Guava 用户手册其它关于 Guava 的帮助链接

Guava Cache 是运行在应用程序的本地缓存,是线程安全的,提供了一些其它特性,例如多种过期策略。

如果对本地缓存的数据大小控制在一定范围,不需要复杂的操作,只是简单的使用,甚至可以使用 ConcurrentMap 作为本地缓存,这也是 Spring Cache 缓存体系里默认的简单缓存实现。

Guava Cache 应用

通过 CacheBuilder 构建一个缓存对象,构建通常会传入一个缓存加载器 CacheLoader,重写 CacheLoader 里的 load 方法,在 load 中加载数据库的数据。

通过 CacheBuilder 对象的 get 方法获取数据,如果在缓存中存在数据则获取并返回,如果缓存中不存在则执行 load 中的逻辑,从数据库中查询数据并缓存。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public Account findById(String id) throws ExecutionException {
LoadingCache<String, Account> loadingCache = CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.expireAfterAccess(1, TimeUnit.SECONDS)
.maximumSize(1000)
.build(new CacheLoader<String, Account>() {
@Override
public Account load(String key) throws Exception {
return accountDao.findById(id);
}
});

return loadingCache.get(id);
}
}

CacheLoader

LoadingCache 使用 CacheLoader 来构建建 Cache,创建 CacheLoader 对象和实现 load 方法、抛出异常比较简单,如下示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
.maximumSize(1000)
.build(
new CacheLoader<Key, Graph>() {
public Graph load(Key key) throws AnyException {
return createExpensiveGraph(key);
}
});

...
try {
return graphs.get(key);
} catch (ExecutionException e) {
throw new OtherException(e.getCause());
}

LoadingCache

  1. get(key)

    LoadingCache 对象提供了 get(key) 方法来查询缓存中的数据,如果数据不存在, 则执行 CacheLoader 对象中实现 load 方法里的逻辑,CacheLoader 以原子方式将新值加载到缓存中。

  2. getAll(Iterable<? extends K>)

    LoadingCache 对象提供了 getAll(Iterable<? extends K>) 方法执行批量查找,返回一个不可变 Map。默认情况下,getAll 将为缓存中不存在的每个 Key 发出对 CacheLoader.load 的单独调用,并缓存新值。所以返回的 Map 包含了缓存中已存在的数据和执行 load 方法获取到的新数据。另外可以编写 CacheLoader.loadAll 实现来执行批量检索。

  3. get(key, new Callable<Value>()

    如果构建 LoadingCache 对象 CacheLoader,则可以调用些方法,如果 Key 值不存在,则执行实现 Callable.call 方法里的逻辑,call 方法是另起线程来执行查询。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    Cache<Key, Value> cache = CacheBuilder.newBuilder()
    .maximumSize(1000)
    .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());
    }
  4. put(key, value)

    直接使用此方法将键值插入到缓存中,如果 Key 已存在,则覆盖。

  5. Cache.asMap()

    取出缓存加载关联对象的所有数据,返回 ConcurrentMap。asMap() 方法上的所有操作都不会自动加载(同步)到缓存中。

LoadingCache 示例

注意,同一关联对象必须使用同一个 LoadingCache ,不能多次请求多次 new,否则每次都会是新值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@RestController
@RequestMapping("/account")
public class AccountController {

// @Autowired
private LoadingCache<String, Account> loadingCache;

@Autowired
private AccountDao accountDao;

@RequestMapping("/findById")
public Account findById(String id) throws ExecutionException {
if(loadingCache == null ){
this.createLoadingCache();
}

Account account = loadingCache.get(id);
ConcurrentMap<String, Account> accountConcurrentMap = loadingCache.asMap();
System.out.println(JSON.toJSONString(accountConcurrentMap));
return account;

}

//创建 LoadingCache,也可注册为 Bean,使用时注入
public void createLoadingCache(){
CacheLoader<String, Account> cacheLoader = new CacheLoader<String, Account>() {
@Override
public Account load(String key) throws Exception {
System.out.println("缓存不存在,从数据库加载数据------------");
return accountDao.findById(key);
}
};

LoadingCache<String, Account> loadingCache = CacheBuilder.newBuilder()
.expireAfterWrite(1, TimeUnit.MINUTES)
.concurrencyLevel(10)
.recordStats()
.initialCapacity(10000)
.maximumSize(10000)
.build(cacheLoader);
this.loadingCache = loadingCache;
}
}

Guava Cache 过期策略

几乎肯定没有足够的内存来缓存所有内容。所以必须确定何时不保留缓存数据,Guava Cache 提供三种基本类型的驱逐策略:基于规模的驱逐,基于时间的驱逐和基于引用的驱逐。

基于规模的驱逐策略

  1. 基于个数的驱逐策略:maximumSize

    如果要限制缓存大小,使用 *CacheBuilder.maximumSize(long)*,缓存将尝试驱逐最近不被使用或长时间未使用的数据。

    注意:缓存可以会在超出此限制之前驱逐缓存数据,通常是在缓存大小接近限制时执行。

  2. 基于权重的驱逐策略:maximumWeight

    如果不同的缓存数据具有不同的 权重,可以使用 CacheBuilder.weigher(Weigher) 指定权重函数,使用 CacheBuilder.maximumWeight(long) 指定最大缓存权重。

    注意:执行驱逐策略的时机与 maximumSize 相同,但权重是在缓存数据创建时计算的,并且此后是静态的。

1
2
3
4
5
6
7
8
9
10
11
12
13
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
.maximumWeight(100000)
.weigher(new Weigher<Key, Graph>() {
public int weigh(Key k, Graph g) {
return g.vertices().size();
}
})
.build(
new CacheLoader<Key, Graph>() {
public Graph load(Key key) { // no checked exception
return createExpensiveGraph(key);
}
});

基于时间的驱逐策略

CacheBuilder 提供 2 种基于时间的驱逐策略:

如下所述,在写入期间以及在读取期间偶尔进行定期维护来执行定时过期策略。

测试定时驱逐策略:

测试定时驱逐并不一定是痛苦的……并且实际上不需要花费两秒钟来测试两秒钟的到期时间。 使用 Ticker 接口和CacheBuilder.ticker(Ticker) 方法在缓存构建器中指定时间源,而不必等待系统时钟。

基于引用的驱逐策略

通过对键或值使用弱引用,和对值使用软引用,Guava 允许设置缓存被 JVM 垃圾收回器回收。垃圾回收器使用等号(==)来比较键,而不是 equals() 。

  • CacheBuilder.weakKeys():使用弱引用存储键,如果没有其他(强或软)引用键,则对其执行垃圾回收。
  • CacheBuilder.weakValues():使用弱引用存储值,如果没有其他(强或软)引用,则对其执行垃圾回收。
  • CacheBuilder.softValues():包装软引用中的值。为了释放内存,软引用对象以全局最近使用最少的方式被垃圾回收。

由于使用软引用对性能会有影响,通常建议使用更可预测的最大缓存大小。

显式删除缓存数据

在任何时候,都可以显式地使缓存数据失效,而不是等待执行过期策略来驱逐。使用以下方式:

什么时候执行清理

使用 CacheBuilder 构建的缓存不会自动执行清除和逐出值,不会在值过期后立即清除和逐出值。而是在写操作执行少量的维护,或者在偶尔的读操作(如果写操作很少)期间执行少量的维护。

原因如下:如果想持续执和地缓存维护,就需要创建一个线程,此线程的操作将与用户操作竞争共享锁。此外,一些环境限制线程的创建,这将使 CacheBuilder 在该环境中不可用。

相反,把何时清理缓存交由用户控制。如果缓存是高吞吐高,则不必担心执行缓存维护以清理过期数据。如果缓存很少写入并且不想阻塞缓存读取,则可以创建自己的维护线程,定期调用 *Cache.cleanUp()*。如果要为很少有写操作的缓存使用定期维护,只需使用 ScheduledExecutorService 计划维护。

Guava Cache 删除监听器

可以通过 CacheBuilder.removalListener(RemovalListener) 来指定监听器,监听缓存数据被删除并执行相关操作。RemovealListener 会传递一个 RemovalNotification(删除通知),包含了 RemovalCause、key and value。

注意:由 RemovealListener 抛出的任何异常都会被记录(使用 Logger)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CacheLoader<Key, DatabaseConnection> loader = new CacheLoader<Key, DatabaseConnection> () {
public DatabaseConnection load(Key key) throws Exception {
return openConnection(key);
}
};
RemovalListener<Key, DatabaseConnection> removalListener = new RemovalListener<Key, DatabaseConnection>() {
public void onRemoval(RemovalNotification<Key, DatabaseConnection> removal) {
DatabaseConnection conn = removal.getValue();
conn.close(); // tear down properly
}
};

return CacheBuilder.newBuilder()
.expireAfterWrite(2, TimeUnit.MINUTES)
.removalListener(removalListener)
.build(loader);

注意:默认情况下,缓存删除监听器操作是同步执行的,并且由于缓存维护通常在正常的缓存操作期间执行,因此昴贵的删除监听器可能会降低正常的缓存性能。如果确实需要一个删除监听器,建议使用 RemovalListeners.asynchronous(RemovalListener, Executor) 来装饰一个 RemovealListener ,执行异步操作。

Guava Cache 刷新缓存

缓存刷新与过期驱逐不完全相同。正如 LoadingCache.refresh(K) 中指定的,刷新键会加载键的新值,可能是异步的。在刷新键时仍会返回旧值(如果有的话),而过期驱逐会强制检索等待值重新加载。

如果刷新时抛出异步,则保留旧值,并记录异步。

CacheLoader 可以通过重写 CacheLoader.reload(K, V) 来指定要在刷新时使用的智能行为,这允许在计算新值时使用旧值。如下示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Some keys don't need refreshing, and we want refreshes to be done asynchronously.
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
.maximumSize(1000)
.refreshAfterWrite(1, TimeUnit.MINUTES)
.build(
new CacheLoader<Key, Graph>() {
public Graph load(Key key) { // no checked exception
return getGraphFromDatabase(key);
}

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

可以使用 CacheBuilder.refreshAfterWrite(long, TimeUnit) 将自动定时刷新添加到缓存中。

不同于 expireAfterWrite 相反,refreshAfterWrite 将使键在指定的持续时间后符合刷新条件,但只有在查询缓存时才会实际刷新。

(如果将 CacheLoader.reload 实现为异步,则刷新不会降低查询速度)因此,可以在同一缓存上指定 refreshAfterWrite 和 expireAfterWrite,这样当缓存数据符合刷新条件时,该缓存上的过期计时器就不会被盲目重置,如果一个缓存数据在符合刷新条件后没有被查询,就允许过期。

Guava Cache 特性

统计

通过使用 CacheBuilder.recordStats() ,可以开启 Guava Cache 的统计信息收集。Cache.stats() 返回一个 CacheStats 对象,此对象包含了统计信息。

还有更多的统计数据。这些统计信息对于缓存优化至关重要,建议在注重性能的应用中密切关注这些统计信息。

asMap

可以使用 asMap 视图将任何 Cache 视为 ConcurrentMap ,但 asMap 视图如何与 Cache 交互需要一些解释。如下:

  • **cache.asMap()*:包含缓存当前加载的所有元素。例如,cache.asMap().keySet()* 包含当前所有加载的键。
  • **asMap().get(key)**:本质上等同于 **cache.getIfPresent(key)**,并且永远不会导致加载值,这与 Map 规则一致。
  • 访问时间由所有缓存读写操作(包括 Cache.asMap().get(Object) 和 *Cache.asMap().put(K, V)*)重置,但不由 containsKey(Object) 重置,也不由 Cache.asMap() 上的操作重置。
    例如, cache.asMap().entrySet() 不会重置所要查询的缓存的访问时间。

其它参考

  1. Guava 系列文章
作者

光星

发布于

2019-06-05

更新于

2022-07-12

许可协议

评论