前阵子团队把一批商品维度数据从 Redis 迁到了 JVM 进程缓存,理由是接口 QPS 上了几个量级,Redis 响应和带宽开始吃紧。迁完之后接口 RT 确实掉了,但随后两次事故差点让人想把代码回滚:一次是 Full GC 把老年代占满,另一次是缓存重建时数据库被回源请求打满。回过头看,问题不在“JVM 进程缓存”这个技术本身,而在于我们把它当成了 Redis 的平替来用。进程缓存不是把数据往 Map 里一塞就完事,它对数据筛选、容量规划、失效策略、多实例一致性都有完全不同的要求。
这篇文章会把我在实际项目里实现 JVM 进程缓存时踩过的坑、验证过的参数、最后沉淀下来的方案,按条理写清楚。适合正在做本地缓存选型、想把部分热点数据从分布式缓存下沉到应用内存,或者已经在用但时不时被淘汰策略和内存问题困扰的 Java 开发。
1. 进程缓存前必须想清楚的三件事:数据范围、内存容量、失效模型
很多人一开始动手就写了个 ConcurrentHashMap,或者引个 Caffeine 就开始 put。真正决定这个东西能不能稳定跑起来的,是动手之前对三个问题的判断。
1.1 不是所有数据都适合放进进程内存
进程缓存最大的优势是零网络开销,数据在同一个 JVM 堆里,读起来就是一次指针访问。但它的代价也很直接:每个 JVM 实例都保存一份完整副本,数据量乘以实例数就是总内存开销。适合放进进程缓存的,我归纳下来有三类:
- 读多写少、允许短暂不一致的配置和字典类数据,比如活动开关、商品标签、城市列表。
- 单机热点极高的数据,比如某个爆款商品的详情,Redis 扛的是整集群 QPS,进程缓存每台机器只处理自己的流量。
- 重建代价可控的数据,即使缓存全部失效,回源查询数据库在几十毫秒内能完成,且并发压力可控。
不适合的也很明显:频繁更新的库存余额、强一致要求高的账户状态、单条数据体积巨大但访问量低的“死数据”。这类数据放进进程缓存要么带来一致性问题,要么白占内存。
1.2 容量规划算的是堆内存,不是接口数量
进程缓存的内存来自 -Xmx 分配的堆。很多人只看到“ -Xmx2g 很充裕”,却忘了堆里还跑着业务对象、线程栈上下文、框架的元数据。一个 2G 堆的应用,刨掉 Metaspace、Code Cache、Direct Buffer 和正在处理的请求对象,真正能稳定留给缓存的可能只有 400~600MB。
我们在项目里做容量评估时,不是按“需求上说要缓存多少条”来定的,而是先采样单条缓存对象的真实大小,再算实例数乘以条目数。用 jcmd GC.class_histogram 或 jmap -histo:live 可以看到不同类实例数量和占用字节数。比如一个商品 DTO 如果带上嵌套的 SKU 列表、图片列表、活动标签,单条很容易占到 5KB 甚至更多,10 万条就是 500MB。你要是只按“10 万条 int 拼出来的 Map”来估算,上线第二天就等着 Full GC。
提示:估算容量时不要用平均值,要用 P99。缓存里总有那么几条超大对象,平均值看着人畜无害,P99 才是压垮老年代的元凶。
1.3 失效模型和 Redis 有本质区别
Redis 缓存挂了可以重建,JVM 进程缓存呢?应用重启,缓存整个清空;JVM 崩溃,缓存跟着没了。它没有持久化,也不会帮你做跨节点的数据同步。所以一切进程缓存方案都必须有一个共同前提:业务能接受缓存丢失后回源重新加载,并且系统在回源瞬间扛得住。
这个前提直接决定了缓存数据的选择。我们在实际落地时,给进程缓存定的基调是“缓存是加速手段,不是数据副本”,数据库始终是最终真相。所有查询链路都保留了完整的 DB 兜底逻辑,缓存只是挡在 DB 前面的一层加速板。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 从手写 Map 到 Caffeine:选型时的技术账和实测参考
选型是第一步,也是最容易拍脑袋的一步。我见过不少团队直接 new ConcurrentHashMap 做缓存,理由是不想引入依赖。这个做法在数据量很小、几乎不淘汰的场景下能用,但距离真正的“缓存”还差得远。
2.1 ConcurrentHashMap 做缓存,缺的是整套淘汰机制
ConcurrentHashMap 本身是一个优秀的并发 Map,但它不管数据总量、不管过期时间、不管淘汰策略。你需要自己维护:过期扫描线程、容量超限时的淘汰算法、命中和淘汰统计。这些逻辑一旦写起来,你就会发现和 Caffeine 的源码越来越像,但质量和覆盖度远不如它。
还有一个隐蔽问题:computeIfAbsent 在并发加载场景下如果加载函数里递归访问同一个 key,会直接抛 IllegalStateException: Recursive update。这个坑我在线上遇到过,排查时一度怀疑是 JDK 版本问题,后来才发现是缓存回源逻辑里出现了嵌套查询同一个 key 的情况。
2.2 Caffeine 的 W-TinyLFU 为什么更省内存
Caffeine 和 Guava Cache 的差异不仅是性能优化。Caffeine 的核心是 W-TinyLFU 淘汰算法:它用频率粗略记录访问历史,把高频数据放到保护段,低频数据更容易被淘汰。相比简单的 LRU,它能避免“批量扫描一次冷数据就把热门数据全部挤出缓存”的问题。
这一点对我们非常实用。运营后台偶尔会跑一次全量导出,如果走 LRU,几万条冷数据一次性访问就可能把热数据全部淘汰,导致线上接口命中率瞬间崩塌。W-TinyLFU 的频次过滤机制会让这种偶发访问对缓存内容的冲击小很多。
从实测看,Caffeine 命中率普遍高于 Guava,在相同容量和相同访问模式下可以做到更少的淘汰数量。如果项目里已经有 Guava,迁移到 Caffeine 改动量也不大,它是 Guava Cache 的 API 超集。
2.3 一份可以直接抄的配置骨架
我用的是 Caffeine + Spring Boot,声明一个全局单例的 LoadingCache,加载函数里走 DB 查询。基础配置是这样的:
java复制LoadingCache<String, ProductSnapshot> productCache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(60))
.refreshAfterWrite(Duration.ofSeconds(30))
.recordStats()
.build(new CacheLoader<String, ProductSnapshot>() {
@Override
public ProductSnapshot load(String key) throws Exception {
return loadFromDb(key);
}
});
几个参数为什么要这么配:
expireAfterWrite(60s):从上一次写入开始计时,60 秒后过期。它保证数据不会无限期不更新。refreshAfterWrite(30s):30 秒后,如果 key 再次被访问,会触发异步刷新,同时继续返回旧值。它不是等过期后再阻塞加载,而是让“热点数据”始终保持新鲜。这个参数在很多资料里被称为“优雅过期”。maximumSize(10_000):限制条目数,超出后按 W-TinyLFU 淘汰。
注意:
refreshAfterWrite只对LoadingCache生效,并且如果expireAfterWrite时间小于等于refreshAfterWrite,刷新就没有意义了,因为数据在刷新前已经过期并触发阻塞加载。
3. 过期与淘汰策略:四个真实场景下踩出来的坑
配置写完只是开始,真正麻烦的是运行一段时间之后暴露出来的问题。
3.1 固定过期时间引发的缓存击穿
我们把某个配置项设置了 5 分钟固定过期。到了整点后的第 5 分钟,所有请求同时发现缓存过期,于是一起回源,数据库瞬间被打满。这个场景在固定过期时间下几乎必然出现,尤其是并发量大的 key。
解决办法是给过期时间加上随机抖动。Caffeine 支持自定义过期策略:
java复制Caffeine.newBuilder()
.expireAfter(new Expiry<String, ProductSnapshot>() {
@Override
public long expireAfterCreate(String key, ProductSnapshot value, long currentTime) {
long base = Duration.ofMinutes(5).toNanos();
long jitter = ThreadLocalRandom.current().nextLong(Duration.ofSeconds(30).toNanos());
return base + jitter;
}
@Override
public long expireAfterUpdate(String key, ProductSnapshot value,
long currentTime, long currentDuration) {
return currentDuration;
}
@Override
public long expireAfterRead(String key, ProductSnapshot value,
long currentTime, long currentDuration) {
return currentDuration;
}
});
抖动范围通常取过期时间的 5%~10%,既能打散回源波峰,又不会让数据过期得过早或过晚。
3.2 expireAfterAccess 会把“不存在的过期”变成“永远不过期”
expireAfterAccess 是从最后一次访问开始重新计时。这意味着一个高频 key 只要一直被访问,就永远不会过期。听起来很爽,但对于需要定期更新的数据,它会导致旧值长期驻留。我们踩过的坑是:某个营销配置设置 10 分钟过期,但因为这个 key 每秒被访问上千次,它在老年代里躺了一整天,配置更新后用户始终看到旧活动。
如果业务需要的是“从写入时间开始算,到期必须更新”,用 expireAfterWrite。如果单纯想淘汰不活跃的 key,再考虑 expireAfterAccess,但心里要清楚它会带来过期延迟。
3.3 W-TinyLFU 的频次保护对冷启动的影响
Caffeine 的频次统计并不是全量的,它用一个近似计数 Sketch 记录访问频率。好处是内存占用极低,代价是冷启动阶段所有 key 频率都低,淘汰时容易出现“刚刚被放入缓存的数据很快被扫走”的现象。
这个问题的常见表现是:服务发布后,缓存命中率从 0% 慢慢爬升,但中间会频繁抖动,因为前几分钟数据还没建立起频率,新数据不断进来把老数据挤出去。解决方案是发布脚本里做一次缓存预热,在应用启动后主动访问一批核心 key,让它们快速积累频率。也有人在最大容量内预留一部分“窗口空间”,Caffeine 源码里 admission window 的默认大小是容量的 1%,不用改它,预热更实用。
3.4 堆外缓存不是银弹
当缓存对象体积大、堆内存吃紧时,很多人会想到堆外缓存。Caffeine 本身不支持堆外存储,社区里常配 MapDB 或 Chronicle Map。
我实际试过之后的态度是:除非单条数据超过几十 KB 并且数量很大,否则不要轻易上堆外。堆外缓存意味着每次读写都要做序列化和反序列化,CPU 开销会明显上升;对象在堆外失去类型结构,调试、监控、快照都变得更困难。我们后来把一条数据从 5KB 压缩到 800B(只保留接口真正需要的字段),直接就塞回堆内了,效果比上堆外简单得多。
4. 多实例部署时的一致性:回源合并、主动失效和容忍窗口
应用上了多台实例之后,进程缓存最大的麻烦不是内存,而是“同一份数据在不同机器上可能不一致”。这个话题很多时候被忽略,因为单机环境下永远复现不出来。
4.1 回源合并:别让一次缓存击穿变成数据库雪崩
多实例部署时,同一个 key 的缓存如果在所有节点同时过期,数据库会同时收到 N 份回源请求。N 是实例数,实例越多越危险。
Caffeine 的 LoadingCache 已经做了同实例内同 key 的并发合并:多个线程同时访问同一个 key,只有一个线程执行加载函数,其他线程阻塞等待结果。但跨实例它管不了。所以我们做了一层业务侧的“分布式锁 + 本地加载”回源策略:回源前先尝试获取 Redis 上的短锁,获取不到就等待并重新查一次缓存,只有拿到锁的实例才真正去查数据库。
简化后的逻辑是:进程缓存查不到 -> 尝试获取分布式锁 -> 获取成功则查库并写缓存 -> 获取失败则短暂 sleep 后重新查缓存。这个方案能让同 key 同时回源的请求数从“实例数”降为“1”。
4.2 主动失效:通过消息广播通知所有实例清缓存
业务更新数据后,只更新本机缓存是不够的。我们的做法是用 Redis Pub/Sub 广播一个失效消息,所有实例收到消息后执行 cache.invalidate(key),下次请求再按需加载。
伪代码类似这样:
java复制// 数据更新方
redisTemplate.convertAndSend("cache_channel", key);
// 每个应用实例里的监听器
public class CacheInvalidateListener implements MessageListener {
private final Cache<String, ProductSnapshot> cache;
@Override
public void onMessage(Message message, byte[] pattern) {
String key = new String(message.getBody());
cache.invalidate(key);
}
}
这个方案有个前提:消息通道必须可靠。Pub/Sub 模式如果消费者瞬时不可用会丢消息。所以我们对一致性要求略高的数据加了双保险——主动失效 + 短 TTL,即使消息丢了,expireAfterWrite 也会最终把旧数据清掉。
4.3 定好“容忍窗口”,比追求强一致更实在
进程缓存天然是最终一致。与其纠结有没有办法做到所有实例在同一毫秒看到新数据,不如认真定一个业务能接受的容忍窗口。
我们实际操作时,是把每一类缓存数据都标注了“最大容忍过期时间”:配置类数据容忍 60 秒,商品标签类数据容忍 30 秒,价格类数据不放进程缓存。规定容忍窗口后,主动失效消息只负责缩短这个窗口,而不是负责消灭它。这样你做 TTL、做刷新、做广播失效的时候,就有了一个明确的判断标准。
5. 内存占用估算与 G1 调优:别让缓存变成 Full GC 的导火索
进程缓存的数据常驻堆内存,而且存活期长,一旦超过老年代容量,就会成为 Full GC 的直接推手。这一节讲讲我们做过的估算和调优。
5.1 先算清楚一条缓存到底占多少内存
一个 Java 对象在堆里不是只有字段本身的开销。对象头在开启指针压缩后通常占 12 字节,数组额外有长度字段;String 底层是 byte[],如果 CompactStrings 开启,每个 ASCII 字符只占 1 字节,但非 ASCII 字符占 2 字节;容器对象还有内部数组和节点开销。这些加起来,一个字段看着清爽的 DTO,实际占用往往是“直觉”的 2~3 倍。
建议用 jmap -histo:live 或 JFR 的 Object Allocation 采样,看缓存类实例在堆里的真实占用。我们在排查时发现,某个 DTO 里一个 List<String> 的图片地址列表,光字符串对象就占了单条数据的 60%。后来改成只缓存压缩后的前 6 张图 URL,内存立刻降了一个量级。
5.2 G1 参数和缓存策略的配合
现在新应用基本都用 G1。缓存对象是典型的“老年代常驻对象”,它们不会因为业务请求结束而回收,会逐渐堆积到老年代。
我们最终给缓存应用设置的是:
bash复制-Xmx4g
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100
-XX:MaxRAMPercentage=75.0
MaxRAMPercentage 比裸写 -Xmx 更友好一点,容器环境下按配额比例自动算堆大小。MaxGCPauseMillis 控制 G1 对暂停时间的追求,但要提醒一句:设置得过小,G1 会更激进地做并发标记和混合回收,反而可能增加 CPU 开销。100ms 对我们这个场景是平衡点。
还有一点容易被忽视:如果缓存区的对象数量上了百万量级,G1 的 Remember Set 开销会很大。这种时候优先考虑的不是调 GC 参数,而是把缓存容量降下来。GC 再怎么调,也扛不住一个超出堆容量的缓存设计。
5.3 监控指标:命中率和过期淘汰数比 JVM 堆占用更早报警
我们给缓存接了一套简易监控,指标直接暴露到 Prometheus 风格的接口里:
cache_hit_rate:命中率。长期低于 70% 说明容量过小或数据选择不合理。cache_eviction_count:淘汰数量。突增说明容量不足,大量数据在“写入-淘汰-写入”循环。cache_load_success_time:回源耗时。如果耗时变大,先看是不是数据库压力上来了。JVM G1 Old Generation:老年代占用曲线,持续上涨说明缓存清空速度小于增长速度。
Caffeine 只需要开启 recordStats(),然后 cache.stats() 就能拿到 hitRate()、evictionCount()、averageLoadPenalty()。我们把这些值接力到监控系统后,后续几次缓存问题都是监控先报警,人还没感觉到。这一点强烈建议所有做进程缓存的团队都加上。
6. 一次进程缓存故障的完整排查链路:从 Full GC 到数据库被打满
把前面的知识串起来看一个真实例子。我们上线新缓存策略后,某天中午收到告警:应用频繁 Full GC,紧接着数据库连接池被占满。
排查过程是逐步收敛的:
第一步,看 JVM 状态。用 jstat -gcutil <pid> 1000 观察,发现 Old 区占用接近 100%,Full GC 后下降幅度很小,说明有大量长存活对象。
第二步,抓堆快照。用 jmap -dump:live,format=b,file=heap.hprof <pid> 导出现场堆,用 MAT 打开。Dominator Tree 里排在前面的是一个缓存框架的 ConcurrentHashMap 后端数组,里面挂了两千多万个字符串对象。
第三步,看缓存统计。因为我们从第一天就开了 recordStats(),通过 cache.stats() 看到命中率只有 43%,evictionCount() 高得离谱。也就是说缓存容量远小于实际写入量,大量数据一直在“写入-淘汰-重建-淘汰”。
第四步,摸清数据真实大小。用 jmap 直方图对比单个缓存 DTO 的实例占用后,发现单条对象实际占用比预期大了 4 倍。原因是我们把整个详情聚合接口的返回对象直接塞进了缓存,里面嵌套了三层对象结构,还带了几百个字符的富文本。
第五步,做整改。把缓存的 key 从“聚合对象”改为“基础字段对象”,单条体积从约 6KB 压到约 900B;同时把 maximumSize 从 50 万调整为 10 万;TTL 从 30 分钟缩短到 5 分钟并加了抖动;回源处加了分布式锁合并请求。
整改后命中率稳定在 88% 左右,Full GC 频率从每半小时一次降到几乎不出现。这次故障给我的核心教训是:排查不是先看代码,而是先看内存里到底有什么、缓存命中率到底如何。没有统计数据,所有猜测都是瞎子摸象。
写到这,我发现进程缓存这个“小技术”里其实藏着大量容易被忽略的决策点:数据怎么选、容量怎么算、过期怎么设置、多实例怎么保持一致、出了故障怎么定位。它没有一个标准答案,但有一套可以复用的分析框架。你在实现之前先按这个框架过一遍,大概率能少走我走过的那些弯路。
