前阵子团队把一批商品维度数据从 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,但它不管数据总量、不管过期时间
