二,MyBatis体系结构与工作原理

1.MyBatis应用分析与实践
2.MyBatis体系结构与工作原理
3.MyBatis插件原理及Spring集成
4.手写自己的MyBatis框架
本节目标:

1、 掌握 MyBatis 的工作流程

2、 掌握 MyBatis 的架构分层与模块划分

3、 掌握 MyBatis 缓存机制

4、 通过阅读 MyBatis 源码掌握 MyBatis 底层工作原理与设计思想

一,MyBatis 的工作流程分析

在上一篇《应用分析与实践》里面,我们学习了 MyBatis 的编程式使用的方法,我们再来回顾一下 MyBatis 的主要工作流程:

首先在 MyBatis 启动的时候我们要去解析配置文件,包括全局配置文件和映射器 配置文件,这里面包含了我们怎么控制 MyBatis 的行为,和我们要对数据库下达的指令, 也就是我们的 SQL 信息。我们会把它们解析成一个 Configuration 对象。
接下来就是我们操作数据库的接口,它在应用程序和数据库中间,代表我们跟数据库之间的一次连接:这个就是 SqlSession 对象。
我们要获得一个会话 , 必须有一个会话工厂 SqlSessionFactory 。 SqlSessionFactory 里面又必须包含我们的所有的配置信息,所以我们会通过一个 Builder 来创建工厂类。
我们知道,MyBatis 是对 JDBC 的封装,也就是意味着底层一定会出现 JDBC 的一 些核心对象,比如执行 SQL 的 Statement,结果集 ResultSet。在 Mybatis 里面, SqlSession 只是提供给应用的一个接口,还不是 SQL 的真正的执行对象。

我们上次提到了,SqlSession 持有了一个 Executor 对象,用来封装对数据库的操作。

在执行器 Executor 执行 query 或者 update 操作的时候我们创建一系列的对象, 来处理参数、执行 SQL、处理结果集,这里我们把它简化成一个对象:StatementHandler, 在阅读源码的时候我们再去了解还有什么其他的对象。

这个就是 MyBatis 主要的工作流程,如图:

MyBatis工作流程.png

二,MyBatis 架构分层与模块划分

在 MyBatis 的主要工作流程里面,不同的功能是由很多不同的类协作完成的,它们分布在MyBatis jar 包的不同的 package 里面。

我们来看一下 MyBatis 的 jar 包(基于 3.5.6),jar 包结构是这样的(22 个包):

MyBatis-Package.png

大概有 300 多个类,这样看起来不够清楚,不知道什么类在什么环节工作,属于什么层次。跟 Spring 一样,MyBatis 按照功能职责的不同,所有的 package 可以分成不同的工作层次。我们可以把 MyBatis 的工作流程类比成餐厅的服务流程:

  • 第一个是跟客户打交道的服务员,它是用来接收程序的工作指令的,我们把它叫做接口层。
  • 第二个是后台的厨师,他们根据客户的点菜单,把原材料加工成成品,然后传到窗口。这一层是真正去操作数据的,我们把它叫做核心层。
  • 最后就是餐厅也需要有人做后勤(比如清洁、采购、财务),来支持厨师的工作和整个餐厅的运营。我们把它叫做基础层。

来看一下这张图,我们根据刚才的分层,和大体的执行流程,做了这么一个总结。 当然,从不同的角度来描述,架构图的划分有所区别,这张图画起来也有很多形式。我们先从总体上建立一个印象。每一层的主要对象和主要的功能我们也给大家分析一下。

MyBatis架构图.png

接口层

首先接口层是我们打交道最多的。核心对象是 SqlSession,它是上层应用和 MyBatis 打交道的桥梁,SqlSession 上定义了非常多的对数据库的操作方法。接口层在接收到调用请求的时候,会调用核心处理层的相应模块来完成具体的数据库操作。

核心处理层

接下来是核心处理层。既然叫核心处理层,也就是跟数据库操作相关的动作都是在 这一层完成的。核心处理层主要做了这几件事:

  1. 把接口中传入的参数解析并且映射成 JDBC 类型;

  2. 解析 xml 文件中的 SQL 语句,包括插入参数,和动态 SQL 的生成;

  3. 执行 SQL 语句;

  4. 处理结果集,并映射成 Java 对象。

插件也属于核心层,这是由它的工作方式和拦截的对象决定的。

基础支持层

最后一个就是基础支持层。基础支持层主要是一些抽取出来的通用的功能(实现复用),用来支持核心处理层的功能。比如数据源、缓存、日志、xml 解析、反射、IO、 事务等等这些功能。

这个就是 MyBatis 的主要工作流程和架构分层。接下来我们来学习一下基础层里面 的一个主要模块,缓存。我们一起来了解一下 MyBatis 一级缓存和二级缓存的区别,和 它们的工作方式,以及使用过程里面有什么注意事项。

三,MyBatis 缓存详解

cache 缓存

缓存是一般的 ORM 框架都会提供的功能,目的就是提升查询的效率和减少数据库的压力。跟 Hibernate 一样,MyBatis 也有一级缓存和二级缓存,并且预留了集成第三方缓存的接口。

缓存体系结构

MyBatis 跟缓存相关的类都在 cache 包里面,其中有一个 Cache 接口,只有一个默认的实现类 PerpetualCache,它是用 HashMap 实现的。

除此之外,还有很多的装饰器,通过这些装饰器可以额外实现很多的功能:回收策 略、日志记录、定时刷新等等:

Cache.png

“装饰者模式(Decorator Pattern)是指在不改变原有对象的基础之上,将功能附加到对象上,提供了比继承更有弹 性的替代方案(扩展原有对象的功能)。”

但是无论怎么装饰,经过多少层装饰,最后使用的还是基本的实现类(默认 PerpetualCache)。

所有的缓存实现类总体上可分为三类:基本缓存、淘汰算法缓存、装饰器缓存:

缓存实现类 描述 作用 装饰条件
基本缓存 缓存基本实现类 默认是 PerpetualCache,也可以自定义比如 RedisCache、EhCache 等,具备基本功能的缓存类
LruCache LRU 策略的缓存 当缓存到达上限时候,删除最近最少使用的缓存 (Least Recently Use) eviction="LRU"(默 认)
FifoCache FIFO 策略的缓存 当缓存到达上限时候,删除最先入队的缓存 eviction="FIFO"
SoftCache WeakCache 带清理策略的缓存 通过 JVM 的软引用和弱引用来实现缓存,当 JVM 内存不足时,会自动清理掉这些缓存,基于 SoftReference 和 WeakReference eviction="SOFT" eviction="WEAK"
LoggingCache 带日志功能的缓存 比如:输出缓存命中率 基本
SynchronizedCache 同步缓存 基于 synchronized 关键字实现,解决并发问题 基本
BlockingCache 阻塞缓存 通过在 get/put 方式中加锁,保证只有一个线程操作缓存,基于 Java 重入锁实现 blocking=true
SerializedCache 支持序列化的缓存 将对象序列化以后存到缓存中,取出时反序列化 readOnly=false(默 认)
ScheduledCache 定时调度的缓存 在进行 get/put/remove/getSize 等操作前,判断缓存时间是否超过了设置的最长缓存时间(默认是 一小时),如果是则清空缓存--即每隔一段时间清 空一次缓存 flushInterval 不为空
TransactionalCache 事务缓存 在二级缓存中使用,可一次存入多个缓存,移除多 个缓存 在 TransactionalCach eManager 中用 Map 维护对应关系

思考:缓存对象在什么时候创建?什么情况下被装饰? 我们要弄清楚这个问题,就必须要知道 MyBatis 的一级缓存和二级缓存的工作位置和工作方式的区别。

一级缓存

一级缓存(本地缓存)介绍

一级缓存也叫本地缓存,MyBatis 的一级缓存是在会话(SqlSession)层面进行缓存的。MyBatis 的一级缓存是默认开启的,不需要任何的配置。

首先我们必须去弄清楚一个问题,在 MyBatis 执行的流程里面,涉及到这么多的对象,那么缓存 PerpetualCache 应该放在哪个对象里面去维护?如果要在同一个会话里面共享一级缓存,这个对象肯定是在 SqlSession 里面创建的,作为SqlSession的一个属 性。

DefaultSqlSession里面只有两个属性,Configuration是全局的,所以缓存只可能 放在 Executor 里面维护——SimpleExecutor/ReuseExecutor/BatchExecutor的父类BaseExecutor的构造函数中持有了 PerpetualCache。

在同一个会话里面,多次执行相同的 SQL 语句,会直接从内存取到缓存的结果,不会再发送 SQL 到数据库。但是不同的会话里面,即使执行的 SQL 一模一样(通过一个 Mapper 的同一个方法的相同参数调用),也不能使用到一级缓存。

LocalCache.png

接下来我们来验证一下,MyBatis 的一级缓存到底是不是只能在一个会话里面共享, 以及跨会话(不同 session)操作相同的数据会产生什么问。

一级缓存验证

(注意演示一级缓存需要先关闭二级缓存, localCacheScope 设置为 SESSION)

判断是否命中缓存:如果再次发送 SQL 到数据库执行,说明没有命中缓存;如果直接打印对象,说明是从内存缓存中取到了结果。

  1. 在同一个 session 中共享

    /**
         * 测试一级缓存需要先关闭二级缓存,localCacheScope设置为SESSION
         * @throws IOException
         */
        @Test
        public void testCache() throws IOException {
            String resource = "mybatis-config.xml";
            InputStream inputStream = Resources.getResourceAsStream(resource);
            SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    
            SqlSession session1 = sqlSessionFactory.openSession();
            SqlSession session2 = sqlSessionFactory.openSession();
            try {
                BlogMapper mapper0 = session1.getMapper(BlogMapper.class);
                BlogMapper mapper1 = session1.getMapper(BlogMapper.class);
                Blog blog = mapper0.selectBlogById(1);
                System.out.println(blog);
    
                System.out.println("第二次查询,相同会话,获取到缓存了吗?");
                System.out.println(mapper1.selectBlogById(1));
    
                System.out.println("第三次查询,不同会话,获取到缓存了吗?");
                BlogMapper mapper2 = session2.getMapper(BlogMapper.class);
                System.out.println(mapper2.selectBlogById(1));
    
            } finally {
                session1.close();
            }
        }
    
  2. 不同 session 不能共享

    SqlSession session1 = sqlSessionFactory.openSession();
    BlogMapper mapper1 = session1.getMapper(BlogMapper.class);
    System.out.println(mapper.selectBlog(1));
    

    PS:一级缓存在 BaseExecutor 的 query()——queryFromDatabase()中存入。在 queryFromDatabase()之前会 get()。

  3. 同一个会话中,update(包括 delete)会导致一级缓存被清空

    /**
         * 一级缓存失效
         * @throws IOException
         */
        @Test
        public void testCacheInvalid() throws IOException {
            String resource = "mybatis-config.xml";
            InputStream inputStream = Resources.getResourceAsStream(resource);
            SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    
            SqlSession session = sqlSessionFactory.openSession();
            try {
                BlogMapper mapper = session.getMapper(BlogMapper.class);
                System.out.println(mapper.selectBlogById(1));
    
                Blog blog = new Blog();
                blog.setBid(1);
                blog.setName("2021年7月24日14:39:58");
                mapper.updateByPrimaryKey(blog);
                session.commit();
    
                // 相同会话执行了更新操作,缓存是否被清空?
                System.out.println("在执行更新操作之后,是否命中缓存?");
                System.out.println(mapper.selectBlogById(1));
    
            } finally {
                session.close();
            }
        }
    

一级缓存是在 BaseExecutor 中的 update()方法中调用 clearLocalCache()清空的(无条件),query 中会判断。如果跨会话,会出现什么问题?

  1. 其他会话更新了数据,导致读取到脏数据(一级缓存不能跨会话共享)。

    /**
         * 因为缓存不能跨会话共享,导致脏数据出现
         * @throws IOException
         */
        @Test
        public void testDirtyRead() throws IOException {
            String resource = "mybatis-config.xml";
            InputStream inputStream = Resources.getResourceAsStream(resource);
            SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    
            SqlSession session1 = sqlSessionFactory.openSession();
            SqlSession session2 = sqlSessionFactory.openSession();
            try {
                BlogMapper mapper1 = session1.getMapper(BlogMapper.class);
                System.out.println(mapper1.selectBlogById(1));
    
                // 会话2更新了数据,会话2的一级缓存更新
                Blog blog = new Blog();
                blog.setBid(1);
                blog.setName("after modified 112233445566");
                BlogMapper mapper2 = session2.getMapper(BlogMapper.class);
                mapper2.updateByPrimaryKey(blog);
                session2.commit();
    
                // 其他会话更新了数据,本会话的一级缓存还在么?
                System.out.println("会话1查到最新的数据了吗?");
                System.out.println(mapper1.selectBlogById(1));
            } finally {
                session1.close();
                session2.close();
            }
        }
    
一级缓存的不足

使用一级缓存的时候,因为缓存不能跨会话共享,不同的会话之间对于相同的数据可能有不一样的缓存。在有多个会话或者分布式环境下,会存在脏数据的问题。如果要解决这个问题,就要用到二级缓存。

【思考】一级缓存怎么命中?CacheKey 怎么构成?

【思考】一级缓存是默认开启的,怎么关闭一级缓存?

二级缓存

二级缓存介绍

二级缓存是用来解决一级缓存不能跨会话共享的问题的,范围是 namespace 级别的,可以被多个 SqlSession 共享(只要是同一个接口里面的相同方法,都可以共享), 生命周期和应用同步。

思考一个问题:如果开启了二级缓存,二级缓存应该是工作在一级缓存之前,还是 在一级缓存之后呢?二级缓存是在哪里维护的呢?

作为一个作用范围更广的缓存,它肯定是在 SqlSession 的外层,否则不可能被多个 SqlSession 共享。而一级缓存是在 SqlSession 内部的,所以第一个问题,肯定是工作在一级缓存之前,也就是只有取不到二级缓存的情况下才到一个会话中去取一级缓存。

第二个问题,二级缓存放在哪个对象中维护呢? 要跨会话共享的话,SqlSession 本身和它里面的 BaseExecutor 已经满足不了需求了,那我们应该在 BaseExecutor 之外创建一个对象。

实际上 MyBatis 用了一个装饰器的类来维护,就是 CachingExecutor。如果启用了 二级缓存,MyBatis 在创建 Executor 对象的时候会对 Executor 进行装饰。

CachingExecutor 对于查询请求,会判断二级缓存是否有缓存结果,如果有就直接返回,如果没有委派交给真正的查询器 Executor 实现类,比如 SimpleExecutor 来执行查询,再走到一级缓存的流程。最后会把结果缓存起来,并且返回给用户。

二级缓存.png

一级缓存是默认开启的,那二级缓存怎么开启呢?

开启二级缓存的方法

第一步:在 mybatis-config.xml 中配置了(可以不配置,默认是 true):


只要没有显式地设置 cacheEnabled=false,都会用 CachingExecutor 装饰基本的执行器。

第二步:在 Mapper.xml 中配置标签。



eviction="LRU" 
flushInterval="120000" 
readOnly="false"/> 
cache 属性详解:
属性 含义 取值
type 缓存实现类 需要实现 Cache 接口,默认是 PerpetualCache
size 最多缓存对象个数 默认 1024
eviction 回收策略(缓存淘汰算法) LRU – 最近最少使用的:移除最长时间不被使用的对象(默认)。
FIFO – 先进先出:按对象进入缓存的顺序来移除它们。
SOFT – 软引用:移除基于垃圾回收器状态和软引用规则的对象。
WEAK – 弱引用:更积极地移除基于垃圾收集器状态和弱引用规则的对象。
flushInterval 定时自动清空缓存间隔 自动刷新时间,单位 ms,未配置时只有调用时刷新
readOnly 是否只读 true:只读缓存;会给所有调用者返回缓存对象的相同实例。因此这些对象 不能被修改。这提供了很重要的性能优势。
false:读写缓存;会返回缓存对象的拷贝(通过序列化),不会共享。这 会慢一些,但是安全,因此默认是 false。 改为 false 可读写时,对象必须支持序列化。
blocking 是否使用可重入锁实现缓存的并发控制 true,会使用 BlockingCache 对 Cache 进行装饰 默认 false

Mapper.xml 配置了之后,select()会被缓存。update()、delete()、insert() 会刷新缓存。

思考:如果 cacheEnabled=true,Mapper.xml 没有配置标签,还有二级缓存吗? 还会出现 CachingExecutor 包装对象吗?

只要 cacheEnabled=true 基本执行器就会被装饰。有没有配置,决定了在启动的时候会不会创建这个 mapper 的 Cache 对象,最终会影响到 CachingExecutor query 方法里面的判断:

@Override
  public  List query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    Cache cache = ms.getCache();
    if (cache != null) {
      flushCacheIfRequired(ms);
      if (ms.isUseCache() && resultHandler == null) {
        ensureNoOutParams(ms, boundSql);
        @SuppressWarnings("unchecked")
        List list = (List) tcm.getObject(cache, key);
        if (list == null) {
          list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        return list;
      }
    }
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
  }

如果某些查询方法对数据的实时性要求很高,不需要二级缓存,怎么办?

我们可以在单个 Statement ID 上显式关闭二级缓存(默认是 true)