
1. 从“缓存”说起为什么我们绕不开EhCache做后端开发的朋友对“缓存”这个词应该再熟悉不过了。数据库扛不住高并发查询加缓存。接口响应慢想提升用户体验加缓存。计算密集型操作耗时太长还是加缓存。可以说缓存是提升系统性能、降低后端负载的“银弹”之一。但当我们真正开始选型时会发现选择很多Redis、Memcached、Caffeine还有今天要聊的主角——EhCache。你可能会有疑问现在分布式缓存大行其道Redis几乎成了标配为什么还要关注一个“老牌”的、主要用作本地缓存的EhCache这个问题问到了点子上。我最初也有同样的疑惑直到在几个实际项目中因为“杀鸡用牛刀”或者架构过度复杂而踩了坑才重新审视EhCache的价值。简单来说EhCache的核心优势在于“简单”和“零依赖”。它不是要替代Redis而是在特定的场景下提供一种更轻量、更高效、集成成本更低的解决方案。当你需要一个进程内的、速度极快的缓存又不想引入额外的中间件和网络开销时EhCache就是一个非常优雅的选择。它的配置可以简单到几行XML或一个注解就能让应用获得立竿见影的性能提升。2. EhCache的三层架构不只是内存那么简单很多人对EhCache的印象停留在“一个内存Map”这其实低估了它的能力。现代EhCache这里主要指EhCache 3.x版本它经过了彻底的重构与2.x差异很大设计了一个清晰的三层存储模型这也是它灵活性和强大功能的基石。2.1 堆内存储速度的极致这是缓存的第一层也是最快的一层。数据直接存储在JVM的堆内存中。访问速度就是直接的内存指针引用纳秒级别。但是堆内存是有限的并且受垃圾回收的影响。EhCache在这里采用了类似“分代”的思想通过org.ehcache.sizeof.SizeOf工具来估算对象在堆中的占用大小从而进行精细化的容量控制。你可以通过配置heap资源池来限制缓存最多在堆中存放多少条目entries或者总共占用多少内存例如10MB。注意估算对象大小本身是有开销的对于超高频访问的缓存这个开销需要纳入考量。通常对于明确知道条目数量不会太大的缓存使用entries单位更直接对于条目大小差异大的缓存使用MB或GB单位更合理。2.2 堆外存储突破GC的瓶颈当缓存数据量较大或者缓存的对象较大时全部放在堆内会引发频繁的Full GC导致应用“卡顿”。EhCache的堆外存储Off-Heap就是为了解决这个问题。它使用Java的NIOByteBuffer在JVM堆之外分配内存这部分内存不受JVM垃圾回收机制的管理。它的速度比堆内稍慢因为涉及序列化/反序列化和直接内存访问但远比磁盘和网络快并且提供了比堆内大得多的存储空间同时避免了GC停顿。配置时使用offheap资源池单位通常是MB或GB。例如你可以设置一个缓存其中最新的1000条记录在堆内更多的历史数据则溢出到堆外。2.3 磁盘存储数据的持久化兜底这是速度最慢但容量最大的一层数据被持久化到本地磁盘文件。这层存储的意义在于持久化应用重启后缓存数据不丢失。这对于一些预热成本很高的数据如复杂的计算结果、从外部服务拉取的数据非常有用。海量数据当缓存的数据集远远超过内存容量时磁盘可以作为最终存储。EhCache使用org.ehcache.impl.persistence.DefaultDiskResourceService来管理磁盘存储数据文件默认位于系统临时目录下但强烈建议在生产环境中通过配置persistence目录来指定一个稳定的位置。三层如何协作EhCache采用了类似“缓存链”或“分层存储”的策略。你可以配置一个缓存同时使用多层。例如heap(100 entries) - offheap(1GB) - disk(10GB)。当访问一个条目时EhCache会从最快的堆内开始查找如果未命中则逐级向下查找。当向缓存写入数据时通常会先放入堆内当堆内满了之后根据配置的淘汰策略如LRU将一些数据“降级”到堆外或磁盘。!-- 一个典型的三层缓存配置示例 (EhCache 3.x XML) -- cache aliasmyCache uses-templatemyTemplate/ cache-template namemyTemplate key-typejava.lang.String/key-type value-typecom.example.MyObject/value-type heap unitentries1000/heap !-- 第一层堆内最多1000个条目 -- offheap unitMB500/offheap !-- 第二层堆外最多500MB -- resources disk unitGB persistenttrue5/disk !-- 第三层磁盘持久化最多5GB -- /resources /cache-template3. 核心配置详解从入门到精通EhCache的配置是其灵魂所在。理解每个配置项的含义才能让它真正为你所用。配置方式主要有三种XML文件、编程式API和Spring Boot的application.yml/properties。这里我们以最直观的XML和最常见的Spring Boot配置为例。3.1 基础配置项拆解一个完整的缓存配置通常包含以下几个核心部分缓存别名与模板alias是缓存在代码中引用的唯一标识。cache-template用于定义可复用的配置模板避免重复。键值类型key-type和value-type。在EhCache 3.x中类型安全被加强必须明确指定。这有助于序列化和防止ClassCastException。支持Java基本类型、Serializable对象等。资源池定义存储层次和容量即我们上面提到的heap、offheap、disk。过期策略决定缓存条目何时失效。ttl生存时间自条目创建后多久过期。tti空闲时间自条目最后一次被访问后多久过期。 可以同时配置以先到者为准。淘汰策略当存储资源耗尽时如何移除旧条目为新条目腾出空间。EhCache内部默认使用类似LRU的策略但配置层面更关注资源池的分配。3.2 Spring Boot中的优雅集成在Spring Boot项目中使用EhCache 3.x变得异常简单。首先引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-cache/artifactId /dependency dependency groupIdorg.ehcache/groupId artifactIdehcache/artifactId version3.10.8/version !-- 使用当前稳定版本 -- /dependency dependency groupIdjavax.cache/groupId artifactIdcache-api/artifactId /dependency然后在application.yml中配置spring: cache: jcache: provider: org.ehcache.jsr107.EhcacheCachingProvider # 指定JSR-107提供者为EhCache config: classpath:ehcache.xml # 指定配置文件位置最后创建src/main/resources/ehcache.xml文件内容就是你的缓存配置。在代码中你可以直接使用Spring的Cacheable、CacheEvict等注解来操作缓存Spring会自动委托给EhCache处理。3.3 一个实战配置案例用户信息缓存假设我们有一个用户服务需要缓存用户基本信息。用户ID是Long类型用户对象是UserDTO。我们期望最活跃的1000个用户放在堆内保证最快访问总共最多缓存10万个用户超出部分放到堆外并且缓存数据在服务器重启后不丢失。config xmlnshttp://www.ehcache.org/v3 xmlns:jsr107http://www.ehcache.org/v3/jsr107 service jsr107:defaults enable-statisticstrue/ !-- 开启统计方便监控 -- /service cache aliasuserCache key-typejava.lang.Long/key-type value-typecom.example.dto.UserDTO/value-type expiry tti unitminutes30/tti !-- 30分钟无访问则过期 -- /expiry heap unitentries1000/heap offheap unitMB100/offheap !-- 假设10万用户约100MB -- resources disk unitGB persistenttrue2/disk /resources /cache /config在Service层使用Service public class UserService { Cacheable(cacheNames userCache, key #userId) public UserDTO getUserById(Long userId) { // 模拟从数据库查询 return userRepository.findById(userId).orElseThrow(...); } CacheEvict(cacheNames userCache, key #userId) public void updateUser(UserDTO user) { // 更新数据库 userRepository.update(user); // 注解会自动移除缓存中该用户的数据 } }4. 高级特性与生产级考量当EhCache用于简单的场景时基础配置就足够了。但如果要上生产环境尤其是面对高并发、集群化部署时以下几个高级特性和考量点就必须纳入视野。4.1 缓存穿透、击穿和雪崩的应对策略这是使用任何缓存都必须面对的“三座大山”EhCache也不例外。缓存穿透查询一个必然不存在的数据。解决方案是在EhCache层面对“空值”进行短时间缓存例如缓存5分钟的空对象NullValue或者使用布隆过滤器提前拦截。EhCache本身不内置布隆过滤器需要业务层实现。缓存击穿某个热点key过期瞬间大量请求同时打到数据库。EhCache可以与Cacheable的sync属性结合在Spring环境中设置Cacheable(sync true)可以确保只有一个线程执行加载数据库的方法其他线程阻塞等待结果。但要注意这依赖于EhCache的CacheLoaderWriter机制的正确实现和锁的粒度。缓存雪崩大量key在同一时间过期。解决方案是为不同的key设置随机的、分散的过期时间。在EhCache配置中可以为不同的缓存设置不同的ttl或者在加载数据时在代码层面为每个条目设置一个基础值加随机偏移的过期时间。4.2 监听器与事件驱动EhCache提供了丰富的事件监听机制允许你在缓存条目被创建、更新、移除或过期时执行自定义逻辑。这对于维护缓存与数据源的一致性、记录审计日志、触发后续业务流程非常有用。import org.ehcache.event.*; public class MyCacheListener implements CacheEventListenerLong, UserDTO { Override public void onEvent(CacheEvent? extends Long, ? extends UserDTO event) { switch (event.getType()) { case CREATED: log.info(缓存创建: Key{}, event.getKey()); break; case UPDATED: log.info(缓存更新: Key{}, OldValue{}, NewValue{}, event.getKey(), event.getOldValue(), event.getNewValue()); break; case REMOVED: log.info(缓存移除: Key{}, event.getKey()); break; case EXPIRED: log.info(缓存过期: Key{}, event.getKey()); break; } } }在XML配置中注册这个监听器cache aliasuserCache ... listeners listener classcom.example.MyCacheListener/class event-firing-modeASYNCHRONOUS/event-firing-mode !-- 异步执行不阻塞缓存操作 -- event-ordering-modeUNORDERED/event-ordering-mode events-to-fire-onCREATED/events-to-fire-on events-to-fire-onUPDATED/events-to-fire-on events-to-fire-onREMOVED/events-to-fire-on events-to-fire-onEXPIRED/events-to-fire-on /listener /listeners /cache4.3 集群与分布式方案EhCache 3.x的核心定位是进程内缓存但其通过Terracotta产品线提供了商业版的集群解决方案Ehcache Enterprise。对于开源用户常见的做法是**“EhCache Redis”的多级缓存架构**。本地一级缓存EhCache每个应用实例独享速度极快用于扛住绝大部分的读请求特别是热点数据。分布式二级缓存Redis所有应用实例共享数据一致性的保证同时作为一级缓存的数据源和备份。实现上可以使用Spring Cache的抽象自定义一个CacheManager使其同时管理EhCache和Redis的Cache实例。对于读请求先查本地EhCache未命中则查Redis再未命中则查数据库并将结果回填到两级缓存。写请求则直接失效或更新两级缓存对应的key。这套架构的挑战在于如何维护两级缓存之间的一致性通常采用消息队列如RabbitMQ、Kafka广播失效事件或者使用Redis的Pub/Sub功能让所有实例监听并清除本地的EhCache条目。4.4 监控与管理没有监控的缓存是危险的。EhCache通过JMX暴露了丰富的管理接口和统计信息。在配置中开启统计后你可以使用JConsole、VisualVM或通过编程方式获取诸如命中率、命中/未命中次数、缓存条目数量、堆/堆外/磁盘使用量等关键指标。service jsr107:defaults enable-statisticstrue enable-managementtrue/ /service这些指标对于容量规划、性能调优和故障排查至关重要。例如如果发现某个缓存的命中率持续低于80%可能需要重新评估其过期策略或检查数据加载逻辑如果堆外存储使用率长期接近100%则需要考虑扩容或优化淘汰策略。5. 性能调优与避坑指南纸上得来终觉浅绝知此事要躬行。在实际项目中使用EhCache我积累了一些血泪教训和调优心得。5.1 序列化性能的隐形杀手当你使用堆外或磁盘存储时对象必须在存储前被序列化读取时被反序列化。这个过程的性能开销不容忽视。坑点使用Java默认的Serializable接口序列化产生的字节数组可能很大效率也未必最优。优化考虑使用更高效的序列化库如Kryo、FST或Protobuf。EhCache 3.x允许你通过Serializer接口自定义序列化器。虽然配置稍显复杂但对于存储大量或复杂对象的缓存性能提升是显著的。实测对比在一个存储ListUserDTO的缓存中从默认Java序列化切换到Kryo序列化后的体积减少了约60%吞吐量提升了近3倍。5.2 过期与淘汰的权衡ttl和tti的设置需要结合业务场景。静态数据如行政区划、配置信息可以设置很长的ttl甚至永不过期通过监听数据源变更事件来主动刷新缓存。准实时数据如用户会话、商品库存需要较短的tti如5-30分钟保证数据的相对新鲜同时利用缓存扛住短时高峰。写多读少的数据缓存价值不大甚至可能因为频繁失效和加载反而降低性能需要谨慎评估。淘汰策略方面EhCache内部算法已经比较成熟。你需要关注的是资源池大小的设置。堆内池设置太小会导致频繁的淘汰和缓存命中率下降设置太大又会挤压应用本身的堆内存引发GC问题。一个实用的方法是通过监控观察缓存条目的增长趋势和命中率进行动态调整。生产环境可以先设置一个保守值再根据监控逐步上调。5.3 线程安全与并发控制EhCache的实例本身是线程安全的多个线程并发读写没有问题。但在高并发场景下特别是使用CacheLoaderWriter自动加载数据时需要注意锁的粒度EhCache在加载一个缺失的key时会锁定该key防止多个线程同时加载。这通常是好事。但要确保你的CacheLoaderWriter.load方法本身没有死锁或长时间阻塞的风险。putIfAbsent操作这是一个原子性的“如果不存在则放入”操作在实现简单的分布式锁或幂等性控制时很有用但要注意其性能开销比普通put略高。批量操作EhCache提供了getAll、putAll等方法。在需要批量加载数据的场景下使用这些方法比循环调用单条API要高效得多因为它们可以减少内部锁的竞争和上下文切换。5.4 类加载与部署陷阱这是一个容易忽略但可能导致严重问题的坑。在Web容器如Tomcat中如果应用被热部署或重启而EhCache配置了磁盘持久化并且缓存条目中存储的类发生了变更例如你修改了UserDTO类增加了字段那么重启后反序列化旧数据时可能会抛出InvalidClassException。解决方案对于重要且结构可能变化的数据不要依赖EhCache的磁盘持久化作为唯一数据源。它应该被视为加速重启后缓存预热的一种手段而不是数据库。在应用启动时可以尝试加载持久化数据如果失败则清空磁盘缓存并重新构建。考虑使用向前/向后兼容的序列化格式如Protobuf、Avro或者为实体类显式指定serialVersionUID并确保其稳定。最后我的个人体会是技术选型没有银弹。EhCache在“进程内缓存”这个细分领域凭借其成熟度、灵活的层级设计和与Java生态特别是Spring的无缝集成依然拥有强大的生命力。它特别适合用在数据量可控、访问频率极高、且对延迟极度敏感的场景中作为系统性能提升的第一道屏障。把它和Redis等分布式缓存组合使用往往能取得“112”的效果。下次当你设计缓存方案时不妨先问自己这部分数据真的需要立刻走到分布式缓存吗也许EhCache就是那个更简单、更高效的答案。