前言

本地缓存是指数据保存在当前 Java 进程的堆内存中。它省去了网络访问 Redis 的开销,读延迟通常更低,但每个应用实例都有自己的副本。因此,本地缓存的核心不是“如何 putget”,而是四个问题:缓存多大、何时过期、数据变更如何失效、多实例同时失效时如何避免集体回源。

面试中常见误区是把本地缓存和 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
2
3
4
5
6
7
Cache<String, ProductSummary> productCache = Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();

ProductSummary product = productCache.get(productId, this::loadProduct);

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
2
3
4
5
6
7
8
9
@Cacheable(cacheNames = "productSummary", key = "#productId", sync = true)
public ProductSummary getProductSummary(Long productId) {
return productRepository.findSummaryById(productId);
}

@CacheEvict(cacheNames = "productSummary", key = "#command.productId")
public void updateProduct(ProductCommand command) {
productRepository.update(command);
}

它解决的是“业务如何声明缓存”,不解决“缓存如何存”。底层可以是 Caffeine、Redis、Ehcache 等。sync = true 可降低单实例同 Key 缓存击穿,但是否支持、具体行为取决于 CacheManager。还要注意 Spring AOP 的自调用问题:同一个 Bean 内部直接调用带缓存注解的方法,通常不会经过代理,注解不会生效。

为什么 ConcurrentHashMap 不是完整方案

ConcurrentHashMap 只保证并发访问安全,不具备缓存治理能力。下面的写法没有过期、没有容量上限,也没有淘汰统计:

1
2
Map<String, Object> cache = new ConcurrentHashMap<>();
cache.put(key, value);

如果 Key 来自用户 ID、请求参数或动态查询条件,Map 会持续增长,最终触发 Old GC 压力甚至 OOM。只有数据规模很小、生命周期与应用一致且能由业务明确清理时,才适合直接使用;大多数场景应换成 Caffeine。

淘汰、过期与刷新

这三个概念经常被混为一谈:

机制 触发条件 解决什么问题 注意点
容量淘汰 超过 maximumSizemaximumWeight 防止 JVM 堆无限增长 淘汰不等于数据过期,热点仍可能被保留
写后过期 距离写入超过 TTL 控制数据最大陈旧时间 所有 Key 同时写入会形成集中失效
访问后过期 长时间没人访问 回收冷数据 热 Key 会长期保留,需要容量上限兜底
刷新 到刷新时间后异步重载 降低请求直接撞上过期的概率 旧值会继续返回,适合允许短暂陈旧的数据
主动失效 数据变更时显式 invalidate 提高更新后的收敛速度 多实例仍需要传播失效消息

对于对象大小差异明显的缓存,优先用 maximumWeight + weigher,而不是只按条目数限制。一个 10 MB 的对象和一个 1 KB 的对象都算“一条”,会让按数量限制失去意义。

1
2
3
4
5
Cache<String, byte[]> fileMetaCache = Caffeine.newBuilder()
.maximumWeight(64L * 1024 * 1024)
.weigher((String key, byte[] value) -> value.length)
.expireAfterAccess(Duration.ofMinutes(10))
.build();

多级缓存与一致性

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 仍可能有数十个实例一起回源。

生产上通常组合使用:

  1. 给 TTL 加随机抖动,避免大量 Key 同一时刻过期。
  2. 对核心热点在流量高峰前预热,并用 refreshAfterWrite 平滑刷新。
  3. 单实例使用 Caffeine 的加载合并;跨实例可用 Redis 短期互斥、分布式 singleflight 或消息队列排队,但要设置超时与降级。
  4. 回源接口设置并发上限、超时和兜底数据,不能让缓存问题拖垮数据库。
  5. 缓存空值或使用布隆过滤器,避免不存在的 Key 持续穿透到数据库。

对于“极热且写少”的商品详情,L1 本地缓存通常非常有效;对于“极热且频繁写”的库存,不能简单复制多个 L1 副本。库存扣减仍应通过 Redis 原子操作或数据库条件更新完成,本地缓存至多用于展示层的非实时值。

Spring Boot + Caffeine 实践

配置 CacheManager

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@EnableCaching
public class CacheConfig {

@Bean
public CacheManager cacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager("productSummary");
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats());
return manager;
}
}

这适合所有缓存区域共用同一策略。真实项目中,不同数据的 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 或其他方案。