1. 为什么MyBatis原生缓存不够用?从一次数据库压测事故说起
去年参与一个电商订单查询模块的性能优化,系统上线前压测时QPS刚过800,MySQL连接池就频繁告警,慢查询日志里全是重复的SELECT * FROM order_info WHERE user_id = ? AND status = 'PAID'。当时第一反应是加索引——确实有效,但把QPS撑到1200后,数据库CPU又顶到了95%。排查发现,同一用户在10秒内反复刷新订单页,MyBatis一级缓存(SqlSession级)根本不起作用——每次HTTP请求都新建SqlSession;二级缓存(Mapper级)倒是开了,但默认只支持简单POJO,而我们的订单对象里嵌套了支付流水、物流轨迹等动态集合,序列化时直接抛NotSerializableException。
这才意识到:MyBatis自带的二级缓存只是个“骨架”,它不提供内存管理策略、没有过期时间分级、无法穿透到分布式环境,更关键的是——它连最基本的缓存雪崩防护都没有。当某个热点商品详情页被刷单机器人狂刷,所有请求瞬间穿透到DB,缓存击穿+数据库锁表,整个服务就挂了。这时候才真正理解Ehcache的价值:它不是简单替换MyBatis的Cache接口,而是把缓存从“可有可无的装饰品”升级成“可控、可观测、可防御的基础设施”。比如它的maxEntriesLocalHeap=1000参数,表面看是限制内存条目数,实际是给缓存设了一道安全阀——当订单查询缓存暴涨时,自动触发LRU淘汰,避免JVM堆内存OOM;而timeToLiveSeconds=300则让每个缓存项自带“保质期”,比手动写定时清理代码可靠十倍。
提示:别被“第三方缓存”这个词迷惑。Ehcache不是MyBatis的插件,而是通过实现MyBatis的
org.apache.ibatis.cache.Cache接口,把自己伪装成MyBatis原生缓存组件。这种设计哲学很像Java的SPI机制——框架只定义契约,具体实现由你选。所以整合过程本质是“填空题”:MyBatis画好缓存操作的流程图(putObject/getObject/removeObject),Ehcache负责把每个步骤的执行逻辑写实。
现在回头看,那次压测事故暴露的根本问题不是技术选型错误,而是对缓存认知的断层:把缓存当成“加速器”而非“流量调节阀”。当你需要控制缓存容量、设置多级过期策略、甚至集成监控埋点时,Ehcache这类专业缓存库的价值就凸显出来了——它让缓存行为变得可预测、可调试、可运维。
2. Ehcache与MyBatis的握手协议:Cache接口的7个关键方法拆解
MyBatis和Ehcache的整合,核心就藏在org.apache.ibatis.cache.Cache这个接口里。很多人以为只要配置好<cache type="org.mybatis.caches.ehcache.EhcacheCache"/>就万事大吉,结果运行时发现缓存不生效、数据不一致,甚至OOM。根源在于没吃透这个接口里7个方法的真实语义。我画了个对比表,把MyBatis的调用场景和Ehcache的实际动作对应起来:
| MyBatis Cache接口方法 | 调用时机 | Ehcache底层真实操作 | 常见陷阱 |
|---|---|---|---|
get(Object key) | 查询SQL执行前 | cache.get(key),返回Element对象 | 若Ehcache配置了copyOnRead=true,这里会触发深拷贝,影响性能 |
putObject(Object key, Object value) | 查询SQL执行后 | cache.put(new Element(key, value)) | MyBatis传入的value是原始POJO,Ehcache默认序列化存储,若POJO含非序列化字段会失败 |
removeObject(Object key) | 执行UPDATE/DELETE语句后 | cache.remove(key) | 注意:MyBatis只在当前Mapper的namespace下清除,跨Mapper需手动同步 |
clear() | SqlSession.commit()后 | cache.removeAll() | 这是全局清空!生产环境慎用,建议改用cache.removeWithWriter()按条件清理 |
getSize() | MyBatis统计缓存命中率时 | cache.getSize() | 返回的是Element数量,不是内存占用字节数,监控时需额外采集cache.getMemoryStoreSize() |
getReadWriteLock() | 多线程并发访问时 | 返回ReentrantReadWriteLock实例 | 若自定义Cache实现未正确处理读写锁,会导致脏读 |
hashCode()/equals() | MyBatis校验缓存实例唯一性 | 默认Object方法 | 若自定义Cache类重写了这两个方法,必须保证逻辑一致,否则MyBatis会创建多个缓存实例 |
特别要强调putObject方法里的坑。MyBatis在执行完SELECT后,会把整个查询结果List作为value传进来。但Ehcache的Element构造器要求value必须是可序列化的。我们项目里有个订单DTO包含java.time.LocalDateTime字段,JDK8默认不支持序列化,结果缓存写入时静默失败,日志里只有WARN [net.sf.ehcache.store.MemoryStore] - Unable to set element on cache。解决方法不是改DTO,而是在Ehcache配置里加<cache name="orderCache" ... copyOnWrite="true">,让Ehcache用CopyOnWrite策略替代序列化。
再看removeObject的局限性。假设你有OrderMapper和PaymentMapper,当更新订单状态时,OrderMapper.updateStatus()会触发orderCache.remove(orderId),但支付记录缓存paymentCache不会自动失效。这导致用户看到订单已发货,但支付流水还显示“待支付”。解决方案有两个:一是用Ehcache的CacheManager广播事件,在PaymentMapper里监听orderCache的remove事件;二是更彻底的——放弃MyBatis二级缓存,改用Spring Cache + @CacheEvict组合,用业务语义控制缓存生命周期。
注意:MyBatis的缓存key生成规则是
Mapper全限定名:SQL指纹:参数值。比如com.example.OrderMapper.selectByUserId:select * from order where user_id = ?:1001。这个key字符串会被Ehcache直接用作Element的key,所以千万别在SQL里拼接动态表名或列名,否则key会无限膨胀。
3. 从XML配置到Java Config:Ehcache整合的三种落地姿势
整合Ehcache的方式不止一种,不同姿势适用不同场景。我经历过三个典型项目,每种方案都踩过坑,也总结出最佳实践。
3.1 最简模式:MyBatis XML配置直连(适合单体应用)
这是官方文档推荐的入门方式,配置文件ehcache.xml放在src/main/resources下:
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="ehcache.xsd"> <diskStore path="java.io.tmpdir/ehcache"/> <cache name="defaultCache" maxEntriesLocalHeap="1000" eternal="false" timeToIdleSeconds="300" timeToLiveSeconds="600" overflowToDisk="true" diskPersistent="false"/> <cache name="orderCache" maxEntriesLocalHeap="500" eternal="false" timeToIdleSeconds="120" timeToLiveSeconds="300" memoryStoreEvictionPolicy="LRU"/> </ehcache>然后在Mapper XML里声明:
<mapper namespace="com.example.OrderMapper"> <cache type="org.mybatis.caches.ehcache.EhcacheCache" eviction="org.mybatis.caches.ehcache.EhcacheCache" flushInterval="60000" size="1000" readOnly="true"/> <!-- SQL语句 --> </mapper>这个方案的问题在于:flushInterval和size等参数在MyBatis层和Ehcache层重复配置,且MyBatis的flushInterval是强制刷新间隔,而Ehcache的timeToLiveSeconds是自然过期时间,两者冲突时以Ehcache为准。更致命的是,readOnly="true"意味着MyBatis认为缓存是只读的,但实际Ehcache允许写入——这导致MyBatis在事务回滚时不会清理缓存,产生脏数据。
3.2 进阶模式:Spring Boot自动装配(推荐用于微服务)
Spring Boot 2.4+内置了Ehcache3支持,只需三步:
- 引入依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> </dependency> <dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> </dependency>- 配置
application.yml:
spring: cache: type: ehcache ehcache: config: classpath:ehcache.xml mybatis: configuration: cache-enabled: true- 在Service层用注解驱动:
@Service public class OrderService { @Cacheable(value = "orderCache", key = "#userId + '_' + #status") public List<Order> getOrdersByUser(Long userId, String status) { return orderMapper.selectByUserAndStatus(userId, status); } @CacheEvict(value = "orderCache", key = "#order.userId + '_' + #order.status") public void updateOrderStatus(Order order) { orderMapper.updateStatus(order); } }这种模式的优势是:缓存生命周期完全由Spring管理,事务回滚时自动清理缓存;@Cacheable的key支持SpEL表达式,比MyBatis的硬编码SQL指纹灵活得多;还能结合@CachePut实现“先查缓存,再查DB,最后更新缓存”的三段式操作。
3.3 生产模式:代码化CacheManager构建(应对复杂缓存策略)
某金融项目要求订单缓存按金额分三级:小额订单(<100元)缓存5分钟,中额(100-1000元)缓存30分钟,大额(>1000元)缓存2小时。XML配置无法满足这种动态策略,必须手写CacheManager:
@Configuration public class CacheConfig { @Bean public CacheManager cacheManager() { CacheConfigurationBuilder cacheConfig = CacheConfigurationBuilder .newCacheConfigurationBuilder(Object.class, Object.class, ResourcePoolsBuilder.heap(1000)); // 定义三种缓存策略 CacheConfiguration smallOrderConfig = cacheConfig .withExpiry(Expirations.timeToLiveExpiration(Duration.ofMinutes(5))) .build(); CacheConfiguration largeOrderConfig = cacheConfig .withExpiry(Expirations.timeToLiveExpiration(Duration.ofHours(2))) .build(); CachingProvider provider = Caching.getCachingProvider(); CacheManager cacheManager = provider.getCacheManager(); // 动态创建缓存实例 cacheManager.createCache("smallOrderCache", smallOrderConfig); cacheManager.createCache("largeOrderCache", largeOrderConfig); return cacheManager; } }然后在Mapper里通过@Options(useCache = true)启用,并在DAO层根据订单金额选择对应缓存名。这种方案虽然代码量大,但把缓存控制权完全收归业务层,避免了配置与代码的割裂。
实操心得:无论用哪种模式,务必在启动时验证Ehcache是否生效。最简单的方法是开启MyBatis日志:
logging.level.org.apache.ibatis=DEBUG,观察SQL执行前后是否有Cache Hit或Cache Miss日志。如果只有Cache Miss,大概率是Mapper没加<cache>标签,或者实体类没实现Serializable接口。
4. 缓存穿透、击穿、雪崩:Ehcache的三重防御实战
很多团队把Ehcache当“加速器”用,结果线上一出问题就全盘否定缓存方案。其实Ehcache本身提供了完整的防御体系,只是需要正确激活。下面用真实故障场景说明如何配置。
4.1 缓存穿透:恶意ID攻击下的布隆过滤器集成
某次促销活动,黑客用脚本遍历/order/{id}接口,id从1递增到1000000,其中99%的ID在数据库里根本不存在。MyBatis二级缓存对空结果不缓存,导致所有请求穿透到DB,MySQL CPU飙升到100%。解决方案不是加大数据库连接池,而是用布隆过滤器(Bloom Filter)在缓存层拦截。
Ehcache本身不支持布隆过滤器,但可以扩展Cache接口:
public class BloomFilterCache implements Cache { private final Cache delegate; private final BloomFilter<String> bloomFilter; public BloomFilterCache(String id) { this.delegate = new EhcacheCache(id); // 初始化布隆过滤器,预计100万订单,误判率0.01% this.bloomFilter = BloomFilter.create( Funnels.stringFunnel(Charset.defaultCharset()), 1000000, 0.01); } @Override public Object getObject(Object key) { String cacheKey = key.toString(); if (!bloomFilter.mightContain(cacheKey)) { return null; // 根本不存在,直接返回 } return delegate.getObject(key); } @Override public void putObject(Object key, Object value) { if (value != null) { // 只对非空结果布隆 bloomFilter.put(key.toString()); } delegate.putObject(key, value); } }部署后,恶意请求的99%在Ehcache层就被拦截,DB压力下降80%。关键是布隆过滤器的初始化参数:expectedInsertions要略大于实际数据量,fpp(false positive probability)设为0.01意味着每100次查询有1次误判,这个代价远小于穿透DB。
4.2 缓存击穿:热点Key失效时的互斥锁方案
双十一零点,某个爆款商品详情页的缓存到期,瞬间上万请求同时打到DB。Ehcache的timeToLiveSeconds到期后,所有线程都会触发getObject返回null,进而执行SQL。这时需要“只让一个线程查DB,其他线程等结果”。
标准解法是加分布式锁,但Ehcache本身提供了更轻量的方案——BlockingCache:
<cache name="hotItemCache" maxEntriesLocalHeap="100" eternal="false" timeToLiveSeconds="60" blocking="true"/> <!-- 关键:开启阻塞模式 -->当第一个线程发现缓存为空时,它会获取内部锁并执行DB查询;其他线程在getObject时会被阻塞,直到第一个线程写入缓存后唤醒它们。这个锁是Ehcache内置的,无需Redis或ZooKeeper,性能损耗极小。
4.3 缓存雪崩:批量Key失效引发的连锁崩溃
某次发布,运维同事误删了Ehcache配置里的timeToLiveSeconds,导致所有缓存永不过期。三天后缓存占满内存,GC频繁,服务响应变慢。紧急扩容后,他给所有缓存统一设置了timeToLiveSeconds=300,结果整点时刻大量缓存同时失效,DB再次被打爆。
根治方案是“错峰过期”:Ehcache支持随机过期时间偏移量:
<cache name="productCache" maxEntriesLocalHeap="5000" eternal="false" timeToLiveSeconds="300" timeToIdleSeconds="120" statistics="true"> <searchable/> </cache>在Java代码里启用随机偏移:
CacheConfigurationBuilder builder = CacheConfigurationBuilder .newCacheConfigurationBuilder(Object.class, Object.class, ResourcePoolsBuilder.heap(5000)) .withExpiry(Expirations.timeToLiveExpiration(Duration.ofSeconds(300))); // 添加±10%的随机偏移 builder = builder.withExpiry(Expirations.timeToLiveExpiration( Duration.ofSeconds(300), Duration.ofSeconds(30))); // 第二个参数是最大偏移量这样每个缓存项的实际过期时间在270~330秒之间随机分布,避免整点雪崩。配合Ehcache的statistics="true",还能通过JMX监控CacheStatistics里的cacheHits、cacheMisses指标,及时发现异常。
关键提醒:防御措施要分层。布隆过滤器防穿透是第一道门,BlockingCache防击穿是第二道门,随机过期防雪崩是第三道门。但所有防御的前提是——你的缓存key设计必须合理。比如订单查询不要用
user_id + status作为key,而要用MD5(user_id + status),避免key字符串过长导致Ehcache内存碎片化。
5. 监控与调优:从Ehcache日志读懂缓存健康度
生产环境不能靠“感觉”判断缓存是否健康。Ehcache提供了三类监控入口,我按优先级排序:
5.1 日志分析:最直接的缓存诊断书
在logback-spring.xml里开启Ehcache详细日志:
<logger name="net.sf.ehcache" level="DEBUG" additivity="false"> <appender-ref ref="FILE"/> </logger> <logger name="org.mybatis.caches.ehcache" level="DEBUG" additivity="false"> <appender-ref ref="FILE"/> </logger>重点关注三类日志:
缓存命中率:
DEBUG [net.sf.ehcache.Cache] - orderCache: hit count: 1234, miss count: 56
健康阈值:命中率 > 85%。若低于70%,检查SQL是否用了SELECT *导致缓存粒度太粗。内存溢出警告:
WARN [net.sf.ehcache.store.MemoryStore] - orderCache is full. Purging...
这表示maxEntriesLocalHeap设置过小,需结合cache.getMemoryStoreSize()监控值调整。序列化失败:
ERROR [net.sf.ehcache.store.disk.DiskStorageFactory] - Failed to serialize element
立即检查POJO是否实现了Serializable,或改用copyOnWrite="true"。
5.2 JMX监控:实时查看缓存运行时状态
Ehcache默认开启JMX,通过JConsole连接即可看到net.sf.ehcache下的MBean。关键指标有:
| MBean属性 | 含义 | 健康值 | 异常表现 |
|---|---|---|---|
CacheHits | 缓存命中次数 | 持续增长 | 突然归零说明缓存被清空 |
CacheMisses | 缓存未命中次数 | 平稳增长 | 短时激增预示穿透攻击 |
InMemoryHits | 堆内存命中次数 | 占总hits 90%+ | 若<50%说明频繁IO |
Size | 当前缓存条目数 | <maxEntriesLocalHeap | 接近上限需扩容 |
我们曾通过JMX发现InMemoryHits占比仅30%,深入排查发现磁盘路径java.io.tmpdir/ehcache被Docker容器限制了空间,导致Ehcache被迫写入磁盘。解决方案是显式指定<diskStore path="/data/ehcache"/>并挂载宿主机目录。
5.3 自定义指标埋点:对接Prometheus的实战代码
Spring Boot项目可轻松集成Micrometer:
@Component public class EhcacheMetrics { private final CacheManager cacheManager; public EhcacheMetrics(CacheManager cacheManager) { this.cacheManager = cacheManager; } @Scheduled(fixedRate = 30000) public void reportCacheStats() { Cache orderCache = cacheManager.getCache("orderCache"); Gauge.builder("ehcache.size", orderCache, c -> c.getSize()) .register(Metrics.globalRegistry); Gauge.builder("ehcache.hits", orderCache, c -> ((Eh107Cache) c).getNativeCache().getStatistics() .getCacheHitCount()) .register(Metrics.globalRegistry); } }在Prometheus里写查询:rate(ehcache_hits_total[5m]) / rate(ehcache_size_total[5m]),就能得到5分钟滑动命中率。当该值跌破0.8时,自动触发企业微信告警。
经验之谈:调优不是盲目加大
maxEntriesLocalHeap。我们曾把订单缓存从1000调到10000,结果GC时间翻倍。后来发现真正瓶颈是timeToLiveSeconds设得太短(60秒),导致缓存项高频创建销毁。最终方案是:保持maxEntriesLocalHeap=1000,把timeToLiveSeconds延长到300秒,并增加timeToIdleSeconds=120,让不活跃的缓存提前释放。效果是命中率从65%提升到92%,GC频率下降70%。
6. 从Ehcache到Caffeine:一次平滑迁移的技术决策复盘
去年团队决定将Ehcache升级为Caffeine,不是因为Ehcache不好,而是业务场景变了。当时遇到三个无法绕开的痛点:
内存占用过高:Ehcache的
Element对象包含大量元数据(创建时间、访问时间、序列化头),10万条订单缓存占内存1.2GB;而Caffeine用LongAdder计数器和紧凑的哈希表结构,同样数据量仅占480MB。API学习成本高:Ehcache的XML配置有50+参数,新人要花两天才能搞懂
overflowToDisk和diskPersistent的区别;Caffeine的Caffeine.newBuilder().maximumSize(10000).expireAfterWrite(5, TimeUnit.MINUTES)一行代码搞定。缺乏异步加载:Ehcache的
CacheLoader是同步阻塞的,而Caffeine的AsyncLoadingCache支持CompletableFuture,能并行加载关联数据(如查订单时异步加载用户信息)。
迁移过程我们坚持“渐进式”原则:
第一阶段(1周):在测试环境双写。所有写操作同时写入Ehcache和Caffeine,读操作只走Ehcache。通过对比日志验证数据一致性。
第二阶段(3天):读写分离。写操作只写Caffeine,读操作80%走Caffeine、20%走Ehcache做AB测试。用
CacheStats监控命中率差异。第三阶段(上线日):切流。通过Spring Profile控制,
@Profile("caffeine")启用新缓存,@Profile("ehcache")保留降级开关。
最关键的兼容层代码:
public class CaffeineCache implements Cache { private final AsyncLoadingCache<Object, Object> cache; public CaffeineCache(String id) { this.cache = Caffeine.newBuilder() .maximumSize(10000) .expireAfterWrite(5, TimeUnit.MINUTES) .recordStats() // 开启统计 .buildAsync((key, executor) -> { // 调用MyBatis的Mapper执行查询 return CompletableFuture.supplyAsync(() -> { return mapper.selectByPrimaryKey(key); }, executor); }); } @Override public Object getObject(Object key) { try { return cache.get(key).get(); // 同步获取,保持MyBatis接口契约 } catch (Exception e) { return null; } } }这次迁移教会我:技术选型不是“新一定比旧好”,而是“合适场景用合适工具”。Ehcache在需要磁盘持久化、复杂过期策略的场景依然不可替代;而Caffeine在追求极致性能、内存效率的微服务场景更具优势。真正的架构能力,是看清每个工具的边界,并在边界模糊处设计优雅的过渡方案。
最后分享个血泪教训:某次迁移后发现订单状态更新延迟,排查发现Caffeine的
expireAfterWrite是“写入后过期”,而Ehcache的timeToLiveSeconds是“创建后过期”。我们把Ehcache的300秒过期直接平移成Caffeine的300秒,但业务要求是“从最后一次更新算起300秒”,必须改用expireAfterAccess。这个细节差异,让团队加班到凌晨两点。