本地缓存介绍
提到缓存我们首先会想到Redis,Redis是一种分布式缓存,应用进程与缓存进程运行在不同主机上,进程之间通过RPC或HTTP的方式通信。这种缓存方式可以实现服务解耦,但数据要经过网络传输,不可避免地会带来性能损耗,对实时性要求更高、缓存数据更少的场景下可以考虑本地缓存。
Redis是独立进程,就算部署到本地也会因为跨进程开销,导致性能不如本地缓存
适用场景:
- 静态配置/元数据类信息:如系统配置、规则参数、黑白名单等;
- 变更不频繁,数据量小的信息:如地区编码、字典表等数据;
- 计算结果缓存:需要密集计算的结果暂存到本地缓存,避免重复计算;
- 临时数据:临时状态(如验证码、临时 token,非跨端共享),数据仅在当前请求有效,无需持久化。
本地缓存实现方法:
- 原生JDK实现:HashMap(线程安全可使用ConcurrentHashMap)。可实现最简单的数据保存功能,但无过期数据清理、淘汰策略、监控等功能,只能用于极简单的项目场景。
- Guava Cache:Google开源的工具类库,可实现容量限制、过期策略,LRU淘汰方案,性能略逊于Caffeine,多在旧项目中使用。
- Caffeine:Java领域性能最好的本地缓存库之一,基于LRU算法的改进版本(W-TinyLFU)实现。
Guava基本使用
Guava cache
引入依赖
xml
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.0.1-jre</version>
</dependency>
使用Cache缓存的基本操作代码如下:
java
public void HandTest() throws ExecutionException {
Cache<String, String> cache = CacheBuilder.newBuilder()
.expireAfterWrite(3, TimeUnit.MINUTES) // 写入后 3 分钟过期
.concurrencyLevel(5) // 最大并发写入线程数
.initialCapacity(100) // 初始容量
.maximumSize(1000) // 最大容量
.build();
// 缓存写入数据
cache.put("key", "value");
String value1=cache.get("key",new Callable<String>() {
// 回调函数,当缓存中不存在key时的操作,先加载到缓存再返回
@Override
public String call(){
return "Null";
}
});
String value2=cache.get("key1",new Callable<String>() {
public String call(){
return "Null";
}
});
// 获取记录
String value3=cache.getIfPresent("key");
System.out.println("获取到value1:"+value1);
System.out.println("获取到value2:"+value2);
// 记录失效
cache.invalidate("key");
// 清除过期记录
cache.cleanUp();
}
LoadingCache
上述过程需要在每次get中指定默认逻辑,在此基础上Guava封装了LoadingCache类,可在build方法内指定通用处理逻辑,同时支持异步刷新,使用实例如下:
java
LoadingCache loadingCache=CacheBuilder.newBuilder()
.refreshAfterWrite(3, TimeUnit.MINUTES) // 写入后刷新,调用reload逻辑
.build(new CacheLoader<String, String>() {
// 未命中时同步加载缓存数据,同步阻塞进程,多个线程同时查找时只会被一个线程加载,防止击穿
@Override
public String load(String s) throws Exception {
System.out.println("缓存未命中,正在查库");
return queryfromdb(s);
}
// 刷新方案
@Override
public ListenableFuture<String> reload(String key, String oldValue) throws Exception {
// 查询更新记录
return Futures.immediateFuture(queryfromdb(key));
}
public String queryfromdb(String s){
return s;
}
});
首先介绍Guava的内置逻辑load,该加载方法会阻塞进程,但自带缓存击穿保护机制,多个线程同时查找相同的记录key时只会触发一次load,避免缓存击穿。
工程优化
上述过程实现了缓存未命中时的记录刷新方案,但从工程角度来说仍然不足,主要是两方面:
- 健壮性。如果下游服务宕机或返回错误数据,Guava缓存可能会保存错误值
- 效率。现有记录刷新采用同步阻塞方案,缓存刷新不影响用户请求响应,可另开线程异步执行。
优化后的代码如下:
java
// 线程池包装成执行器
ListeningExecutorService listeningExecutorService = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(4));
LoadingCache<String, String> loadingCache=CacheBuilder.newBuilder()
.refreshAfterWrite(3, TimeUnit.MINUTES) // 写入指定时间后刷新
.build(new CacheLoader<String, String>() {
// 未命中时同步加载缓存数据,同步阻塞进程,多个线程同时查找时只会被一个线程加载,防止击穿
@Override
public String load(String s) throws Exception {
try {
return getConfig(s);
} catch (Exception e) {
log.error("load config failed, key={}", s, e);
return ""; // 兜底
}
}
// 异步刷新方案
@Override
public ListenableFuture<String> reload(String key, String oldValue) throws Exception {
// 异步更新数据
return listeningExecutorService.submit(()->{
try {
return getConfig(key);
}
catch (Exception e) {
log.error("reload config failed, key={}", key, e);
// 出错了使用旧值
return oldValue;
}
});
}
public String getConfig(String s){
String data=getConfigData(s);
if(s==null||s.isEmpty()){
// 数据出错时返回空数据
return "";
}
return data;
}
});
首次加载是同步阻塞,异步更新是通过 reload 和线程池实现,更新数据时若读取配置抛出异常,会使用旧值兜底,从效率和健壮性两个层面保证了缓存可用。
该方案通过refreshAfterWrite设置刷新策略 ,重写reload刷新方法,保证缓存不为空,在刷新方法中使用线程池,提高缓存系统执行效率。
Caffeine基本使用
Caffeine简介
Caffeine是本地缓存Guava的继任者,使用更先进的缓存淘汰算法(W-TinyLFU),优化了内部数据结构,在命中率、吞吐量和内存效率都优于Guava,目前是Spring Cache的默认底层实现,新建项目的本地缓存模块可优先选择Caffeine。
W-TinyLFU缓存淘汰算法:
- 传统算法局限性:LRU(最近最久未使用)可能淘汰高频但短期未访问的条目;LFU(访问频率最低)需要为每个条目维护精确的访问计数器,内存开销大。
- 设计思想:用极小的内存开销近似统计访问频率,对突发访问和长期高频访问均保持高命中率,避免传统 LFU 的计数器膨胀问题。
- 核心组件:窗口缓存(Window Cache)缓存最新访问的条目, 主缓存(Main Cache)缓存高频访问的条目,TinyLFU 频率统计通过多个哈希函数近似统计访问频率。
- 淘汰流程:新访问条目进入窗口缓存,窗口缓存满时与主缓存中试用区的条目竞争,试用区中高频访问的条目会晋升到保护区。
- 优势:窗口缓存吸收突发流量,主缓存保留长期高频条目,频率统计比LRU更精准,计数器仅需4位,内存开销小,适应数据访问模式的变化,无锁或细粒度锁设计,减少线程竞争。
有关算法详细介绍可见:W-TinyLFU 淘汰算法详解
maven中引入依赖:
xml
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
基本 Cache
使用方法与Guava基本类似,示例如下:
java
@Test
void testCache(){
Cache<String,String> cache= Caffeine.newBuilder()
.maximumSize(1000) // 缓存最大条目数量
.expireAfterAccess(5, TimeUnit.MINUTES) // 过期时间五分钟
.build();
cache.put("username","kevin");
// 获取数据,不存在则返回null
String username=cache.getIfPresent("username");
// 获取数据,不存在则调用方法自动加载
String username1=cache.get("username1",key->{
return "get_kevin";
});
System.out.println(username);
System.out.println(username1);
}
LoadingCache
这种方式是统一了缓存不存在的调用逻辑,将自动加载方法写在LoadingCache缓存类内部,使用示例如下:
java
LoadingCache<String, String> loadingCache = Caffeine.newBuilder()
.maximumSize(10_000) //_分割线,不影响实际数字
.expireAfterAccess(10, TimeUnit.MINUTES) // 访问后10分钟过期
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
// 统一的加载逻辑
return key+"_get_kevin";
}
});
loadingCache.put("username","kevin");
System.out.println(loadingCache.get("username"));
System.out.println(loadingCache.get("username1"));
异步操作
在Guava章节中,我们设置了线程池将缓存更新为异步,caffeine对异步操作提供了原生支持,对应Cache和LoadingCache分别为AsyncCache和AsyncLoadingCache,使用实例如下:
java
AsyncLoadingCache<String,String> asyncLoadingCache=Caffeine.newBuilder()
.refreshAfterWrite(30, TimeUnit.SECONDS) //写入30s后刷新
.buildAsync(new CacheLoader<String, String>() {
public String load(String key) throws Exception {
return key+"_Async_get_kevin";
}
});
// 异步获取缓存数据值,包装成CompletableFuture类
CompletableFuture<String> valueFuture = CompletableFuture.supplyAsync(() -> {
// 模拟耗时操作
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
return "kevin"; // 异步获取到的值
});
// 存入缓存
asyncLoadingCache.put("username",valueFuture);
// 获取异步结果容器
CompletableFuture<String> future=asyncLoadingCache.get("username");
// 成功获取结果的回调
future.thenAccept(username -> {
System.out.println("异步获取到用户名:"+username);
});
// 异常处理回调
future.exceptionally(e -> {
System.out.println("加载缓存失败"+e.getMessage());
return "guest"; // 异常兜底返回默认值
});
// 等待 put 操作和 get 对应的 future 都完成(阻塞主线程)
CompletableFuture.allOf(valueFuture, future).join();
// 读取端可视业务需要选择同步获取方法
try {
// 添加 1 秒超时时间,避免无限阻塞
String us = future.get(1, TimeUnit.SECONDS);
System.out.println("同步获取用户名:" + us);
} catch (ExecutionException | InterruptedException e) {
// 异步任务执行异常或线程被中断
log.error("获取缓存值失败", e);
throw new RuntimeException(e);
} catch (TimeoutException e) {
// 超时异常单独捕获,做针对性兜底
log.warn("获取缓存值超时(1秒)", e);
// 业务兜底:返回默认值,而非直接抛异常
String defaultUs = "guest";
System.out.println("同步获取用户名(兜底):" + defaultUs);
}
完全异步读写速度极快,如果不加最后的阻塞语句,主线程立即退出,而异步任务还未完成。
开发者可以直接调用这些方法,而无需关注底层安全细节,这就是caffeine框架的价值所在。
缓存统计
Caffeine有缓存统计功能,包括缓存命中、加载等情况,帮助开发者优化缓存设计,使用示例如下:
java
Cache<String, User> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.recordStats() // 开启统计
.build();
// 使用一段时间后获取统计信息
CacheStats stats = cache.stats();
System.out.println("命中率:" + stats.hitRate());
System.out.println("平均加载时间(纳秒):" + stats.averageLoadPenalty());
System.out.println("加载失败率:" + stats.loadFailureRate());
/*
* hitCount :命中的次数
* missCount:未命中次数
* requestCount:请求次数
* hitRate:命中率
* missRate:丢失率
* loadSuccessCount:成功加载新值的次数
* loadExceptionCount:失败加载新值的次数
* totalLoadCount:总条数
* loadExceptionRate:失败加载新值的比率
* totalLoadTime:全部加载时间
* evictionCount:丢失的条数
*/
总结
本地缓存对比分布式缓存:分布式缓存有额外的网络通信开销,对于性能要求更高、数据量少的场景可以有效考虑本地缓存。
本地缓存介绍:Java生态中Caffeine是首选方案,基于谷歌Guava的改进,现在是完全的上位替代,使用更新的淘汰算法,更小的内存开销,提供同步和异步两种不同的读写方式,让开发者更关注业务本身,无需考虑底层细节。
参考文献:
Java中的四种本地缓存
Java本地缓存技术选型
本地缓存之王Caffeine
本地缓存王者Caffeine