Mybatis之原理详解,缓存和拦截器

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应用程序通过SqlSessionFactoryBuildermybatis-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的内部缓存使用一个HashMapkeyhashcode+statementId+sql语句。Value为查询出来的结果集映射成的java对象
SqlSession执行insertupdatedelete等操作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对象的数据,但是该对象可以继续使用

一级缓存最多缓存 1024SQL

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 的方法时,最终会执行到 SqlSessionUtilsgetSqlSession 方法,在这个方法中会尝试在事务管理器中获取 SqlSession,如果没有开启事务,那么就会 new 一个 DefaultSqlSession

26aa3ff74556833fdbd37ca3939a5fb1_5b0659fdaee9498f9a7064852397b599.png

所以可以猜测只要将方法开启事务,那么一级缓存就会生效,加上 @Transactional 注解

那么为什么加了@Transactional注解就可以了呢,看源码解析:
看看源码中是什么时候将 SqlSession 设置到事务管理器中的。
SqlSessionUtils 中,在获取到 SqlSession 后,会调用 registerSessionHolder 方法注册 SessionHolder 到事务管理器:

b61fa1ac87df4ac499c324d5e47aef40_72a451ca9ba84761919a850a973fd5db.png

具体是在 TransactionSynchronizationManagerbindResource 方法中操作的,将 SessionHolder 保存到线程本地变量(ThreadLocal) resources 中,这是每个线程独享的。

9c0fb60f5d5136ac9866eccaf4f40056_5d7580c8d3304559934a5c9147149157.png

然后在下次查询时,就可以从这里取出此 SqlSession,使用同一个 SqlSession 查询,一级缓存就生效了。
所以基本原理就是:如果当前线程存在事物,并且存在相关会话,就从 ThreadLocal 中取出。如果没有事务,就重新创建一个 SqlSession 并存储到 ThreadLocal 当中,共下次查询使用
至于缓存查询数据的地方,是在 BaseExecutor 中的 queryFromDatabase方法中。执行 doQuery 从数据库中查询数据后,会立马缓存到 localCache(PerpetualCache类型)中:

595a0e881ad169dd15c1a2c69fa81b67_e21e6ff05271467ea63d865a6c74fb45.png

2.2 二级缓存

2.2.1 基础

二级缓存是mapper级别的,Mybatis默认是没有开启二级缓存的,需要在setting全局参数中配置开启二级缓存
第一次调用mapper下的SQL去查询用户的信息,查询到的信息会存放到该mapper对应的二级缓存区域。 第二次调用namespace下的mapper映射文件中,相同的sql去查询用户信息,会去对应的二级缓存内取结果

二级缓存是多个SqlSession共享的,多个SqlSession去操作同一个Mappersql语句,其作用域是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 注解:
@CacheNamespaceMyBatis 框架中的注解,用于指定命名空间的缓存配置。通过该注解,可以配置该命名空间下的缓存策略,包括缓存类型、缓存大小、缓存过期时间等。
使用 @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 表的更新操作,修改了下名称;
由于itemMapperxxxMapper 不是同一个命名空间,所以 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

  1. SimpleExecutor:每执行一次updateselect,就开启一个Statement对象,用完立刻关闭Statement对象。
  2. ReuseExecutor:执行updateselect,以sql作为key查找Statement对象,存在就使用,不存在就创建,用完后,不关闭Statement对象,而是放置于Map内,供下一次使用。简言之,就是重复使用Statement对象。
  3. BatchExecutor:执行update(没有selectJDBC批处理不支持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();  
    }  
} 

你可能感兴趣的:(Mybatis之原理详解,缓存和拦截器)