缓存是个很大的话题,本文就讲述本地缓存的原理。
简介
引入缓存的目的:
绝大部分时候,读数据写数据符合二八定律。并且读数据中,百分之二十是数据被经常读取(热数据)。
缓存需要解决的问题:
- 内存是有限,所以需要限定缓存的最大容量
- 如何清除缓存
- 如何应对并发读写
- 缓存数据透明化:命中率、失效率等
local cache本质上是一个map,对应map中的每一个键值对,可设置过期时间,也可以通过如LRU(Least Recently Used,最近最少使用)做淘汰策略。可以基于ConcurrentHashMap或者LinkedHashMap来实现一个LRU Cache。
ConcurrentHashMap
Guava Cache
Guava Cache以空间换时间,基于LRU算法实现,支持多种缓存过期策略,注意缓存数据不能超过内存容量。
实例
Guava Cache支持两种方式加载cache:
- cache from cacheLoader
public void test() throws ExecutionException {
CacheLoader<String, String> loader = new CacheLoader<String, String>() {
public String load(String key) throws Exception {
// 模拟加载数据
Thread.sleep(1000);
System.out.println(key + " is loaded from a cacheLoader!");
return key + "'s value";
}
};
LoadingCache<String, String> loadingCache = CacheBuilder.newBuilder()
.maximumSize(3)
// 在构建时指定自动加载器
.build(loader);
loadingCache.get("key1");
}
- cache from callable
Cache<String, String> cache = CacheBuilder.newBuilder().maximumSize(1000).build();
String val1 = cache.get("key", new Callable<String>() {
@Override
public String call() {
return "mock call";
}
});
分析
以27.1-jre
版本进行源码分析,Cache源码:
public interface Cache<K, V> {
V getIfPresent(@CompatibleWith("K") Object var1);
V get(K var1, Callable<? extends V> var2) throws ExecutionException;
ImmutableMap<K, V> getAllPresent(Iterable<?> var1);
void put(K var1, V var2);
void putAll(Map<? extends K, ? extends V> var1);
void invalidate(@CompatibleWith("K") Object var1);
void invalidateAll(Iterable<?> var1);
void invalidateAll();
long size();
CacheStats stats();
ConcurrentMap<K, V> asMap();
void cleanUp();
}
CacheBuilder,构建者模式,用于构建一个Cache对象。
maximumSize()
设置最大k-v存储数,当Cache中的记录数量达到最大值后再调用put方法向其中添加对象,Guava会先从当前缓存的对象记录中选择一条删除掉
expireAfterWrite()
和expireAfterAccess()
设置过期时间,前者表示被写入到缓存后多久过期,后者表示对象多久没有被访问后过期;两者同时使用,满足其一,即过期可被删除。
自动加载:Cache的get方法有两个参数,第一个参数是要从Cache中获取记录的key,第二个记录是一个Callable对象。当缓存中已经存在key对应的记录时,get方法直接返回key对应的记录。如果缓存中不包含key对应的记录,Guava会启动一个线程执行Callable对象中的call方法,call方法的返回值会作为key对应的值被存储到缓存中,并且被get方法返回。
弱引用:可以通过weakKeys和weakValues方法指定Cache只保存对缓存记录key和value的弱引用。这样当没有其他强引用指向key和value时,key和value对象就会被垃圾回收器回收。
清除:invalidateAll
或invalidate(key)
方法,显式清除缓存,invalidateAll也可以接收一个Iterable类型的参数,参数中包含要删除记录的所有key值。
移除监听器:当记录被删除时可以感知到这个事件,触发回调逻辑。RemovalListener的onRemoval(RemovalNotification<String, String> notification)
方法写自定义的业务回调逻辑。
LoadingCache:当从LoadingCache中读取一个指定key的记录时,如果该记录不存在,则LoadingCache可以自动执行加载数据到缓存的操作。源码:
public interface LoadingCache<K, V> extends Cache<K, V>, Function<K, V> {
V get(K var1) throws ExecutionException;
V getUnchecked(K var1);
ImmutableMap<K, V> getAll(Iterable<? extends K> var1) throws ExecutionException;
@Deprecated
V apply(K var1);
void refresh(K var1);
ConcurrentMap<K, V> asMap();
}
在调用CacheBuilder的build方法时,必须传递一个CacheLoader类型的参数,CacheLoader的load方法需要我们提供实现。当调用LoadingCache的get方法时,如果缓存不存在对应key的记录,则CacheLoader中的load方法会被自动调用从外存加载数据,load方法的返回值会作为key对应的value存储到LoadingCache中,并从get方法返回。
统计信息:recordStats()
方法可用于开启对Cache的命中率、加载数据时间等信息进行统计,随后stats()
方法可查看统计信息。
缓存清除
Guava Cache提供3种缓存清除策略:
- size-based eviction
基于cache容量的移除。如果你的cache不允许扩容,即不允许超过设定的最大值,那么使用CacheBuilder.maxmuSize(long)即可。在这种条件下,cache会自己释放掉那些最近没有或者不经常使用的entries内存。注意:
- 并不是在超过限定时才会删除掉那些entries,而是在即将达到这个限定值时,那么你就要小心考虑这种情况了,因为很明显即使没有达到这个限定值,cache仍然会进行删除操作。
- 如果一个key-entry已经被移除,当你再次调用get(key)时,如果CacheBuilder采用的是CacheLoader模式,那依然会从cacheLoader中加载一次。
此外,如果你的cache里面的entries有着截然不同的内存占用如果你的cache values有着截然不同的内存占用,你可以通过CacheBuilder.weigher(Weigher)来为不同的entry设定weigh,然后使用CacheBuilder.maximumWeight(long)设定一个最大值。在tpn会通过local cache缓存用户对消息类目的订阅信息,有的用户订阅的消息类目比较多,所占的内存就比较多,有的用户订阅的消息类目比较少,自然占用的内存就比较少。那么我就可以通过下面的方法来根据用户订阅的消息类目数量设置不同的weight,这样就可以在不更改cache大小的情况下,使得缓存尽量覆盖更多地用户:
LoadingCache<Key, User> Users= CacheBuilder.newBuilder()
.maximumWeight(100000)
.weigher(new Weigher<Key, User>() {
public int weigh(Key k, User u) {
if(u.categories().szie() >5) {
return 2;
} else {
return 1;
}
}
}).build(new CacheLoader<Key, User>() {
public Userload(Key key) {
// no checked exception
return createExpensiveUser(key);
}
});
说明weight的用法。
- time-based eviction
基于时间的移除。Guava cache提供两种方法来实现这个逻辑:
expireAfterAccess(long, TimeUnit)
从最后一次访问(读或者写)开始计时,过了这段指定的时间就会释放掉该entries。注意:那些被删掉的entries的顺序时和size-based eviction是十分相似的。
expireAfterWrite(long,TimeUnit)
从entries被创建或者最后一次被修改值的点来计时的,如果从这个点开始超过了那段指定的时间,entries就会被删除掉。这点设计的很精明,因为数据会随着时间变得越来越陈旧。
如果想要测试Timed Eviction,使用Ticker interface和CacheBuilder.ticker(Ticker)方法对你的cache设定一个时间即可,那么你就不需要去等待系统时间了。 - reference-based eviction
基于引用的移除。Guava为你准备了entries的垃圾回收器,对于keys或者values可以使用weak reference ,对于values可以使用soft reference.
CacheBuilder.weakKeys(): 通过weak reference存储keys。在这种情况下,如果keys没有被strong或者soft引用,那么entries会被垃圾回收。
CacheBuilder.weakValues() : 通过weak referene 存储values.在这种情况下,如果valves没有被strong或者soft引用,那么entries会被垃圾回收。
这种条件下的垃圾回收器是建立在引用之上的,那么这会造成整个cache是使用==来比较俩个key的,而不是equals()。
Ehcache
EhCache是一个纯Java的进程内缓存框架,具有快速、精干等特点,是Hibernate中默认的CacheProvider。已经推出第三版。
Caffeine
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.8.5</version>
</dependency>
使用Java8对Guava-cache进行重写,在SB2.0中将取代,基于LRU算法实现,支持多种缓存过期策略。
示例
public void test() {
LoadingCache<String, String> build = Caffeine.newBuilder()
.initialCapacity(1).maximumSize(100)
.expireAfterWrite(1, TimeUnit.DAYS)
.build(new CacheLoader<String, String>() {
// 默认的数据加载实现,兜底逻辑返回空字符串
@Override
public String load(String key) {
return "";
}
});
}
build()
同步;
buildAsync
异步;
过期策略
在Caffeine中分为两种缓存:有界缓存和无界缓存,无界缓存不需要过期并且没有界限。在有界缓存中提供三个过期API:
- expireAfterWrite:写入之后多久过期
- expireAfterAccess:最后一次访问之后多久过期
- expireAfter:在expireAfter中需要自己实现Expiry接口,支持create、update、以及access之后多久过期。这个API和前面两个API是互斥的。在具体的某个时间过期,也就是通过前面的重写create,update,以及access的方法,获取具体的过期时间。
Expiry接口:
public interface Expiry<K, V> {
long expireAfterCreate(@NonNull K var1, @NonNull V var2, long var3);
long expireAfterUpdate(@NonNull K var1, @NonNull V var2, long var3, @NonNegative long var5);
long expireAfterRead(@NonNull K var1, @NonNull V var2, long var3, @NonNegative long var5);
}
更新策略
即设定多长时间后会自动刷新缓存。Caffeine提供refreshAfterWrite()方法来进行写后多久更新策略,注意要被访问afterRead()
才更新。
填充策略
即Population,Caffeine提供三种填充策略:手动、同步和异步
手动加载(Manual)
Cache<String, Object> manualCache = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(10_100)
.build();
String key = "name1";
// 根据key查询一个缓存,如果没有返回NULL
graph = manualCache.getIfPresent(key);
// 根据Key查询一个缓存,如果没有调用createExpensiveGraph方法,并将返回值保存到缓存。
// 如果该方法返回Null则manualCache.get返回null,如果该方法抛出异常则manualCache.get抛出异常
graph = manualCache.get(key, k -> createExpensiveGraph(k));
// 将一个值放入缓存,如果以前有值就覆盖以前的值
manualCache.put(key, graph);
// 删除一个缓存
manualCache.invalidate(key);
ConcurrentMap<String, Object> map = manualCache.asMap();
cache.invalidate(key);
Cache接口允许显式的去控制缓存的检索,更新和删除。可以通过cache.getIfPresent(key)
方法来获取一个key的值,通过cache.put(key, value)
方法显示的将数控放入缓存,但是这样子会覆盖缓原来key的数据。更加建议使用cache.get(key,k - > value)
的方式,get 方法将一个参数为 key 的 Function (createExpensiveGraph) 作为参数传入。如果缓存中不存在该键,则调用这个 Function 函数,并将返回值作为该缓存的值插入缓存中。get 方法是以阻塞方式执行调用,即使多个线程同时请求该值也只会调用一次Function方法。这样可以避免与其他线程的写入竞争,这也是为什么使用 get 优于 getIfPresent 的原因。
如果调用该方法返回NULL(如上面的 createExpensiveGraph 方法),则cache.get返回null,如果调用该方法抛出异常,则get方法也会抛出异常。
可以使用Cache.asMap()
方法获取ConcurrentMap进而对缓存进行一些更改。
同步加载,Loading
LoadingCache<String, Object> loadingCache = Caffeine.newBuilder()
.maximumSize(10000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
String key = "name1";
// 采用同步方式去获取一个缓存和上面的手动方式是一个原理。在build Cache的时候会提供一个createExpensiveGraph函数。
// 查询并在缺失的情况下使用同步的方式来构建一个缓存
Object graph = loadingCache.get(key);
// 获取组key的值返回一个Map
List<String> keys = new ArrayList<>();
keys.add(key);
Map<String, Object> graphs = loadingCache.getAll(keys);
LoadingCache是使用CacheLoader来构建的缓存的值。批量查找可以使用getAll方法。默认情况下,getAll将会对缓存中没有值的key分别调用CacheLoader.load方法来构建缓存的值。我们可以重写CacheLoader.loadAll方法来提高getAll的效率。
可以编写一个CacheLoader.loadAll来实现为特别请求的key加载值。例如,如果计算某个组中的任何键的值将为该组中的所有键提供值,则loadAll可能会同时加载该组的其余部分。
异步加载(Asynchronously Loading)
AsyncLoadingCache<String, Object> asyncLoadingCache = Caffeine.newBuilder()
.maximumSize(10100)
.expireAfterWrite(10, TimeUnit.MINUTES)
// Either: Build with a synchronous computation that is wrapped as asynchronous
.buildAsync(key -> createExpensiveGraph(key));
// Or: Build with a asynchronous computation that returns a future
// .buildAsync((key, executor) -> createExpensiveGraphAsync(key, executor));
String key = "name1";
// 查询并在缺失的情况下使用异步的方式来构建缓存
CompletableFuture<Object> graph = asyncLoadingCache.get(key);
// 查询一组缓存并在缺失的情况下使用异步的方式来构建缓存
List<String> keys = new ArrayList<>();
keys.add(key);
CompletableFuture<Map<String, Object>> graphs = asyncLoadingCache.getAll(keys);
// 异步转同步
loadingCache = asyncLoadingCache.synchronous();
AsyncLoadingCache是继承自LoadingCache类的,异步加载使用Executor去调用方法并返回一个CompletableFuture。异步加载缓存使用了响应式编程模型。
同步方式调用使用CacheLoader。异步使用AsyncCacheLoader,并返回一个CompletableFuture。
synchronous()方法返回一个LoadingCacheView视图,LoadingCacheView也继承自LoadingCache。调用该方法后就相当于你将一个异步加载的缓存AsyncLoadingCache转换成了一个同步加载的缓存LoadingCache。
默认使用ForkJoinPool.commonPool()
来执行异步线程,但是可以通过Caffeine.executor(Executor)
方法来替换线程池。
驱逐策略
驱逐,eviction,即删除,后台自动进行。Caffeine提供三类驱逐策略:基于大小(size-based),基于时间(time-based)和基于引用(reference-based)。
基于大小
基于大小驱逐,有两种方式:基于缓存大小及权重:
使用Caffeine.maximumSize(long)方法来指定缓存的最大容量。当缓存超出这个容量的时候,会使用Window TinyLfu策略来删除缓存。也可以使用权重的策略来进行驱逐,可以使用Caffeine.weigher(Weigher) 函数来指定权重,使用Caffeine.maximumWeight(long) 函数来指定缓存最大权重值。
maximumWeight与maximumSize不可以同时使用。
// Evict based on the number of entries in the cache
// 根据缓存的计数进行驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
.build(key -> createExpensiveGraph(key));
// Evict based on the number of vertices in the cache
// 根据缓存的权重来进行驱逐(权重只是用于确定缓存大小,不会用于决定该缓存是否被驱逐)
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumWeight(10_000)
.weigher((Key key, Graph graph) -> graph.vertices().size())
.build(key -> createExpensiveGraph(key));
基于时间
// Evict based on a fixed expiration policy
// 基于固定的到期策略进行退出
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfterAccess(5, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(key -> createExpensiveGraph(key));
// Evict based on a varying expiration policy
// 基于不同的到期策略进行退出
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.expireAfter(new Expiry<Key, Graph>() {
@Override
public long expireAfterCreate(Key key, Graph graph, long currentTime) {
// Use wall clock time, rather than nanotime, if from an external resource
long seconds = graph.creationDate().plusHours(5)
.minus(System.currentTimeMillis(), MILLIS)
.toEpochSecond();
return TimeUnit.SECONDS.toNanos(seconds);
}
@Override
public long expireAfterUpdate(Key key, Graph graph, long currentTime, long currentDuration) {
return currentDuration;
}
@Override
public long expireAfterRead(Key key, Graph graph, long currentTime, long currentDuration) {
return currentDuration;
}
}).build(key -> createExpensiveGraph(key));
基于引用
Java4种引用的级别由高到低依次为:强引用 > 软引用 > 弱引用 > 虚引用
可以将缓存的驱逐配置成基于垃圾回收器。为此,我们可以将key 和 value 配置为弱引用或只将值配置成软引用。AsyncLoadingCache不支持弱引用和软引用。
// Evict when neither the key nor value are strongly reachable
// 当key和value都没有引用时驱逐缓存
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.weakKeys()
.weakValues()
.build(key -> createExpensiveGraph(key));
// Evict when the garbage collector needs to free memory
// 当垃圾收集器需要释放内存时驱逐
LoadingCache<Key, Graph> graphs = Caffeine.newBuilder()
.softValues()
.build(key -> createExpensiveGraph(key));
无效
invalidation,也是删除缓存,与驱逐不同的是,无效是由调用方手动删除缓存:
cache.invalidate(key)
cache.invalidateAll(keys)
cache.invalidateAll()
移除
removal,监听驱逐或无效操作的监听器。Caffeine.removalListener(RemovalListener)
为缓存指定一个删除侦听器,以便在删除数据时执行某些操作。RemovalListener可以获取到key、value和RemovalCause(删除的原因)。
删除侦听器的里面的操作是使用Executor来异步执行的。默认执行程序是ForkJoinPool.commonPool(),可以通过Caffeine.executor(Executor)
覆盖。当操作必须与删除同步执行时,请改为使用CacheWrite。
由RemovalListener抛出的任何异常都会被记录(使用Logger)并不会抛出。
Cache<Key, Graph> graphs = Caffeine.newBuilder()
.removalListener((Key key, Graph graph, RemovalCause cause) ->
System.out.printf("Key %s was removed (%s)%n", key, cause))
.build();
统计
Statistics,使用Caffeine.recordStats(),您可以打开统计信息收集。Cache.stats() 方法返回提供统计信息的CacheStats,如:
hitRate():返回命中与请求的比率
hitCount(): 返回命中缓存的总数
evictionCount():缓存逐出的数量
averageLoadPenalty():加载新值所花费的平均时间
Cache<Key, Graph> graphs = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats()
.build();
集成SpringBoot
配置类的两种方式:
@Configuration
public class CacheConfig {
/**
* 引入 Caffeine 和 Spring Cache 依赖,使用 SpringCache 注解方法实现缓存
*/
@Bean("caffeineCacheManager")
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(Caffeine.newBuilder()
// 设置最后一次写入或访问后经过固定时间过期
.expireAfterAccess(60, TimeUnit.SECONDS)
.initialCapacity(100)
.maximumSize(1000));
return cacheManager;
}
/**
* 引入 Caffeine 依赖,使用 Caffeine 方法实现缓存
*/
@Bean
public Cache<String, Object> caffeineCache() {
return Caffeine.newBuilder()
// 设置最后一次写入或访问后经过固定时间过期
.expireAfterWrite(60, TimeUnit.SECONDS)
.initialCapacity(100)
.maximumSize(1000)
.build();
}
}
部分配置:
参数 |
类型 |
描述 |
initialCapacity |
integer |
初始的缓存空间大小 |
maximumSize |
long |
缓存的最大条数 |
maximumWeight |
long |
缓存的最大权重 |
expireAfterAccess |
duration |
最后一次写入或访问后经过固定时间过期 |
refreshAfterWrite |
duration |
最后一次写入后经过固定时间过期 |
refreshAfterWrite |
duration |
创建缓存或者最近一次更新缓存后经过固定的时间间隔,刷新缓存 |
weakKeys |
boolean |
打开 key 的弱引用 |
weakValues |
boolean |
打开 value 的弱引用 |
softValues |
boolean |
打开 value 的软引用 |
recordStats |
- |
开发统计功能 |
weakValues 和 softValues 不可以同时使用。
maximumSize 和 maximumWeight 不可以同时使用。
expireAfterWrite 和 expireAfterAccess 同时存在时,以 expireAfterWrite 为准:
// 软引用
Caffeine.newBuilder().softValues().build();
// 弱引用
Caffeine.newBuilder().weakKeys().weakValues().build();
性能比较
caffeine-versus-guava-cache