MyBatis一级二级缓存:不了解它会造成的数据不一致问题
2026/4/30大约 8 分钟
MyBatis一级二级缓存:不了解它会造成的数据不一致问题
适读人群:Java后端开发、对MyBatis底层机制感兴趣的工程师 | 阅读时长:约20分钟
开篇故事
2020年,我们的运营系统出了一个诡异的bug:在同一个HTTP请求内,先修改了商品价格,然后立即查询,查到的还是旧价格。
代码看起来完全没问题,事务也是对的,就是查出来的数据不对。
最后发现:MyBatis的一级缓存在作怪。
在同一个SqlSession内,第一次查询的结果被缓存了,后续相同的查询直接返回缓存,不去数据库查。即使中间有其他代码修改了数据,缓存也不会自动刷新。
更糟糕的是:如果你用了Spring + MyBatis,不了解SqlSession的生命周期,一级缓存的行为和你预期的完全不同。
今天把MyBatis的一级缓存和二级缓存彻底讲清楚,以及在分布式环境下不应该用二级缓存的原因。
一、MyBatis缓存体系概览
MyBatis缓存层次:
一级缓存(LocalCache):
- 作用域:SqlSession级别
- 默认:开启,不可关闭(只能降级为STATEMENT级别)
- 失效条件:sqlSession.close(), commit(), rollback(), 手动clearCache()
- 同一个SqlSession内相同查询直接返回缓存
二级缓存(SecondLevelCache):
- 作用域:Mapper(namespace)级别,跨SqlSession
- 默认:关闭,需要手动开启
- 失效条件:该namespace下有增删改操作时清空所有缓存
- 数据在sqlSession.close()后才放入二级缓存二、底层原理:一级缓存的工作机制
2.1 一级缓存的缓存Key
MyBatis用五个元素组成缓存Key:
// CacheKey的组成
CacheKey cacheKey = new CacheKey();
cacheKey.update(ms.getId()); // Mapper方法的全限定名
cacheKey.update(rowBounds.getOffset()); // 分页偏移
cacheKey.update(rowBounds.getLimit()); // 分页限制
cacheKey.update(boundSql.getSql()); // SQL语句
cacheKey.update(parameterObject); // 参数值
// 如果是环境: cacheKey.update(configuration.getEnvironment().getId());
// 只有五个元素完全相同,才算是同一个缓存命中2.2 Spring与MyBatis整合后一级缓存的行为变化
这是最容易踩坑的地方:
Spring事务模式下:
每个@Transactional方法共用一个SqlSession
方法结束(事务提交/回滚)后,SqlSession关闭,一级缓存清空
↓ 影响:在同一个事务方法内,相同查询使用一级缓存(正常行为)
Spring非事务模式下(大多数查询的实际情况):
每次执行Mapper方法,MyBatis-Spring都会:
1. 从Spring管理的连接池获取连接
2. 创建一个新的SqlSession
3. 执行SQL
4. 关闭SqlSession(立即关闭!)
↓ 影响:每次查询都是新的SqlSession,一级缓存从未命中!也就是说,Spring环境下,非事务方法的一级缓存实际上是无效的。但在同一个事务内,一级缓存正常工作,这就是开篇bug的根源。
三、完整解决方案与代码
3.1 复现开篇的Bug
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
// 问题场景:同一个事务内,修改后的查询读到了缓存
@Transactional
public void updateAndQuery(Long productId) {
// 第一次查询:查数据库,结果存入一级缓存
// cache key = {selectById, 0, Integer.MAX_VALUE, SELECT..., productId}
Product p1 = productMapper.selectById(productId);
System.out.println("第一次查询: price=" + p1.getPrice()); // 99.00
// 通过JDBC直接更新(不通过MyBatis,不会清除MyBatis的一级缓存!)
jdbcTemplate.update("UPDATE product SET price = 199.00 WHERE id = ?", productId);
// 第二次查询:同一个SqlSession,相同参数 → 命中一级缓存!
Product p2 = productMapper.selectById(productId);
System.out.println("第二次查询: price=" + p2.getPrice()); // 还是99.00!旧数据!
// 如果通过MyBatis自己的update,会自动清除一级缓存
// productMapper.updatePrice(productId, 199.00);
// 上面这种方式不会有问题
}
}3.2 验证一级缓存的命中与失效
@SpringBootTest
public class MyBatisCacheTest {
@Autowired
private SqlSessionFactory sqlSessionFactory;
@Autowired
private ProductMapper productMapper;
@Test
public void testFirstLevelCache() {
// 手动创建SqlSession(不通过Spring管理)
try (SqlSession session = sqlSessionFactory.openSession()) {
ProductMapper mapper = session.getMapper(ProductMapper.class);
long start1 = System.currentTimeMillis();
Product p1 = mapper.selectById(1L);
long time1 = System.currentTimeMillis() - start1;
System.out.println("第一次查询: " + time1 + "ms"); // 约50ms(查数据库)
long start2 = System.currentTimeMillis();
Product p2 = mapper.selectById(1L);
long time2 = System.currentTimeMillis() - start2;
System.out.println("第二次查询: " + time2 + "ms"); // 约0ms(命中缓存)
System.out.println("p1 == p2: " + (p1 == p2)); // true!同一个对象!
// 清空缓存
session.clearCache();
long start3 = System.currentTimeMillis();
Product p3 = mapper.selectById(1L);
long time3 = System.currentTimeMillis() - start3;
System.out.println("清空后查询: " + time3 + "ms"); // 约50ms(再次查数据库)
System.out.println("p2 == p3: " + (p2 == p3)); // false!不同对象
}
}
@Test
public void testFirstLevelCacheInvalidation() {
try (SqlSession session = sqlSessionFactory.openSession()) {
ProductMapper mapper = session.getMapper(ProductMapper.class);
Product p1 = mapper.selectById(1L);
System.out.println("修改前: " + p1.getPrice()); // 99.00
// 通过MyBatis执行更新(自动清除一级缓存)
mapper.updatePrice(1L, new BigDecimal("199.00"));
// update操作后,该namespace的所有一级缓存都被清空
Product p2 = mapper.selectById(1L);
System.out.println("修改后: " + p2.getPrice()); // 199.00(正确!)
}
}
}3.3 二级缓存的风险:分布式环境下不应使用
<!-- 开启二级缓存(ProductMapper.xml)-->
<mapper namespace="com.example.mapper.ProductMapper">
<cache
eviction="LRU" <!-- 淘汰策略:最近最少使用 -->
flushInterval="60000" <!-- 每60秒自动清空 -->
size="512" <!-- 最多缓存512个对象 -->
readOnly="true"/> <!-- 只读缓存(返回缓存对象的引用,不是副本) -->
<!-- 此mapper中的所有查询都会使用二级缓存 -->
</mapper>二级缓存在分布式环境下的问题:
服务A(实例1) 服务A(实例2)
┌─────────────────┐ ┌─────────────────┐
│ 二级缓存(JVM内)│ │ 二级缓存(JVM内)│
│ product:1 → 99 │ │ product:1 → 99 │
└─────────────────┘ └─────────────────┘
↓
用户在实例1上将price从99改为199
→ 实例1的二级缓存清空(该namespace的所有缓存)
→ 实例2的二级缓存没有清空!还是99!
→ 其他用户通过实例2查询,得到旧数据!解决方案:在分布式应用中,禁用MyBatis二级缓存,改用Redis统一缓存。
// 正确的缓存方案:使用Spring Cache + Redis
@Service
public class ProductService {
@Autowired
private ProductMapper productMapper;
// 查询时使用Redis缓存(所有实例共享)
@Cacheable(value = "product", key = "#id")
public Product getProduct(Long id) {
return productMapper.selectById(id);
}
// 更新时删除Redis缓存(所有实例的缓存都会失效)
@CacheEvict(value = "product", key = "#product.id")
@Transactional
public void updateProduct(Product product) {
productMapper.update(product);
}
}3.4 一级缓存的正确使用姿势
// 场景:在同一个事务内多次查询同一个对象
// 利用一级缓存避免重复查询数据库
@Transactional
public OrderVO getOrderDetail(Long orderId) {
// 第一次查询订单(查数据库)
Order order = orderMapper.selectById(orderId);
// 查用户信息(查数据库)
User user = userMapper.selectById(order.getUserId());
// 查订单明细(查数据库)
List<OrderItem> items = orderItemMapper.selectByOrderId(orderId);
// 组装VO(这里可能再次查订单,命中一级缓存,不查数据库)
OrderVO vo = buildVO(order, user, items);
return vo;
}
// 注意:只有在@Transactional方法内,一级缓存才能在多次方法调用间生效
// 非事务场景下,每次调用Mapper都是新的SqlSession,一级缓存无效四、踩坑实录
坑1:修改了缓存对象,影响了一级缓存中存储的数据
// 一级缓存存储的是对象引用(不是副本!)
@Transactional
public void bugExample(Long productId) {
Product p1 = productMapper.selectById(productId);
// 直接修改p1的属性(不通过数据库)
p1.setPrice(BigDecimal.ZERO); // ← 危险!修改了缓存中的对象
// 后续查询命中缓存,返回同一个对象引用
Product p2 = productMapper.selectById(productId);
System.out.println(p2.getPrice()); // 0!被p1的修改影响了
}解决方案:
// 方案1:对查询结果做深拷贝(成本高)
Product p1 = BeanUtils.copyProperties(productMapper.selectById(productId), Product.class);
// 方案2:设置readOnly=false(缓存返回副本,不返回引用)
// 在mybatis配置中:<cache readOnly="false"/>
// 方案3(最推荐):不在业务代码中直接修改从数据库查出来的对象,
// 而是创建新对象进行修改
Product updateObj = new Product();
updateObj.setId(productId);
updateObj.setPrice(BigDecimal.ZERO);
productMapper.update(updateObj); // 通过update清除缓存坑2:开启了二级缓存,数据延迟30秒
配置:<cache flushInterval="30000"/> ← 30秒刷新一次
现象:
10:00:00 - 运营修改了商品价格,从99改为199
10:00:05 - 用户查商品,看到的还是99(二级缓存中的旧数据)
10:00:35 - 缓存过期后,用户才看到199
业务反馈:价格修改后30秒内的订单,是按旧价格还是新价格?
→ 数据不一致,引发财务问题解决方案:
<!-- 不要在读多写多的场景使用二级缓存 -->
<!-- 要么彻底禁用二级缓存 -->
<settings>
<setting name="cacheEnabled" value="false"/>
</settings>
<!-- 要么针对特定Mapper禁用 -->
<mapper namespace="com.example.mapper.ProductMapper">
<cache-ref namespace="com.example.mapper.DontCacheThisMapper"/>
<!-- 或者直接不写<cache>标签 -->
</mapper>坑3:同一Mapper中有多个查询,一个update清空了所有查询缓存
<!-- ProductMapper的二级缓存 -->
<cache/>
<!-- 这些SELECT都共享同一个二级缓存 -->
<select id="selectById">...</select>
<select id="selectByName">...</select>
<select id="selectByCategory">...</select>
<!-- 任何一个UPDATE操作都会清空整个namespace的所有缓存! -->
<update id="updatePrice">...</update>这意味着:频繁的更新操作会导致二级缓存不断被清空,缓存命中率极低,反而增加了内存GC压力。
这是二级缓存设计上的根本限制,只适合极少修改的数据(如字典表、配置表)。
五、总结与延伸
MyBatis缓存的使用准则:
一级缓存:
- 默认开启,在Spring环境下只有在同一个事务(@Transactional)内的相同查询才会命中
- 非事务查询每次都是新SqlSession,一级缓存无效
- 不要直接修改从缓存返回的对象(浅引用问题)
二级缓存:
- 默认关闭,不要在分布式应用中开启(多实例数据不同步)
- 只适合极少修改的全局配置数据(字典表、系统参数)
- 即使在单机环境,频繁更新的表也不适合二级缓存
生产推荐:
# 完全禁用MyBatis缓存
mybatis:
configuration:
cache-enabled: false # 禁用二级缓存
local-cache-scope: statement # 一级缓存降级为statement级别(每次查询结束清空)用Redis作为统一的缓存层,所有服务实例共享,无数据不一致问题。
