资讯动态

Spring Boot 集成 EhCache:本地缓存让接口性能优化更简单

发布时间:2026/10/15 15:36:41 来源:尧图企业网站定制
做接口性能优化时我一开始并没打算引入缓存组件。后台系统部署在单台云主机上数据库查询量不算大但接口响应明显带着“迟钝感”。慢查询日志一翻很多请求都在反复读同一批用户资料数据几乎不变。最直接的办法是给热点数据加缓存。可项目就一个实例没有集群需求为缓存维护一台独立服务完全划不来。最后我选了 EhCache本地缓存、随应用启动在 Spring Boot 里配置一下半天时间就把接口响应时间降下来了。这篇文章记录我在实际项目里使用 EhCache 的完整过程包括依赖怎么引入、配置文件怎么写、注解怎么用以及几个容易踩的坑。适合正在做单体应用、内部系统或工具服务的同学参考。如果你的项目还没到需要分布式缓存的规模本文的方案可以直接拿去用。1. EhCache 是什么为什么要在 Spring Boot 里用它EhCache 是一个内嵌在 Java 进程里的缓存框架数据直接存在应用自己的 JVM 堆内存、堆外内存或磁盘上。应用启动它启动应用挂掉缓存也就没了。它不需要单独部署一个程序也没有额外端口要监听所以引入成本比很多外部缓存组件低得多。配合 Spring 的缓存抽象之后可以用注解声明式地控制缓存读写业务代码里基本看不到手动管理缓存的痕迹。大多数人理解 EhCache 只需要记住一句话本地缓存。它和分布式缓存最大的区别就是数据存放位置。数据存在本进程里访问不需要走网络性能自然更高但代价是多个实例之间互相看不到对方的缓存各存各的。1.1 跟 Redis 这类缓存怎么取舍做技术选型时总会有人问有 Redis 为什么还要用 EhCache答案很简单看规模。先看一个对比表对比点EhCacheRedis部署位置应用内随应用启动独立服务需要单独部署访问路径本地内存直接读写网络 IO再快也有网络开销数据共享单进程内多实例各自维护多实例共享同一份数据维护成本几乎为零跟着应用走需要部署、监控、持久化、高可用设计缓存容量受 JVM 内存限制可独立规划容量更灵活适合规模单机、中小型项目集群、多实例、大流量系统当前页面是单机系统时为了几个热点查询专门部署一套 Redis从成本和维护角度看都不划算。EhCache 放在应用内部启动即用半小时就能完成接入。等到系统真的扩容到多实例了再考虑分布式缓存也不迟。1.2 什么时候选 EhCache 而不是 Redis按我这几年做项目的经验遇到这些场景优先选 EhCache单实例部署短期内没有水平扩展计划本地缓存就能覆盖全部热点数据。数据量不大但查询频繁并发量集中在少量热点信息上。对数据一致性要求不高允许缓存短暂过期接受“写后不一定立即读到最新值”。想控制基础设施成本减少需要运维的外部组件数量。反过来如果你的服务已经多实例部署用户请求会打到任意一台机器本地缓存就会导致同一份数据在不同实例上不一致或者缓存容量需求大、需要集中管理缓存数据这时才应该考虑 Redis。还有一种常见组合用 EhCache 做一级本地缓存Redis 做二级共享缓存两级结合。不过这个玩法复杂度较高前期不需要的时候别轻易上先做好简单缓存再说。2. 从依赖到配置把 EhCache 接进 Spring BootSpring Boot 官方对缓存支持做得相当到位缓存抽象层已经把缓存读写封装成统一接口。接 EhCache 需要做的事只有三件引入依赖、写一个配置文件、在启动类加注解。2.1 引入依赖时的几点提醒Maven 项目在 pom.xml 里加这些依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency dependency groupIdorg.ehcache/groupId artifactIdehcache/artifactId /dependencyspring-boot-starter-cache是缓存抽象层的启动器它本身不带缓存实现负责把 Spring 的缓存能力和第三方缓存组件衔接起来。org.ehcache:ehcache才是 EhCache 3 的核心依赖。有一点需要注意EhCache 3 是基于 JCacheJSR-107规范实现的。Spring Boot 通过 JCache 的接口来操作缓存所以类路径里还应该有javax.cache:cache-api。大多数情况下引入 EhCache 时它的传递依赖会把这个 API 带进来但如果你发现javax.cache.CacheManager找不到就需要手动补一个dependency groupIdjavax.cache/groupId artifactIdcache-api/artifactId /dependencyGradle 项目写法类似核心依赖就是这两份版本交给 Spring Boot 的依赖管理控制即可。手动指定版本时建议固定使用 3.10 以上的稳定版本3.9 之前某些版本在 JDK 17 下会碰到模块访问问题。2.2 配置文件里怎么声明缓存类型Spring Boot 的缓存自动配置会在启动时扫描类路径看你引入的是哪种缓存组件然后自动生成对应的 CacheManager。引入多个缓存组件时自动选择逻辑可能猜错这时就要显式指定使用哪一种。在 application.yml 里写spring: cache: type: jcache jcache: config: classpath:ehcache.xml这里type: jcache的意思是让 Spring 通过 JCache 规范接入缓存。EhCache 3 的 Spring Boot 集成方式就是以 JCache 为桥梁config指定了缓存定义文件的位置文件放在src/main/resources/ehcache.xml。不写type也能跑前提是你的项目里没有其他缓存实现。我建议还是明确写上因为一旦项目里同时引入了 Caffeine、Redis 之类的组件自动检测就会产生歧义显式声明可以避免这类麻烦。2.3 启动类加一个注解import org.springframework.cache.annotation.EnableCaching; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication EnableCaching public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } }EnableCaching负责开启 Spring 的缓存注解功能。忘了加这个注解后面的Cacheable全部不会生效代码也不会报错只是缓存永远不命中。3. ehcache.xml 缓存策略配置详解配置文件是整个接入过程里最容易出问题的部分。EhCache 3 的 XML 配置和旧版 EhCache 2 差距很大网上很多资料还停留在 EhCache 2 的老写法直接复制下来根本跑不起来。这里用 EhCache 3 的命名空间来写。3.1 最小可用的缓存配置先看一份能直接跑起来的最小配置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.Long/key-type value-typejava.lang.Object/value-type expiry ttl unitminutes10/ttl /expiry heap unitentries1000/heap /cache /configcache aliasuserCache定义了一个缓存分区名字叫 userCache业务代码里Cacheable(cacheNames userCache)就是引用这个分区。key-type和value-type分别声明键和值的类型EhCache 在读写时会用这两个类型做校验和序列化。expiry配置过期策略ttl unitminutes10/ttl表示数据写入后 10 分钟自动过期。过期后下次访问会重新执行被缓存的方法。heap unitentries1000/heap限制了该缓存在堆内存中最多保存 1000 个条目。超过限制后EhCache 会按内置淘汰策略移出部分数据释放空间给新数据。3.2 TTL、TTI 与堆内存限制怎么选过期策略有两种很多人分不清。ttl全称 Time To Live从数据写入那一刻开始计时10 秒过期就是 10 秒后无条件清除即使这 10 秒内一直被访问也没用。tti全称 Time To Idle从最后一次访问开始计时只要在超时时间内有人读取就重新计时。适合那些“经常被访问就不要过期冷下来就尽快释放”的数据。实际使用中接口热点数据通常用ttl因为这类数据有明确的时效性比如商品库存信息、用户状态信息都希望到期后重新拉取最新值。而会话类、权限类的数据可以用tti因为活跃用户的数据要一直保留长时间不活跃的用户淘汰掉反而能节约内存。堆内存限制也有两种写法heap unitentries1000/heap这是按条数限制适合单条数据体积差不多的场景。heap unitMB32/heap这是按内存大小限制适合值对象体积不稳定、或单条数据可能很大的场景。选择时有一个实践参考如果你清楚缓存对象的平均大小先估算总容量。比如 userCache 里一个用户对象大约 2KB1000 条大约是 2MB那么按 entries 设置 1000 条或按 MB 设置 4MB 都可以接受。如果对象大小浮动范围大比如有的接口缓存 List有的缓存单个实体用 MB 更稳妥。3.3 用 cache-template 统一管理缓存规则实际项目不会只有一个缓存分区。用户缓存、订单缓存、商品缓存都可能要设置相近的过期时间。每个分区复制一遍配置不是不可以但维护起来繁琐改一个全局策略要同步改很多地方。EhCache 3 提供了cache-template来解决这个问题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-template namedefaultTemplate expiry ttl unitseconds60/ttl /expiry heap unitentries2000/heap /cache-template cache aliasuserCache uses-templatedefaultTemplate /cache cache aliasorderCache uses-templatedefaultTemplate /cache cache aliasproductCache uses-templatedefaultTemplate /cache /configuses-template引用模板后模板里的规则自动复制到该缓存分区。以后要调整全局过期时间只需要改模板定义。单独某个缓存需要覆盖模板时可以在cache内部重新声明相同属性覆盖优先级更高。还有一个常见需求是给不同类型的缓存设置不同的 key-type。注意key-type不写在模板里因为键类型往往因分区而异放在具体缓存定义里更合适。4. 用注解在业务代码里落地缓存配置完成之后业务接入主要靠注解。Spring 缓存抽象提供了一个统一的注解体系用起来非常简单。这里挑最常用的几个注解结合真实场景说明。4.1 开启缓存并识别核心注解启动类的EnableCaching已经开了缓存开关业务代码里就可以放心使用注解了。先看一个典型场景根据用户 ID 获取用户信息数据库查询比较慢需要加缓存。import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; Service public class UserService { private final UserRepository userRepository; public UserService(UserRepository userRepository) { this.userRepository userRepository; } Cacheable(cacheNames userCache, key #id) public User findById(Long id) { return userRepository.findById(id).orElse(null); } }Cacheable的语义是调用方法前先查缓存缓存命中就直接返回缓存值方法体不再执行缓存没有命中才执行方法体执行完把返回值写入缓存。注意key #id这个写法是 SpEL 表达式意思是把方法参数id作为缓存的键。如果不指定 keySpring 默认会以方法所有参数自动生成一个组合键。参数简单时这样也行但显式指定 key 的语义更清晰也便于和后面CacheEvict对应。删除用户时要同时清掉缓存用CacheEvictCacheEvict(cacheNames userCache, key #id) public void deleteUser(Long id) { userRepository.deleteById(id); }更新用户资料的场景用CachePutCachePut(cacheNames userCache, key #user.id) public User updateUser(User user) { return userRepository.save(user); }CachePut每次都会执行方法体并把返回值写进缓存。这和Cacheable不同它不会因为缓存命中而跳过方法体。适合用在“更新数据后同步刷新缓存”的场景。还要介绍一个类级注解CacheConfigService CacheConfig(cacheNames userCache) public class UserService { ... Cacheable(key #id) public User findById(Long id) { ... } }CacheConfig定下默认缓存名后类里其他注解就不用每个都写cacheNames了。不过我还是建议关键场景下显式写因为类里方法多的时候一眼扫过去能直接看出来哪些方法用了哪个缓存排查问题更方便。4.2 SpEL 键表达式与结果过滤键表达式的灵活性决定了缓存能不能精确控制。常用写法// 简单参数直接引用参数名 Cacheable(cacheNames userCache, key #id) // 对象属性作为键 Cacheable(cacheNames productCache, key #product.id) // 组合键适合联合查询 Cacheable(cacheNames orderCache, key #userId : #orderId) // 包含方法名避免不同方法共用同一 key 混淆 Cacheable(cacheNames userCache, key #root.methodName : #id)组合键的userId : orderId生成一个字符串格式的键后续查询同样条件时能命中。这种写法在键由多个维度组成时非常实用比默认的复合键读起来更直观。再说condition和unless。condition在方法调用前判断满足条件才走缓存逻辑Cacheable(cacheNames userCache, key #id, condition #id ! null #id 0) public User findById(Long id) { ... }unless在方法执行后判断满足条件就不缓存返回值Cacheable(cacheNames userCache, key #id, unless #result null) public User findById(Long id) { ... }这里unless #result null表达的含义是如果查询结果为空就不写入缓存。这个写法能避免大量无效 key 被塞进缓存减少内存占用。还有一个重要参数syncCacheable(cacheNames productCache, key #id, sync true) public Product getProductById(Long id) { ... }sync true表示当同一个 key 大量并发访问且缓存未命中时只有一个线程去执行方法体其他线程等待结果。没有这个参数多个请求会同时打到数据库瞬间产生缓存击穿。热点数据接口建议开启。4.3 不用注解时怎么操作缓存有些场景没法用注解。比如在一个定时任务里批量刷新缓存或者在一个事务方法里先把数据写库再刷新缓存注解在方法装饰上的方式就不够灵活。这时可以直接注入 Spring 的 CacheManager 来编程式操作import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; import org.springframework.stereotype.Component; Component public class CacheOperator { private final CacheManager cacheManager; public CacheOperator(CacheManager cacheManager) { this.cacheManager cacheManager; } public void put(String cacheName, String key, Object value) { Cache cache cacheManager.getCache(cacheName); if (cache ! null) { cache.put(key, value); } } public Object get(String cacheName, String key) { Cache cache cacheManager.getCache(cacheName); if (cache null) { return null; } return cache.get(key, Object.class); } public void evict(String cacheName, String key) { Cache cache cacheManager.getCache(cacheName); if (cache ! null) { cache.evict(key); } } }这段代码里的CacheManager来自org.springframework.cache包不是 javax.cache 下的那个。Spring 已把 EhCache 包装成自己的缓存抽象所以操作方式跟注解完全一致可以放心混用。实际项目中注解负责常规方法调用编程式操作负责特殊场景两者各管一摊组合使用效果最好。5. 排查与避坑我在使用中踩过的典型问题配置和代码都写完后真正让人花时间的往往是问题排查。缓存不像普通函数出错时可能没有任何报错只是默默不生效。下面这些坑是我在多个项目里实际遇到过的整理成速查表方便对照。5.1 缓存不生效的三种常见原因第一种忘了加 EnableCaching。这个问题最常见。项目启动时没有任何报错缓存注解全部像装饰一样被忽略。排查顺序一定要先检查启动类。第二种同类内部方法调用。看这段代码Service public class OrderService { public Order getOrderWithDetail(Long id) { // 这里直接调用了同类方法缓存切面不生效 return getOrderFromCache(id); } Cacheable(cacheNames orderCache, key #id) public Order getOrderFromCache(Long id) { return orderRepository.findById(id).orElse(null); } }getOrderWithDetail内部调用getOrderFromCache时走的是 this 引用没有经过 Spring 代理对象所以Cacheable完全不会执行。这种现象叫内部调用导致切面失效。解决办法有三种把被缓存的方法拆到另一个 Service 类中通过注入的依赖调用。在当前类注入自己的代理对象比如ObjectProviderOrderService通过代理调用方法。方法内部直接改用编程式缓存绕开注解机制。第三种动态缓存名不对。例如试图在注解里拼接cacheNames userCache_ idJava 注解要求属性必须是编译期常量这样写直接编译报错。即使通过编程式方式cacheManager.getCache(user_ userId)访问EhCache 默认也不会自动创建不存在的缓存别名返回 null 等于什么都没做。5.2 缓存名与配置对不上启动时报这样的错误Cannot find cache named userCache for Builder[...]意思是代码里用到了userCache但 EhCache 配置里没有定义这个缓存别名。JCache 规范本身要求缓存名必须预先存在Spring 拿到不存在的名字时无法完成解析。解决办法是在 ehcache.xml 中把用到的缓存别名都定义出来。如果缓存分区特别多或者希望通过配置清单动态控制哪些分区启用可以考虑在 application.yml 里加spring: cache: cache-names: userCache,orderCache,productCache这个配置会在启动时预先创建这些缓存让 Spring 在启动阶段就完成缓存名验证比运行到一半才报错更容易处理。但缓存的实际过期策略、堆大小仍然由 ehcache.xml 决定。5.3 序列化与堆内存的注意点EhCache 的数据存储涉及对象序列化。当配置了堆外内存、磁盘存储或者开启了持久化时缓存对象必须能够被序列化。即使默认堆内存储也建议让缓存对象实现Serializable避免后续调整配置时突然踩坑。public class User implements Serializable { private static final long serialVersionUID 1L; private Long id; private String username; ... }序列化问题另一个表现是缓存数据升级后反序列化失败。如果缓存的类字段结构变了并且旧数据仍残留在磁盘持久化文件里读取时会抛InvalidClassException。升级类结构时要么保证兼容性要么清空持久化目录。堆内存设置不当也会出问题。heap unitentries1000/heap限制的是这 1000 条数据占用的堆空间但缓存对象本身还在 JVM 堆里GC 压力不会因为配置了缓存就消失。如果缓存值对象特别大建议用unitMB并且定时监控堆内存使用率。5.4 缓存穿透、击穿的小技巧缓存穿透的含义是查询一个必然不存在的数据。比如用户传了一个不存在的 ID数据库查不到返回空缓存也不会写入这个查询每次都落到数据库。处理方式可以分两种在Cacheable里配合unless #result null不放空值进缓存配合一个布隆过滤器提前拦截无效 ID。适合 ID 规律性强的场景。或者主动缓存空对象设置较短的过期时间比如 30 秒。这样持续攻击无效 ID 时数据库压力也能降下来。我自己的实践经验是如果接口的非法请求不多方案一就够了如果有批量扫描或高频无效请求方案二更省心。缓存击穿特指缓存过期瞬间有大量并发请求涌入。前面提到过的sync true就是最直接的解法。注意sync和unless混用时行为比较微妙建议测试时多测几轮并发场景。最后还有一个小技巧在缓存方法里不要写太多无关逻辑。缓存的是方法返回值不是方法调用过程。把一个查询方法加十个缓存注解参数不如把查询逻辑保持干净把额外逻辑放在外层方法这样缓存语义清晰排查问题也快。我个人的体会是本地缓存这类东西一旦用顺了很容易形成路径依赖。每次排查缓存问题时我都会按固定顺序检查四点是否开启 EnableCaching、缓存名是否在配置里存在、对象是否实现序列化、方法是否被内部调用绕过了代理。这套顺序帮我解决过不少莫名其妙的问题。如果你刚接触 EhCache建议先手动把上面的示例跑通再逐步加到自己的业务代码里踩坑时也能更快定位到是配置问题还是代码问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑