刚接手一个内部运营系统的时候,遇到过这么个事:某个商品分类接口的QPS被营销活动顶到上千,数据库连接池在高峰期直接被打满,后台监控里全是慢查询。当时第一反应是上Redis,但仔细一看,这个接口的数据半小时才变一次,而且服务是单机部署,完全没必要再引入一套中间件。后来把SpringBoot整合Ehcache的本地缓存方案落地下去,高峰期数据库连接数直接降了一个数量级。这篇文章就围绕SpringBoot整合Ehcache这个主题,把本地缓存的选型理由、配置细节、踩坑记录和生产优化完整梳理一遍。如果你也在纠结"要不要上分布式缓存"、或者已经决定用本地缓存但不知道从哪下手,这篇应该能帮你省不少时间。
1. 为什么是Ehcache:本地缓存的角色定位与适用边界
老规矩,先想清楚一个问题:这个场景真的需要缓存吗?如果要,为什么是本地缓存,而不是直接上Redis?很多人一提到缓存就想到分布式缓存,其实在大部分中小型项目里,本地缓存才是性价比最高的那一个。
1.1 本地缓存 vs 分布式缓存,别再二选一
本地缓存跑在应用进程内,和JVM共用一块内存,读写不走网络,所以延迟极低,一般在微秒到几十微秒级别。分布式缓存则是独立的中间件,比如Redis、Memcached,数据存在独立进程里,每次读写都要走一次网络,正常情况下的耗时在毫秒级。这中间的差距,在QPS很高、单次查询很快的场景下会被放大得很明显。
我更习惯把两者定位成互补关系,而不是替代关系:
| 维度 | 本地缓存(Ehcache) | 分布式缓存(Redis) |
|---|---|---|
| 访问延迟 | 微秒级,无网络开销 | 毫秒级,有网络开销 |
| 存储容量 | 受堆内存/堆外内存限制 | 可横向扩容,容量更大 |
| 数据一致性 | 每个节点各自一份,节点间天然不一致 | 全局一份,一致性更容易保证 |
| 运维成本 | 零依赖,随应用启动 | 需要额外部署、监控、持久化 |
| 适用场景 | 单机/少量节点、读多写少、容忍分钟级一致 | 多节点共享状态、需要跨节点失效、大容量存储 |
所以我的判断标准很简单:如果数据的变化频率很低,服务节点不超过两三个,而且即使缓存里的数据偶尔旧几分钟也没什么影响,那就优先用本地缓存。反过来,如果多个节点必须读到同一份最新数据、或者缓存要承载几十GB的热数据,那才需要上分布式缓存。最怕的是项目刚起步,还没遇到并发瓶颈,就先为Redis引入了一堆运维负担,这在很多团队里其实是过度设计。
1.2 Ehcache的三个独特优势:标准、多级存储、平滑演进
缓存家族里有Caffeine、Guava Cache,为什么选Ehcache?我主要看中三点。
第一,它是JSR-107(Java Caching标准)的规范实现。这意味着你用Ehcache时,API可以面向javax.cache标准接口编写,而不是绑死某家私有API。以后想换其他JSR-107兼容实现,改动成本远低于替换Caffeine。
第二,它支持堆内、堆外、磁盘三级存储。堆内缓存访问最快但会挤占JVM堆,导致GC压力;堆外缓存利用DirectMemory,既保留接近堆内的性能,又能避开老年代GC;磁盘存储则可以在应用重启后快速预热冷数据。这种"按热点分层"的模型,是Caffeine这种纯堆内缓存不具备的。
第三,它和Spring Cache抽象配合得非常好。Spring早就把缓存抽成了CacheManager接口,Ehcache有对应的JCacheCacheManager和EhcacheCacheManager。在SpringBoot里整合Ehcache,业务代码只需要写注解,不需要关心底层的存取逻辑,后续想从本地缓存演进到Redis,业务层几乎不用动。
2. 整合前的准备:依赖版本、配置骨架与启动验证
确定了用Ehcache,接下来就是落地。我见过不少人在第一步就卡住,问题大多出在SpringBoot版本和Ehcache版本的匹配上,这一节先把地基打牢。
2.1 依赖引入与版本匹配
SpringBoot 2.x时代,官方集成的是Ehcache 3.x;SpringBoot 3.x开始全面迁移到Jakarta命名空间,如果直接引旧的Ehcache依赖,经常出现ClassNotFoundException: javax.cache.CacheManager这类错误。我的做法是让SpringBoot的依赖管理器来统一版本,而不是手动写死Ehcache版本。
Maven项目里这样加:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> </dependency>SpringBoot 2.7.x对应Ehcache 3.10.x,SpringBoot 3.x对应Ehcache 3.10.8及以上。注意spring-boot-starter-cache一定要有,它负责引入Spring Cache抽象和自动配置。如果你用的是Gradle,对应加implementation 'org.springframework.boot:spring-boot-starter-cache'和implementation 'org.ehcache:ehcache'。
还有一个容易忽略的点:如果你同时引入了Redis依赖,SpringBoot会自动配置RedisCacheManager作为默认缓存管理器。此时你想用Ehcache,就必须在配置里明确指定,否则注解缓存会走到Redis上去,跟你预期的本地缓存完全不是一回事。
2.2 缓存配置文件如何设计
SpringBoot整合Ehcache有两种配置风格:一种是纯Java配置,用CacheManagerBuilder在代码里构建;另一种是XML配置,通过ehcache.xml定义缓存模板。我推荐XML方式,因为缓存策略(TTL、淘汰策略、磁盘路径)属于基础设施配置,写成独立文件更容易被非开发同事review,也方便在不同环境间复用。
下面是一份经过生产验证的ehcache.xml,放在src/main/resources下:
<?xml version="1.0" encoding="UTF-8"?> <config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://www.ehcache.org/v3" xsi:schemaLocation="http://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.10.xsd"> <persistence directory="${java.io.tmpdir}/ehcache-data"/> <cache alias="productCache"> <key-type>java.lang.String</key-type> <value-type>java.lang.Object</value-type> <expiry> <ttl unit="minutes">30</ttl> </expiry> <heap unit="entries">10000</heap> <offheap unit="MB">64</offheap> </cache> <cache alias="dictCache"> <key-type>java.lang.String</key-type> <value-type>java.lang.Object</value-type> <expiry> <ttl unit="hours">12</ttl> </expiry> <heap unit="entries">5000</heap> </cache> </config><persistence>是可选的,我一般在需要磁盘持久化的场景才开。注意不要在每台机器上硬编码/data/xxx目录,用${java.io.tmpdir}或者配置中心下发路径更稳妥。heap和offheap的配置决定了缓存能占多大空间,堆内条数不宜设置太大,否则频繁GC反而拖慢应用。
然后在application.yml里指定配置文件路径,并打开缓存调试日志:
spring: cache: type: ehcache ehcache: config: classpath:ehcache.xml logging: level: org.springframework.cache: TRACEspring.cache.type=ehcache这一步很关键,它强制让SpringBoot使用EhcacheCacheManager,避免系统里同时存在多个CacheManager时自动装配混乱。
2.3 用一次启动验证缓存是否真正生效
配置写完先别急着写业务代码,先做一个最小化验证。创建一个极简的Service:
@Service public class CacheCheckService { @Cacheable(cacheNames = "productCache", key = "#name") public String check(String name) { System.out.println("方法被调用,未走缓存: " + name); return "hello " + name; } }然后在启动类所在包下写一个CommandLineRunner:
@SpringBootApplication public class DemoApplication { public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } @Bean CommandLineRunner runner(CacheCheckService service) { return args -> { service.check("spring"); service.check("spring"); service.check("spring"); }; } }如果配置正确,控制台只打印一次"方法被调用"。看到这个结果,说明Spring AOP代理已经生效,CacheManager也被正确装配。这时候再去看SpringBoot启动日志,会看到类似Registered Ehcache Cache: productCache的提示,说明缓存区域已经建立。这一步虽然简单,但能帮你把"配置问题"和"业务代码问题"彻底分开,后面排查会轻松很多。
3. 缓存策略落地的核心操作:注解、TTL与淘汰策略的配置
基础环境就绪后,就要考虑缓存策略本身了。这一节不展开讲API文档,重点讲我实际项目中怎么定TTL、怎么用注解、以及怎么防止缓存被流量打穿。
3.1 基于注解的缓存读写
Spring Cache提供的三个注解基本覆盖了日常大部分场景:
@Cacheable:先查缓存,缓存没有才执行方法,并把结果写入缓存。@CachePut:无条件执行方法,并把结果更新到缓存,适合"DB已更新成功后必须刷新缓存"的写操作。@CacheEvict:删除缓存条目,适合删除数据后清理过期缓存的场景。
实际项目里我经常看到有人把@CachePut和@CacheEvict混着用,搞不清该用哪个。我自己的判断标准是:如果写操作之后还希望缓存里有新值,用@CachePut;如果写操作之后希望让缓存失效、下次读取再回源数据库,用@CacheEvict。前者适合配置类、字典类数据,后者适合列表类、聚合类数据,因为列表聚合往往和多个底层对象有关,直接更新一个key反而容易漏。
key的生成一定要明确,Spring默认的SimpleKeyGenerator在方法只有一个参数时用参数本身做key,这样看起来省事,但两个方法如果参数一样、cacheNames不同还好,同一个cacheNames下就乱了。我的习惯是显式写SpEL:
@Cacheable(cacheNames = "productCache", key = "#productId") public Product getProduct(String productId) { return productMapper.selectByPrimaryKey(productId); }如果查询条件由多个字段组成,用T(java.util.Objects).hash(#a, #b)或者直接@Caching组合多个key都不是好办法,最简单的就是定义一个业务意义的key对象:
@Cacheable(cacheNames = "productCache", key = "#req.sku() + ':' + #req.channel()") public Product getProduct(ProductQuery req) { ... }等到后面排查线上问题时你会发现,一个好的key策略能省下大量时间,看到缓存里的key,立刻能反推出请求参数。
3.2 TTL、最大条数与淘汰策略的配置原则
本地缓存的本质是"用空间换时间",但空间终究是有限的,所以必须给每种数据定义一个合适的"过期时间"和"容量上限"。
| 缓存数据特征 | 推荐TTL | 容量策略 |
|---|---|---|
| 字典数据、枚举配置,变化频率极低 | 12h ~ 24h | 按业务条目数设上限,足够装下全量即可 |
| 商品详情、用户基础信息,分钟级变化 | 15min ~ 30min | 按热点数量设上限,如10000条 |
| 聚合统计、报表数据,小时级计算 | 5min ~ 10min | 少放,容量不够宁可失效重新计算 |
| 登录态、验证码等临时数据 | 与业务有效期对齐 | 建议短TTL,如2~10min |
定TTL有个通用公式思路:缓存TTL ≤ 数据允许的最大陈旧时间 / 2。比如业务要求用户看到的价格最多不能迟到10分钟,那TTL就设5分钟;如果根本没这个要求,那就设成一个"既不会频繁回源数据库,也不至于让内存积压大量垃圾"的值。我常用30分钟作为默认起点,跑几天看命中率再微调。
淘汰策略方面,Ehcache 3默认使用LFU(Least Frequently Used)和近似LRU的混合策略,日常没特殊需要就不用改。真正需要注意的,是<heap>条数千万别拍脑袋设一个巨大的值。堆内缓存条数越多,GC扫描和晋升压力越大,我见过有人把heap设到1000万,结果Full GC频繁到服务直接失去响应。如果是大缓存实例,用堆外内存分担才是正解。
3.3 缓存穿透、击穿、雪崩在本地场景下的应对
这三个词在Redis文章里见得最多,但本地缓存同样存在,只是表现不同。
缓存穿透是查询了一个肯定不存在的数据,比如恶意遍历不存在的商品ID,每次都会打到数据库。本地缓存的应对方式和Redis一样,就是把"查不到"的结果也缓存起来,只不过Value要特殊处理。我在实际项目里会定义一个NullValue占位对象,TTL设置得比正常数据短很多,比如正常数据30分钟、空值3分钟。这样既挡住了穿透压力,也不会因为空值缓存太久而影响数据恢复。
缓存击穿是某个热点key突然过期,大量请求同时打到数据库。在单机应用里,因为所有请求都在同一个JVM进程,可以用synchronized或者Ehcache自带的CacheLoaderWriter完成原子加载。不过注意,加了分布式锁反而没必要,本地场景锁就锁在进程内,更直接。
缓存雪崩在本地缓存场景反而没那么恐怖,因为每个节点只影响自己,不会像Redis那样一个节点挂掉影响全链路。但要注意TTL集中过期的问题,比如整点启动的服务,所有缓存同时写入又同时过期,会出现周期性抖动。解法很简单:初始化时给TTL加一个随机偏移量,或者异步预热,别让所有key在同秒过期。
4. 实际踩过的坑:缓存不生效到数据不一致的完整排查链路
这节才是全文最值钱的部分。配置文档谁都能看,但真正把缓存落到生产环境时,各种隐蔽问题才会冒出来。我按"现象 → 排查链路 → 根因 → 解决方案"的模式,把自己踩过的三个大坑完整还原。
4.1 @Cacheable不生效:藏在代理机制里的陷阱
现象是:缓存注解加了,方法也被调用了,但每一层日志都显示"方法执行了",缓存好像完全没工作。
排查链路是这样的:我先去看了启动日志,确认CacheManager注册成功、缓存区域创建正常;又检查了spring.cache.type,没错,是ehcache;最后单步调试,发现调用进入的是原始Bean,而不是被Spring AOP包装过的代理Bean。这时候才反应过来,问题出在"类内部调用"上。
写过Spring的人都知道,@Cacheable是基于AOP动态代理实现的。代理对象只能在Bean外部通过注入的方式拿到。如果你在一个类里用this.method()直接调用自己的缓存方法,this是原始对象,代理逻辑根本不会执行。
我踩这个坑是因为写了一个CategoryService,其中有个公开方法getCategoryTree(),它内部调用了同类里的loadCategoryFromDb(),而缓存注解加在了loadCategoryFromDb()上。结果外层方法每次进来都直接this.loadCategoryFromDb(),代理完全不参与。
解决方案有三种:
- 把需要缓存的方法拆到另一个Bean里,注入过来调用,这是我最推荐的做法,代码结构也清晰。
- 在类内部注入自己的代理,比如通过
@Lazy @Autowired注入自身,但要求Spring开启@EnableAopAutoProxy(exposeProxy = true),然后用(CategoryService) AopContext.currentProxy()调用,侵入性较强。 - 自行注入
CacheManager,用编程式缓存代替注解,适合需要动态控制缓存的复杂逻辑。
排查这个问题的关键,是养成一个习惯:给org.springframework.cache包里打开TRACE日志。缓存命中时Spring会打印Cache hit相关信息,一旦发现日志里完全没有命中记录,立刻怀疑代理没生效,而不是去怀疑CacheManager配置。
4.2 缓存了错误数据:序列化与可变对象的坑
另一个让我头疼的问题是:缓存返回的数据被调用方改了,导致后续所有请求都拿到脏数据。这个坑比缓存不生效还隐蔽,因为不报错,只是数据莫名其妙变脏。
起因是我把数据库查询出来的Product对象直接放进了缓存。Product是一个带setter的可变POJO,从缓存里取出来后,某段业务代码直接对这个对象做了字段修改,修改后的结果又因为对象引用是同一个,被"回写"到了缓存里。下一个请求再取缓存,拿到的是被污染过的对象。
这个问题的根子在于:本地缓存存的是对象引用,不是副本。如果缓存对象本身是可变的,调用方一旦改动,缓存就跟着变。
解决方案我试过几种:
- 最彻底的办法:缓存里只放不可变对象,比如Java 17 record、Guava的
ImmutableList,或者干脆用Map.copyOf、List.copyOf。 - 折中的办法:在放入缓存前做一次深拷贝,取出时也做一次深拷贝。缺点是对象很大时拷贝成本高,但数据安全性好。
- 不推荐的办法:依赖序列化。Ehcache堆内存储默认不序列化,只有配了序列化器才拷贝,很多人以为配置了XML就等于序列化了,其实堆内默认还是引用直接存储。
后来我在项目里定了条规矩:凡是放进缓存的对象,必须实现不可变或深度防御性拷贝,并且在代码评审时把这个作为硬性检查项。
4.3 并发场景下的数据不一致与缓存刷新策略
第三类问题集中在"先更新数据库,再更新缓存"的顺序上。我有一次遇到的现象是:后台修改了商品价格,数据库里已经是新值了,但前端接口还是一直读到旧价格,持续了整整一个TTL周期。
排查过程比较有意思。代码逻辑是:修改价格的方法用@Transactional管理事务,事务提交后调用@CacheEvict删除缓存key。但Spring默认的事务代理顺序是:先提交事务,再执行缓存拦截器吗?其实不是。实际执行顺序取决于注解拦截器顺序,@Transactional拦截器在@CacheEvict拦截器外部,所以缓存删除发生在事务提交之前。万一事务提交失败了,缓存却被删了,下次请求重新回源还好;可我的场景是事务提交成功了,但缓存删除时因为key生成规则不一致,删了个寂寞。
排查时我先确认了数据库确实更新成功,再手工调用了一次缓存的get(key),发现还是旧值。然后把数据库更新和缓存删除分开测试,最后定位到:@CacheEvict的key写的是#product.getId(),但实际写入缓存时用的key是#productId,两者不一致,删除操作永远删不到目标key。
这类问题的通用解法,我建议不要相信"先更新DB、再删缓存"这个顺序一定没错,而是分层保证:
- 尽量把缓存key的定义收敛成一个常量方法,比如
cacheKey(sku, channel),所有注解统一引用,不手写字符串。 - 在事务内部用
TransactionSynchronizationManager.registerSynchronization注册事务提交后的回调,在回调用执行缓存删除,保证"数据先落库,再动缓存"。 - 并发更高时用延迟双删:先删除缓存,等待几百毫秒,再删除一次,避免"旧数据回写缓存"的竞态。
延迟双删不是银弹,但对本地缓存和Redis缓存都适用。在单体应用里,事务同步回调已经能解决绝大多数一致性问题。
5. 生产级优化:监控、动态刷新与多级缓存协同
缓存上线只是开始,真正让它稳定发挥价值,还差"监控""容量""演进"这三件事。
5.1 命中率监控与接入Spring Boot Actuator
没有监控的缓存就是黑盒。Ehcache自带的CacheStatistics能提供命中次数、未命中次数、缓存大小、Eviction次数等指标,但手动拉取很不方便。我更推荐接入Spring Boot Actuator的Metrics端点。
先在依赖里加上:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-actuator</artifactId> </dependency>启动后访问/actuator/metrics,默认就能看到cache.gets、cache.puts、cache.evictions这一堆指标,还能够按cache维度进一步查看:
curl 'http://localhost:8080/actuator/metrics/cache.gets?tag=cache:productCache'看到的数值里有count和result=hit、result=miss两种标签。命中率这样算:
命中率 = hit次数 / (hit次数 + miss次数)我在生产环境会定时拉取这些指标并接到告警平台,一旦某个核心缓存的命中率低于80%,就触发评估,看看是不是TTL设太短、key设计不合理,或者热点数据量超出了heap上限。监控的意义不在于事后看数据,而在于帮你尽早发现"缓存正在退化"的信号。
5.2 大缓存实例的堆外内存配置
当缓存数据量超过几万条时,继续放大<heap>就不是好主意了。堆内对象的存活、晋升、RemSet扫描都会对GC产生压力。这时候把数据放到堆外内存<offheap>更合适。
我处理过一个比较极端的场景:缓存里要放10万条运营配置,每条Value几十KB,总共接近2GB。一开始全部放heap,老年代GC飙到几十毫秒,高峰期甚至出现过秒级停顿。后来把80%的容量切到offheap,GC时间立刻降下来了,读取性能只损失了不到20%。
配置方式很简单:
<cache alias="largeConfigCache"> <key-type>java.lang.String</key-type> <value-type>java.lang.Object</value-type> <heap unit="entries">1000</heap> <offheap unit="MB">2048</offheap> </cache>要提醒三个点:
offheap占用的是DirectMemory,不受-Xmx控制,但受JVM参数-XX:MaxDirectMemorySize限制。启动参数里最好显式设置,比如-XX:MaxDirectMemorySize=4g。- 堆内
heap不要设为0,最好保留一个很小的缓冲层,因为Ehcache的读写通路是"heap → offheap",完全去掉堆内层会导致某些操作路径异常。 - 堆外缓存的数据不会自动持久化到磁盘,应用重启后要重新回源加载。如果数据加载成本高,再配合
<persistence>把重要数据落盘。
5.3 本地缓存+分布式缓存的团队实践
最后聊聊演进。很多服务一开始单机跑得好好的,后来业务量上来了要扩到多节点。这时候如果直接把本地缓存拆掉换Redis,代价不小;更平滑的做法是"本地缓存 + 分布式缓存"两级结构:
- 第一级:Ehcache本地缓存,扛住单节点内的热点读,延迟最低。
- 第二级:Redis缓存,承载多节点共享数据,本地缓存未命中时从Redis读。
- 兜底:数据库,Redis未命中才回源,回源后同时更新两级缓存。
架构不复杂,但一致性问题变大了。我在项目里采用的策略是:本地缓存的TTL设得很短(比如5分钟),Redis缓存TTL设得较长(比如1小时)。这样即使某节点更新数据时没有及时刷掉其他节点本地缓存,最坏也只是最多旧5分钟,而Redis里永远是最新数据,最终一致性能够很快收敛。
通知本地缓存失效通常有两种方案:一种是利用Redis的Pub/Sub,数据变更时广播一条消息,各节点收到后删除本地缓存;另一种是依赖CacheManager的clearCache定时扫描版本号,有变化就整体刷新。前者实时性好,后者实现简单。对小团队来说,先别追求极致实时性,短TTL配合版本号轮询已经非常省心了。
说到底,工程化不是越复杂越好。我在实际处理这些缓存项目时,最大的体会就是尊重数据的"新鲜度容忍度",该用本地缓存就用本地缓存,该接受几分钟延迟就接受,不要为了架构好看而引入不必要的复杂度。Ehcache这套方案帮我扛过多次营销峰值,直到今天,那套系统里的核心读接口依然依赖本地缓存兜底,数据库稳定得很。下次再遇到类似需求,不妨先试试这招。