Spring Boot集成EhCache实战:本地缓存配置与踩坑指南 在Spring Boot项目里做缓存很多人第一反应就是上Redis。但我之前在某电商后台项目里遇到一个反直觉的情况某个报表接口每天被定时任务刷上万次查的始终是同一批统计数据数据库连接池快被打满。引入Redis意味着要改运维拓扑、要处理网络抖动和序列化成本最后只在应用本地加了一个EhCache数据源压力直接降了大半。今天不聊分布式缓存的宏大叙事就说说怎么在Spring Boot里以最快速度接入EhCache以及把这类本地缓存真正用稳、用透的思路。这篇内容适合刚接触Spring Boot缓存注解的开发者也适合正在为热点数据反复查库发愁、想先用本地缓存兜底的后端同行。1. 为什么是EhCache本地缓存选型的现实考量和适用边界1.1 先搞清楚你缺的到底是哪一层缓存很多团队一开始就上Redis但Redis解决的是多个应用实例之间共享缓存数据的问题。如果你的场景是单个服务实例内部有大量热点数据被反复读取比如字典表、配置项、统计结果那么本地缓存才是更合适的候选者。本地缓存和分布式缓存的本质差异可以拿食堂打饭来类比。Redis是中央厨房所有窗口都去同一个地方取餐好处是一致的坏处是排队和运输成本。本地缓存是每个窗口自己备一口小锅先把热菜扣在锅里谁点谁直接盛速度快得多但每个窗口的口味可能不一致。我见过不少项目明明只是单个实例的单点查询热点却非要把Redis拉进来结果引入了一堆网络超时和序列化问题。Redis适合做应用间共享的二级缓存而一级缓存也就是离应用最近的那一层通常用本地缓存更合适。1.2 EhCache、Caffeine、手写ConcurrentHashMap怎么选如果要给这三个方案排个序我个人的经验是手写ConcurrentHashMap适合极少量、极短生命周期的缓存需求比如单次请求内的临时缓存。但淘汰策略、过期清理、统计监控全都要自己写一旦需求复杂一点代码就变得很脏。Caffeine性能确实是本地缓存里的天花板使用基于Window TinyLFU的淘汰策略命中率表现很好。如果你对极致性能有追求且不需要持久化、不需要管理界面Caffeine是首选。EhCache性能稍逊于Caffeine但胜在功能完整支持堆外内存、支持磁盘持久化、支持JSR-107标准规范、有自带的管理监控页面而且Spring Boot对它有一等公民的自动配置支持。Caffeine和EhCache在绝大多数业务场景下性能差距并不明显真正决定选择的是周边能力。我选择EhCache主要是看中它支持磁盘溢出和堆外内存这一块可以把缓存容量做得很大又不占用堆内存。如果项目对JVM堆内存比较敏感我会直接用EhCache否则用Caffeine也完全没问题。1.3 什么时候不该用本地缓存本地缓存最大的坑在于多实例数据不一致。如果服务部署了多个实例每个实例各自维护一份缓存一旦某个实例更新了缓存其他实例不会感知用户请求被负载均衡到不同实例时就可能看到不一样的数据。建议只在以下场景使用本地缓存数据变更频率极低比如系统配置、黑白名单允许一定时间内的短暂不一致比如几分钟的容忍窗口单实例部署或者可以接受实例间各自独立缓存如果业务要求强一致那就别碰本地缓存直接上分布式缓存或者把本地缓存作为分布式缓存之上的一层通过短期内快速过期来缓解一致性问题。2. 环境搭建与基础配置依赖引入、配置文件与YAML对不上的问题2.1 引入依赖和版本选择Spring Boot集成EhCache非常简单核心依赖就一个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency dependency groupIdorg.ehcache/groupId artifactIdehcache/artifactId /dependency这里要特别注意版本问题。Spring Boot 2.x默认管理的是EhCache 3.x而网上大量教程写的还是EhCache 2.x的配置方式两者差异非常大。配置文件名虽然都是ehcache.xml但内部的XML结构完全是两套如果拿2.x的配置直接放到3.x项目里启动会直接报错。如果你还在用Spring Boot 2.x我的建议是直接用Boot管理的EhCache 3.x版本不要再去看老教程里的cache namexxx maxElementsInMemory1000/那种写法那是2.x的语法。3.x的配置完全不一样我下面会详细给出一份可以跑通的配置。2.2 一份可以跑通的EhCache 3.x配置文件在src/main/resources下新建ehcache.xmlconfig 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.0.xsd persistence directory/tmp/ehcache-data/ cache-template namedefaultTemplate key-typejava.lang.String/key-type value-typejava.lang.Object/value-type expiry ttl unitseconds600/ttl /expiry resources heap unitentries1000/heap offheap unitMB10/offheap disk unitMB50/disk /resources /cache-template cache aliasuserCache uses-templatedefaultTemplate expiry ttl unitseconds300/ttl /expiry /cache /config这段配置的含义是定义了一个名为defaultTemplate的缓存模板堆内最多1000条堆外10MB磁盘50MB默认过期时间600秒。然后定义了userCache这个具体缓存复用了模板但把过期时间改成了300秒。persistence目录是磁盘溢出的落地路径目录必须存在且可写否则启动会失败。unitentries表示按条数计算unitMB表示按内存大小计算这两个单位很容易写错写错也不会报错但实际容量和预期会差很多。2.3 Spring Boot侧的YAML配置配置文件准备好之后在application.yml里告诉Spring Boot启用缓存并选择EhCachespring: cache: type: ehcache ehcache: config: classpath:ehcache.xml这里有个常见的坑spring.cache.type如果不显式指定Spring Boot会按照依赖顺序自动探测缓存实现。如果项目里同时引入了Redis和EhCache的依赖Spring Boot默认会优先使用Redis你的EhCache配置就不会生效。所以只要项目里有多个缓存依赖一定要显式写type: ehcache。还有一个更隐蔽的问题spring.cache.ehcache.config这个配置项在旧版本Spring Boot里叫spring.cache.ehcache.config但在某些版本里路径是spring.cache.ehcache.config多一个层级少一个层级拼写稍有出入就会静默使用默认配置。我的排查经验是启动日志里如果看到Creating CacheManager时加载的配置文件路径不是你那个xml就是配置项没生效。2.4 启用缓存注解最后在启动类或者配置类上加EnableCachingSpringBootApplication EnableCaching public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }不加这个注解下面的所有Cacheable、CacheEvict都不会生效Spring根本不会创建缓存代理。这个步骤太基础了但我真见过有人配置文件全对就是忘了加EnableCaching排查了半天才发现。3. 注解驱动缓存拆解Cacheable背后到底发生了什么3.1 三个核心注解的分工在Spring Boot里使用EhCache方式和使用Redis缓存完全一样因为都是通过Spring Cache抽象层。最常用的三个注解是Cacheable方法执行前先查缓存有就直接返回没有才执行方法体并把结果放入缓存。CachePut不管缓存里有没有都执行方法体并用结果更新缓存。适合写操作后刷新缓存。CacheEvict根据条件清除缓存适合删除操作后清理缓存。一个典型的使用场景是用户信息查询Service public class UserService { Cacheable(cacheNames userCache, key #userId) public User getUserById(Long userId) { // 模拟数据库查询 return userMapper.selectById(userId); } CachePut(cacheNames userCache, key #userId) public User updateUser(Long userId, User user) { userMapper.updateById(user); return user; } CacheEvict(cacheNames userCache, key #userId) public void deleteUser(Long userId) { userMapper.deleteById(userId); } }这里的执行流程是第一次调用getUserById(1)缓存里没有key为1的数据执行数据库查询把结果放入userCache。第二次再调用同样的方法Spring会直接返回缓存里的对象方法体根本不进入。当执行updateUser时CachePut会重新执行方法并更新缓存。执行deleteUser时CacheEvict会把对应的key清掉。3.2 key生成策略SpEL才是真正的主角key参数是Spring缓存里最容易出错的地方。如果不写keySpring默认使用SimpleKeyGenerator它会把所有方法参数组合成一个key。比如getUserById(1L, inner)和getUserById(1L)生成的key不同这可能导致缓存不命中。更常见的是用SpEL表达式Cacheable(cacheNames userCache, key #userId) public User getUserById(Long userId) Cacheable(cacheNames userCache, key #user.id) public User getUserByUser(User user)这里要注意#userId是取参数名#user.id是取参数的属性。如果编译时没有加-parameters参数参数名可能被编译成arg0导致#userId这个表达式解析失败。我在某个老项目里就遇到过这个问题Spring Cache在运行时会抛出SpelEvaluationException但只在特定情况下出现非常迷惑。解决方法是在pom.xml里给maven-compiler-plugin加上parameterstrue/parameters或者直接改用#p0、#a0这种位置下标写法。3.3 condition和unless两个参数不能搞混condition和unless看起来都是条件判断但执行时机完全不同。condition在方法调用前判断如果为false则既不会查缓存也不会把结果写入缓存相当于这个缓存注解完全失效。unless在方法执行后判断如果为true则结果不放入缓存但查询缓存还是会查。举个例子Cacheable(cacheNames userCache, key #userId, condition #userId 0, unless #result.status VIP) public User getUserById(Long userId) { return userMapper.selectById(userId); }这段代码的含义是只对userId 0的请求启用缓存如果查出来的用户是VIP则不缓存。unless里用的#result就是方法返回值这个变量只在unless和CachePut里可用在condition里是取不到的。3.4 类内部调用不走代理这个坑必须知道Spring Cache的底层实现是AOP代理。只有通过Spring容器注入的Bean调用被Cacheable注解的方法时代理才会生效。如果是同一个类里的方法调用另一个方法比如Service public class UserService { public User getProfile(Long userId) { return getUserById(userId); // 直接调用缓存失效 } Cacheable(cacheNames userCache, key #userId) public User getUserById(Long userId) { return userMapper.selectById(userId); } }这种自调用不会经过代理getUserById的缓存逻辑完全失效。这不算EhCache的问题而是Spring AOP的天然限制。我以前排查过一个诡异问题接口第一次查库第二次查库第三次还在查库缓存就是不生效最后定位到就是另一个方法里直接调用了this.getUserById()。解决办法有三个把需要缓存的方法抽到另一个Service里通过ApplicationContext.getBean获取代理对象再调用或者在类里注入self引用。最推荐的是第一种结构清晰也顺带解决了自我引用带来的循环依赖隐患。3.5 缓存对象的并发控制Cacheable默认的sync是false也就是说当缓存过期瞬间有100个请求同时进来100个请求都会去查数据库这就是缓存击穿。设置sync true可以开启本地锁让同一时刻只有一个线程去执行方法体其他线程等待结果。Cacheable(cacheNames userCache, key #userId, sync true) public User getUserById(Long userId) { return userMapper.selectById(userId); }在EhCache 3.x里开启sync true时EhCache自身会处理并发同步效果比自己去加ReentrantLock或者写双重检查锁要可靠得多。特别在定时任务刷新的场景下sync true几乎是必备的否则缓存冷启动那一下数据库瞬间就会被压垮。4. 三级缓存架构与序列化堆内、堆外、磁盘如何分工4.1 从堆内到磁盘容量与性能的阶梯EhCache 3.x最明显的特点就是支持三级存储架构堆内Heap、堆外Off-Heap、磁盘Disk。三者的关系我一般这样理解堆内是收银台的钱堆外是门店保险柜的钱磁盘是银行金库的钱。取钱的速度和方便程度逐级下降但容量逐级上升。在ehcache.xml里它们通过resources节点组合配置resources heap unitentries1000/heap offheap unitMB10/offheap disk unitMB50/disk /resources堆内存储数据直接存在JVM堆内存里读写速度最快但会占用堆空间可能引发GC压力。按条数限制时超过上限后会走淘汰算法。堆外存储数据存在JVM堆之外的直接内存里不占用堆不受GC影响读写速度比堆内慢但比磁盘快得多。按字节数限制启动时会向操作系统申请一块固定大小的直接内存。磁盘存储数据存到文件系统容量最大但读写涉及IO性能最差。一般只用来做冷数据的溢出存放。最常见的配置组合是堆内放热数据、堆外放温数据、磁盘放冷数据。数据写入时先进堆内堆内满了之后最久没被访问的数据被降级到堆外堆外满了再溢写到磁盘。读取时反过来如果数据在堆内直接返回如果只有磁盘有这份数据会先把数据提升到堆外再返回。4.2 序列化方式默认的JDK序列化是真的坑当数据从堆内降级到堆外或磁盘就需要把对象序列化成字节流。EhCache 3.x默认使用JDK原生的序列化也就是要求缓存的value-type必须实现java.io.Serializable接口。这个默认方案在真实项目里很不友好。JDK序列化出来的字节流非常大对于复杂的业务对象序列化和反序列化耗时明显而且一旦对象结构变更反序列化有可能失败。我在某个项目里缓存一个包含20多个字段的用户对象堆外命中比堆内命中慢了十几毫秒排查下来都是JDK序列化在拖后腿。解决办法是换成Kryo或者JSON序列化。以JSON为例需要引入序列化实现类并在配置里指定序列化器cache aliasuserCache key-typejava.lang.String/key-type value-typecom.example.User/value-type resources heap unitentries1000/heap offheap unitMB10/offheap /resources serialization transformer classorg.ehcache.impl.serialization.PlainJavaSerializer/class /transformer /serialization /cache这里的PlainJavaSerializer是EhCache 3.x内置的另一个序列化实现如果项目里有现成的JSON序列化器也可以自定义Serializer实现类替换上去。要注意的是一旦指定了value-type为具体业务类缓存里就不能再存放其他类型的数据否则会报类型转换异常。4.3 没有实现Serializable会怎样如果你的value-type不实现Serializable而且没有配置自定义序列化器数据直接放堆内时是没问题的因为堆内本身就是对象引用。但一旦数据被淘汰到堆外或者磁盘EhCache会尝试序列化这时就会抛出java.io.NotSerializableException导致缓存读写失败。这个错误的诡异之处在于它不是启动时报的而是运行了一段时间、堆内满了、数据开始溢出时才出现。排查起来很费劲日志里全是序列化异常但代码层面看起来一切正常。所以我建议用EhCache缓存任何自定义对象时要么把对象实现Serializable接口要么从一开始就配置好自定义序列化器别等内存满了才去踩坑。4.4 TTL和TTI不要搞混在ehcache.xml的expiry节点里有两个时间配置ttlTime To Live从数据写入开始计算的生存时间无论这个数据被访问多少次到了指定时间就过期。ttiTime To Idle从最后一次访问开始计算的空闲时间。数据如果一直在被访问就一直不过期一旦某段时间没有被访问到了指定时间就过期。场景选择上如果缓存的是定时刷新的报表数据用ttl到期后统一刷新。如果缓存的是用户在线状态用tti更合适用户活跃时就一直保留不活跃了自动踢掉。ttl和tti可以同时设置只要任何一个条件满足就过期。在2.x时代这两个参数直接写在ehcache.xml里的defaultCache节点上很多老教程和3.x混着看容易把5.0写属性、10.0写属性这种新旧用法搞混。3.x里的ttl是在expiry子节点里配置语法和2.x完全不同。5. 线上踩坑实录脏数据、缓存穿透和抖动的完整排查链路5.1 脏数据问题的完整排查过程我之前在一个支付工单查询服务里碰到过一个脏数据问题。用户提交了工单状态从处理中变成已完成但工单详情接口在几秒内仍然返回处理中过了一段时间才恢复正常。查数据库数据是正确的查Redis完全没有这些数据最后锁定在应用本地的EhCache里。排查过程是这样一步步推进的第一步复现并确认缓存是否存在。通过JMX连上应用的缓存管理器查看工单缓存里确实存在该工单ID的旧数据且ttl设置了600秒。问题就出在这里工单状态更新的接口走的是CacheEvict理论上更新状态时会清掉缓存但日志显示CacheEvict执行了缓存也确实被清了。第二步定位为什么清缓存没生效。仔细看代码后发现状态更新接口是在事务方法内调用CacheEvict的而CacheEvict的默认执行时机是在方法返回之后。问题是事务提交和缓存清理的先后顺序不对事务提交成功但缓存清理在事务提交之前执行了。更确切地说事务方法内部先执行了缓存清理逻辑然后事务才提交。如果事务提交失败了缓存已经被清空没问题关键场景是事务提交成功但随后有其他线程在事务提交前又查询了数据库并把旧数据放进了缓存。第三步确认竞态窗口。实际情况是CacheEvict清空缓存后另一个线程进来查库此时事务尚未提交查到的还是旧值于是把旧值放进了缓存。等事务提交后缓存里已经是清空后又写入的旧数据而这个旧数据又要等下一个600秒TTL才能消失。这个问题的最佳解法是使用TransactionSynchronizationManager在事务提交之后再去执行缓存清理CacheEvict(cacheNames workOrderCache, key #workOrderId) public void updateStatus(String workOrderId) { // 业务逻辑 } // 或者用编程式缓存清理 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { cacheManager.getCache(workOrderCache).evict(workOrderId); } });使用CacheEvict时留意它的beforeInvocation和afterInvocation参数它们决定缓存清理是在方法调用前还是调用后执行但都没有和事务的afterCommit挂钩。推荐做法是让缓存清理逻辑显式注册到事务提交后的回调里。5.2 缓存穿透空结果也要缓存还有一次是查询接口被大量不存在的ID打过来每个请求都穿透到数据库数据库压力徒增。原因很简单Cacheable只会缓存方法正常返回的结果如果方法返回null默认不会缓存。对于需要防穿透的场景可以在unless里把空结果也缓存起来Cacheable(cacheNames userCache, key #userId, unless #result null) public User getUserById(Long userId) { return userMapper.selectById(userId); }这段代码的问题在于unless #result null如果查询结果为null反倒不会缓存了此时应该反过来处理允许缓存空结果Cacheable(cacheNames userCache, key #userId, unless false) public User getUserById(Long userId) { return userMapper.selectById(userId); }这里unless false表示永远不拦截结果即使是null也会被缓存。但要注意EhCache默认是不允许缓存null值的所以需要自己包装一层比如返回一个OptionalUser或者定义一个空对象占位。我用Optional比较多方法是Cacheable(cacheNames userCache, key #userId) public OptionalUser getUserById(Long userId) { return Optional.ofNullable(userMapper.selectById(userId)); }这样查询结果为空时缓存里存的是一个Optional.empty()而不是null。等真的有人创建了这个用户再通过CacheEvict清掉对应key即可。5.3 缓存抖动堆内淘汰策略和访问不均匀在一次压测中某个配置缓存接口的TP99突然从20ms飙升到500ms。查看监控发现堆内数据被频繁淘汰大量访问落到了磁盘层。堆内设置了1000条上限配置项却有大概8000条高频访问的只有前面几百条但因为有定时任务会全量扫描配置导致5000多条低频数据被加载进堆内直接把高频热点给挤出去了。这个问题的本质是访问模式是倾斜的而淘汰策略是全局LRU。解决办法有两个一是调大堆内容量。这是最直接的但会挤占堆空间。我选择的方案是把堆内容量从1000条调到5000条给热点数据留足空间。二是改造查询逻辑把低频全量查询和热点查询拆成两个缓存低频查询走单独的缓存区互不干扰。另外要注意EhCache 3.x默认的淘汰策略是LFULeast Frequently Used和LRULeast Recently Used的组合策略根据使用频率和最近使用情况来判断淘汰。在heap节点上没有直接暴露可配置的淘汰算法参数所以遇到访问倾斜问题时优先考虑容量规划和缓存拆分不要试图去改淘汰算法。6. 监控、调优与日常维护命中率统计和参数调整的实战经验6.1 命中率怎么看JMX和Actuator双管齐下我刚开始用EhCache时完全不知道它命中率到底是多少配了个ttl600就丢那不管了。后来加了监控才发现有些缓存命中率只有30%相当于70%的请求都在白查数据库。获取命中率的方式有两种。第一种是直接通过JMX启动参数加上-Dcom.sun.management.jmxremote.port9999 -Dcom.sun.management.jmxremote.authenticatefalse -Dcom.sun.management.jmxremote.sslfalse然后用JConsole或者VisualVM连上找到Cache相关的MBean看CacheHitCount和CacheMissCount两个属性命中率就是Hits / (Hits Misses)。第二种是在项目里通过统计API获取CacheManager cacheManager CacheManagerBuilder.newCacheManagerBuilder().build(); CacheString, User cache cacheManager.getCache(userCache, String.class, User.class); CacheRuntimeConfigurationString, User config cache.getRuntimeConfiguration();但这种方式需要手动拿到Cache实例和Spring的注解缓存结合时略麻烦。更简单的方式是用Spring Boot Actuator的Cache端点management: endpoints: web: exposure: include: cache访问/actuator/cache就能看到所有缓存的名字、大小和命中统计信息。6.2 一个实际调优案例TTL不是越长越好之前在某社区系统的热门文章接口上我最初设置了ttl 3600秒也就是一小时。当时想的是文章内容基本不变缓存久一点能减少数据库压力。结果监控显示命中率只有45%数据库压力也没降多少。原因很微妙文章列表接口的数据在每次有新文章发布时就会被CacheEvict清掉而新文章发布频率大约每2分钟一次也就是说缓存每次刚建立起来没几分钟就被清了一小时TTL根本用不上形同虚设。调优思路是把文章列表的缓存策略从写时清除改成定时刷新把TTL降为5分钟加上sync true允许缓存自然过期后由单个线程重新加载。这样命中率从45%提升到87%数据库的查询量明显下降。这次调优的经验是TTL的长短必须结合数据的更新频率来决定而不是单纯越长越好。如果数据的写入动作很频繁过长的TTL只会带来反复清缓存和反复重建的浪费反而拉低命中率。6.3 缓存预热本地缓存有一个天然的冷启动问题。应用刚启动时缓存是空的如果紧接着有大量请求进来会直接穿透到数据库。对于核心的热点数据我通常会在应用启动完成后做一次预热。实现方式是在ApplicationRunner里调用需要缓存的服务方法Component public class CacheWarmer implements ApplicationRunner { private final UserService userService; public CacheWarmer(UserService userService) { this.userService userService; } Override public void run(ApplicationArguments args) { for (Long id : hotUserIdList) { userService.getUserById(id); } } }这里要注意预热时调用的目标方法必须是被Spring代理的Bean方法也就是要从容器里注入的实例去调用不能在CacheWarmer内部自己new一个Service。否则预热走的不是缓存代理达不到预热效果。6.4 日常维护里最容易被忽略的Checks最后列几个我日常排查时经常检查的项很多线上问题都出在这里检查ehcache.xml的路径是否真的被加载启动日志是最直接的证据检查堆外内存大小是否和JVM的MaxDirectMemorySize配套堆外配大了但DirectMemory调小会直接OOM检查多实例部署时本地缓存的TTL是否短到能容忍实例间差异检查缓存value的类结构变更时是否有清理旧缓存或版本化方案避免反序列化失败如果你用的是堆外和磁盘务必关注JVM参数-XX:MaxDirectMemorySize。堆外内存不受堆大小限制但受DirectMemory限制默认情况下等于堆大小如果堆外配置超过了这个值运行中可能直接抛出OutOfMemoryError: Direct buffer memory。这类问题在生产环境爆出来往往都很难现场排查所以在上线前就做好预估。我在实际项目里面对堆外和磁盘方案时还有个习惯给每个缓存设置独立的persistence目录不要所有缓存共用一个。这样即使某一个缓存的数据文件损坏其他缓存不会受到影响。磁盘文件格式在EhCache 3.x里是*.data文件重启后如果数据文件损坏EhCache会自动删除并重建但如果多个缓存共用目录文件之间的干扰会明显增多。从最开始只会在Cacheable上写个cacheNames到后来能根据不同业务的访问特征定制堆内堆外和TTL策略这个过程的转变靠的就是一次次把问题现场拆开来看。本地缓存不是银弹但它确实是一个足够趁手的工具关键是你要清楚知道每一层配置到底在解决什么问题。