Java本地缓存:Caffeine、多级缓存、一致性与高并发治理
前言
本地缓存是指数据保存在当前 Java 进程的堆内存中。它省去了网络访问 Redis 的开销,读延迟通常更低,但每个应用实例都有自己的副本。因此,本地缓存的核心不是“如何 put 和 get”,而是四个问题:缓存多大、何时过期、数据变更如何失效、多实例同时失效时如何避免集体回源。
面试中常见误区是把本地缓存和 Redis 当成替代品。实际上,生产系统更常见的组合是:本地缓存承担极热点读取,Redis 承担跨实例共享缓存,MySQL/PostgreSQL 承担权威业务数据。
本地缓存是什么,适合什么场景
flowchart LR
A[应用请求] --> L1{L1: 本地 Caffeine}
L1 -->|命中| R[直接返回]
L1 -->|未命中| L2{L2: Redis}
L2 -->|命中| U[回填 L1]
L2 -->|未命中| DB[(数据库或远程服务)]
DB --> W[回填 Redis 与 L1]
U --> R
W --> R
适合本地缓存的数据通常具有这些特征:读多写少、数据量可控、允许短暂不一致、可从权威源重建。例如字典项、区域配置、权限元数据、功能开关、热点商品基础信息、费率配置。
不适合直接只靠本地缓存的数据包括余额、实时库存、订单支付状态、强权限校验结果等。它们可以被短时间缓存为优化,但最终判断仍要依赖权威数据源或条件更新。
| 对比维度 | 本地缓存 | Redis 缓存 |
|---|---|---|
| 访问延迟 | 无网络调用,通常最低 | 有网络与序列化开销,但仍很低 |
| 数据范围 | 单个 JVM 实例 | 多个应用实例共享 |
| 容量 | 占用应用堆,必须严格设上限 | 独立内存集群,容量较大 |
| 一致性 | 每个实例一份副本,失效传播较难 | 集中存储,更新较容易统一 |
| 故障影响 | 实例重启丢失,可重建 | Redis 故障可能影响所有实例 |
| 典型职责 | L1 热点缓存、短配置缓存 | L2 共享缓存、会话、分布式协调 |
常见本地缓存方案
方案总览
| 方案 | 本质 | 适合场景 | 主要短板 |
|---|---|---|---|
| Caffeine | 高性能 JVM 本地缓存库 | 新建 Spring Boot 服务、热点数据、需要异步加载和精细淘汰 | 只在单 JVM 内;需要自行处理集群失效 |
| Guava Cache | Guava 提供的本地缓存 | 已深度使用 Guava 的存量项目、简单场景 | 功能和性能演进较慢,新项目通常优先 Caffeine |
| Ehcache 3 | 成熟缓存框架,可堆内/堆外/磁盘,支持 JCache | 需要多层介质、标准 API、较复杂企业缓存治理 | 配置和运行复杂度高,纯 JVM L1 场景通常偏重 |
| Spring Cache | Spring 的缓存抽象,不是具体缓存实现 | 希望通过注解统一接入缓存 | 淘汰、容量和加载能力由底层 CacheManager 决定 |
ConcurrentHashMap |
并发容器,不是缓存库 | 极少量、无过期、由业务完全管理的数据 | 没有 TTL、容量淘汰、统计和加载合并,容易内存泄漏 |
| Cache2k / Cache4k 等 | 其他 JVM 本地缓存库 | 有特定生态、API 或许可偏好的团队 | 社区与团队经验通常不如 Caffeine 普及 |
结论可以直接记住:新 Java 服务需要 JVM 本地缓存时,优先 Caffeine;需要注解时用 Spring Cache + Caffeine;只有明确需要堆外、磁盘或 JCache 标准时再评估 Ehcache;不要把 ConcurrentHashMap 当通用缓存。
Caffeine:新项目的默认选择
Caffeine 是 Java 生态中常用的高性能本地缓存库,支持容量与权重淘汰、写入/访问过期、刷新、同步/异步加载、统计和监听器。其默认淘汰策略基于 Window TinyLFU 思想:一小部分空间用于观察新访问的数据,大部分空间保存高价值数据,再用频率估算决定新数据是否值得进入主缓存。
它不是简单的“最近最少使用(LRU)”。LRU 容易被一次性的大量冷数据刷掉原本的热点;TinyLFU 会参考访问频率,通常更能保护稳定热点。
1 | Cache<String, ProductSummary> productCache = Caffeine.newBuilder() |
cache.get(key, loader) 能将“读取、未命中回源、写回缓存”收敛在一个调用中。同一个 Key 的并发未命中通常会合并加载,避免同一实例内大量请求一起打到 Redis 或数据库;但这并不能自动合并多实例的回源。
Guava Cache:存量项目可用,新项目通常不优先
Guava Cache 提供了 maximumSize、过期、CacheLoader 等基本能力,API 与 Caffeine 相似。它适合已有 Guava 基础设施、功能要求简单且不计划改造的项目。
但新项目一般直接选择 Caffeine:后者在高并发实现、淘汰策略、异步加载、刷新和维护活跃度方面更适合当前服务端应用。迁移时不能只替换包名,需重新核对过期、刷新、异常加载和统计语义。
Ehcache:需要多介质或 JCache 时再选择
Ehcache 3 支持堆内、堆外和磁盘层,并可通过 JCache(JSR-107)接口接入。它适合缓存体积较大、希望减少 JVM 堆占用,或企业已有统一 Ehcache/JCache 规范的场景。
代价是配置、序列化、磁盘 IO 与运维复杂度更高。对于一个典型 Spring Boot 接口的热点对象缓存,Caffeine 的堆内方案通常更简单、延迟更稳定。不要为了“数据不丢”把业务权威数据塞进本地磁盘缓存,数据库仍应是事实来源。
Spring Cache:统一入口,不负责具体淘汰
Spring Cache 提供 @Cacheable、@CacheEvict、@CachePut 等抽象,使业务代码不直接依赖某个缓存库:
1 |
|
它解决的是“业务如何声明缓存”,不解决“缓存如何存”。底层可以是 Caffeine、Redis、Ehcache 等。sync = true 可降低单实例同 Key 缓存击穿,但是否支持、具体行为取决于 CacheManager。还要注意 Spring AOP 的自调用问题:同一个 Bean 内部直接调用带缓存注解的方法,通常不会经过代理,注解不会生效。
为什么 ConcurrentHashMap 不是完整方案
ConcurrentHashMap 只保证并发访问安全,不具备缓存治理能力。下面的写法没有过期、没有容量上限,也没有淘汰统计:
1 | Map<String, Object> cache = new ConcurrentHashMap<>(); |
如果 Key 来自用户 ID、请求参数或动态查询条件,Map 会持续增长,最终触发 Old GC 压力甚至 OOM。只有数据规模很小、生命周期与应用一致且能由业务明确清理时,才适合直接使用;大多数场景应换成 Caffeine。
淘汰、过期与刷新
这三个概念经常被混为一谈:
| 机制 | 触发条件 | 解决什么问题 | 注意点 |
|---|---|---|---|
| 容量淘汰 | 超过 maximumSize 或 maximumWeight |
防止 JVM 堆无限增长 | 淘汰不等于数据过期,热点仍可能被保留 |
| 写后过期 | 距离写入超过 TTL | 控制数据最大陈旧时间 | 所有 Key 同时写入会形成集中失效 |
| 访问后过期 | 长时间没人访问 | 回收冷数据 | 热 Key 会长期保留,需要容量上限兜底 |
| 刷新 | 到刷新时间后异步重载 | 降低请求直接撞上过期的概率 | 旧值会继续返回,适合允许短暂陈旧的数据 |
| 主动失效 | 数据变更时显式 invalidate |
提高更新后的收敛速度 | 多实例仍需要传播失效消息 |
对于对象大小差异明显的缓存,优先用 maximumWeight + weigher,而不是只按条目数限制。一个 10 MB 的对象和一个 1 KB 的对象都算“一条”,会让按数量限制失去意义。
1 | Cache<String, byte[]> fileMetaCache = Caffeine.newBuilder() |
多级缓存与一致性
Cache Aside 是最常见的读写模型
读路径通常是 L1 本地缓存 -> L2 Redis -> 数据库。写路径通常是先更新数据库,再删除 L2 和 L1 缓存:
sequenceDiagram
participant App as 应用实例
participant DB as 数据库
participant Redis as Redis L2
participant MQ as 失效消息
participant L1 as 各实例本地缓存
App->>DB: 更新权威数据并提交事务
App->>Redis: 删除共享缓存
App->>MQ: 发布 product:1001 失效事件
MQ-->>L1: 各实例删除对应 L1 Key
严格意义上,“更新数据库后删除缓存”也可能因为应用宕机、消息丢失或消费延迟而短暂不一致。常见补强方式包括:事务 Outbox 保证失效事件最终发出、消费者幂等删除、TTL 兜底、定时对账/预热。不要承诺本地缓存绝对实时,应该先定义业务可接受的陈旧窗口。
失效传播的常见方案
| 方案 | 一致性收敛速度 | 复杂度 | 适合场景 |
|---|---|---|---|
| 仅 TTL | 最慢,等每台机器自然过期 | 低 | 配置、字典等允许分钟级陈旧的数据 |
| 数据库更新后本机删除 | 只影响写入该实例 | 低 | 单实例服务或本地开发,不适合集群 |
| Redis Pub/Sub | 通常较快 | 中 | 可接受订阅者短暂离线时漏消息,TTL 可兜底 |
| MQ / Outbox 失效事件 | 可重试、可追踪 | 中高 | 多实例生产服务、需要可靠最终失效 |
| 版本号校验 | 可避免旧值被误用 | 中 | 配置、规则和读取量较高的元数据 |
Pub/Sub 的订阅者离线期间无法补收历史消息,所以关键数据不能只依赖它。可靠方案应让消息可重放,或让本地缓存 TTL 足够短并具备定期校验。
高并发下的缓存击穿和热点治理
缓存击穿发生在热点 Key 失效时,大量请求同时回源。对单个 JVM,Caffeine 的 get(key, loader) 或 Spring Cache 的 sync 可以合并相同 Key 的加载;跨 JVM 仍可能有数十个实例一起回源。
生产上通常组合使用:
- 给 TTL 加随机抖动,避免大量 Key 同一时刻过期。
- 对核心热点在流量高峰前预热,并用
refreshAfterWrite平滑刷新。 - 单实例使用 Caffeine 的加载合并;跨实例可用 Redis 短期互斥、分布式 singleflight 或消息队列排队,但要设置超时与降级。
- 回源接口设置并发上限、超时和兜底数据,不能让缓存问题拖垮数据库。
- 缓存空值或使用布隆过滤器,避免不存在的 Key 持续穿透到数据库。
对于“极热且写少”的商品详情,L1 本地缓存通常非常有效;对于“极热且频繁写”的库存,不能简单复制多个 L1 副本。库存扣减仍应通过 Redis 原子操作或数据库条件更新完成,本地缓存至多用于展示层的非实时值。
Spring Boot + Caffeine 实践
配置 CacheManager
1 |
|
这适合所有缓存区域共用同一策略。真实项目中,不同数据的 TTL 和容量往往不同,例如“商品详情 5 分钟、字典 1 小时、权限 30 秒”。此时应为不同 cacheName 单独构建 Caffeine,避免用一个全局 TTL 解决所有业务。
Key 设计与空值策略
缓存 Key 要稳定、可读且包含影响结果的维度,例如 product:summary:{productId}:{language}。不能只使用 productId 而忽略租户、地区、语言、价格渠道等会改变结果的条件,否则会发生串数据。
对于不存在的数据可以短期缓存空值,防止穿透;但空值 TTL 应明显短于正常数据,并在数据创建后主动删除对应空值缓存。不要把异常结果长期缓存为“空”,否则下游临时故障会被放大为长时间业务不可用。
监控、排查与容量治理
本地缓存最危险的问题通常不是命中率低,而是没有上限导致应用 OOM。至少应监控:
| 指标 | 说明 | 异常后的优先排查 |
|---|---|---|
hitRate |
命中率 | Key 是否过度离散、TTL 是否过短、是否频繁主动失效 |
loadSuccessCount / loadFailureCount |
回源成功/失败数 | 下游是否变慢、加载器是否抛异常、是否出现穿透 |
totalLoadTime |
回源耗时 | 数据库/Redis/远程服务耗时,线程池是否拥塞 |
evictionCount |
驱逐数量 | 容量是否过小、Key 是否突然膨胀、热点是否变化 |
estimatedSize |
估算条目数 | 是否存在无界 Key;结合堆监控评估对象真实大小 |
| JVM 堆、Old GC、Full GC | 内存健康度 | 缓存对象大小、引用链、最大容量、序列化副本 |
启用 recordStats() 后,可定期采集 cache.stats()。但命中率高不代表缓存健康:如果缓存的对象很大或 Key 无界,命中率高仍可能把堆耗尽。
高频面试题
| 问题 | 回答要点 |
|---|---|
| 为什么要本地缓存,Redis 不够吗? | 本地缓存省网络开销,适合极热点读;Redis 负责跨实例共享。代价是每实例副本与失效传播问题。 |
| Caffeine 和 Guava Cache 怎么选? | 新项目优先 Caffeine,淘汰策略、并发和异步能力更成熟;Guava 适合低改动存量项目。 |
| Caffeine 和 Ehcache 怎么选? | 纯堆内 L1 热点缓存优先 Caffeine;需要堆外、磁盘或 JCache 标准时评估 Ehcache。 |
| Spring Cache 是不是缓存实现? | 不是,它是抽象层;具体容量、过期、淘汰取决于 Caffeine、Redis、Ehcache 等 CacheManager。 |
| 本地缓存如何保证一致性? | 不追求绝对同步,而是定义陈旧窗口;更新权威库后发可靠失效事件,多实例幂等删除,TTL 兜底。 |
| 如何防止缓存击穿? | 单 JVM 合并加载,跨实例加互斥/限流,热点预热与刷新,回源超时降级。 |
ConcurrentHashMap 能否做缓存? |
只能做极简单、生命周期可控的数据容器;没有 TTL、容量淘汰和监控,不能做通用业务缓存。 |
总结
本地缓存的价值是以 JVM 内存换取更低延迟和更少的共享缓存压力,但它必须是有边界的加速层:容量有上限、数据可重建、失效有传播、回源有保护。
生产选型中,Caffeine 是多数 Java 服务的默认 L1 方案,Spring Cache 负责统一接入,Redis 通常作为共享 L2。只有当数据介质、标准兼容或已有企业平台提出明确要求时,再选择 Ehcache 或其他方案。


