思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。
作者:毅航😜
在专栏文章Mybatis流程分析(三):构建SqlSession实现Mybatis的会话管理中我们对SqlSession
进行了详细的分析,不了解的SqlSession
相关知识的可翻看往期文章~~
事实上,如果你有留心看过Mybatis
中有关SqlSession
实现类的的代码的话,你会看到在DefaultSqlSession
类的注释中有这样一段话,即 DefaultSqlSession this class is not Thread-Safe
。
换句话说,在Mybatis
内部对于SqlSession
的默认实现DefaultSqlSession
而言,其在使用过程中存在线程安全问题的 。今天我们便来对DefaultSqlSession
线程不安全的原因进行一次深入的探究,希望文章能有助于你理解Mybatis
中DefaultSqlSession
的理解。
前言
当在Java
中谈论线程安全时,通常指的是 "多线程环境下的代码或数据结构是否能够在并发执行时保持正确的行为,而不会导致不确定的结果或数据损坏" 。同时,线程安全还是多线程编程中的一个关键概念,其核心目的是确保在多个线程同时访问共享资源时,不会出现竞态条件、数据不一致或其他并发问题。
为了实现线程安全的目标,通常可使用Synchronized、final
等关键字的来进一步确保资源状态的一致性,或者通过并发类
容器来确保数据在并发操作时的有序性,从而避免因竞态条件的发生而导致数据的不一致性。
虽然Java
中提供了很多行之有效的方式来保证多线程环境下数据访问的一致性, 但是如果你有深入了解过DefaultSqlSession
的话,你会发现在DefaultSqlSession
内部其实并没使用任何行之有效的手段来确保数据资源访问的一致性,因此其才会在类上给出 DefaultSqlSession this class is not Thread-Safe
的提示。
注:当然确保线程安全也不一定非要使用
JUC
所提供的工具类,完全可以自己实现,只不过比较繁琐而已~
说了这么多,你可能会觉得,你说线程不安就不安全吗?怎么我使用的时候怎么没发现Mybatis
有线程不安全的问题?为了消除你心中的疑惑,我们来通过下面这个例子来直观的感受下Mybatis
中SqlSession
线程不安全性。
复现Mybatis
的线程不安全问题
为了更深刻的理解Mybatis
中SqlSession
的线程不安全性,我们将通过入一下这段简单的代码来快速模拟Mybatis
中SqlSession
的线程不安全性,并在此基础上再深入剖析导致SqlSession
线程不安全的原因。
但在开始之前我们有必要对用到的数据库表进行一个简单的介绍,其大致内容如下:
可以看到测试
所用到的数据表也非常简单,其中主要会包括user_name、age、id
三个字段。且在初始时表中一共有三条
数据。我们的测试
代码如下所示:
java
public void test() throws IOException {
// 省略配置文件读取细节......
// 线程1
Thread thread1 = new Thread(() -> {
// 线程1中尝试向数据库插入一条数据
userMapper.insert("Yi_hang");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
// 查询所有
List<User> users = userMapper.selectUsers();
log.info("Thread1 User size is [{}] " , users.size());
});
// 线程2
Thread thread2 = new Thread(() -> {
// 线程2中尝试执行数据库操作
List<User> users = userMapper.selectUsers();
log.info("Thread2 User size is [{}] " , users.size());
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
}
在上述实例代码中,我们分别启动了两个线程线程1
与线程2
。其中线程1
的内部主要会完成两件操作,即会首先向数据库和中插入一条数据,然后休眠1s
后再进行查询。而线程2
则会直接查询数据库。 执行上述代码后,得到的日志如下所示:
log
UserMapper.insert - ==> Preparing: insert into t_user values(?,1,1)
UserMapper.selectUsers - ==> Parameters
UserMapper.insert - ==> Parameters: Yi_hang(String)
UserMapper.insert - <== Updates:1
UserMapper.selectUsers - <== Total:3
MybatisTest - Thread2 User size is [3]
MybatisTest - Thread1 User size is [3]
不难发现,由于我们的初始数据有3条
,因此对于线程2
输出的User size is [3]
,这是完全没问题的。与此同时我们也注意到,对于线程1
而言,其内部的数据插入insert
语句已经执行成功了,但是查询用户数据时其返回的结果依旧为3
。
对于线程1
打印出Thread1 User size is [3]
这样合理吗?当然不合理!
因为通过分析打印的日志
可以发现,对于Mybatis
内部其实其已经将相关的数据进行了插入,并且也已经插入成功。但即使是在数据插入成功的这一大前提下,线程1
在查询数据时,其查询到的却是未插入数据之前的总量信息。
那究竟是什么导致这一问题的发生呢?带着这一的问题我们进入到Mybatis
源码内部进行剖析。
剖析Mybatis
的线程不安全性
在之前的文章我们曾分析过在Mybatis
内部中执行sql
的任务通常会交给Executor
来进行处理,具体来说,所有的执行器都会继承自BaseExecutor
这个父类。而在BaseExecutor
中,有关查询的逻辑则主要委托于query
方法来进行执行。这部分源码如下:
java
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler)
throws SQLException {
BoundSql boundSql = ms.getBoundSql(parameter);
CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
@SuppressWarnings("unchecked")
@Override
public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler,
CacheKey key, BoundSql boundSql) throws SQLException {
// 省略无关代码......
List<E> list;
try {
queryStack++;
// <1> 尝试从缓存中获取信息
list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
if (list != null) {
handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
} else {
// <2> 当缓存中数据不存在时尝试查询数据库
list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
}
}
// 省略无关代码......
return list;
}
不难发现,其实在query
方法中的两个核心方法即queryFromDatabase
和localCache.getObject(key)
明确了待分析重点后,我们首先先来分析 queryFromDatabase
的内部逻辑。其大致逻辑如下:
java
private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
List<E> list;
// 存入站位符信息 (此处主要为了解决Mybatis)内部循环依赖的问题
localCache.putObject(key, EXECUTION_PLACEHOLDER);
try {
list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
} finally {
localCache.removeObject(key);
}
// 数据放入缓存
localCache.putObject(key, list);
return list;
}
不难发现,在queryFromDatabase
内部主要完成了如下两步操作:
- 执行
sql
语句完成对数据库的查询。 - 将查询到的数据返回,同时保留一份存入
localCache
中,方便下次使用。
而这里的localCache
其实就是就是我们常谈及到的一级缓存
,通常Mybatis
内部会使用PerpetualCache
来进行缓存。而PerpetualCache
的内部也很简单,就是通过一个HashMap
来将相关的结果进行缓存处理。谈及到HashMap
的结构,其本身就是key-value
的形式。
具体到此处,我们知道其中的value
即为我们从数据中的查询的数据信息,而key
的确定则是通过 createCacheKey(ms, parameter, rowBounds, boundSql);
来确定的。通过如参信息,不难发现对于Mybatis
中缓存key
的确定,主要受如下几点的影响:
- 同一个
SqlSession
会话 Sql
语句相同,参数相同- 相同的
StatementID
RowBounds
相同 (默认会相同)
注:这便是一级缓存在构造
key
的关键影响因素
明白了queryFromDatabase
中的逻辑,再回看 query
中的localCache.getObject(key)
方法,是不是也就明白了其get
方法所取内容的设定时机,即 每当sql
第一次执行时,Mybatis
内部会将相关的执行结果存入到一个Map
中,以备下次使用。其中key 通过 上述的 4条
因素影响,value则是sql
语句本次执行的结果。
明白了sqlSession
内部执行sql
时缓存的原理后,我们再来看之前测试
代码查询出错的原因:
- 首先,对于
线程2
而言,其使用select * from t_user
进行查询。由于数据中最开始只有3
条数据,因此其查询出3
条数据。同时Mybatis
内部会对其执行结果进行缓存。 - 进一步,对于
线程1
而言,其首先会首先插入一条数据,然后再去查询数据总量。当其执行查询操作时,由于其与线程2
查询sql
一致、且共用一个sqlSession
所以其在执行createCacheKey(ms, parameter, rowBounds, boundSql)
计算得到key
与线程2
查询时构建的缓存Key
一致,所以其在查询时并不会去数据库中查询,而是直接取出缓存中的内容[3]
, 这样就导致了线程2
的缓存,进而也就导致了线程1
查询结果的不正确。
明确了问题发生的原因后,那有没解决之道呢?当然是有的!可以通过sqlsession
的clearCache()
方法,来每次执行sql
完毕后清理掉缓存的内容,这样就可以避免缓存所引起的线程安全问题!
总结
其实对于Mybatis
一级缓存所引起的线程安全问题,可以说是面试八股文
经常提到的一个点。笔者
也相信当你在面试中遇到这类问题时,你可以脱口而出答案。但是如果我继续追问,你是如何断定一级缓存所导致这个问题的呢?面对这一问题阁下又该如何应对呢?
注:
笔者
并不反对面试背八股文,请理性看待问题~
最后,如果觉得文章对你理解Mybatis
有所帮助的话,不妨点赞+收藏。当然如果觉得笔者
文章还可以不妨加个关注~~~