01 引言
在追求高性能系统的征途中,本地缓存(Local Cache
)扮演着关键角色。它将数据存储在应用进程内存中,省去了网络开销和磁盘I/O,提供近乎瞬时的数据访问。
由于本地缓存的高效性,备受广大开发者喜爱,但是本地缓存的内存和程序进程共享一个JVM
,缓存的大小直接影响到程序进程的内存,若使用不当,这把“利器”也可能伤及自身。
02 常用的本地缓存
本地缓存就是数据存储在应用程序进程的内存中,每个应用实例拥有自己独立的缓存副本。
2.1 Caffeine
当前性能最优、功能最丰富的 Java 本地缓存库。API
友好,支持多种淘汰策略、异步加载、刷新等。
Maven
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>${latest.version}</version>
</dependency>
案例
Cache<String, User> cache = Caffeine.newBuilder()
.maximumSize(10_000) // 最大条目数
.expireAfterWrite(10, TimeUnit.MINUTES) // 写入后过期时间
.recordStats() // 开启统计
.build();
// 取值(自动加载):取值后自动放入缓存
User user = cache.get(userId, id -> userDao.getUser(id));
2.2 Guava Cache
Google
出品,成熟稳定,功能强大,曾是主流选择,现在常被 Caffeine
替代(性能更好)。
Maven
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${latest.version}</version>
</dependency>
案例
// 使用Guava Cache加载全局配置
LoadingCache<String, Config> configCache = CacheBuilder.newBuilder()
.refreshAfterWrite(5, TimeUnit.MINUTES)
.build(new CacheLoader<>() {
@Override
public Config load(String key) {
return configService.loadConfig(key);
}
});
// 获取配置(数万次/秒)
Config config = configCache.get("system_params");
2.3 ConcurrentHashMap
ConcurrentHashMap
+ 自定义逻辑,以最基础的方式,需要手动处理过期、淘汰、并发等问题,不推荐用于复杂场景。
ConcurrentHashMap cache = new ConcurrentHashMap();
简单来说,就是提供了一个存储数据的集合容器,一个加了分段锁的Map集合而已,里面的过期、淘汰等都需要自行设计。Spring
的三级缓存也使用的是ConcurrentHashMap
。
2.4 对比
Caffeine
和Guava Cache
都是非常优秀的本地缓存框架,有丰富的API
,已经满足我们日常的需求。既有过期策略,又有淘汰机制还有权重以及缓存命中统计等功能。使用者任选其一即可。
而ConcurrentHashMap
就是一个普通的Map
,仅适用于简单的场景。
03 修罗场
本地缓存的使用非常简单,但在使用过程中会出现问题呢?看看你有没有被击中!
3.1 内存溢出
由于本地缓存都是和程序进程共享的一个JVM
内存,如果不设置容量的上线,随着缓存的增大,程序进程的内存被严重压缩,内存溢出就像定时炸弹一样,随时都有可能引爆。
排查问题难度增大。因为随着程序的终止,重启之后内存被重新分配,几乎没有留下任何线索。不知道是缓存的导致的还是程序进程导致的内存溢出。
知道了问题的所在,解决方案也就变的简单了。
- 必须设置本地缓存的容量上限(
maximumSize/maximumWeight
),以确保缓存占用的内存不影响程序进程的内存。 - 必须定义过期策略(
expireAfterWrite/expireAfterAccess
),确保缓存的内存及时释放,给更需要的缓存腾出空间。
3.2 集群一致性
生产的节点一般都不是一个节点而是多节点部署,多节点部署中,每一个节点都会拥有独属于自己的本地缓存,而且多节点之间的缓存并不可见。
还记得之前遇到的生产问题,因为线上需要修改数据,修改数据之后发现并不生效,排查下来是因为使用了本地缓存导致的。本地缓存无法及时更新,设置的有效期时间又偏长,为了使修改的数据生效,只能重启应用重置本地缓存才解决。
本地缓存不像Redis
等分布式缓存一样,可以通过客户端统一修改,以达到最终数据一致性。为了解决这一问题,我们特意为应用程序留了后门以便操作本地缓存,但是由于多节点的部署,需要手动更新每一个节点的缓存才会生效。
最终的解决方案来了,通过广播通知的方式,通知每一个节点更新缓存。这里广播通知的技术有很多。
Redis
的发布订阅模,MQ
消息队列的方式(Kafka、ActiveMQ、RocketMQ
等)
3.3 内存监控
监控是一个项目架构设计的重要指标,缓存的使用情况、命中情况,内存的使用情况等可以协助我们了解缓存使用的合理不合理。但是,日常开发中可能大家更聚焦于缓存的使用,至于缓存的命中率、使用的效果可能并不关注。
而本地缓存本身实现了缓存的使用情况,通过对缓存的分析,及时调整参数和冷热数据,让缓存发挥自己最大的效果。
04 小结
Java
本地缓存是把双刃剑,合理使用可使QPS
提升10倍以上,滥用则会导致服务崩溃。大家在使用本地缓存的时候,有没有遇到过这些问题呢?又是怎么解决的呢?