本地缓存这事,说小也小,说大也大。小到加个注解就完事,大到命中率上不去、缓存雪崩、数据不一致,哪一个都能把你折腾到加班。今天要聊的SpringBoot整合Ehcache本地缓存,就是我在多个项目里反复用、反复踩坑之后,沉淀下来的一套相对可靠的做法。这篇不端着讲理论,直接按项目落地的顺序走一遍,从依赖引入、配置文件写法、注解实操,到多环境下的取舍和排障,把你可能碰到的问题尽量都摊开来讲。
如果你是刚接触SpringBoot缓存的新手,这篇文章能让你少走很多弯路;如果你已经在项目里用过Redis或者Caffeine,想换一套真正符合JSR-107规范、支持堆外和磁盘持久化的本地缓存方案,那Ehcache正好合适。废话不多说,咱们从为什么需要本地缓存聊起。
1. 为什么本地缓存是绕不开的一环
1.1 先从一次接口性能优化说起
之前接手过一个订单查询服务,单表两三千万数据,查询条件乱七八糟,最夸张的时候一个列表接口要跑两秒多。刚开始我以为是SQL太烂,结果看执行计划,索引其实都用上了,瓶颈不在数据库,而在高频次的重复查询上。同一个用户一小时内反复查看同一个订单详情,请求参数一模一样,数据库却要实打实响应几十次。这种情况,靠优化SQL收效甚微,最直接的办法就是加本地缓存。
说白了,本地缓存就是进程内缓存,数据直接躺在JVM内存里,一次查询连网络IO都省了。对比一下:查一次数据库可能耗时50ms到200ms,查一次Redis也要走网络回路,大约1ms到5ms,而查一次本地缓存,就是一次内存读取,纳秒级,连0.01ms都用不到。性能差距不是一倍两倍,是几个数量级。所以不管你的项目用了多牛的数据库、多快的Redis,只要接口存在重复查询,本地缓存就是性价比最高的第一道防线。
1.2 本地缓存、分布式缓存与进程内缓存的边界感
很多同学分不清Ehcache、Caffeine和Redis之间的关系,其实一句话就能说清楚:Ehcache和Caffeine都是进程内缓存,活在应用自己的JVM里;Redis是分布式缓存,独立部署,多个应用节点共享同一份数据。分布式缓存的优势是数据全局一致,所有机器读到的是同一份,但劣势也很明显——再多一层网络开销,还多一个组件要运维。本地缓存没有网络开销,但每个机器节点各存各的,天然存在数据不一致的窗口期。
所以边界感很重要:读多写少、允许短时间不一致、单机数据量可控的数据,适合放本地缓存;强一致、跨节点共享、需要全局失效的数据,才应该放Redis或者直接查库。Ehcache在这个定位里的优势在于,它是JCache(JSR-107)标准实现,Spring Boot对它有一等公民的支持,同时还支持堆内、堆外、磁盘三级存储,这是Caffeine不具备的。Caffeine胜在极致性能和简单的API,但遇到需要几十GB缓存数据、超过堆内存容量的场景,Caffeine就无能为力了,而Ehcache可以把数据挪到堆外,甚至持久化到磁盘。
我还整理过一张对比表,放在团队内部做技术选型用,这里直接分享出来:
| 维度 | Ehcache | Caffeine | Redis |
|---|---|---|---|
| 存储位置 | 堆内/堆外/磁盘 | 堆内 | 独立进程/网络 |
| 访问延迟 | 纳秒级 | 纳秒级 | 毫秒级 |
| 容量 | 可扩至磁盘 | 受JVM堆限制 | 受机器内存限制 |
| JSR-107支持 | 原生 | 需适配 | 不支持 |
| 持久化 | 支持磁盘 | 不支持 | RDB/AOF |
| 多实例共享 | 不支持 | 不支持 | 支持 |
| 适合场景 | 单机大数据量缓存 | 单机小数据量高性能缓存 | 多节点共享缓存 |
2. 环境准备:依赖引入与版本搭配
2.1 spring-boot-starter-cache 的引入
SpringBoot整合Ehcache的第一步,不是直接引Ehcache坐标,而是先引入Spring的缓存抽象层。Spring官方提供了一个叫做spring-boot-starter-cache的起步依赖,这个依赖本身不实现任何缓存逻辑,而是统一管理缓存管理器(CacheManager)的生命周期,顺便带上spring-context-support中关于缓存注解的能力。有了这层抽象,你在代码里写@Cacheable、@CacheEvict,底下的具体缓存实现是Ehcache还是Caffeine,随时可以切换。
Maven坐标很简单:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency>注意,这个starter不会自动帮你带出Ehcache。Spring Boot默认会优先在classpath里找缓存实现,如果啥都没找到,它就启用一个简单的ConcurrentMapCacheManager,也是能跑,但功能有限。想要用上Ehcache,你得单独再引Ehcache的依赖。
我见过不少同事在这里踩坑:只加了starter,没加Ehcache,然后在yml里写spring.cache.type=ehcache,结果启动报错,因为classpath里压根没有Ehcache的CacheManager实现类。所以第一步必须把Ehcache的坐标也加上。
2.2 版本选择:2.x还是3.x,别再搭错了车
Ehcache目前主流的版本是2.x和3.x,两者差距很大。2.x时代使用net.sf.ehcache包名,配置走ehcache.xml,里面有defaultCache、maxElementsInMemory这些老面孔;3.x时代改用org.ehcache包名,配置模型升级为CacheManagerBuilder,存储模型引入了堆外和磁盘资源池的概念。
Spring Boot 2.x之后,官方推荐使用的是Ehcache 3.x,因为Spring Boot自动配置类EhCacheCacheConfiguration默认适配的是org.ehcache包下的实现。如果非要用2.x,你得手动排除自动配置,再自己创建net.sf.ehcache.CacheManager,操作繁琐,也没有必要。我见过一个老项目卡在Spring Boot 1.5时代,强行用了Ehcache 2.10,后面升级Boot版本差点把缓存层重写。
在这里我建议直接锁定3.x。我实测比较稳的搭配是:Spring Boot 2.7.x对应Ehcache 3.9.7以上,Spring Boot 3.x对应Ehcache 3.10.8以上。3.10.x对javax.cache与jakarta.cache的兼容性都做了适配,跨Boot大版本时不用太纠结。
2.3 一个最小可用配置长什么样
引入依赖和基本配置的顺序我放在一起说。完整依赖如下:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> <version>3.10.8</version> </dependency>然后在application.yml里声明缓存类型和配置文件位置:
spring: cache: type: ehcache ehcache: config: classpath:ehcache.xml接着在启动类上加上@EnableCaching,这是激活Spring缓存注解的开关,很多人漏掉这一步,结果注解写了一大堆,程序跑起来毫无反应。加上之后,Spring Boot会自动检测到org.ehcache的CacheManager,并用你指定的ehcache.xml初始化。到这里,本地缓存的主干已经立起来了,接下来就是把配置写明白。
3. 核心配置拆解:heap、offheap与disk三级存储
3.1 三级存储模型详解
既然用Ehcache 3.x,就必须理解它的存储模型。Ehcache把存储划分为三层:堆内(heap)、堆外(offheap)和磁盘(disk)。写入缓存时,数据默认先进堆内,堆内满了之后按淘汰策略往堆外挪,堆外也满了再往磁盘挪。读取时则是反过来,优先从最快的堆内找,找不到再查堆外,最后才是磁盘。
这三层的性能和容量关系可以用一句话概括:越靠近CPU,速度越快,但容量越小。堆内就是你的JVM堆内存,受-Xmx限制,而且频繁存储大对象会加剧GC压力;堆外是DirectByteBuffer管理的内存,不受堆大小限制,不参与GC扫描,容量可以做得很大,但读写速度比堆内慢一个档次;磁盘就是普通文件存储,适合数据量特别大、但访问频率不高的场景。
一个典型的ehcache.xml配置是这样的:
<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"> <cache alias="userCache"> <key-type>java.lang.String</key-type> <value-type>com.example.User</value-type> <expiry> <ttl unit="seconds">300</ttl> </expiry> <resources> <heap unit="entries">1000</heap> <offheap unit="MB">64</offheap> <disk unit="MB">256</disk> </resources> </cache> </config>这里heap的单位是entries,意思是最多保存1000个条目;offheap和disk的单位是MB,意思是可用空间上限。key-type和value-type建议显式声明,既方便Ehcache做序列化,也能在类型不匹配时提前报错,而不是等到运行时莫名其妙抛异常。
3.2 怎么定heap和offheap的大小
配置缓存容量的时候,最常被问的问题是:堆内到底开多大合适?这个问题没有标准答案,但有一个经验法则:堆内大小取决于你最热的数据量,而不是全部数据量。假设用户信息表有一千万行,但一个小时内活跃用户可能只有一万人,那堆内缓存撑死存一万个对象就够了。真正的大头冷数据交给堆外去扛。
我一般这么估算:先统计缓存对象的平均字节大小,然后计算QPS中最热的那个key集合的条目数,堆内条目数设置为热点条目的两倍左右,留出余量。堆外内存则参考JVM最大堆内存的三分之一以内,因为堆外内存不像堆内那样受GC控制,如果设置过大,反而会和堆内内存抢占物理内存,造成整体性能下降。磁盘容量一般比堆外再大几倍,当作冷数据的最终兜底。
另外要注意,<heap unit="entries">和<heap unit="MB">写哪个好?如果缓存对象体积不稳定,建议用MB做人维度控制;如果对象体积相对均匀,用entries更直观,因为Ehcache的淘汰是用LFU近似算法做条目淘汰,你控制条目数就等于控制了淘汰粒度。
3.3 TTL与TTI:两种生命周期的本质区别
Ehcache配置里有两个和时间相关的参数,很多人搞混:timeToLiveSeconds(TTL)和timeToIdleSeconds(TTI)。TTL的意思是缓存条目从创建时间开始计时,到了设定秒数就强制过期,不管这段时间内有没有被访问过;TTI的意思是从最后一次访问开始计时,如果在设定时间内没有被访问,就让它过期。
用生活场景来类比:TTL就像面包上的保质期,到点就过期,哪怕今天没人买也是这个点;TTI更像会员卡,你老不来消费,过段时间就被注销,但只要你不断续费(访问),就能一直用下去。
在ehcache.xml里的写法是:
<expiry> <ttl unit="seconds">300</ttl> </expiry>或:
<expiry> <tti unit="seconds">600</tti> </expiry>两者可以同时配置吗?Ehcache控制台会检测冲突,实际上同一条缓存里TTL和TTI只能二选一,因为它们的计算起点不同。我个人的选择是:对数据实时性要求高的配置类数据用TTL,比如系统参数十分钟刷新一次;对访问习惯性强的业务数据用TTI,比如用户会话信息,一小时没访问就失效,但用户不停访问就一直续期。另外,eternal这个古老配置项表示永久有效,建议不要轻易使用,数据只进不出是会出大问题的。
4. 注解实操:@Cacheable 的正确使用姿势
4.1 开启缓存与验证是否生效
配置再完美,注解不启用也白搭。启动类上加上@EnableCaching之后,Spring就会在运行时为目标方法创建代理,在方法执行前先查缓存,命中就直接返回缓存结果,不再进入方法体。这一层本质上是AOP,理解了AOP你才能理解后面很多坑。
验证缓存是否生效的最快办法:在方法里加一行打印日志,然后连续调用两次接口。第一次执行方法体,打印日志;第二次不再打印,说明缓存已经命中。如果你两次都看到日志,别急着怀疑配置,先检查启动类有没有@EnableCaching,再看application.yml里有没有把spring.cache.type写对。
4.2 三个关键参数:cacheNames、key、condition/unless
常用的@Cacheable有三个关键参数。第一个是cacheNames,对应你ehcache.xml里配置的缓存区域名称。比如ehcache.xml里定义了一个userCache区域,代码里就得写@Cacheable(cacheNames = "userCache"),两边名字对不上,Spring会直接创建默认容器,无法享受你预设的内存和过期策略。
第二个是key,负责生成缓存的索引。Spring默认的key生成策略是使用方法参数,如果方法有多个参数,就用SimpleKey组合起来。但多参数模型的key可读性差,而且容易出奇奇怪怪的拼接,我建议你总是显式声明:
@Cacheable(cacheNames = "userCache", key = "#userId") public User getUserById(Long userId) { return userMapper.selectById(userId); }如果你要根据多个字段生成key,用SpEL表达式拼起来就行:
@Cacheable(cacheNames = "userCache", key = "#tenantId + ':' + #userId") public User getUser(Long tenantId, Long userId) { return userMapper.select(tenantId, userId); }第三个组合是condition和unless。condition在方法执行前判断,满足条件的请求才走缓存;unless在方法执行后根据返回值判断,满足条件的返回值不缓存。比如订单状态只有已支付才允许缓存:
@Cacheable(cacheNames = "orderCache", key = "#orderId", unless = "#result == null") public Order getOrderById(Long orderId) { return orderMapper.selectById(orderId); }注意unless里用#result引用返回值,这个选项特别适合过滤空值或者异常占位对象。对于查不到数据的情况,我建议主动做空值缓存,防止缓存穿透——也就是把null也存进去,设置较短的TTL。这种做法对高并发下防止无效请求穿透到数据库非常有效。
4.3 缓存更新与删除:@CachePut 和 @CacheEvict
读操作有了@Cacheable,写操作就要配合@CachePut和@CacheEvict。@CachePut的作用是执行方法体,然后把返回值强制写到缓存里,适用于更新数据时同步刷新缓存:
@CachePut(cacheNames = "userCache", key = "#user.userId") public User updateUser(User user) { userMapper.updateById(user); return user; }@CacheEvict则是执行完方法后删除缓存条目,适用于删除数据时清掉旧缓存。它有两个常用属性:allEntries表示清空整个cacheNames区域的所有条目,beforeInvocation表示在方法执行前就删缓存,防止方法中途抛异常导致缓存与数据库不一致。
有一个细节值得注意:@CachePut方法运行时如果缓存里已经有旧值,新值会覆盖旧值,但如果此时有其他线程正在读旧值,就可能出现短时间的脏读。想要彻底安全,你就得引入分布式锁或者版本号机制,但那样复杂度会上升不少。对一个允许短时不一致的业务系统来说,@CachePut已经够用了。
4.4 同类内部调用为何失效
使用注解缓存的过程中,最普遍的坑就是同类内部调用导致缓存失效。比如UserService里有一个公开方法getUserById,它在内部又调用了本类的另一个方法getUserDetail,你给getUserDetail加了@Cacheable,结果完全没有缓存效果。
原因很简单:Spring缓存注解是基于动态代理的,代理类拦得到外部调用,但内部this调用直接调到了原始对象上,根本没经过代理。解决办法有三个:把需要缓存的方法拆到另一个Service类里,通过注入调用;或者自己注入CacheManager,手动getCache("xxx").put();再或者用@Resource注入自身的代理对象(配合@Lazy防止循环依赖)。
我个人最推荐第一个方案,因为拆类自然、可读性好,而且代理链路清晰。手动操作缓存的方案适合在不方便拆类的场景下做兜底。
5. 缓存一致性与多实例部署下的取舍
5.1 与数据库的一致性同步问题
本地缓存与数据库的一致性,是很多人掉进去的坑。你的业务数据更新了,数据库已经写入,但缓存里还是旧值,用户刷新页面看到的依然是老数据。解决思路主要有三种:先更新数据库,再主动@CacheEvict删除缓存;先更新数据库,再用@CachePut刷新缓存;或者设置一个很短TTL,让数据自然过期。
这三种方案里,主动删除缓存其实是更安全的做法,为什么呢?因为更新操作对缓存的命中率影响很大,如果你用@CachePut强制更新,更新的数据可能不是热点数据,白白浪费一次写入;而@CacheEvict只是删掉缓存,下次读请求到来时再回源加载,逻辑最简单,也最能保证最终一致。真正比较高频、同时读写比例悬殊的场景下,我才会用@CachePut去主动刷新。
还有一个极容易翻车的情况:缓存更新在事务里。假设你的事务很复杂,有一个使用方法:
@Transactional public void updateUserAndOrder(User user) { userService.update(user); orderService.update(user); }方法里某个@CacheEvict在你还没提交事务时就已经执行了。此时如果事务因为后面的操作异常回滚,数据库并没有真正更新,但缓存已经被删掉。下一个查询线程就会回源数据库,而数据库数据没变,反而把旧值重新加载进缓存,最终数据与缓存还是一致的。这里真正危险的是@CachePut:事务回滚了,但缓存却被写入了新值(已经回滚的假数据),数据库还是旧值,二者就不一致了。
所以,在事务方法里执行缓存操作时,我建议把缓存更新动作放到事务提交后,利用TransactionSynchronizationManager.registerSynchronization:
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { @Override public void afterCommit() { cacheManager.getCache("userCache").evict(userId); } });这种写法虽然啰嗦,但能从根本上避免事务回滚导致缓存更新的问题。
5.2 多实例部署下的数据一致性
本地缓存永远绕不开一个问题:多实例部署时,每个节点各自为政。你有两台应用服务器,用户请求打到A节点时,A的本地缓存里有数据;请求转移到B节点,B的缓存里是空的,还得查库。更糟的是,A更新了数据库并清掉自己缓存,B节点并不知道,如果之后请求落到了B,B可能继续返回旧缓存。
很多团队用本地缓存踩坑之后,又推倒重来全面转向Redis,就是被这个问题搞怕了。其实对一致性要求不高的业务,本地缓存加上一个极短的TTL,比如30到60秒,就能在不引入额外组件的情况下解决大部分问题:即使某节点缓存未失效,最多多等几十秒也就过期了。对一致性有硬性要求的场景,建议直接放弃本地缓存,改用Redis。
如果你一定要在多实例下用本地缓存,但又想尽量更新及时,可以走发布订阅的思路:数据库更新成功后,向Redis的某个Channel发一条通知,所有应用节点订阅该Channel,收到通知后各自清掉对应的本地缓存。这个方案比直接改用Redis做缓存要轻量很多,但实现复杂度确实不低。
6. 监控、命中率与常见坑位排查实录
6.1 如何拿到命中率和缓存统计
本地缓存上线后,绝对不能不管。我一直跟团队说:没有监控的缓存就是盲盒,你根本不知道命中率是高是低。Ehcache 3内置了Statistics接口,可以通过CacheManager拿到:
CacheManager cacheManager = ...; Cache<String, User> cache = cacheManager.getCache("userCache", String.class, User.class); Statistics stats = cache.getRuntimeStatistics(); System.out.println("missCount = " + stats.getCacheMissCount()); System.out.println("hitCount = " + stats.getCacheHitCount());从这些指标可以算出命中率。命中率低于50%的缓存配置意义就不大了,你需要审视是不是key设计不合理、TTL太短、每次缓存的都是冷数据。我之前有一个缓存区域命中率长期在30%徘徊,一查发现是key生成器默认拼接了几个无关参数,导致同一个业务key分裂成几十个缓存条目,命中率自然上不去。
6.2 排查实录:四个高频异常与解决方案
整理一下我在实际项目里碰到过的几类问题,一条条来说。
第一类:缓存疑似不生效,日志里看不到任何缓存提示。优先排查顺序是:启动类有没有@EnableCaching,Spring容器里现在到底用的是哪个CacheManager,是不是有自己定义的Bean把默认的覆盖了。项目中如果有多个CacheManager,Spring会根据@Primary或者名字来决定注解用谁,一旦配错,注解就会静默失效。
第二类:堆内内存溢出或频繁Full GC。这种问题往往是因为把整个大表都缓存到了堆内,上千万元素,对象体积还不小。我经历过一次服务频繁GC,排查到最后就是缓存条目过多。解决方式:把大区域改为堆外存储,设置合理的offheap大小,堆内只留热点数据。这样GC压力会明显下降。
第三类:序列化异常或者磁盘缓存恢复乱码。一旦使用磁盘持久化,所有缓存对象必须可序列化。如果你往磁盘缓存里存放了一个没实现Serializable接口的对象,运行时会直接抛ClassNotFoundException或SerializationException。排查思路:把所有value-type指定的对象都检查一遍,确保继承Serializable;另外,磁盘持久化的文件如果被手动删除,重启时也可能报Diskstore corruption,解决方式是临时在配置里禁用disk,让Ehcache重建。
第四类:CacheManager的Bean冲突。Spring Boot里如果你自定义了CacheManager,且没有加@Primary,多个CacheManager存在时会报No CacheResolver或NoUniqueBeanDefinitionException。标准做法是给默认的缓存管理器加@Primary,然后其他场景通过@Cacheable(cacheManager = "secondaryCacheManager")指定。
6.3 避免缓存击穿、穿透和雪崩
本地缓存一样逃不开三大经典问题:缓存穿透、击穿和雪崩。
穿透是查一个肯定不存在的数据,缓存里没有,数据库里也没有,每次请求都会穿透到数据库。处理方式前面提过,做空值缓存,把null也放进缓存,设置一个比正常数据短一些的TTL,比如30秒。
击穿是某个热点key过期的一瞬间,大量请求同时发现缓存没有,全部打到数据库。处理方式是在@Cacheable上加上sync = true,让并发请求只有一个线程去回源加载,其他线程阻塞等待。这个属性特别重要,热点数据一定要开。
雪崩是大量key在同一时间段集体过期,导致数据库瞬时压力暴增。处理方式有两种思路:TTL后面加一个随机数,让过期时间分散;或者检查业务特性,把某些缓存区域的TTL从固定改为动态。我在配置用户详情缓存时,会给TTL加上一个基于userId哈希的偏移量,这样就自动错开了过期高峰。
7. 基于实战的几点体会
写了这么多,最后分享一点个人感悟。本地缓存用得好不好,往往能看出一个开发者对业务读写模型的理解深不深。我见过很多人不管什么数据都往缓存里塞,最后内存吃紧、命中率低下,又反过来骂缓存没用。真正的做法是先判断数据特征:是否高频读、是否写少、是否允许最终一致、单机容量是否可控。四个条件都满足,才值得上本地缓存。
Ehcache在本地缓存里的位置,我觉得正好是“介于Caffeine和Redis之间的中间派”:它比Caffeine成熟,有完整的持久化能力,又比Redis更贴身、更快。用在单机应用、内部管理系统、垂直拆分后的某个服务节点上,它都能发挥很大作用。但也要清醒地知道,它的数据一致性和跨节点共享能力天然偏弱,该用Redis的时候别硬扛。
最后再给一个建议:新项目里可以直接基于Spring Boot Cache抽象层写代码,配置上先用Ehcache,以后再按需切Redis。缓存类型只改一个配置项,代码里的注解不用大动,这是Spring缓存抽象层最大的价值。希望这篇实操笔记能让你少踩几个坑,也欢迎你有更刁钻的本地缓存问题,咱们评论区继续聊。