ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

SpringBoot整合Ehcache本地缓存:从配置到缓存一致性避坑指南

2026/10/1 23:18:21 拓冰建站 浏览量
SpringBoot整合Ehcache本地缓存:从配置到缓存一致性避坑指南 本地缓存这事说小也小说大也大。小到加个注解就完事大到命中率上不去、缓存雪崩、数据不一致哪一个都能把你折腾到加班。今天要聊的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在这个定位里的优势在于它是JCacheJSR-107标准实现Spring Boot对它有一等公民的支持同时还支持堆内、堆外、磁盘三级存储这是Caffeine不具备的。Caffeine胜在极致性能和简单的API但遇到需要几十GB缓存数据、超过堆内存容量的场景Caffeine就无能为力了而Ehcache可以把数据挪到堆外甚至持久化到磁盘。我还整理过一张对比表放在团队内部做技术选型用这里直接分享出来维度EhcacheCaffeineRedis存储位置堆内/堆外/磁盘堆内独立进程/网络访问延迟纳秒级纳秒级毫秒级容量可扩至磁盘受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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency注意这个starter不会自动帮你带出Ehcache。Spring Boot默认会优先在classpath里找缓存实现如果啥都没找到它就启用一个简单的ConcurrentMapCacheManager也是能跑但功能有限。想要用上Ehcache你得单独再引Ehcache的依赖。我见过不少同事在这里踩坑只加了starter没加Ehcache然后在yml里写spring.cache.typeehcache结果启动报错因为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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency dependency groupIdorg.ehcache/groupId artifactIdehcache/artifactId version3.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:xsihttp://www.w3.org/2001/XMLSchema-instance xmlnshttp://www.ehcache.org/v3 xsi:schemaLocationhttp://www.ehcache.org/v3 http://www.ehcache.org/schema/ehcache-core-3.10.xsd cache aliasuserCache key-typejava.lang.String/key-type value-typecom.example.User/value-type expiry ttl unitseconds300/ttl /expiry resources heap unitentries1000/heap offheap unitMB64/offheap disk unitMB256/disk /resources /cache /config这里heap的单位是entries意思是最多保存1000个条目offheap和disk的单位是MB意思是可用空间上限。key-type和value-type建议显式声明既方便Ehcache做序列化也能在类型不匹配时提前报错而不是等到运行时莫名其妙抛异常。3.2 怎么定heap和offheap的大小配置缓存容量的时候最常被问的问题是堆内到底开多大合适这个问题没有标准答案但有一个经验法则堆内大小取决于你最热的数据量而不是全部数据量。假设用户信息表有一千万行但一个小时内活跃用户可能只有一万人那堆内缓存撑死存一万个对象就够了。真正的大头冷数据交给堆外去扛。我一般这么估算先统计缓存对象的平均字节大小然后计算QPS中最热的那个key集合的条目数堆内条目数设置为热点条目的两倍左右留出余量。堆外内存则参考JVM最大堆内存的三分之一以内因为堆外内存不像堆内那样受GC控制如果设置过大反而会和堆内内存抢占物理内存造成整体性能下降。磁盘容量一般比堆外再大几倍当作冷数据的最终兜底。另外要注意heap unitentries和heap unitMB写哪个好如果缓存对象体积不稳定建议用MB做人维度控制如果对象体积相对均匀用entries更直观因为Ehcache的淘汰是用LFU近似算法做条目淘汰你控制条目数就等于控制了淘汰粒度。3.3 TTL与TTI两种生命周期的本质区别Ehcache配置里有两个和时间相关的参数很多人搞混timeToLiveSecondsTTL和timeToIdleSecondsTTI。TTL的意思是缓存条目从创建时间开始计时到了设定秒数就强制过期不管这段时间内有没有被访问过TTI的意思是从最后一次访问开始计时如果在设定时间内没有被访问就让它过期。用生活场景来类比TTL就像面包上的保质期到点就过期哪怕今天没人买也是这个点TTI更像会员卡你老不来消费过段时间就被注销但只要你不断续费访问就能一直用下去。在ehcache.xml里的写法是expiry ttl unitseconds300/ttl /expiry或expiry tti unitseconds600/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.registerSynchronizationTransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { cacheManager.getCache(userCache).evict(userId); } });这种写法虽然啰嗦但能从根本上避免事务回滚导致缓存更新的问题。5.2 多实例部署下的数据一致性本地缓存永远绕不开一个问题多实例部署时每个节点各自为政。你有两台应用服务器用户请求打到A节点时A的本地缓存里有数据请求转移到B节点B的缓存里是空的还得查库。更糟的是A更新了数据库并清掉自己缓存B节点并不知道如果之后请求落到了BB可能继续返回旧缓存。很多团队用本地缓存踩坑之后又推倒重来全面转向Redis就是被这个问题搞怕了。其实对一致性要求不高的业务本地缓存加上一个极短的TTL比如30到60秒就能在不引入额外组件的情况下解决大部分问题即使某节点缓存未失效最多多等几十秒也就过期了。对一致性有硬性要求的场景建议直接放弃本地缓存改用Redis。如果你一定要在多实例下用本地缓存但又想尽量更新及时可以走发布订阅的思路数据库更新成功后向Redis的某个Channel发一条通知所有应用节点订阅该Channel收到通知后各自清掉对应的本地缓存。这个方案比直接改用Redis做缓存要轻量很多但实现复杂度确实不低。6. 监控、命中率与常见坑位排查实录6.1 如何拿到命中率和缓存统计本地缓存上线后绝对不能不管。我一直跟团队说没有监控的缓存就是盲盒你根本不知道命中率是高是低。Ehcache 3内置了Statistics接口可以通过CacheManager拿到CacheManager cacheManager ...; CacheString, 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 排查实录四个高频异常与解决方案整理一下我在实际项目里碰到过的几类问题一条条来说。第一类缓存疑似不生效日志里看不到任何缓存提示。优先排查顺序是启动类有没有EnableCachingSpring容器里现在到底用的是哪个CacheManager是不是有自己定义的Bean把默认的覆盖了。项目中如果有多个CacheManagerSpring会根据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缓存抽象层最大的价值。希望这篇实操笔记能让你少踩几个坑也欢迎你有更刁钻的本地缓存问题咱们评论区继续聊。