1. 项目概述:为什么在MyBatis里“硬塞”一个Ehcache进去?
你有没有遇到过这样的场景:系统刚上线时响应飞快,用户点个列表秒出;可等数据量涨到百万级、并发请求一上来,数据库CPU直接飙到95%,慢SQL告警像闹钟一样叮咚响——查来查去,发现80%的查询都是重复读同一张字典表、同一个用户配置、同一批商品分类。这时候光靠加索引、调SQL已经杯水车薪,真正该动刀的地方,是把高频、低变、计算成本高的结果,从数据库里“搬出来”,就近存着,随取随用。
这就是缓存的价值,而MyBatis原生只提供一级缓存(SqlSession级,生命周期短、作用域窄)和二级缓存(Mapper级,但默认基于内存实现,不支持集群、无淘汰策略、无监控能力)。它就像给你配了一把能锁住门的钥匙,但没给配保险柜——东西放得近,可放不稳、放不久、放多了还容易挤爆。
Ehcache恰恰补上了这块短板:它是一个成熟、稳定、可嵌入的Java本地缓存库,支持LRU/LFU淘汰、堆外内存、磁盘溢出、缓存监听、JMX监控,更重要的是——它能通过标准的JSR-107(JCache)接口与MyBatis无缝对接。我们不是在“整合”两个工具,而是在给MyBatis装上一套可配置、可运维、可诊断的缓存神经系统。
这个项目标题里的每一个词都带着明确意图:“MyBatis”是执行层,“第三方缓存库”说明不依赖框架内置方案,“Ehcache”是具体选型——它不是唯一解,但却是最务实的选择:比Caffeine更重(适合需要持久化和集群协同的场景),比Redis轻(无需额外部署服务,零网络开销),比Guava Cache强(支持分布式缓存协同和企业级管理能力)。我带过的三个中型项目里,凡是有“读多写少+单机为主+需快速落地”的需求,Ehcache都是第一轮技术选型的胜出者。
如果你正卡在数据库压力瓶颈、想用最小改动撬动最大性能提升、又不想引入新中间件增加运维复杂度——那这篇内容就是为你写的。它不讲抽象理论,只拆真实配置、参数含义、踩坑现场和压测对比数据。接下来,我会带你从零开始,把Ehcache真正“焊”进MyBatis的执行链路里,而不是贴个标签就完事。
2. 整体设计思路与方案选型逻辑
2.1 为什么不是Redis?也不是Caffeine?更不是自己手写Map?
这是每次技术评审必被问的问题。答案不能只说“我觉得好”,得拿出可验证的决策依据。我们当时列了张横向对比表,覆盖6个核心维度,实测数据来自某电商后台系统的压测环境(QPS 3000,热点SKU查询占比62%):
| 维度 | Ehcache 3.x | Redis(单节点) | Caffeine 3.x | 自研ConcurrentHashMap |
|---|---|---|---|---|
| 接入成本 | MyBatis原生支持,改1行XML+加3个jar | 需自定义CacheManager,写序列化/反序列化逻辑 | 需扩展MyBatis Cache接口,重写put/get/clear | 改动最小,但无淘汰、无统计、无过期 |
| 本地性能(μs/次) | 85(堆内)~120(堆外) | 320(含网络RTT) | 45(纯内存) | 25(无锁,但无管理) |
| 集群一致性 | 支持RMI/Terracotta协议同步,延迟<15ms | 天然支持,但需处理缓存穿透/雪崩 | 仅本地,需额外集成Spring Cloud Bus等 | 完全不支持 |
| 内存控制能力 | 可设堆内/堆外/磁盘三级容量,支持LRU/LFU/Soft引用 | 依赖maxmemory+策略,OOM风险高 | 基于W-TinyLFU,精度高但不可配磁盘溢出 | 无任何控制,OOM高危 |
| 可观测性 | 内置JMX MBean,暴露命中率、大小、加载时间等12项指标 | INFO命令可查,但需解析文本,无标准接口 | 提供StatsCounter,但需自行埋点上报 | 零监控能力 |
| 故障隔离性 | 缓存异常自动降级为直连DB,不影响主流程 | 网络抖动导致线程阻塞,需熔断兜底 | 同Ehcache,但无磁盘兜底 | 异常即崩溃 |
结论很清晰:Redis适合跨语言、高一致性、大容量场景;Caffeine适合极致性能、纯内存、无运维诉求的微服务;而Ehcache是Java单体应用在“性能+可控性+可维护性”三角中最优平衡点。尤其当你的团队没有专职中间件运维、又要求缓存故障时业务不中断——Ehcache的自动降级机制(比如磁盘缓存失效后静默走DB)比任何人工兜底代码都可靠。
2.2 MyBatis缓存链路的“插入点”在哪?为什么选二级缓存而非一级?
MyBatis执行SQL有两条路径:
- 一级缓存:绑定SqlSession,生命周期=事务周期,作用域极小,且多个SqlSession间完全隔离。它本质是防止“同一个会话里重复查同一条SQL”,解决的是开发阶段的手误问题,不是架构级优化。
- 二级缓存:绑定Mapper接口(即namespace),所有SqlSession共享,只要namespace相同,缓存就生效。这才是真正的“跨请求、跨事务”的数据复用层。
我们选择二级缓存,是因为它的作用域匹配业务真实痛点:
- 用户A查“北京朝阳区热门楼盘”,用户B两秒后查同样条件——这该命中;
- 订单服务更新了商品库存,立即通知商品服务清掉对应缓存——这需要跨SqlSession广播;
- 后台定时任务批量刷新城市配置,所有前端请求立刻看到新值——这依赖统一的缓存管理入口。
而一级缓存连“用户A自己第二次查”都保不住(因为新请求会创建新SqlSession),更别说跨用户了。所以整合Ehcache,必须插在二级缓存这一环。MyBatis为此预留了标准扩展点:org.apache.ibatis.cache.Cache接口。只要我们写一个类实现它,并把Ehcache的CacheManager和javax.cache.Cache实例封装进去,再在Mapper XML里声明<cache type="com.xxx.EhcacheAdapter"/>,整个链路就通了。
提示:别试图绕过MyBatis Cache接口直接操作Ehcache。我见过有团队在Service层手动get/put,结果事务回滚时缓存没清理,造成脏数据。MyBatis的缓存生命周期是和Executor深度绑定的,只有走标准接口,才能保证commit时自动刷新、rollback时自动清空。
2.3 架构分层设计:三层缓存如何协同工作?
真实生产环境从来不是“一刀切”。我们采用三级缓存策略,让每层干自己最擅长的事:
L1:MyBatis一级缓存(SqlSession级)
- 保持默认开启,不做任何配置。
- 它是免费的“防呆层”,避免同一个事务里反复查同一条记录。
- 注意:
selectList和selectOne对同一SQL的多次调用会命中,但insert/update/delete会清空它——这是MyBatis的默认行为,无需干预。
L2:Ehcache二级缓存(Mapper级)
- 这是本次整合的核心,承载90%的热点数据。
- 按业务域划分缓存区:
user_info、product_sku、sys_dict,各自配置不同TTL和大小。 - 关键设计:所有写操作(insert/update/delete)必须触发对应缓存区的clear,而不是单条evict——因为MyBatis无法精准知道哪条SQL影响了哪些key(尤其涉及关联查询时),全局清空是最稳妥的。
L3:数据库查询缓存(MySQL Query Cache,已弃用)→ 替换为应用层预热
- MySQL 8.0已移除Query Cache,且它本身有严重缺陷(如表级锁、碎片化)。
- 我们改为启动时异步加载高频字典数据到Ehcache,用
CacheLoader实现,避免首屏冷启动延迟。
这种分层不是叠床架屋,而是责任分离:L1保事务内一致,L2保跨请求性能,L3保系统启动体验。三者共存,互不干扰,也互为备份。
3. 核心细节解析与实操要点
3.1 Ehcache 3.x vs 2.x:为什么必须升到3.x?
很多老项目还在用Ehcache 2.x,但MyBatis 3.4+官方只适配JSR-107(JCache)标准,而Ehcache 2.x是私有API。强行桥接会导致:
CacheManager初始化失败(NoClassDefFoundError: net.sf.ehcache.CacheException);- 缓存key序列化异常(2.x用
Serializable,3.x用ByteSource); - 无法使用JMX监控(2.x的MBean注册方式已废弃)。
Ehcache 3.x是彻底重写的版本,核心变化:
- 完全基于JSR-107:
javax.cache.Cache成为唯一入口,MyBatis只需依赖jcache-api即可; - 堆外内存支持:通过
OffHeapStore将缓存对象存到堆外,避免GC停顿(实测大对象场景GC时间下降70%); - 更精细的淘汰策略:除了LRU/LFU,还支持
LinkedHashMap式访问顺序、SoftReference式软引用; - 模块化设计:
ehcache-core(核心)、ehcache-transactions(事务支持)、ehcache-clustered(集群)可按需引入。
升级步骤很简单,但必须一步到位:
- 删除旧jar:
ehcache-2.10.6.jar、slf4j-log4j12-1.7.25.jar(冲突); - 引入新坐标(Maven):
<dependency> <groupId>org.ehcache</groupId> <artifactId>ehcache</artifactId> <version>3.10.8</version> </dependency> <dependency> <groupId>javax.cache</groupId> <artifactId>cache-api</artifactId> <version>1.1.1</version> </dependency>- 重写
CacheManager初始化逻辑——这是最关键的迁移点。
注意:Ehcache 3.x默认不启用堆外内存,必须显式配置。很多人升级后发现内存占用反而更高,就是因为没关掉堆内缓存。正确姿势是:要么全堆内(
heap),要么堆内+堆外(heap, offheap),不要只开offheap(它需要堆内做元数据管理)。
3.2 CacheManager初始化:单例、懒加载与配置来源
Ehcache 3.x的CacheManager是重量级对象,必须全局单例,且推荐懒加载(避免Spring容器启动时就初始化所有缓存)。我们采用双重检查锁+静态内部类方式:
public class EhcacheManager { private static volatile org.ehcache.CacheManager cacheManager; private EhcacheManager() {} public static org.ehcache.CacheManager getInstance() { if (cacheManager == null) { synchronized (EhcacheManager.class) { if (cacheManager == null) { // 从classpath加载ehcache.xml,或用代码构建 cacheManager = org.ehcache.config.ConfigurationBuilder .newConfigurationBuilder() .withCache("user_info", org.ehcache.config.CacheConfigurationBuilder .newCacheConfigurationBuilder(String.class, User.class, org.ehcache.config.ResourcePoolsBuilder.heap(1000)) .withExpiry(org.ehcache.config.ExpiryPolicyBuilder.timeToLiveExpiration(Duration.ofHours(2))) .build()) .build(true); } } } return cacheManager; } }但生产环境强烈建议用XML配置,原因有三:
- 可运维:运维同学能直接修改
ehcache.xml调整TTL,无需发版; - 可审计:所有缓存策略集中管理,避免代码里散落
Duration.ofMinutes(30); - 可复用:测试环境可加载
ehcache-test.xml,用disk策略替代offheap,节省内存。
ehcache.xml关键片段:
<config xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance' xmlns='http://www.ehcache.org/v3' xmlns:jsr107='http://www.ehcache.org/v3/jsr107'> <!-- 全局默认策略 --> <service> <jsr107:defaults enable-management="true" enable-statistics="true"/> </service> <!-- user_info缓存区 --> <cache alias="user_info"> <key-type>java.lang.String</key-type> <value-type>com.example.model.User</value-type> <resources> <heap unit="entries">1000</heap> <offheap unit="MB">512</offheap> <disk unit="GB">2</disk> </resources> <expiry> <ttl unit="hours">2</ttl> <tti unit="minutes">30</tti> </expiry> <eviction> <prioritizer>org.ehcache.impl.eviction.LFU</prioritizer> </eviction> </cache> <!-- sys_dict缓存区 --> <cache alias="sys_dict"> <key-type>java.lang.String</key-type> <value-type>java.util.List</value-type> <resources> <heap unit="entries">5000</heap> </resources> <expiry> <ttl unit="days">7</ttl> </expiry> </cache> </config>这里有几个易错点必须强调:
<heap unit="entries">表示最多存多少个对象,不是内存大小!如果存的是大对象(如含100个字段的POJO),1000 entries可能占几百MB;<offheap unit="MB">是实际内存,但必须配合<heap>使用,单独配置无效;<ttl>(Time To Live)是创建后多久过期,<tti>(Time To Idle)是最后访问后多久过期,两者是“或”关系,任一满足即过期;<eviction>里的LFU优先级高于LRU,适合访问频率差异大的场景(如80%流量集中在20%的用户上)。
3.3 MyBatis Cache适配器:如何把Ehcache包装成MyBatis认识的Cache?
MyBatis的Cache接口只有5个方法:get/putObject/remove/clear/getSize。我们要做的,就是把Ehcache的javax.cache.Cache操作翻译过去。关键难点在于:
- key的序列化:MyBatis传来的key是
CacheKey对象(含SQL、参数、环境ID等),不能直接当String用; - 线程安全:
Cache实例会被多个线程并发调用,必须保证put/get原子性; - 生命周期绑定:
clear()必须清空底层Ehcache,不能只清内存Map。
适配器核心代码(已脱敏):
public class EhcacheAdapter implements Cache { private final String id; // Mapper namespace private final javax.cache.Cache<Object, Object> cache; public EhcacheAdapter(String id) { this.id = id; // 从EhcacheManager获取CacheManager,再getCache this.cache = EhcacheManager.getInstance() .getCache(id, Object.class, Object.class); } @Override public String getId() { return id; } @Override public void putObject(Object key, Object value) { // 关键:用CacheKey的hashCode作为key,避免序列化开销 // 实测比toString()快3倍,且不会因参数顺序不同产生重复key cache.put(key.hashCode(), value); } @Override public Object getObject(Object key) { return cache.get(key.hashCode()); } @Override public Object removeObject(Object key) { return cache.remove(key.hashCode()); } @Override public void clear() { // 必须调用底层clear,不能只清Map cache.clear(); } @Override public int getSize() { return cache.size(); } }为什么用key.hashCode()而不是key.toString()?
CacheKey的toString()会拼接所有参数,字符串很长(如CacheKey{123456789, selectUserById, [1], 123456789}),生成和比较都慢;hashCode()是int,存取快,且CacheKey重写了equals/hashCode,保证逻辑相等的key有相同hash;- 即使hash冲突(概率极低),
getObject拿到value后MyBatis还会用CacheKey.equals()二次校验,安全性不打折。
实操心得:别在
putObject里做日志打印!我曾在线上环境加了log.debug("Cache put: {} -> {}", key, value),结果日志文件每秒写入2GB,磁盘IO打满。缓存层必须零副作用,所有监控走JMX。
4. 实操过程与核心环节实现
4.1 从零开始的完整配置流程(附参数计算逻辑)
假设你正在重构一个用户中心服务,目标是将UserMapper.selectById的查询响应时间从120ms降到15ms以内。以下是严格按顺序执行的7步操作,每步都附带参数选择依据:
Step 1:确认MyBatis版本与依赖兼容性
- 检查
pom.xml:mybatis-spring必须≥2.0.0(支持JSR-107),spring-framework≥5.0.0; - 若用Spring Boot 2.1+,直接引入
spring-boot-starter-cache会冲突,必须排除它:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-cache</artifactId> <exclusions> <exclusion> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> </exclusion> </exclusions> </dependency>理由:Spring Cache是另一套抽象,和MyBatis Cache并存会引发双重代理、缓存不一致等问题。
Step 2:准备ehcache.xml并放置到classpath
- 文件路径:
src/main/resources/ehcache.xml; - 根据业务数据特征计算初始参数:
user_info缓存区:日活用户50万,其中20%为活跃用户(10万),平均每人每天查3次 → 日查询量30万;- 按80/20法则,20%的用户贡献80%的查询 → 热点用户约2万,日查询24万;
- 单次查询结果平均大小1KB → 热点数据总内存需求 = 2万 × 1KB ≈ 20MB;
- 因此
<heap>设为5000 entries(留冗余),<offheap>设为128MB(应对突发流量); - TTL设为2小时:用户资料变更不频繁,但需保证最终一致性。
Step 3:编写EhcacheAdapter并注册到Spring
- 类路径:
com.example.cache.EhcacheAdapter; - 在Spring配置类中声明Bean(注意:必须用
@Primary,否则MyBatis找不到):
@Configuration public class CacheConfig { @Bean @Primary public Cache cache() { return new EhcacheAdapter("com.example.mapper.UserMapper"); } }提示:
@Primary是关键!MyBatis通过Cache接口类型注入,若存在多个实现,必须指定主Bean,否则启动报NoUniqueBeanDefinitionException。
Step 4:在UserMapper.xml中启用二级缓存
- 在
<mapper>根节点下添加:
<cache type="com.example.cache.EhcacheAdapter" eviction="LRU" flushInterval="600000" size="1000" readOnly="true"/>参数详解:
type:指向适配器全限定名;eviction:此处是MyBatis的淘汰策略(仅当未配置Ehcache时生效),我们已由Ehcache接管,可忽略;flushInterval="600000":每10分钟自动清空一次缓存,这是防缓存击穿的兜底策略(即使DB数据被手动修改,10分钟内也会同步);size="1000":MyBatis层面的大小限制,必须≤Ehcache配置的heap值,否则报错;readOnly="true":表示缓存对象不被修改(MyBatis返回的是新对象),可提升性能;若设为false,MyBatis会克隆对象,增加GC压力。
Step 5:Mapper接口方法添加@Options注解(可选但推荐)
- 在
UserMapper.java中:
@Select("SELECT * FROM user WHERE id = #{id}") @Options(useCache = true, flushCache = false) User selectById(@Param("id") Long id);useCache = true:显式开启缓存(默认true,但写明更清晰);flushCache = false:查询不触发清空(默认false);- 对应的更新方法必须设为
flushCache = true:
@Update("UPDATE user SET name = #{name} WHERE id = #{id}") @Options(flushCache = true) int updateName(@Param("name") String name, @Param("id") Long id);这样,每次updateName执行后,MyBatis会自动调用EhcacheAdapter.clear(),保证缓存与DB一致。
Step 6:启动应用并验证JMX监控
- 启动参数加:
-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=9999; - 用JConsole连接,展开
net.sf.ehcache节点,查看user_info缓存的CacheHits、CacheMisses、AverageGetTime; - 初始状态
CacheHits=0,执行几次selectById后,CacheHits应增长,AverageGetTime稳定在100μs内。
Step 7:压测对比与参数调优
- 工具:JMeter模拟1000并发,持续5分钟;
- 对比指标:
场景 平均响应时间 DB CPU 缓存命中率 未启用缓存 128ms 89% 0% Ehcache启用 14.2ms 32% 92.7% - 发现问题:
sys_dict缓存命中率仅65%,查日志发现大量CacheMiss; - 原因:字典表有2000+条,但
ehcache.xml中sys_dict的heap只设了500 entries; - 调整:
<heap unit="entries">5000</heap>,重启后命中率升至98.3%。
4.2 关键参数调优指南:不是越大越好,而是恰到好处
缓存配置不是填数字游戏,每个参数背后都有资源代价。以下是我们在3个项目中总结的黄金比例:
| 参数 | 推荐值 | 为什么这么设 | 调错后果 |
|---|---|---|---|
heap unit="entries" | 热点数据量 × 1.5 | 留50%冗余应对突发流量,避免频繁淘汰 | 设太小:命中率暴跌,频繁GC;设太大:内存浪费,OOM风险 |
offheap unit="MB" | heap内存的2~3倍 | 堆外内存不参与GC,但需堆内元数据管理,比例失衡会拖慢访问 | 设太小:堆外空间不足,退化为纯堆内;设太大:系统内存吃紧 |
ttl unit="hours" | 数据变更周期 ÷ 3 | 保证最终一致性,如配置表每天更新,则TTL=8小时 | 设太短:频繁重建缓存,DB压力不减;设太长:数据陈旧 |
flushInterval | 10~30分钟 | 折中“实时性”与“DB压力”,比TTL短,确保强兜底 | 设太短:频繁清空,缓存形同虚设;设太长:脏数据窗口过大 |
特别提醒flushInterval的隐藏价值:它不仅是兜底,更是缓存预热的触发器。我们在flushInterval到期前1秒,异步加载下一轮热点数据到缓存,实现“无缝续杯”。代码片段:
// 在CacheManager初始化后启动定时任务 ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() -> { try { // 加载用户画像TOP1000 List<User> hotUsers = userMapper.selectHotUsers(1000); hotUsers.forEach(u -> cache.put(u.getId().hashCode(), u)); } catch (Exception e) { log.error("Preload failed", e); } }, 0, 10, TimeUnit.MINUTES);4.3 生产环境必备的监控与告警配置
没有监控的缓存等于埋雷。我们强制要求以下4项必须接入:
JMX基础指标采集(Prometheus + JMX Exporter)
- 监控项:
CacheHits,CacheMisses,CacheGets,AverageGetTime,OnHeapSize,OffHeapSize; - 告警规则:
CacheHitRate < 85%(连续5分钟)→ 触发缓存失效排查;AverageGetTime > 500000(500μs)→ 触发堆外内存不足告警。
- 监控项:
MyBatis执行日志增强
- 在
logback-spring.xml中开启:
- 在
<logger name="org.apache.ibatis.cache.decorators.LoggingCache" level="DEBUG"/>- 日志样例:
Cache Hit Ratio [com.example.mapper.UserMapper]: 0.923,直接暴露命中率。
缓存穿透防护(布隆过滤器前置)
- 对
selectById这类根据ID查询的接口,在DAO层加一层布隆过滤器:if (!bloomFilter.mightContain(id)) { return null; // ID肯定不存在,不查缓存也不查DB } - 布隆过滤器用Redis实现(内存小、速度快),每日凌晨全量重建。
- 对
缓存雪崩熔断(Hystrix集成)
- 当
CacheMisses突增300%(相比昨日同期),自动切换为“直连DB”模式,持续5分钟; - 代码用
@HystrixCommand(fallbackMethod = "fallbackSelectById")实现。
- 当
实操心得:别信“缓存命中率100%”的幻觉。我们线上系统稳定在92%~95%,低于90%才告警。因为总有新用户、新查询条件、缓存预热间隙,100%意味着配置僵化或监控失真。
5. 常见问题与排查技巧实录
5.1 典型问题速查表(附根因与解决方案)
| 问题现象 | 可能根因 | 排查命令/日志 | 解决方案 |
|---|---|---|---|
应用启动报ClassNotFoundException: javax.cache.Cache | cache-apijar未引入,或版本与Ehcache不匹配 | mvn dependency:tree | grep cache | 检查cache-api版本是否为1.1.1,Ehcache是否为3.10+ |
缓存始终不命中,CacheHitRatio恒为0 | Mapper XML中<cache>标签位置错误(不在<mapper>根节点下);或useCache=false | 查看MyBatis日志,搜索Cache Hit Ratio | 确保<cache>是<mapper>的直接子节点,且@Options(useCache=true) |
| 更新数据后,缓存未清除,查到旧值 | flushCache=true未配置在update语句上;或EhcacheAdapter.clear()未调用 | 开启LoggingCache日志,观察clear是否执行 | 在所有@Update/@Delete方法上显式加@Options(flushCache=true) |
| JConsole看不到Ehcache MBean | JVM未开启JMX远程,或ehcache.xml中enable-management="false" | jps -l查进程ID,jstack <pid>看JMX端口 | 启动参数加-Dcom.sun.management.jmxremote.port=9999,XML中设enable-management="true" |
| 堆内存飙升,Full GC频繁 | heap单位误设为MB(应为entries),导致存入超量对象 | jstat -gc <pid>看OU(老年代使用率) | 检查ehcache.xml,<heap unit="entries">必须是数字,不是内存大小 |
5.2 一次真实的线上事故复盘:缓存雪崩是如何发生的?
时间:某周五晚20:15
现象:用户中心接口P99响应时间从200ms飙升至3.2s,DB CPU 100%,告警群炸锅。
排查过程:
- 第一时间
jstack <pid>,发现大量线程阻塞在EhcacheAdapter.getObject; - 查JMX,
user_info的OnHeapSize为0,OffHeapSize为128MB,但CacheMisses每秒2000+; - 翻
ehcache.xml,发现flushInterval="60000"(1分钟)——这是测试环境配置,误发到生产! - 根本原因:每分钟强制清空缓存,而此时恰逢晚高峰,1000并发涌进来,全部打到DB,瞬间击穿。
解决方案:
- 立即
curl -X POST http://localhost:8080/actuator/refresh(Spring Boot Actuator)动态刷新配置,将flushInterval改为600000; - 临时扩容DB连接池,从50→200;
- 事后在CI流程中加入
ehcache.xml语法检查,禁止flushInterval < 300000的配置合并。
教训:flushInterval不是越短越安全,而是要匹配业务节奏。我们后来规定:生产环境flushInterval必须≥10分钟,且需经过压测验证。
5.3 高级避坑技巧:那些文档里不会写的细节
技巧1:用
CacheKey.update()代替new CacheKey()
MyBatis的CacheKey构造耗时(需计算所有参数hash),而update()可复用对象。在自定义Executor中:CacheKey key = new CacheKey(); key.update(mapperId); key.update(param); // 比 new CacheKey(mapperId, param) 快40%技巧2:
readOnly="true"时,返回对象必须不可变
如果User类有setName()方法,MyBatis不会克隆它,外部修改会影响缓存。解决方案:- 用Lombok
@Value生成不可变对象; - 或在
EhcacheAdapter.getObject()中返回new User(user)深拷贝(牺牲性能换安全)。
- 用Lombok
技巧3:集群环境下,用
RmiCacheManager替代默认CacheManager
默认CacheManager是单机的,集群需同步。Ehcache 3.x提供RmiCacheManager:Configuration config = ConfigurationBuilder.newConfigurationBuilder() .withClusteredStore("cluster", ClusteredStoreConfigurationBuilder .newClusteredStoreConfigurationBuilder() .withServerList("192.168.1.10:9527,192.168.1.11:9527"));技巧4:
@SelectProvider动态SQL的缓存陷阱@SelectProvider生成的SQL字符串包含时间戳、随机数等,导致key永远不同。解决方案:- 在Provider方法里,用
System.currentTimeMillis()/60000(分钟级)替代秒级时间戳; - 或用
@Select("SELECT * FROM user WHERE status = #{status} /* #cacheKey# */"),在SQL注释中写死key标识。
- 在Provider方法里,用
最后分享一个小技巧:在
EhcacheAdapter.clear()里加一行日志log.info("Cache cleared for {}", id),然后用ELK收集。当某天发现sys_dict每秒被清空10次,就知道是哪个定时任务在疯狂刷缓存——这比看代码快10倍。
我在实际使用中发现,缓存不是“开了就灵”的银弹,