MyBatis 缓存体系剖析
一、MyBatis 缓存体系基础:先搞懂这两个核心概念
1.1 一级缓存:SqlSession 内的"临时缓存"
作用范围:
- 仅限于当前 SqlSession 实例
- 不同 SqlSession 之间的缓存相互隔离
- 示例:用户 A 和用户 B 同时使用不同 SqlSession 查询相同数据,会各自维护独立的一级缓存
生命周期:
- 随 SqlSession 的创建而初始化
- 随 SqlSession 的 close()、commit() 或 rollback() 操作而清空
- 典型场景:在事务中多次查询相同数据时,只有第一次会访问数据库
触发逻辑:
- MyBatis 默认开启一级缓存,无需额外配置
- 执行流程:
- 执行相同 SQL 查询(相同的 SQL 语句 + 相同的参数)
- 第一次查询从数据库获取数据并写入一级缓存
- 后续查询直接从缓存中读取,避免重复数据库交互
核心原理:
- 底层数据结构:SqlSession 内部维护的 HashMap
- 缓存键(Key)组成:
- MappedStatement 的 ID(Mapper 接口方法的全限定名)
- SQL 语句
- 参数值
- 分页信息(RowBounds)
- 环境 ID(Environment ID)
- 缓存值(Value):查询结果对象
注意事项:
- 执行 insert/update/delete 操作会自动清空当前 SqlSession 的一级缓存
- 可以调用 clearCache() 方法手动清空一级缓存
1.2 二级缓存:跨 SqlSession 的"共享缓存"
作用范围:
- 跨 SqlSession 共享
- 属于 Mapper 接口级别的缓存(同一个 Mapper.xml 或 @Mapper 接口下的所有方法共享)
- 示例:不同用户通过不同 SqlSession 访问相同 Mapper 方法时,可以共享缓存数据
生命周期:
- 随 SqlSessionFactory 的创建而初始化
- 默认会一直存在,除非:
- 手动清空(调用 clearCache())
- 配置了过期策略(如 LRU 淘汰)
- 执行了对应的修改操作(insert/update/delete)
开启条件:
- 全局配置中开启二级缓存(MyBatis 3.4.0+ 默认开启):
<settings><setting name="cacheEnabled" value="true"/> </settings>
- 在具体 Mapper 中启用:
- XML 方式:在 Mapper.xml 中添加
<cache/>
标签 - 注解方式:在 @Mapper 接口上添加 @CacheNamespace 注解
- XML 方式:在 Mapper.xml 中添加
核心原理:
- 底层实现:Cache 接口的实现类(默认是 PerpetualCache,本质也是 HashMap)
- 管理方式:由 Mapper 接口对应的 Configuration 对象管理
- 数据同步流程:
- SqlSession 提交时,会将当前 SqlSession 的一级缓存数据同步到二级缓存
- 其他 SqlSession 查询相同数据时:
- 先检查二级缓存
- 不存在则检查一级缓存
- 最后才访问数据库
缓存策略配置:
<cacheeviction="FIFO" <!-- 淘汰策略:FIFO/LRU/SOFT/WEAK -->flushInterval="60000" <!-- 刷新间隔(毫秒) -->size="512" <!-- 缓存对象数量 -->readOnly="true" <!-- 是否只读 -->
/>
注意事项:
- 实体类必须实现 Serializable 接口
- 查询结果越大,二级缓存占用的内存越多
- 分布式环境下需要考虑缓存一致性问题
- 对于频繁修改的数据,建议关闭二级缓存
二、一级缓存的 “隐形坑”:90% 开发者都踩过的问题
2.1 问题 1:多线程共享 SqlSession 导致的数据不一致
场景复现与详细分析
在传统 Web 项目中,常见错误做法是在 DAO 层使用如下代码模式:
public class UserDao {private static SqlSession sqlSession = MybatisUtil.getSqlSession(); // 错误示例:静态变量持有SqlSessionpublic User getById(int id) {return sqlSession.selectOne("userMapper.getById", id);}
}
当多个HTTP请求同时访问时(如用户A访问个人中心页面,用户B同时修改个人资料),线程A通过getById
查询到的可能仍是修改前的旧数据,因为:
- 线程B执行update操作后,数据库实际数据已变更
- 但线程A的SqlSession一级缓存未感知这个变更
- 线程A后续相同查询继续返回缓存结果
问题根源的深层解析
MyBatis架构设计中:
- SqlSession相当于JDBC的Connection+Statement组合体
- 一级缓存本质是PerpetualCache实例(HashMap实现)
- 缓存键由MapperId+SQL+参数值等要素构成
线程安全问题具体表现为:
- HashMap本身非线程安全,并发读写可能导致死循环
- 即使使用ConcurrentHashMap,业务逻辑上也不应共享缓存视图
- 事务隔离级别无法作用在同一SqlSession的不同线程上
解决方案的完整实践
方案一:Spring集成最佳实践(推荐)
<!-- spring-mybatis配置 -->
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"><property name="dataSource" ref="dataSource"/>
</bean><bean class="org.mybatis.spring.SqlSessionTemplate"><constructor-arg ref="sqlSessionFactory"/><!-- 关键配置:每个请求新建SqlSession --><constructor-arg value="SIMPLE"/>
</bean>
方案二:手动管理场景示例
try (SqlSession session = sqlSessionFactory.openSession()) {UserMapper mapper = session.getMapper(UserMapper.class);User user = mapper.getById(1);// 执行更新后必须清空缓存mapper.updateName(1, "newname");session.clearCache(); // 关键操作// 再次查询将获取最新数据User updatedUser = mapper.getById(1);
}
2.2 问题 2:事务内多次查询的 "脏读" 风险
典型业务场景示例
电商订单处理流程:
@Transactional
public void processOrder(int orderId) {// 第一次查询订单状态Order order = orderMapper.selectById(orderId); // 状态为"未支付"// 业务逻辑处理...orderMapper.updateStatus(orderId, "已支付");// 第二次查询(错误期望获取"已支付"状态)Order currentOrder = orderMapper.selectById(orderId); // 实际可能仍返回"未支付"状态!
}
缓存机制原理图
事务开始│├─ SELECT操作 → 存入一级缓存│├─ UPDATE操作 → 仅标记脏数据(不立即清空)│└─ 再次SELECT → 优先读取缓存(可能脏数据)
事务提交 → 清空关联缓存
解决方案对比
方案 | 实现方式 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|
手动清缓存 | sqlSession.clearCache() | 精确控制 | 需修改多处代码 | 关键业务逻辑 |
flushCache | @Options(flushCache=true) | 声明式配置 | 性能损耗 | 强一致性要求 |
分离查询 | 将查询移出事务 | 彻底避免 | 架构改动大 | 读写分离架构 |
2.3 问题 3:分页查询的缓存 "失效" 假象
缓存键生成机制详解
MyBatis缓存键组成要素:
- Mapper方法全限定名
- 分页参数(offset+limit)
- 参数对象hashCode
- 环境ID(environment)
示例缓存键对比:
第一次查询(page=1):
UserMapper.getByPage::1:10:0:12345:development第二次查询(page=2):
UserMapper.getByPage::10:10:0:12345:development
分页插件协作原理
PageHelper执行流程:
- 拦截Executor.query()方法
- 解析ThreadLocal中的分页参数
- 改写SQL添加LIMIT子句
- 参数最终成为缓存键组成部分
二级缓存配置示例
<cache eviction="LRU" flushInterval="60000"size="512" readOnly="true"/><select id="getByPage" resultType="User" useCache="true">select * from users
</select>
性能优化建议
热点数据分页缓存策略:
- 第一页缓存时间较短(如1分钟)
- 后续页缓存时间较长(如10分钟)
缓存更新策略:
@CacheEvict(value="userPageCache", allEntries=true) public void updateUser(User user) {userMapper.update(user); }
特殊场景处理:
- 实时性要求高的场景:添加
flushCache=true
- 海量数据分页:考虑使用游标分页代替传统分页
- 实时性要求高的场景:添加
三、二级缓存的 “深水区”:数据一致性与性能的平衡难题
3.1 问题 1:多表关联查询的缓存脏数据
详细场景复现
假设我们有一个电商系统,包含用户和订单两个核心业务模块:
1.UserMapper 接口:
public interface UserMapper {@CacheNamespace // 开启二级缓存User getUserWithOrders(@Param("userId") Long userId);
}
2.OrderMapper 接口:
public interface OrderMapper {int updateOrderStatus(@Param("orderId") Long orderId, @Param("status") String status);
}
具体执行流程:
- 用户A访问个人中心页面,调用
getUserWithOrders(1)
,查询结果包含:- 用户信息:ID=1,name="张三"
- 订单信息:[{orderId=101, status="待支付"}]
- 该结果被缓存到UserMapper的二级缓存中
- 用户B(商家)通过后台系统调用
updateOrderStatus(101, "已发货")
成功更新订单状态 - OrderMapper的更新操作触发了自身二级缓存的清空
- 用户A刷新个人中心页面,再次调用
getUserWithOrders(1)
,仍然从UserMapper的缓存中读取到旧的订单状态"待支付"
问题深入分析
MyBatis二级缓存的实现机制:
- 每个Mapper拥有独立的缓存空间
- 缓存键的生成规则:Mapper方法签名 + 参数值
- 更新操作会清空当前Mapper的缓存空间
- 关联查询的结果会完整缓存在发起查询的Mapper空间内
扩展解决方案
方案1优化:共享缓存空间的最佳实践
<!-- UserMapper.xml -->
<cache-ref namespace="com.example.mapper.CommonCache"/><!-- OrderMapper.xml -->
<cache-ref namespace="com.example.mapper.CommonCache"/><!-- CommonCache.xml -->
<cache type="org.mybatis.caches.ehcache.EhcacheCache"><property name="timeToLiveSeconds" value="3600"/>
</cache>
注意事项:
- 建议创建专门的CommonCache作为共享空间
- 使用第三方缓存实现(如Ehcache)比内置缓存更稳定
- 需要评估共享缓存对性能的影响
方案2扩展:精确缓存清理的实现示例
public class CacheUtil {private final SqlSessionFactory sqlSessionFactory;public void clearMapperCache(String mapperClassName) {Cache cache = sqlSessionFactory.getConfiguration().getCache(mapperClassName);if (cache != null) {cache.clear();}}// 在OrderService中调用public void updateOrder(Order order) {orderMapper.update(order);cacheUtil.clearMapperCache("com.example.mapper.UserMapper");}
}
方案3补充:Redis缓存设计建议
- 缓存键设计:
user:orders:{userId}
- 缓存更新策略:
@Transactional
public void updateOrder(Order order) {// 更新数据库orderMapper.update(order);// 删除关联缓存redisTemplate.delete("user:orders:" + order.getUserId());
}
3.2 问题 2:缓存穿透(Cache Penetration)
典型攻击场景
- 恶意请求示例:
/user/get?id=-1
/user/get?id=999999999
(不存在的ID)
- 攻击特征:
- 高频请求(1000+ QPS)
- 参数明显不合法
- 每次请求都会直达数据库
解决方案实现细节
方案1完整配置
<select id="getUserById" resultType="User" cacheNulls="true">SELECT * FROM user WHERE id = #{id}
</select>
缓存效果:
- 查询id=1(存在):缓存User对象
- 查询id=-1(不存在):缓存NULL值
- 缓存时间与其他数据一致
方案3的布隆过滤器实现
public class UserBloomFilter {private final BloomFilter<Long> filter;public UserBloomFilter() {this.filter = BloomFilter.create(Funnels.longFunnel(), 1000000, // 预期数据量0.01 // 误判率);}public void init(List<Long> userIds) {userIds.forEach(filter::put);}public boolean mightContain(Long userId) {return filter.mightContain(userId);}
}// 在查询前校验
public User getUser(Long id) {if (!bloomFilter.mightContain(id)) {return null;}return userMapper.getUserById(id);
}
3.3 问题 3:缓存雪崩(Cache Avalanche)
真实案例
某电商平台在每日凌晨2点出现服务不可用,经排查发现:
- 200+个Mapper配置了相同的1小时缓存
- 促销活动期间QPS达到5000+
- 缓存同时失效导致数据库连接池耗尽
解决方案实施指南
方案1:时间随机化实现
public class RandomCacheDecorator implements Cache {private final Cache delegate;private final int randomRange; // 随机范围(分钟)public RandomCacheDecorator(Cache delegate, int randomRange) {this.delegate = delegate;this.randomRange = randomRange;}@Overridepublic void putObject(Object key, Object value) {// 原始过期时间60分钟 + 随机0-10分钟int randomOffset = new Random().nextInt(randomRange);int ttl = 60 + randomOffset; // 实际实现需要根据具体缓存组件调整delegate.putObject(key, value, ttl);}
}
方案2:多级缓存架构
请求流程:
1. 先查询MyBatis一级缓存(SqlSession级别)
2. 未命中则查询MyBatis二级缓存(Mapper级别)
3. 仍未命中则查询Redis集群
4. 最后才访问数据库
各级缓存配置建议:
- 一级缓存:默认开启,生命周期同SqlSession
- 二级缓存:存活时间5-10分钟
- Redis缓存:存活时间30分钟+随机偏移
3.4 问题 4:序列化异常(SerializedException)
典型错误堆栈
Caused by: org.apache.ibatis.cache.CacheException: Error serializing objectat org.apache.ibatis.cache.decorators.SerializedCache.serialize(SerializedCache.java:98)at org.apache.ibatis.cache.decorators.SerializedCache.putObject(SerializedCache.java:58)
深度解决方案
方案1的完整实现示例
public class User implements Serializable {private static final long serialVersionUID = 1L;private Long id;private String name;private transient String password; // 敏感字段不序列化// 嵌套对象也需要序列化private List<Address> addresses; // 自定义序列化逻辑private void writeObject(java.io.ObjectOutputStream out)throws IOException {out.defaultWriteObject();// 自定义处理...}
}
方案2的缓存配置详解
<cache type="org.mybatis.caches.ehcache.EhcacheCache"><!-- 使用LRU淘汰策略 --><property name="memoryStoreEvictionPolicy" value="LRU"/><!-- 最大缓存对象数 --><property name="maxEntriesLocalHeap" value="1000"/><!-- 不启用磁盘存储 --><property name="overflowToDisk" value="false"/>
</cache>
注意事项:
- 非序列化缓存不能跨JVM共享
- 分布式环境必须使用支持序列化的缓存方案
- 实体类变更时需要维护serialVersionUID
四、MyBatis 缓存问题排查:实用工具与方法论
4.1 开启 MyBatis 缓存日志
通过日志查看 MyBatis 缓存的命中、写入、清空过程,是定位缓存相关问题的核心手段。在日志配置文件中添加以下内容:
Logback 配置示例
<!-- 配置MyBatis缓存相关日志级别 -->
<logger name="org.apache.ibatis.cache" level="DEBUG"/>
<logger name="org.apache.ibatis.executor.CachingExecutor" level="DEBUG"/>
<logger name="org.apache.ibatis.executor.BaseExecutor" level="DEBUG"/>
log4j2 配置示例
<Loggers><Logger name="org.apache.ibatis.cache" level="DEBUG"/><Logger name="org.apache.ibatis.executor.CachingExecutor" level="DEBUG"/><Logger name="org.apache.ibatis.executor.BaseExecutor" level="DEBUG"/>
</Loggers>
配置后,日志中会输出以下关键信息:
缓存命中信息:
Cache Hit Ratio [com.example.mapper.UserMapper]: 0.5
:显示指定Mapper的缓存命中率(50%)Cache Hit: select * from user where id = ?
:表示查询命中缓存
缓存未命中信息:
Cache Miss: select * from user where id = ?
:表示未命中缓存,将执行数据库查询Cache Put: select * from user where id = ?
:将查询结果存入缓存
缓存清理信息:
Cache Clear: com.example.mapper.UserMapper
:表示该Mapper的缓存被清空Cache Flush: com.example.mapper.UserMapper
:表示该Mapper的缓存被刷新
4.2 缓存命中率监控
缓存命中率是衡量缓存有效性的核心指标。理想情况下,二级缓存命中率应保持在70%以上。若命中率过低(如低于30%),说明缓存策略需要优化。
监控方式1:自定义Cache实现类
创建自定义缓存类,扩展MyBatis的PerpetualCache,加入命中率统计功能:
public class MonitorCache extends PerpetualCache {private final AtomicLong hitCount = new AtomicLong(0);private final AtomicLong requestCount = new AtomicLong(0);public MonitorCache(String id) {super(id);}@Overridepublic Object getObject(Object key) {requestCount.incrementAndGet();Object value = super.getObject(key);if (value != null) {hitCount.incrementAndGet();}return value;}// 获取当前命中率public double getHitRatio() {return requestCount.get() == 0 ? 0 : (double) hitCount.get() / requestCount.get();}// 获取总请求次数public long getRequestCount() {return requestCount.get();}// 获取命中次数public long getHitCount() {return hitCount.get();}
}
在MyBatis配置中启用自定义缓存:
<cache type="com.yourpackage.MonitorCache"/>
监控方式2:使用Spring Boot Actuator
- 添加依赖:
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency><groupId>io.micrometer</groupId><artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
- 配置application.yml:
management:endpoints:web:exposure:include: health,metrics,prometheusmetrics:export:prometheus:enabled: true
- 通过以下端点获取缓存指标:
/actuator/metrics/cache.gets
:缓存获取次数/actuator/metrics/cache.hits
:缓存命中次数/actuator/metrics/cache.miss
:缓存未命中次数
监控方式3:集成Prometheus+Grafana
- 配置Prometheus抓取应用指标:
scrape_configs:- job_name: 'spring-actuator'metrics_path: '/actuator/prometheus'static_configs:- targets: ['localhost:8080']
在Grafana中创建仪表盘,监控以下关键指标:
- 缓存命中率:
cache_hits / cache_gets * 100
- 缓存未命中率:
cache_miss / cache_gets * 100
- 缓存大小变化趋势
- 缓存命中率:
设置告警规则,当命中率低于设定阈值(如30%)时触发告警
五、MyBatis 缓存最佳实践:避坑指南与性能优化
5.1 一级缓存最佳实践
SqlSession 线程私有规范
在 Spring 环境中,应当完全依赖 SqlSessionTemplate
自动管理 SqlSession 生命周期。开发者不应手动创建或长期持有 SqlSession 实例,因为:
- Spring 的
SqlSessionTemplate
会为每个事务/方法调用创建新的 SqlSession - 手动创建的 SqlSession 可能导致线程安全问题
- 未正确关闭的 SqlSession 会造成数据库连接泄漏
事务内缓存复用策略
当事务内部包含更新操作时,需要特别注意一级缓存的同步问题:
- 在更新操作(INSERT/UPDATE/DELETE)后调用
SqlSession#clearCache()
方法 - 特别是当事务中包含"先查询后更新"的逻辑时,必须清理缓存
- 示例场景:
@Transactional
public void updateProductPrice(Long productId, BigDecimal newPrice) {// 查询操作会填充一级缓存Product product = productMapper.selectById(productId);// 更新操作product.setPrice(newPrice);productMapper.update(product);// 必须清空缓存,确保后续查询获取最新数据sqlSession.clearCache();
}
分页查询缓存处理
MyBatis 的一级缓存会基于完整的 SQL 语句和参数生成缓存键,这意味着:
LIMIT 10 OFFSET 0
和LIMIT 10 OFFSET 10
会被视为不同的查询- 不需要特别处理分页查询的缓存问题
- 应当避免试图"复用"不同分页的查询结果
5.2 二级缓存最佳实践
适用场景分析
二级缓存最适合以下业务场景:
- 系统配置表(如 sys_config)
- 数据字典表(如 sys_dict)
- 地区编码表(如 region_code)
- 其他变更频率低(每天更新≤3次)的基础数据表
多表关联处理方案
当涉及多表关联查询时,二级缓存的处理策略:
- 首选方案:避免使用二级缓存,改用业务层缓存
- 次选方案:通过
<cache-ref>
让关联的 Mapper 共享同一缓存空间<!-- OrderMapper.xml --> <cache-ref namespace="com.example.mapper.UserMapper"/>
- 备选方案:为关联查询设置较短的
flushInterval
(如60秒)
精细化缓存配置
二级缓存的配置参数需要根据业务特点精心调整:
参数 | 推荐值 | 适用场景 | 注意事项 |
---|---|---|---|
eviction | LRU | 绝大多数场景 | 比FIFO更能提高命中率 |
flushInterval | 3600000 | 配置类数据 | 单位毫秒,0表示永不过期 |
size | 1000 | 数据量中等 | 监控内存使用情况 |
readOnly | true | 只读数据 | 提升性能但无法更新 |
大字段处理建议
对于包含大字段(如文章内容、产品详情)的查询:
- 在
<select>
中设置useCache="false"
- 或者配置单独的 DTO 只包含必要字段
- 或者使用延迟加载策略
5.3 分布式缓存整合方案
5.3.1 Redis 集成详细实现
依赖配置详解
<!-- 使用新版本以获取更好的性能和特性支持 -->
<dependency><groupId>org.mybatis.caches</groupId><artifactId>mybatis-redis</artifactId><version>1.0.0-beta2</version>
</dependency>
<dependency><groupId>redis.clients</groupId><artifactId>jedis</artifactId><version>4.3.1</version>
</dependency>
Redis 高级配置
# redis.properties
redis.host=${REDIS_HOST:127.0.0.1}
redis.port=${REDIS_PORT:6379}
redis.password=${REDIS_PASSWORD:}
redis.database=${REDIS_DB:0}
redis.timeout=2000
redis.maxTotal=100
redis.maxIdle=20
redis.minIdle=5
redis.testOnBorrow=true
缓存策略优化
<cache type="org.mybatis.caches.redis.RedisCache"><!-- 使用更高效的序列化方案 --><property name="serializer" value="org.mybatis.caches.redis.serialize.JacksonRedisSerializer"/><!-- 动态过期时间 --><property name="defaultExpiration" value="1800"/><!-- 开启缓存预热 --><property name="preload" value="true"/>
</cache>
5.3.2 其他分布式方案
- Memcached 方案:
<dependency><groupId>org.mybatis.caches</groupId><artifactId>mybatis-memcached</artifactId><version>1.1.0</version> </dependency>
- Caffeine 本地缓存(适合混合架构):
<dependency><groupId>org.mybatis.caches</groupId><artifactId>mybatis-caffeine</artifactId><version>1.1.0</version> </dependency>
5.4 缓存监控与调优
监控指标
- 命中率监控:
Cache cache = sqlSessionFactory.getConfiguration().getCache("com.example.mapper.UserMapper"); if (cache instanceof PerpetualCache) {int hits = ((PerpetualCache) cache).getHitCount();int misses = ((PerpetualCache) cache).getMissCount();double hitRatio = (double)hits/(hits+misses); }
- 内存占用监控(通过JMX)
调优策略
- 当命中率<30%时,考虑:
- 缩小缓存范围
- 缩短过期时间
- 完全禁用缓存
- 当命中率>70%但响应仍慢时,考虑:
- 增加缓存容量
- 优化序列化方案
- 升级缓存服务器
5.5 特殊场景处理
批量操作缓存处理
@Transactional
public void batchUpdate(List<Product> products) {// 批量操作前清空缓存sqlSession.clearCache();for (Product product : products) {productMapper.update(product);}// 操作后再次清空确保一致性sqlSession.clearCache();
}
多级缓存方案
对于极高并发的系统,可以采用:
- 一级缓存(Session级)
- 二级缓存(Redis集群)
- 本地缓存(Caffeine) 通过
@Cacheable
注解实现多级缓存联动。