导读
本文详细分析了MyBatis中“一级缓存”在实际项目中如何产生脏数据,并并给出了具体的实施建议,本文适合对MyBatis有1年以上使用经验的开发者阅读,对MyBatis小白不适合。
另外,本文无意引发口水之争,请就技术谈技术!MyBatis盲目迷信、不愿看清事实的技术拳民请出门左转
如果将localCacheScope设为SESSION(默认值),通过SqlSession(或Mapper)查询得到的返回值都会被缓存在一级缓存中,这样就带来了一个风险:如果程序对这些返回值所引用的对象进行修改——实际上就是修改了一级缓存里的对象(关于对象与引用的关系请参考《疯狂Java讲义》第5章),这样就会影响整个SqlSession生命周期内通过缓存所返回的值,从而造成了一级缓存的脏数据。因此,永远不要对MyBatis返回的对象进行修改,这样才能避免一级缓存产生脏数据。
注意
永远不要对MyBatis返回到对象进行修改。
为了避免一级缓存产生脏数据,MyBatis还进行了另一个预防:SqlSession执行DML语句时会自动flush缓存,这样可以初步避免一级缓存中的脏数据。
假设MyBatis执行DML语句时不会自动flush缓存,让我们“推演”一下会发生什么?
(1)sqlSession执行select语句加载id为1的News对象。
(2)sqlSession执行update语句更新id为1的News对象——注意此时id为1的News对象应该已经发生了改变。
(3)当sqlSession想再次获取id为1的News对象时,如果第2步没有flush缓存,MyBatis将直接返回缓存中id为1的News对象,缓存中id为1的News对象依然是修改之前的脏数据——与数据表中实际记录不一致的数据就是脏数据。
正是由于SqlSession执行DML语句都会flush缓存,因此上面第3步sqlSession想再次获取id为1的News对象时,MyBatis会让它重新查询数据,这样就避免了脏数据。
但请记住:一级缓存的生命周期与SqlSession一致,这意味着一级缓存不可能跨SqlSession产生作用。实际应用通常存在多个并发线程使用不同SqlSession访问数据:一条线程的SqlSession读取数据,一条线程的SqlSession修改数据,这样就可能在一级缓存中产生脏数据。
如下示例示范了一级缓存产生脏数据的场景,本实例的数据脚本非常简单,下面是本例的数据库脚本。
create database mybatis;
use mybatis;
-- 创建数据表
create table news_inf
(
news_id integer primary key auto_increment,
news_title varchar(255),
news_content varchar(255)
);
insert into news_inf
values (null, '李刚的公众号', '大家可关注李刚老师的公众号:fkbooks');
insert into news_inf
values (null, 'Java 13来了', 'Java 13新增了块字符串,用起来更爽了');
本示例的NewsMapper组件,它只是定义了getNews和updateNews两个方法。下面是NewsMapper组件的XML映射文件。
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.crazyit.app.dao.NewsMapper">
<!-- 为getNews方法定义SQL语句 -->
<select id="getNews" resultType="news">
select news_id id, news_title title,
news_content content from news_inf
where news_id=#{id}
</select>
<!-- 为updateNews方法定义SQL语句 -->
<update id="updateNews">
update news_inf
set news_title=#{arg0}
where news_id=#{arg1}
</update>
</mapper>
接下来使用如下测试程序来模拟多线成并发,从而看到并发状态下一级缓存的脏数据。
public class NewsManager
{
private static SqlSessionFactory sqlSessionFactory;
public static void main(String[] args) throws Exception
{
String resource = "mybatis-config.xml";
// 使用Resources工具从类加载路径下加载指定文件
InputStream inputStream = Resources.getResourceAsStream(resource);
sqlSessionFactory = new SqlSessionFactoryBuilder()
.build(inputStream);
new Thread(NewsManager::update).start(); // ①
cacheTest(); // ②
}
public static void cacheTest()
{
SqlSession sqlSession = sqlSessionFactory.openSession();
// 获取Mapper对象
NewsMapper newsMapper = sqlSession.getMapper(NewsMapper.class);
// 查询id为1的News对象,会执行select语句
News news = newsMapper.getNews(1);
System.out.println(news.getTitle());
try
{
// 线程暂停10ms,是为了模拟让另外一条线程在此处获得CPU的情形
Thread.sleep(10);
}
catch (Exception ex){}
News news2 = newsMapper.getNews(1);
System.out.println(news2.getTitle());
// 提交事务
sqlSession.commit();
// 关闭资源
sqlSession.close();
}
public static void update()
{
SqlSession sqlSession = sqlSessionFactory.openSession();
// 获取Mapper对象
NewsMapper newsMapper = sqlSession.getMapper(NewsMapper.class);
// 更新id为1的News对象
newsMapper.updateNews("测试", 1);
// 提交事务
sqlSession.commit();
// 关闭资源
sqlSession.close();
}
}
上面程序中①号代码执行了update()方法——但不是直接调用,而是以多线程并发的方式调用。程序中②号粗体字代码正常调用了cacheTest()方法,那么该方法将处于主线程内。
为了模拟实际项目中多线程并发的一种如下场景:
(1)A线程内的SqlSession先获取id为1的News对象。
(2)CPU切换给B线程,B线程内的SqlSession修改id为1的News对象。
(3)A线程内的SqlSession再次获取id为1的News对象。
上面程序在cacheTest()方法中增加了一条Thread.sleep(10)的代码,这行代码是为了让线程调度在此处切换。
如果发生上面所示的场景,A线程内的SqlSession第二次获取id为1的News对象时,MyBatis不应该使用缓存——因为B线程已经更新了底层数据表中的数据,A线程内SqlSession内缓存的News对象是脏数据。
但实际呢?实际上A线程内的SqlSession依然会使用缓存中id为1的News对象——记住:MyBatis的一级缓存的最大范围是SqlSession内部,因此它不可能跨SqlSession执行缓存flush。
实际运行看看吧,控制台生成如下日志输出:
[java] DEBUG [main] org.crazyit.app.dao.NewsMapper.getNews ==> Preparing: select news_id id, news_title title, news_content content from news_inf where news_id=?
[java] DEBUG [main] org.crazyit.app.dao.NewsMapper.getNews ==> Parameters: 1(Integer)
[java] 李刚的公众号
[java] DEBUG [Thread-0] org.crazyit.app.dao.NewsMapper.updateNews ==> Preparing: update news_inf set news_title=? where news_id=?
[java] DEBUG [Thread-0] org.crazyit.app.dao.NewsMapper.updateNews ==> Parameters: 测试(String), 1(Integer)
[java] DEBUG [Thread-0] org.crazyit.app.dao.NewsMapper.updateNews <== Updates: 1
[java] DEBUG [main] org.crazyit.app.dao.NewsMapper.getNews <== Total: 1
[java] 李刚的公众号
从上面运行日志可以清楚地看到:A线程(此处用main线程模拟)第二次读取id为1的News对象时,MyBatis并未重新读取数据表中最新的数据,而是依然使用缓存中的id为1的News对象——但请记住:此时B线程(此处用Thread-0线程模拟)已经修改了数据表中id为1的记录,这意味着A线程第二次读取的id为1的News对象是脏数据。
现在问题来了:如何避免MyBatis一级缓存产生这种脏数据呢?MyBatis的一级缓存可以关闭吗?
首先需要说明的是,本书已经多次强调:尽量缩短SqlSession的生命周期!其中一个理由就是为了避免SqlSession一级缓存产生脏数据。
为了避免MyBatis一级缓存产生这种脏数据,最佳实践有两个要点:
先说说第一种实践方式适合的场景:对于数据实时性要求没那么高(允许有一定的脏数据)的应用,只要项目避免使用长生命周期的SqlSession,即使MyBatis的一级缓存产生了脏数据,但由于SqlSession的生命周期特别短暂,这种脏数据也许处于可控范围之内。
再说说第二种实践方式适合的场景:对于数据实时性要求非常高的引用,项目基本不允许使用脏数据,此时就应该避免使用MyBatis的一级缓存!但请记住:MyBatis并不允许关闭一级缓存,因为它需要一级缓存来处理循环引用等问题。
为了避免使用MyBatis一级缓存,程序有两种方式:
总之,MyBatis一级缓存是一个“食之无味,弃之可惜”的机制,如果你打算使用它,就要接受它可能产生脏数据的风险,而且无法避免。通常建议避免在数据实时性要求较高的应用中使用MyBatis的一级缓存。
注意
避免在数据实时性要求较高的应用中使用MyBatis的一级缓存。
扫码关注腾讯云开发者
领取腾讯云代金券
Copyright © 2013 - 2025 Tencent Cloud. All Rights Reserved. 腾讯云 版权所有
深圳市腾讯计算机系统有限公司 ICP备案/许可证号:粤B2-20090059 深公网安备号 44030502008569
腾讯云计算(北京)有限责任公司 京ICP证150476号 | 京ICP备11018762号 | 京公网安备号11010802020287
Copyright © 2013 - 2025 Tencent Cloud.
All Rights Reserved. 腾讯云 版权所有