1. 为什么需要关注MyBatis一级缓存?
当你在同一个SqlSession中连续两次执行完全相同的SQL查询时,第二次查询会直接从内存返回结果而不访问数据库。这个看似简单的机制背后,隐藏着许多开发者容易忽视的细节问题。去年我们线上系统就曾因为对一级缓存理解不透彻,导致在金融对账场景下出现了严重的数据不一致问题——系统读取到了缓存中的旧数据而非数据库最新状态,最终造成数十万元的资金差错。
一级缓存(Local Cache)是MyBatis默认开启的会话级缓存,它的生命周期与SqlSession绑定。理解它的工作原理不仅能避免踩坑,更能让我们在合适的场景主动利用它提升性能。根据我的实战经验,90%的MyBatis缓存问题都源于对一级缓存边界条件的不清晰认知。
2. 一级缓存的核心实现机制
2.1 缓存存储结构解析
MyBatis使用PerpetualCache作为一级缓存的默认实现,其内部采用简单的HashMap存储缓存数据。关键源码如下:
java复制public class PerpetualCache implements Cache {
private final String id;
private final Map<Object, Object> cache = new HashMap<>();
// 其他方法实现...
}
缓存Key的生成逻辑特别值得关注,它由以下要素构成:
- MappedStatement的id(即命名空间+方法名)
- 分页参数(RowBounds)
- SQL语句本身
- 实际参数值
- 环境ID(当配置多数据源时)
这种设计意味着即使相同的SQL,如果参数值不同也会被视为不同的缓存Key。我曾遇到过这样一个案例:某查询方法接收Map参数,但由于Map是无序集合,相同的键值对在不同调用中可能产生不同的参数顺序,导致意外缓存未命中。
2.2 缓存生命周期管理
一级缓存的生命周期与SqlSession严格绑定,这带来几个重要特性:
- 自动创建:每个新创建的SqlSession都会初始化一个全新的一级缓存
- 自动清除:执行INSERT/UPDATE/DELETE操作时会清空整个缓存
- 自动销毁:调用SqlSession.close()后缓存不可用
这里有个容易误解的点:并非所有写操作都会触发缓存清除。只有通过该SqlSession执行的写操作才会影响其缓存。如果其他会话修改了数据,当前会话的缓存不会自动失效。这就是我们之前遇到数据不一致问题的根本原因。
3. 一级缓存的典型应用场景
3.1 循环依赖查询优化
考虑这样一个场景:需要批量查询订单及其关联的商品信息。初级实现可能会在循环中多次查询相同商品:
java复制List<Order> orders = orderMapper.listByIds(orderIds);
for (Order order : orders) {
// 每次都会执行SQL查询
Product product = productMapper.selectById(order.getProductId());
order.setProduct(product);
}
利用一级缓存特性,可以优化为:
java复制try (SqlSession session = sqlSessionFactory.openSession()) {
OrderMapper orderMapper = session.getMapper(OrderMapper.class);
ProductMapper productMapper = session.getMapper(ProductMapper.class);
List<Order> orders = orderMapper.listByIds(orderIds);
for (Order order : orders) {
// 相同商品ID只会查询一次数据库
Product product = productMapper.selectById(order.getProductId());
order.setProduct(product);
}
return orders;
}
这种优化在商品重复率高的场景下(如团购订单)效果尤为明显。实测显示,当订单中商品重复率达到30%时,可减少约25%的数据库查询。
3.2 事务中的重复查询
在事务处理中,我们经常需要多次读取同一数据以确保业务一致性。例如资金转账时需要反复校验账户状态:
java复制@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accountMapper.selectById(fromId); // 第一次查询
// 业务逻辑处理...
Account current = accountMapper.selectById(fromId); // 命中缓存
if (current.getBalance().compareTo(amount) < 0) {
throw new InsufficientBalanceException();
}
// 后续操作...
}
这种场景下,一级缓存可以避免不必要的重复查询。但要注意的是,如果事务中需要绝对最新的数据(如库存扣减),就需要通过配置或编程方式绕过缓存。
4. 一级缓存的常见问题与解决方案
4.1 数据一致性问题
这是最危险的一类问题,通常表现为:
- 其他会话已更新数据,但当前会话仍读取到旧值
- 跨数据源操作时缓存未同步
- 手动执行SQL导致缓存与实际数据不一致
解决方案包括:
- 对关键业务查询添加
flushCache=true选项:xml复制<select id="selectById" resultType="Account" flushCache="true"> SELECT * FROM account WHERE id = #{id} </select> - 在需要强制刷新的方法调用前手动清除缓存:
java复制sqlSession.clearCache(); Account account = accountMapper.selectById(id); - 对于多数据源场景,建议直接关闭一级缓存:
xml复制<settings> <setting name="localCacheScope" value="STATEMENT"/> </settings>
4.2 内存泄漏风险
长时间存活的SqlSession会累积大量缓存对象。我曾排查过一个OOM案例:某个后台任务创建了SqlSession但未及时关闭,运行一周后缓存了超过50万条订单数据,最终导致Full GC频繁触发。
最佳实践包括:
- 始终使用try-with-resources确保SqlSession关闭
- 对于批处理任务,定期调用clearCache()
- 监控缓存大小(可通过自定义Cache实现)
5. 一级缓存的高级配置与监控
5.1 缓存作用域配置
MyBatis提供两个级别的缓存作用域:
- SESSION(默认):缓存对整个SqlSession有效
- STATEMENT:缓存仅对当前语句有效,相当于关闭一级缓存
配置方式:
xml复制<settings>
<setting name="localCacheScope" value="STATEMENT"/>
</settings>
5.2 自定义缓存实现
虽然不常见,但我们可以通过实现Cache接口来替换默认的PerpetualCache。例如实现一个带LRU淘汰策略的缓存:
java复制public class LruCache implements Cache {
private final Cache delegate;
private final Map<Object, Object> keyMap;
private Object eldestKey;
public LruCache(Cache delegate) {
this.delegate = delegate;
this.keyMap = new LinkedHashMap<Object, Object>(16, 0.75f, true) {
protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
boolean tooBig = size() > 1000;
if (tooBig) {
eldestKey = eldest.getKey();
}
return tooBig;
}
};
}
@Override
public void putObject(Object key, Object value) {
delegate.putObject(key, value);
cycleKeyList(key);
}
private void cycleKeyList(Object key) {
keyMap.put(key, key);
if (eldestKey != null) {
delegate.removeObject(eldestKey);
eldestKey = null;
}
}
// 其他方法实现...
}
然后在mapper.xml中配置:
xml复制<cache type="com.example.LruCache"/>
5.3 缓存命中率监控
通过自定义Cache实现可以收集缓存统计信息:
java复制public class MonitoredCache implements Cache {
private final Cache delegate;
private AtomicLong hits = new AtomicLong();
private AtomicLong requests = new AtomicLong();
// 实现所有Cache方法...
@Override
public Object getObject(Object key) {
requests.incrementAndGet();
Object value = delegate.getObject(key);
if (value != null) {
hits.incrementAndGet();
}
return value;
}
public double getHitRatio() {
return (double) hits.get() / requests.get();
}
}
在开发环境定期输出命中率日志,可以帮助识别哪些查询最受益于缓存优化。
6. 一级缓存与二级缓存的协同工作
虽然本文聚焦一级缓存,但理解它与二级缓存的关系也很重要。关键区别如下表所示:
| 特性 | 一级缓存 | 二级缓存 |
|---|---|---|
| 作用域 | SqlSession级别 | Mapper级别 |
| 默认状态 | 开启 | 需要手动配置 |
| 存储位置 | JVM堆内存 | 可配置(Redis等) |
| 生命周期 | 随SqlSession创建/销毁 | 应用生命周期 |
| 跨会话可见 | 不可见 | 所有会话共享 |
| 更新机制 | 自动清除 | 需配置刷新策略 |
实际项目中,我推荐以下组合策略:
- 保持一级缓存默认开启,用于会话内重复查询优化
- 对读多写少的配置类数据启用二级缓存
- 对关键业务数据关闭缓存或设置短过期时间
- 在集群环境中,二级缓存必须使用集中式存储如Redis
7. 实战中的经验总结
经过多个项目的实践验证,我总结了以下一级缓存的最佳实践:
-
明确会话边界:Web应用中,最佳实践是在请求开始时创建SqlSession,在请求结束时关闭。这可以通过拦截器或过滤器实现,确保不会意外持有缓存过久。
-
批处理优化:处理大批量数据时,应该:
- 每处理100-500条记录后调用
sqlSession.clearCache() - 定期提交事务(既释放连接也清空缓存)
- 考虑使用
STATEMENT级别缓存
- 每处理100-500条记录后调用
-
测试验证:编写专门的缓存测试用例,验证:
java复制@Test public void testCacheHit() { try (SqlSession session = sqlSessionFactory.openSession()) { UserMapper mapper = session.getMapper(UserMapper.class); User user1 = mapper.selectById(1L); // 查数据库 User user2 = mapper.selectById(1L); // 应命中缓存 assertThat(session.getConnection().getMetaData().getURL()) .as("第二次查询应该没有实际执行") .isNotIn("jdbc:mock:"); } } -
动态表名处理:当使用动态表名(如分表场景)时,特别注意缓存Key的生成逻辑。建议为不同表的数据使用不同的Mapper接口,避免缓存污染。
-
插件开发注意:如果开发MyBatis插件,要特别注意缓存清除的时机。例如实现审计插件时,在update操作后可能需要手动清除相关查询的缓存。
