Cache
Guava Cache是一款非常优秀的本地缓存框架。
官方文档:https://guava.dev/releases/21.0/api/docs/com/google/common/cache/Cache.html
适用场景
Guava Cache
的实际适用场合:
- 数据读多写少且对一致性要求不高的场景
- 对性能要求极其严苛的场景
- 简单的本地数据缓存,作为
HashMap/ConcurrentHashMap
的替代品
维度 | 简要概述 |
---|---|
优势 | 基于空间换时间的策略,利用内存的高速处理效率,提升机器的处理性能,减少大量对外的IO请求交互,比如读取DB、请求外部网络、读取本地磁盘数据等等操作。 |
弊端 | 整体容量受限,可能对本机内存造成压力。此外,对于分布式多节点集群部署的场景,缓存更新场景会出现缓存漂移问题,导致各个节点之间的缓存数据不一致。 |
初始化
final static Cache<Integer, String> cache =
CacheBuilder.newBuilder()
//设置cache的初始大小为10,要合理设置该值
.initialCapacity(10)
//设置并发数为5,即同一时间最多只能有5个线程往cache执行写入操作
.concurrencyLevel(5)
//设置cache中的数据在写入之后的存活时间为10秒
.expireAfterWrite(10, TimeUnit.SECONDS)
//构建cache实例
.build();
// 放入缓存 cache.put("key", "value");
// 获取缓存 String value = cache.getIfPresent("key")
常见的属性方法,归纳说明如下:
方法 | 含义说明 |
---|---|
newBuilder | 构造出一个Builder实例类 |
initialCapacity | 待创建的缓存容器的初始容量大小(记录条数) |
maximumSize | 指定此缓存容器的最大容量(最大缓存记录条数) |
maximumWeight | 指定此缓存容器的最大容量(最大比重值),需结合weighter 方可体现出效果 |
expireAfterWrite | 设定过期策略,按照数据写入时间进行计算 |
expireAfterAccess | 设定过期策略,按照数据最后访问时间来计算 |
weighter | 入参为一个函数式接口,用于指定每条存入的缓存数据的权重占比情况。这个需要与maximumWeight 结合使用 |
refreshAfterWrite | 缓存写入到缓存之后 |
concurrencyLevel | 用于控制缓存的并发处理能力,同时支持多少个线程并发写入操作 |
recordStats | 设定开启此容器的数据加载与缓存命中情况统计 |
相关方法
接口名称 | 具体说明 |
---|---|
get | 查询指定key对应的value值,如果缓存中没匹配,则基于给定的Callable 逻辑去获取数据回填缓存中并返回 |
getIfPresent | 如果缓存中存在指定的key值,则返回对应的value值,否则返回null(此方法不会触发自动回源与回填操作) |
getAllPresent | 针对传入的key列表,返回缓存中存在的对应value值列表(不会触发自动回源与回填操作) |
put | 往缓存中添加key-value键值对 |
putAll | 批量往缓存中添加key-value键值对 |
invalidate | 从缓存中删除指定的记录 |
invalidateAll | 从缓存中批量删除指定记录,如果无参数,则清空所有缓存 |
size | 获取缓存容器中的总记录数 |
stats | 获取缓存容器当前的统计数据 |
asMap | 将缓存中的数据转换为ConcurrentHashMap 格式返回 |
cleanUp | 清理所有的已过期的数据 |
LoadingCache
LoadingCache,它能够通过CacheLoader自发的加载缓存:如果有缓存则返回,否则运算、缓存、然后返回。
CacheBuilder.newBuilder()
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
// 缓存加载逻辑
...
}
});
过期策略
基于时间的过期策略:
通过
expireAfterWrite()
和expireAfterAccess()
方法来设置缓存项的写入后过期时间和最后访问后过期时间。
expireAfterWrite
:设置缓存项在写入后的过期时间,过期时间从写入操作发生后开始计算。
Cache<String, String> cache = CacheBuilder.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) // 缓存项在写入后10分钟过期
.build();
cache.put("key", "value");
// 在10分钟内访问缓存项,它仍然有效
String value = cache.getIfPresent("key");
// 10分钟后,缓存项过期,再次访问将返回null
value = cache.getIfPresent("key");
expireAfterAccess
:设置缓存项在最后访问后的过期时间,过期时间从最后一次访问操作发生后开始计算。
Cache<String, String> cache = CacheBuilder.newBuilder()
.expireAfterAccess(5, TimeUnit.MINUTES) // 缓存项在最后一次访问后5分钟过期
.build();
cache.put("key", "value");
// 在5分钟内多次访问缓存项,它仍然有效
String value = cache.getIfPresent("key");
value = cache.getIfPresent("key");
value = cache.getIfPresent("key");
// 5分钟后,没有再次访问缓存项,它过期并被自动移除
// 再次尝试获取缓存项将返回null
value = cache.getIfPresent("key"); // 返回null
基于大小的过期策略:
通过
maximumSize()
方法设置缓存的最大大小限制,当缓存项数量达到最大值时,新的元素将会替换掉最旧的元素。
Cache<String, String> cache = CacheBuilder.newBuilder()
.maximumSize(100) // 缓存最大容量为100
.build();
// 在缓存容量达到100后,新的缓存项将替换最旧的缓存项
String value = cache.getIfPresent("key0"); // 返回null,因为"key0"已被替换
value = cache.getIfPresent("key100"); // 返回"value100",因为它仍然在缓存中
基于引用的过期策略:
CacheBuilder
类中的weakKeys()
和weakValues()
方法,允许使用弱引用来存储缓存的键和值。当键或值不再被其他强引用引用时,缓存项将会被自动移除。
weakKeys()
:使用弱引用存储缓存的键。weakValues()
:使用弱引用存储缓存的值。这些弱引用策略可用于在内存受限的情况下,允许缓存项根据垃圾回收的需要进行回收。
Cache<String, Object> cache = CacheBuilder.newBuilder()
.weakKeys() // 使用弱引用存储缓存的键
.weakValues() // 使用弱引用存储缓存的值
.build();
String key = new String("key");
Object value = new Object();
cache.put(key, value);
// 当key不再有其他强引用时,缓存项将被自动移除
value = cache.getIfPresent(key); // 返回null,因为key已没有强引用
基于提醒的过期策略:
通过
CacheBuilder
类的expireAfterWrite()
方法结合CacheBuilderSpec
类的refreshAfterWrite()
方法,可以实现基于提醒的过期策略。
- 该策略允许在缓存项过期时异步地加载新的值,并返回旧值。
这样可以确保在缓存项过期时仍然能够返回旧值,同时异步加载新值,提高了缓存的命中率和性能。
LoadingCache<String, String> cache = CacheBuilder.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES) // 缓存项在写入后10分钟过期
.refreshAfterWrite(1, TimeUnit.MINUTES) // 缓存项在写入后1分钟后异步刷新
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) {
return "value"; // 在缓存项过期时异步加载新的值
}
});
String value = cache.get("key");
// 10分钟内,获取缓存项将返回旧值,并异步加载新的值
value = cache.get("key");
// 10分钟后,缓存项过期,获取缓存项将返回新的值
value = cache.get("key");
在get执行逻辑中进行数据过期清理以及重新回源加载的执行判断。
在执行get请求的时候,会先判断下当前查询的数据是否过期,如果已经过期,则会触发对当前操作的
Segment
的过期数据清理操作。
淘汰策略
当容量触达阈值后,支持根据
FIFO + LRU
策略实施具体淘汰处理以腾出位置给新的记录使用。
淘汰策略 | 具体说明 |
---|---|
FIFO | 根据缓存记录写入的顺序,先写入的先淘汰 |
LRU | 根据访问顺序,淘汰最久没有访问的记录 |
限制缓存记录条数
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.maximumSize(10000L)
.build();
}
限制缓存记录权重
- 按照权重进行限制缓存容量的时候必须要指定
weighter
属性才可以生效。通过计算
value
对象的字节数(byte)来计算其权重信息,每1kb的字节数作为1个权重,整个缓存容器的总权重限制为1w,这样就可以实现将缓存内存占用控制在10000*1k≈10M
左右。基于
weight
权重的控制方式,比较适用于这种对容器体量控制精度有严格诉求的场景,可以在创建容器的时候指定每条记录的权重计算策略(比如基于字符串长度或者基于bytes
数组长度进行计算权重)。
public Cache<String, User> createUserCache() {
return CacheBuilder.newBuilder()
.maximumWeight(10000L)
.weigher((key, value) -> (int) Math.ceil(instrumentation.getObjectSize(value) / 1024L))
.build();
}
更新锁定能力
为了防止缓存击穿问题,可以通过加锁的方式来规避。
当缓存不可用时,仅
持锁的线程
负责从数据库中查询数据并写入缓存中,其余请求重试时先尝试从缓存中获取数据,避免所有的并发请求全部同时打到数据库上。
Guava Cache
自带了并发锁定
机制,同一时刻仅允许一个请求去回源获取数据并回填到缓存中,而其余请求则阻塞等待,不会造成数据源的压力过大。
不回源查询
接口 | 功能说明 |
---|---|
getIfPresent | 从内存中查询,如果存在则返回对应值,不存在则返回null |
getAllPresent | 批量从内存中查询,如果存在则返回存在的键值对,不存在的key则不出现在结果集里 |
上述两种接口,执行的时候仅会从当前内存中已有的缓存数据里面进行查询,不会触发回源的操作。
public static void main(String[] args) {
try {
GuavaCacheService cacheService = new GuavaCacheService();
Cache<String, User> cache = cacheService.createCache();
cache.put("123", new User("123", "123"));
cache.put("124", new User("124", "124"));
System.out.println(cache.getIfPresent("125"));
ImmutableMap<String, User> allPresentUsers =
cache.getAllPresent(Stream.of("123", "124", "125").collect(Collectors.toList()));
System.out.println(allPresentUsers);
} catch (Exception e) {
e.printStackTrace();
}
}
监控统计
提供了
stat
统计日志,支持查看缓存数据的加载或者命中情况统计。可以基于命中情况,不断的去优化代码中缓存的数据策略,以发挥出缓存的最大价值。
Guava Cache的统计信息封装为
CacheStats
对象进行承载,主要包含一下几个关键指标项:
指标 | 含义说明 |
---|---|
hitCount | 命中缓存次数 |
missCount | 没有命中缓存次数(查询的时候内存中没有) |
loadSuccessCount | 回源加载的时候加载成功次数 |
loadExceptionCount | 回源加载但是加载失败的次数 |
totalLoadTime | 回源加载操作总耗时 |
evictionCount | 删除记录的次数 |
缓存容器创建的时候,可以通过
recordStats()
开启缓存行为的统计记录。
public static void main(String[] args) {
LoadingCache<String, User> cache = CacheBuilder.newBuilder()
.recordStats()
.build(new CacheLoader<String, User>() {
@Override
public User load(String key) throws Exception {
System.out.println(key + "用户缓存不存在,尝试CacheLoader回源查找并回填...");
User user = userDao.getUser(key);
if (user == null) {
System.out.println(key + "用户不存在");
}
return user;
}
});
try {
System.out.println(cache.get("123");
System.out.println(cache.get("124"));
System.out.println(cache.get("123"));
System.out.println(cache.get("126"));
} catch (Exception e) {
} finally {
CacheStats stats = cache.stats();
System.out.println(stats);
}
}
缓存刷新
支持定时刷新和显式刷新两种方式,其中只有
LoadingCache
能够进行定时刷新。在进行缓存定时刷新时,需要指定缓存的刷新间隔,和一个用来加载缓存的CacheLoader,当达到刷新时间间隔后,下一次获取缓存时,会调用
CacheLoader
的load
方法刷新缓存。
例如构建个刷新频率为10分钟的缓存:
CacheBuilder.newBuilder()
// 设置缓存在写入10分钟后,通过CacheLoader的load方法进行刷新
.refreshAfterWrite(10, TimeUnit.SECONDS)
// jdk8以后可以使用 Duration
// .refreshAfterWrite(Duration.ofMinutes(10))
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
// 缓存加载逻辑
...
}
});
refresh操作采用一种被动触发的方式来实现。
当get操作执行的时候会判断下如果创建时间已经超过了设定的刷新间隔,则会重新去执行一次数据的加载逻辑。
鉴于缓存读多写少的特点,Guava Cache在数据refresh操作执行的时候,采用了一种非阻塞式的加载逻辑,尽可能的保证并发场景下对读取线程的性能影响。
同一时刻仅允许一个线程执行数据重新加载操作,并阻塞等待重新加载完成之后该线程的查询请求才会返回对应的新值作为结果。
当一个线程正在阻塞执行
refresh
数据刷新操作的时候,其它线程此时来执行get请求的时候,会判断下数据需要refresh操作,但是因为没有获取到refresh执行锁,这些其它线程的请求不会被阻塞等待refresh完成,而是立刻返回当前refresh前的旧值。当执行refresh的线程操作完成后,此时另一个线程再去执行get请求的时候,会判断无需refresh,直接返回当前内存中的当前值即可。
数据expire与refresh关系:
操作 | 优势 | 弊端 |
---|---|---|
expire | 有效防止缓存击穿问题,且阻塞等待的方式可以保证业务层面获取到的缓存数据的强一致性。 | 高并发场景下,如果回源的耗时较长,会导致多个读线程被阻塞等待,影响整体的并发效率。 |
refresh | 可以最大限度的保证查询操作的执行效率,避免过多的线程被阻塞等待。 | 多个线程并发请求同一个key对应的缓存值拿到的结果可能不一致,在对于一致性要求特别严苛的业务场景下可能会引发问题。 |
在具体使用时,需要根据场景综合判断:
- 数据需要永久存储,且不会变更,这种情况下
expire
和refresh
都并不需要设定- 数据极少变更,或者对变更的感知诉求不强,且并发请求同一个key的竞争压力不大,直接使用
expire
即可- 数据无需过期,但是可能会被修改,需要及时感知并更新缓存数据,直接使用
refresh
- 数据需要过期(避免不再使用的数据始终留在内存中)、也需要在有效期内尽可能保证数据的更新一致性,则采用
expire
与refresh
两者结合。对于expire与refresh结合使用的场景,两者的时间间隔设置,需要注意:
- expire时间设定要大于refresh时间,否则的话refresh将永远没有机会执行
数据结构
跟
JDK1.7
的ConcurrentHashMap
类似,提供了基于时间、容量、引用三种回收策略,以及自动加载、访问统计等功能。Guava Cache中采用的是这种分段锁策略来降低锁的粒度,可以在创建缓存容器的时候使用
concurrencyLevel
来指定允许的最大并发线程数,使得线程安全的前提下尽可能的减少锁争夺。
Guava Cache在put写操作的时候,会首先计算出key对应的hash值,然后根据hash值来确定数据应该写入到哪个Segment中,进而对该Segment加锁执行写入操作。
concurrencyLevel
只是一个理想状态下的最大同时并发数,也取决于同一时间的操作请求是否会平均的分散在各个不同的Segment中。极端情况下,如果多个线程操作的目标对象都在同一个分片中时,那么只有1个线程可以持锁执行,其余线程都会阻塞等待。
实际使用中,比较推荐的是将concurrencyLevel设置为CPU核数的2倍,以获得较优的并发性能。
RateLimiter
RateLimiter
采用令牌桶的原理限制同一时刻的并发数量,起到限流作用。
RateLimiter
是一个线程安全的对象,可以在多线程环境下使用。
简单使用
创建一个
RateLimiter
,设定每秒产生的令牌数量:
//每秒产生 10 个令牌
RateLimiter rateLimiter = RateLimiter.create(10);
阻塞获取令牌:
//从令牌桶内获取令牌,获取成功则返回,获取不到则一直阻塞直至获取
rateLimiter.acquire();
尝试获取令牌:
//尝试获取令牌,获取成功则立刻返回 true,否则返回 false
boolean gainSuccess = rateLimiter.tryAcquire()
实现原理
在令牌桶模型中,有定时生成令牌的令牌生成器,而
RateLimiter
中并没有令牌生成器,也没有专门的后台线程来定时生成令牌,而是采用了基于时间戳、纯依赖使用方线程驱动的方式来实现。