在开发中,我们经常需要从数据库中读取数据并进行频繁的读取操作。缓存在各种场景中都有运用,例如,当一个值的计算或检索成本很高,而且在某个输入中需要多次使用该值时,就应该考虑使用缓存,因此将数据缓存在内存中可以显著提高应用程序的性能。
问题描述
假设我们正在开发一个电子商务网站,需要频繁地显示商品信息。商品信息存储在数据库中,并且我们希望将其缓存在内存中,以提高网站的响应速度和性能。
缓存与 ConcurrentMap 相似,但又不完全相同。最根本的区别在于,ConcurrentMap 会持久保存所有添加到其中的元素,直到它们被操作删除。另一方面,缓存通常被配置为自动被销毁,以限制其占用过多内存空间。在某些情况下,LoadingCache 即使不被销毁,也会因其自动加载缓存而发挥作用。
一般来说,Guava 缓存工具适用于以下情况:
- 愿意通过消耗内存来提高数据存取速度。
- 存在热键会被频繁查询。
- 缓存不能存储超过 RAM 容量的数据。(Guava 缓存是应用程序单次运行的本地缓存。它们不会将数据存储在文件中或外部服务器上。)。
LoadingCache
LoadingCache 是通过附加的 CacheLoader 构建缓存。创建 CacheLoader 通常就像实现方法 V load(K key) throws Exception 一样简单。例如,你可以用下面代码创建一个 LoadingCache:
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 的常规方法是使用 get(K) 方法。该方法要么返回一个已缓存的值,要么使用缓存的 CacheLoader 将一个新值加载到缓存中。由于 CacheLoader 可能会抛出异常,因此 LoadingCache.get(K) 会抛出 ExecutionException。(如果LoadingCache抛出一个未检查异常,get(K) 将抛出一个UncheckedExecutionException)。我们可以选择使用 getUnchecked(K),它会用 UncheckedExecutionException 封装所有异常,但底层的 CacheLoader 通常会抛出ExecutionException。
LoadingCache<Key, Graph> graphs = CacheBuilder.newBuilder()
.expireAfterAccess(10, TimeUnit.MINUTES)
.build(
new CacheLoader<Key, Graph>() {
public Graph load(Key key) { // no checked exception
return createExpensiveGraph(key);
}
});
...
return graphs.getUnchecked(key);
批量查找可通过方法 getAll(Iterable<? extends K>) 执行。默认情况下,getAll 会对缓存中不存在的每个键单独调用 CacheLoader.load。当批量检索比多次单独查找更有效时,可以重载 CacheLoader.loadAll 来利用这一点。getAll(Iterable)的性能也会相应提高。
Callable
所有 Guava 缓存(无论是否正在加载)都支持 get(K, Callable<V>) 方法。该方法会返回与缓存中的键相关联的值,或从指定的 Callable 中计算该值并将其添加到缓存中。在加载完成之前,与该缓存相关的可观察状态不会被修改。该方法可简单替代传统的 "如果已缓存,则返回;否则创建、缓存并返回"模式。
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());
}
数据直接插入缓存
可通过 cache.put(key, value) 直接将数值插入缓存。这将覆盖缓存中指定键的任何先前数据。还可以使用 Cache.asMap() 视图中的任何 ConcurrentMap 方法对缓存进行更改。请注意,asMap 视图上的任何方法都不会导致数据被自动加载到缓存中。此外,该视图上的原子操作不在自动加载缓存的范围内,因此在使用 CacheLoader 或 Callable 加载值的缓存中,Cache.get(K, Callable<V>) 应始终优于 Cache.asMap().putIfAbsent()。请注意,Cache.get(K, Callable) 也可能将值插入底层缓存。
缓存销毁
即便缓存存取数据效率如此之高,但现实是通常内存不像硬盘空间可以不是无限制增加,毕竟成本比硬盘成本高很多,所以我们无法使用内存来缓存所有可以缓存的内容。因此开发者必须考虑到:缓存数据应该何时被销毁的问题?Guava 提供了三种基本的销毁类型:基于大小的销毁、基于时间的销毁和基于引用的销毁。
1. 基于内存使用大小的销毁方式
如果我们在开发过程中规定内存空间的使用不应该增长超过一定大小,只需使用 CacheBuilder.maximumSize(long)。缓存会自动销毁最近未使用或不常用的数据。
另外,如果不同的缓存数据具有不同的 "权重",我们可以使用 CacheBuilder.weigher(Weigher) 指定权重函数,并使用 CacheBuilder.maximumWeight(long) 指定最大缓存权重。除了与 maximumSize 要求相同的注意事项外,请注意权重是在创建数据时计的,此后将保持静态。
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);
}
});
2. 定时销毁
CacheBuilder 提供两种定时销毁方法:
- expireAfterAccess(long, TimeUnit) 自数据上次被读取/写入后,只有在指定的持续时间过后,数据才会过期。
- expireAfterWrite(long, TimeUnit) 在数据创建/更新后的指定时间内,使数据过期。
如何测试定时销毁??
测试定时销毁并不一定很痛苦......测试两秒钟的过期也不一定非要花上两秒钟。使用 Ticker 接口和 CacheBuilder.ticker(Ticker) 方法在缓存创建器中指定一个时间源,而不必等待系统时钟。
3. 基于引用的销毁
Guava 允许开发者通过对键或值使用弱引用和对值使用软引用来设置缓存,以允许对数据进行垃圾回收。
- CacheBuilder.weakKeys() 使用弱引用存储键值。这样,如果键没有其他(强或软)引用,数据就能被垃圾回收。由于垃圾回收只依赖于身份相等,这将导致整个缓存使用身份(==)相等来比较键,而不是使用 equals()。
- CacheBuilder.weakValues() 使用弱引用存储值。如果值没有其他(强或软)引用,数据就会被垃圾回收。由于垃圾回收只依赖于身份相等,这将导致整个缓存使用身份(==)相等来比较值,而不是使用 equals()。
- CacheBuilder.softValues() 将值封装为软引用。软引用对象会根据内存需求,在全局范围内以最近使用最少的方式进行垃圾回收。由于使用软引用会影响性能,我们通常建议使用更可预测的最大缓存大小。使用 softValues() 会导致使用身份 (==) 平等而不是 equals() 来比较值。
4. 显式销毁
我们可以随时显式地使缓存数据失效,而不是等待数据被销毁。
- 单数据缓存,使用 Cache.invalidate(key)
- 批量缓存,使用 Cache.invalidateAll(keys)
- 对所有数据,使用 Cache.invalidateAll()
销毁监听器
我们可以通过 CacheBuilder.removalListener(RemovalListener) 为缓存指定一个移除监听器,以便在数据被移除时执行某些操作。RemovalListener 会收到一个 RemovalNotification,其中指定了 RemovalCause、key 和 value。
请注意,RemovalListener 抛出的任何异常都会被记录(使用日志记录器)并覆盖。
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);
何时进行清理?
使用 CacheBuilder 构建的缓存不会 "自动 "或在值过期后立即执行清理和销毁值等操作。相反,它会在写操作过程中进行少量维护,如果写操作很少,则会在偶尔的读操作过程中进行维护。
这样做的原因如下:如果我们想持续执行缓存维护,就需要单独创建一个线程,而线程的操作将与用户操作竞争共享锁。此外,某些环境限制创建线程,这将导致 CacheBuilder 在该环境中无法使用。
因此,我们将选择权交给了开发者。如果开发者需要的缓存是高吞吐量的,那么就不必担心执行缓存维护来清理过期数据等问题。如果开发者使用的缓存缓存很少进行写入操作,而且不希望清理工作阻塞缓存读取,那么就可以创建自己的维护线程,定期调用 Cache.cleanUp()。
如果要为很少写入的缓存安排定期缓存维护,只需使用 ScheduledExecutorService 安排维护即可。
Refresh缓存刷新
刷新与销毁不太一样。正如在 LoadingCache.refresh(K) 中指定的那样,刷新键会为该键加载一个新值,可能是异步加载。在键被刷新的同时,旧值(如果有的话)仍会返回,这与强制检索直到值被重新加载的销毁不同。
如果在刷新时出现异常,旧值会被保留,异常会被记录并吞没。
CacheLoader 可以通过重载 CacheLoader.reload(K,V)来指定刷新时使用的智能行为,这样就可以在计算新值时使用旧值。
// 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,这样,每当一个数据符合刷新条件时,数据的过期计时器就不会被盲目重置,因此,如果一个数据符合刷新条件后没有被查询,它就会过期。
案例实践
咱们就以文章开头举例的应用场景来实现一个自动读取DB数据到缓存的代码。
步骤1:添加依赖
首先,我们需要在项目的构建文件(比如Maven的pom.xml文件)中添加Guava Cache的依赖。
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>30.1-jre</version>
</dependency>
步骤2:创建数据库连接
首先,我们需要创建一个数据库连接,以便从数据库中读取数据。
package cacher;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
public class DatabaseConnection {
private static final String URL = "jdbc:mysql://localhost:3306/quality_assurance";
private static final String USERNAME = "root";
private static final String PASSWORD = "sql123456";
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USERNAME, PASSWORD);
}
}
步骤3:创建ProductOb
package cacher;
import lombok.Data;
@Data
public class Product {
private String name;
private Double price;
private String id;
public Product(String id, String name, Double price) {
this.name = name;
this.price = price;
this.id = id;
}
}
步骤4:创建缓存实例
接下来,我们需要创建一个缓存实例,用于存储从数据库中读取的数据。
package cacher;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
import java.util.concurrent.TimeUnit;
public class ProductCache {
private static final int MAXIMUM_SIZE = 1000;
/*
缓存数据的过期时间,单位为分钟
*/
private static final int EXPIRE_AFTER_WRITE_DURATION = 10;
private static final Cache<Long, Product> cache = CacheBuilder.newBuilder()
.maximumSize(MAXIMUM_SIZE)
.expireAfterWrite(EXPIRE_AFTER_WRITE_DURATION, TimeUnit.MINUTES)
.build();
public static Cache<Long, Product> getCache() {
return cache;
}
}
步骤5:从数据库中读取数据并写入缓存
现在,我们可以使用数据库连接和缓存实例来从数据库中读取数据并写入缓存。
package cacher;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class ProductDao {
public Product getProductById(long id) throws SQLException {
// 先从缓存中查找数据
Product product = ProductCache.getCache().getIfPresent(id);
if (product == null) {
// 如果缓存中不存在数据,则从数据库中读取数据
Connection connection = DatabaseConnection.getConnection();
PreparedStatement statement = connection.prepareStatement("SELECT * FROM products WHERE id = ?");
statement.setLong(1, id);
ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
// 将从数据库中读取的数据存入缓存
product = new Product(resultSet.getString("id"), resultSet.getString("name"), resultSet.getDouble("price"));
ProductCache.getCache().put(id, product);
}
resultSet.close();
statement.close();
connection.close();
}
return product;
}
}
步骤6:使用缓存中的数据
最后,我们开发CacheClient,实现使用缓存中的数据,而无需每次都访问数据库。
package cacher;
import java.sql.SQLException;
public class CacheClient {
public static void main(String[] args) throws SQLException {
ProductDao productDao = new ProductDao();
long productId = 10000001;
Product product = productDao.getProductById(productId);
if (product != null) {
System.out.println("Product found: " + product.getName() + ", Price: " + product.getPrice());
} else {
System.out.println("Product not found with ID: " + productId);
}
}
}