1 Mybatis原理
MyBatis
是目前非常流行的ORM
框架,它的功能很强大,然而其实现却比较简单、优雅。本文主要讲述MyBatis
的架构设计思路,并且讨论MyBatis
的几个核心部件,然后结合一个select
查询实例,深入代码,来探究MyBatis
的实现
Mybatis中文说明文档
1.1 不使用mybatis的原生态jdbc
package com.sxt;
import java.sql.*;
public class JdbcDemo {
public static void main(String[] args) throws Exception {
Connection connection = null;
PreparedStatement preparedStatement = null;
ResultSet resultSet = null;
try {
//1、加载数据库驱动
Class.forName("com.mysql.jdbc.Driver");
//2、通过驱动管理类获取数据库链接
connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mybatis?characterEncoding=utf-8", "root", "root");
//3、定义sql语句 ?表示占位符
String sql = "select * from tb_user where username = ?";
//4、通过连接获取声明statement
preparedStatement = connection.prepareStatement(sql);
//5、设置参数,第一个参数为sql语句中参数的序号(从1开始),第二个参数为设置的参数值
preparedStatement.setString(1, "王五");
//6、向数据库发出sql执行查询,查询出结果集
resultSet = preparedStatement.executeQuery();
//7、遍历查询结果集
while (resultSet.next()) {
System.out.println(resultSet.getString("id") + "" + resultSet.getString("username"));
}
} catch (Exception e) {
e.printStackTrace();
} finally {
//8、释放资源
if (resultSet != null) {
try {
resultSet.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if (preparedStatement != null) {
try {
preparedStatement.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
}
1.2 mybatis核心
Mybatis
里面的核心对象还是比较多,如下
Mybatis核心对象 | 解释 |
---|---|
Configuration | MyBatis所有的配置信息都维持在Configuration对象之中 |
SqlSessionFactory | SqlSession工厂专门创建SqlSession |
SqlSession | 作为MyBatis工作的主要顶层API,表示和数据库交互的会话,完成必要数据库增删改查功能 |
Executor | MyBatis执行器,是MyBatis 调度的核心,负责SQL语句的生成和查询缓存的维护 |
StatementHandler | 封装了JDBC Statement操作,负责对JDBC statement 的操作,如设置参数、将Statement结果集转换成List集合 |
ParameterHandler | 负责对用户传递的参数转换成JDBC Statement 所需要的参数 |
ResultSetHandler | 负责将JDBC返回的ResultSet结果集对象转换成List类型的集合 |
TypeHandler | 负责java数据类型和jdbc数据类型之间的映射和转换 |
MappedStatement | MappedStatement维护了一条mapper.xml文件里面 select 、update、delete、insert节点的封装 |
SqlSource | 负责根据用户传递的parameterObject,动态地生成SQL语句,将信息封装到BoundSql对象中,并返回 |
BoundSql | 表示动态生成的SQL语句以及相应的参数信息 |
1.3 原理图
mybatis
应用程序通过SqlSessionFactoryBuilder
从mybatis-config.xml
配置文件中构建出SqlSessionFactory
,然后,SqlSessionFactory
的实例直接开启一个SqlSession
,再通过SqlSession
实例获得Mapper对象
并运行Mapper
映射的SQL语句
,完成对数据库的CRUD
和事务提交,之后关闭SqlSession
。如下图所示:
大致流程原理图:
Mybatis
执行原理
看下类关系图示
2 Mybatis缓存
2.1 一级缓存
一级缓存Mybatis
的一级缓存是指SQLSession
,一级缓存的作用域是SQLSession, Mabits默认开启一级缓存
。
在同一个SqlSession
中,执行相同的SQL
查询时;第一次会去查询数据库,并写在缓存中,第二次会直接从缓存中取。
当执行SQL
时候两次查询中间发生了增删改
的操作,则SQLSession
的缓存会被清空。
每次查询会先去缓存中找,如果找不到,再去数据库查询,然后把结果写到缓存中。
Mybatis
的内部缓存使用一个HashMap
,key
为hashcode+statementId+sql
语句。Value
为查询出来的结果集映射成的java对象
SqlSession
执行insert
、update
、delete
等操作commit
后会清空该SQLSession
缓存
一级缓存只是相对于同一个SqlSession
而言。所以在参数和SQL
完全一样的情况下,我们使用同一个SqlSession
对象调用一个Mapper
方法,往往只执行一次SQL
,因为使用SelSession
第一次查询后,MyBatis
会将其放在缓存中,以后再查询的时候,如果没有声明需要刷新,并且缓存没有超时的情况下,SqlSession
都会取出当前缓存的数据,而不会再次发送SQL到数据库
2.1.1 一级缓存的生命周期
MyBatis
在开启一个数据库会话时,会 创建一个新的SqlSession
对象,SqlSession
对象中会有一个新的Executor
对象
Executor
对象中持有一个新的PerpetualCache
对象;当会话结束时,SqlSession
对象及其内部的Executor
对象还有PerpetualCache
对象也一并释放掉
如果SqlSession
调用了close()
方法,会释放掉一级缓存PerpetualCache
对象,一级缓存将不可用
如果SqlSession
调用了clearCache()
,会清空PerpetualCache
对象中的数据,但是该对象仍可使用
如果SqlSession
中执行了任何一个update
操作(update()、delete()、insert()
) ,都会清空PerpetualCache
对象的数据,但是该对象可以继续使用
一级缓存最多缓存 1024
条 SQL
2.1.2 怎么判断某两次查询是完全相同的查询
mybatis
认为,对于两次查询,如果以下条件都完全一样,那么就认为它们是完全相同的两次查询:
- 传入的
statementId
- 查询时要求的结果集中的结果范围
- 这次查询所产生的最终要传递给
JDBC java.sql.Preparedstatement的Sql
语句字符串(boundSql.getSql() ) - 传递给
java.sql.Statement
要设置的参数值
2.1.3 Springboot集成时一级缓存不生效问题
因为一级缓存是会话
级别的,要生效的话,必须要在同一个 SqlSession
中。但是与 springboot
集成的 mybatis
,默认每次执行 sql
语句时,都会创建一个新的 SqlSession
,所以一级缓存才没有生效。
当调用 mapper
的方法时,最终会执行到 SqlSessionUtils
的 getSqlSession
方法,在这个方法中会尝试在事务管理器中获取 SqlSession
,如果没有开启事务,那么就会 new 一个 DefaultSqlSession
所以可以猜测只要将方法开启事务,那么一级缓存就会生效,加上 @Transactional
注解
那么为什么加了@Transactional
注解就可以了呢,看源码解析:
看看源码中是什么时候将 SqlSession
设置到事务管理器中的。
SqlSessionUtils
中,在获取到 SqlSession
后,会调用 registerSessionHolder
方法注册 SessionHolder
到事务管理器:
具体是在 TransactionSynchronizationManager
的 bindResource
方法中操作的,将 SessionHolder
保存到线程本地变量(ThreadLocal) resources
中,这是每个线程独享的。
然后在下次查询时,就可以从这里取出此 SqlSession
,使用同一个 SqlSession
查询,一级缓存就生效了。
所以基本原理就是:如果当前线程存在事物,并且存在相关会话,就从 ThreadLocal
中取出。如果没有事务,就重新创建一个 SqlSession
并存储到 ThreadLocal
当中,共下次查询使用。
至于缓存查询数据的地方,是在 BaseExecutor
中的 queryFromDatabase
方法中。执行 doQuery
从数据库中查询数据后,会立马缓存到 localCache(PerpetualCache类型)
中:
2.2 二级缓存
2.2.1 基础
二级缓存是mapper级别
的,Mybatis
默认是没有开启二级缓存的,需要在setting全局参数中配置开启二级缓存
第一次调用mapper
下的SQL
去查询用户的信息,查询到的信息会存放到该mapper
对应的二级缓存区域。 第二次调用namespace
下的mapper
映射文件中,相同的sql去查询用户信息,会去对应的二级缓存内取结果
二级缓存是多个SqlSession
共享的,多个SqlSession
去操作同一个Mapper
的sql
语句,其作用域是mapper
的同一个namespace
,不同的sqlSession
两次执行相同namespace
下的sql语句
且向sql中传递参数也相同即最终执行相同的sql语句,第一次执行完毕会将数据库中查询的数据写到缓存(内存),第二次会从缓存中获取数据将不再从数据库查询,从而提高查询效率
为了更加清楚的描述二级缓存,先来看一个示意图:
sqlSessionFactory
层面上的二级缓存默认是不开启的,二级缓存的开启需要进行配置,实现二级缓存的时候,MyBatis要求返回的POJO必须是可序列化的。 也就是要求实现Serializable接口,配置方法很简单,只需要在映射XML文件配置就可以开启缓存了
,如果配置了二级缓存就意味着:
- 映射语句文件中的所有select语句将会被缓存
- 映射语句文件中的所有insert、update和delete语句会刷新缓存
- 缓存会使用默认的Least Recently Used(LRU,最近最少使用的)算法来收回
- 根据时间表,比如No Flush Interval,(CNFI没有刷新间隔),缓存不会以任何时间顺序来刷新
- 缓存会存储列表集合或对象(无论查询方法返回什么)的1024个引用
- 缓存会被视为是read/write(可读/可写)的缓存,意味着对象检索不是共享的,而且可以安全的被调用者修改,不干扰其他调用者或线程所做的潜在修改
2.2.2 使用二级缓存
2.2.2.1 序列化
将po类
实现Serializable
接口
需要将要缓存的pojo
实现Serializable
接口,为了将缓存数据取出执行反序列化操作,因为二级缓存数据存储介质多种多样,不一定只存在内存中,有可能存在硬盘中,如果我们要再取这个缓存的话,就需要反序列化了。所以建议mybatis
中的pojo都去实现Serializable
接口
由于二级缓存的数据不一定都是存储到内存中,它的存储介质多种多样,所以需要给缓存的对象执行序列化
2.2.2.2 配置缓存
flushInterval="60000"
size="512"
readOnly="true"/>
在需要开启的namespace
下配置cache标签
,其中标签中的属性:
-
eviction:
代表的是缓存回收策略,目前MyBatis
提供以下策略:
LRU:最近最少使用的,一处最长时间不用的对象
FIFO:先进先出,按对象进入缓存的顺序来移除他们
SOFT:软引用,移除基于垃圾回收器状态和软引用规则的对象
WEAK:弱引用,更积极的移除基于垃圾收集器状态和弱引用规则的对象。这里采用的是LRU,移除最长时间不用的对形象
-
flushInterval
:刷新间隔时间,单位为毫秒,这里配置的是100
秒刷新,如果你不配置它,那么当SQL被执行的时候才会去刷新缓存 -
size
:引用数目,一个正整数,代表缓存最多可以存储多少个对象,不宜设置过大。设置过大会导致内存溢出。这里配置的是1024个对象 -
readOnly
:只读,意味着缓存数据只能读取而不能修改,这样设置的好处是我们可以快速读取缓存,缺点是我们没有办法修改缓存,他的默认值是false
,即不允许修改
在对应的sql
语句上增加属性useCache="true"
开启缓存,或者flushCache="true"
刷新缓存
namespace
中配置缓存demo:
或者在全局配置中开启缓存开关,这样就把哪些不需要缓存的使用useCache="false",禁用缓存就可以了
2.2.2.3 SpringBoot使用二级缓存
在 yaml 中配置 cache-enabled 为 true
mybatis:
configuration:
cache-enabled: true
Mapper
接口上添加 @CacheNamespace
注解:
@CacheNamespace
是 MyBatis
框架中的注解,用于指定命名空间的缓存配置。通过该注解,可以配置该命名空间下的缓存策略,包括缓存类型、缓存大小、缓存过期时间等。
使用 @CacheNamespace
注解可以提高 MyBatis
的查询效率,避免频繁地访问数据库,提高系统的性能。同时,也可以通过该注解来控制缓存的更新策略,保证数据的一致性。
需要注意的是,@CacheNamespace
注解只能用于命名空间级别的缓存配置,不能用于单个 SQL
语句的缓存配置。如果需要对单个 SQL
语句进行缓存配置,可以使用 @Options
注解或在 SQL
语句中使用
标签来实现。
@CacheNamespace
和 @CacheNamespaceRef
区别:
-
@CacheNamespace
和@CacheNamespaceRef
都是MyBatis
中用于配置缓存的注解,但是它们的作用和使用方式略有不同。 -
@CacheNamespace
注解用于标注一个Mapper
接口的缓存配置,可以配置该Mapper
接口中所有查询语句的缓存策略。使用方式如下:
@CacheNamespace(implementation = MybatisRedisCache.class, eviction = MybatisRedisCache.class, flushInterval = 60000, size = 1024)
public interface UserMapper {
// ...
}
其中,implementation
属性指定了缓存实现类,eviction
属性指定了缓存的清除策略,flushInterval
属性指定了缓存的刷新时间间隔,size 属性指定了缓存的最大容量。
-
@CacheNamespaceRef
注解用于引用另一个Mapper
接口的缓存配置,可以将该Mapper
接口的缓存配置与另一个Mapper
接口的缓存配置共享。使用方式如下:
@CacheNamespaceRef(UserMapper.class)
public interface OrderMapper {
// ...
}
其中,value 属性指定了被引用的 Mapper 接口。
需要注意的是,@CacheNamespace
和 @CacheNamespaceRef
注解都需要与缓存实现类一起使用,用于指定缓存的具体实现。同时,这两个注解也可以同时使用,用于实现更复杂的缓存配置。
2.2.3 为什么mybatis默认不开启二级缓存
二级缓存虽然能带来一定的好处,但是有很大的隐藏危害
它的缓存是以 namespace(mapper)
为单位的,不同 namespace
下的操作互不影响。且 insert/update/delete
操作会清空所在 namespace
下的全部缓存。
那么问题就出来了,假设现在有 ItemMapper
以及 XxxMapper
,在 XxxMapper
中做了表关联查询,且做了二级缓存。此时在 ItemMapper
中将 item
信息给删了,由于不同 namespace
下的操作互不影响,XxxMapper
的二级缓存不会变,那之后再次通过 XxxMapper
查询的数据就不对了,非常危险。
来看一个例子:
@Mapper
@Repository
@CacheNamespace
public interface XxxMapper {
@Select("select i.id itemId,i.name itemName,p.amount,p.unit_price unitPrice " +
"from item i JOIN payment p on i.id = p.item_id where i.id = #{id}")
List getPaymentVO(Long id);
}
@Autowired
private XxxMapper xxxMapper;
@Test
void test() {
System.out.println("==================== 查询PaymentVO ====================");
List voList = xxxMapper.getPaymentVO(1L);
System.out.println(JSON.toJSONString(voList.get(0)));
System.out.println("==================== 更新item表的name ==================== ");
Item item = itemMapper.selectById(1);
item.setName("java并发编程");
itemMapper.updateById(item);
System.out.println("==================== 重新查询PaymentVO ==================== ");
List voList2 = xxxMapper.getPaymentVO(1L);
System.out.println(JSON.toJSONString(voList2.get(0)));
}
上面的代码,test()
方法中前后两次调用了 xxxMapper.getPaymentVO
方法,因为没有加 @Transactional
注解,所以前后两次查询,是两个不同的会话,第一次查询完后,SqlSession
会自动 commit
,所以二级缓存能够生效;
然后在中间进行了 Item
表的更新操作,修改了下名称;
由于itemMapper
与 xxxMapper
不是同一个命名空间,所以 itemMapper
执行的更新操作不会影响到 xxxMapper
的二级缓存;
再次调用 xxxMapper.getPaymentVO
,发现取出的值是走缓存的,itemName
还是老的。但实际上 itemName
在上面已经被改了
2.3 使用Ehcache
点击此处了解Ehcache原理
Mybatis
本身是一个持久层框架,它不是专门的缓存框架,所以它对缓存的实现不够好,不能支持分布式。
Ehcache
是一个分布式的缓存框架。
2.3.1 mapper.xml文件中使用
设置映射文件中cache标签的type值为ehcache的实现类
2.3.2 添加Ehcache的配置文件
在src/main/resources
下创建cache
文件夹,在文件夹下创建ehcache.xml
defaultCache
标签中属性:
-
name
:缓存名称。 -
maxElementsInMemory
:缓存最大个数。 -
eternal
:对象是否永久有效,一但设置了,timeout将不起作用。 -
timeToIdleSeconds
:设置对象在失效前的允许闲置时间(单位:秒)。仅当eternal=false
对象不是永久有效时使用,可选属性,默认值是0,也就是可闲置时间无穷大。 -
timeToLiveSeconds
:设置对象在失效前允许存活时间(单位:秒)。最大时间介于创建时间和失效时间之间。仅当eternal=false对象不是永久有效时使用,默认是0
,也就是对象存活时间无穷大。 -
overflowToDisk
:当内存中对象数量达到maxElementsInMemory
时,Ehcache
将会对象写到磁盘中。 -
diskSpoolBufferSizeMB
:这个参数设置DiskStore(磁盘缓存)的缓存区大小。默认是30MB。每个Cache都应该有自己的一个缓冲区。 -
maxElementsOnDisk
:硬盘最大缓存个数。 -
diskPersistent
:是否缓存虚拟机重启期数据 ,The default value is false. -
diskExpiryThreadIntervalSeconds
:磁盘失效线程运行时间间隔,默认是120秒 -
memoryStoreEvictionPolicy
:当达到maxElementsInMemory
限制时,Ehcache将会根据指定的策略去清理内存。默认策略是LRU
(最近最少使用)。你可以设置为FIFO
(先进先出)或是LFU(较少使用)。 -
clearOnFlush
:内存数量最大时是否清除
2.4 Mybatis的Executor执行器
Mybatis
有三种基本的Executor
执行器,SimpleExecutor、ReuseExecutor、BatchExecutor
-
SimpleExecutor
:每执行一次update
或select
,就开启一个Statement
对象,用完立刻关闭Statement
对象。 -
ReuseExecutor
:执行update
或select
,以sql
作为key
查找Statement
对象,存在就使用,不存在就创建,用完后,不关闭Statement
对象,而是放置于Map
内,供下一次使用。简言之,就是重复使用Statement
对象。 -
BatchExecutor
:执行update
(没有select
,JDBC
批处理不支持select
),将所有sql
都添加到批处理中(addBatch()
),等待统一执行(executeBatch()
),它缓存了多个Statement
对象,每个Statement
对象都是addBatch()
完毕后,等待逐一执行executeBatch()
批处理。与JDBC
批处理相同
3 Mybatis拦截器
3.1 拦截器介绍
Mybatis
拦截器设计的初衷就是为了供用户在某些时候可以实现自己的逻辑而不必去动Mybatis
固有的逻辑。减少代码侵入
通过Mybatis
拦截器我们可以拦截某些方法的调用,我们可以选择在这些被拦截的方法执行前后加上某些逻辑,也可以在执行这些被拦截的方法时执行自己的逻辑而不再执行被拦截的方法。所以Mybatis
拦截器的使用范围是非常广泛的。
Mybatis
拦截器并不是每个对象里面的方法都可以被拦截的。Mybatis拦截器只能拦截Executor、ParameterHandler、StatementHandler、ResultSetHandler
四个对象里面的方法
默认情况下,MyBatis
允许使用插件来拦截的方法调用包括:
- 拦截执行器方法:
Executor
(update, query, flushStatements, commit, rollback, getTransaction, close, isClosed) - 拦截参数的处理:
ParameterHandler
(getParameterObject, setParameters) - 拦截结果集的处理:
ResultSetHandler
(handleResultSets, handleOutputParameters) - 拦截Sql语法构建的处理:
StatementHandler
(prepare, parameterize, batch, update, query) - 不同拦截器顺序:Executor -> ParameterHandler -> StatementHandler -> ResultSetHandler
3.2 Mybatis拦截器接口
public interface Interceptor {
//代理对象每次调用的方法,就是要进行拦截的时候要执行的方法。在这个方法里面做我们自定义的逻辑处理
Object intercept(Invocation invocation) throws Throwable;
//plugin方法是拦截器用于封装目标对象的,通过该方法我们可以返回目标对象本身,也可以返回一个它的代理,
//当返回的是代理的时候我们可以对其中的方法进行拦截来调用intercept方法 -- Plugin.wrap(target, this),
//当返回的是当前对象的时候 就不会调用intercept方法,相当于当前拦截器无效
Object plugin(Object target);
//用于在Mybatis配置文件中指定一些属性的,注册当前拦截器的时候可以设置一些属性
void setProperties(Properties properties);
}
3.3 @Intercepts注解
Intercepts
注解需要一个Signature(拦截点)参数数组。通过Signature来指定拦截哪个对象里面的哪个方法
@Intercepts
注解定义如下
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface Intercepts {
/**
* 定义拦截点
* 只有符合拦截点的条件才会进入到拦截器
*/
Signature[] value();
}
Signature
来指定咱们需要拦截那个类对象的哪个方法。定义如下:
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({})
public @interface Signature {
/**
* 定义拦截的类 Executor、ParameterHandler、StatementHandler、ResultSetHandler当中的一个
*/
Class> type();
/**
* 在定义拦截类的基础之上,在定义拦截的方法
*/
String method();
/**
* 在定义拦截方法的基础之上在定义拦截的方法对应的参数,
* JAVA里面方法可能重载,不指定参数,不晓得是那个方法
*/
Class>[] args();
}
我们举一个例子来说明,比如我们自定义一个MybatisInterceptor类,来拦截Executor类里面的两个方法。自定义拦截类MybatisInterceptor
@Intercepts({
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
),
@Signature(
type = Executor.class,
method = "update",
args = {MappedStatement.class, Object.class}
)
})
public class MybatisInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// TODO: 自定义拦截逻辑
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this); // 返回代理类
}
@Override
public void setProperties(Properties properties) {
}
}
把源码知道拦截的主要是如下方法:
public List selectList(String statement, Object parameter) {
return this.selectList(statement, parameter, RowBounds.DEFAULT);
}
public List selectList(String statement, Object parameter, RowBounds rowBounds) {
try {
//1.根据Statement Id,在mybatis 配置对象Configuration中查找和配置文件相对应的MappedStatement
MappedStatement ms = configuration.getMappedStatement(statement);
//2. 将查询任务委托给MyBatis 的执行器 Executor
List result = executor.query(ms, wrapCollection(parameter), rowBounds, Executor.NO_RESULT_HANDLER);
return result;
} catch (Exception e) {
throw ExceptionFactory.wrapException("Error querying database. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}