MyBatis Mapper 代理实现数据库调用原理

1. Mapper 代理层执行

Mapper 代理上执行方法调用时,调用被委派给 MapperProxy 来处理。

public class MapperProxy<T> implements InvocationHandler, Serializable {
    private final SqlSession sqlSession;
    private final Class<T> mapperInterface;
    private final Map<Method, MapperMethod> methodCache;

    public MapperProxy(SqlSession sqlSession, Class<T> mapperInterface, Map<Method, MapperMethod> methodCache) {
        this.sqlSession = sqlSession;
        this.mapperInterface = mapperInterface;
        this.methodCache = methodCache;
    }

    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        if (Object.class.equals(method.getDeclaringClass())) {
            try {
                return method.invoke(this, args);
            } catch (Throwable t) {
                throw ExceptionUtil.unwrapThrowable(t);
            }
        }
        // 接口里声明的方法,转换为 MapperMethod 来调用
        final MapperMethod mapperMethod = cachedMapperMethod(method);

        // 与 Spring 集成时此处的 sqlSession 仍然 SqlSessionTemplate
        return mapperMethod.execute(sqlSession, args);
    }

    private MapperMethod cachedMapperMethod(Method method) {
        MapperMethod mapperMethod = methodCache.get(method);
        if (mapperMethod == null) {
            mapperMethod = new MapperMethod(mapperInterface, method, sqlSession.getConfiguration());
            methodCache.put(method, mapperMethod);
        }
        return mapperMethod;
    }
}

MapperMethod 根据 mapperInterface.getName() + "." + method.getName() 从 Configuration 对象里找到对应的 MappedStatement,从而得到要执行的 SQL 操作类型(insert/delete/update/select/flush),然后调用传入的 sqlSession 实例上的相应的方法。

public Object execute(SqlSession sqlSession, Object[] args) {
    Object result;
    if (SqlCommandType.INSERT == command.getType()) {
        // 把参数转换为 SqlSession 能处理的格式
        Object param = method.convertArgsToSqlCommandParam(args);

        // 在 sqlSession 上执行并处理结果
        result = rowCountResult(sqlSession.insert(command.getName(), param));
    } else if (SqlCommandType.UPDATE == command.getType()) {
        Object param = method.convertArgsToSqlCommandParam(args);
        result = rowCountResult(sqlSession.update(command.getName(), param));
    ...省略

如果上述方法传入的是 SqlSessionTemplate,那么这些方法调用会被 SqlSessionInterceptor 拦截,加入与 Spring 事务管理机制协作的逻辑,具体可以看这篇文章MyBatis 事务管理,这里不再展开,最终会调用到 DefaultSqlSession 实例上的方法。

2. 会话层的执行过程

SqlSession 里声明的所有方法的第一个参数如果是 String statement,则都是 mapperInterface.getName() + "." + method.getName(),表示要调用的 SQL 语句的标识符。通过它从 configuration 找到 MappedStatement

会话层最主要的逻辑是进行参数的包装,获取对应的 MappedStatement,然后调用持有的 Executor 的方法去执行。

继续阅读

MyBatis Mapper 代理创建过程

本文主要关注与 SpringBoot 集成时的初始化过程。

1. 核心组件

  • Configuration:MyBatis所有的配置信息都保存在Configuration对象之中,配置文件中的大部分配置都会存储到该类中。
  • SqlSession:MyBatis 的顶层 API,表示与数据库交互的会话,完成数据库增删改查操作。
  • Executor:执行器是 MyBatis 调度的核心,负责 SQL 语句的生成和查询缓存的维护。
  • StatementHandler:封装了 JDBC Statement 操作,如设置参数等。
  • ParameterHandler:负责将用户传递的参数转换成 JDBC Statement 所对应的数据类型。
  • ResultSetHandler:负责将 JDBC 返回的 ResultSet 结果集转换成 List 类型的集合。
  • TypeHandler:负责将 Java 数据类型和 jdbc 数据类型之间的映射与转换。

  • MapperFactoryBean:与 Spring 集成时表示一个 Mapper 原型的工厂 bean,用以创建最终的代理。
  • MapperProxy:与一个 mapperInterface 对应,维护了 Map<Method, MapperMethod> 映射。
  • MapperAnnotationBuilder:基于注解驱动的 MappedStatement 解析器,解析接口类的每个方法,封装成 MappedStatement。
  • MappedStatement:维护了一条 <select|update|delete|insert> 节点的封装。
  • SqlSource:负责根据用户传递的 parameterObject 动态生成 SQL 语句,将信息封装成 BoundSql 对象。
  • BoundSql:表示动态生成的 SQL 语句以及相应的参数信息。

整体架构如下图:
mybatis-整体架构

继续阅读

MyBatis 事务管理

1. 运行环境 Enviroment

当 MyBatis 与不同的应用结合时,需要不同的事务管理机制。与 Spring 结合时,由 Spring 来管理事务;单独使用时需要自行管理事务,在容器里运行时可能由容器进行管理。

MyBatis 用 Enviroment 来表示运行环境,其封装了三个属性:

public class Configuration {
    // 一个 MyBatis 的配置只对应一个环境
    protected Environment environment;
    // 其他属性 .....
}

public final class Environment {
    private final String id;
    private final TransactionFactory transactionFactory;
    private final DataSource dataSource;
}

2. 事务抽象

MyBatis 把事务管理抽象出 Transaction 接口,由 TransactionFactory 接口的实现类负责创建。

public interface Transaction {
    Connection getConnection() throws SQLException;
    void commit() throws SQLException;
    void rollback() throws SQLException;
    void close() throws SQLException;
    Integer getTimeout() throws SQLException;
}

public interface TransactionFactory {
    void setProperties(Properties props);
    Transaction newTransaction(Connection conn);
    Transaction newTransaction(DataSource dataSource, TransactionIsolationLevel level, boolean autoCommit);
}

Executor 的实现持有一个 SqlSession 实现,事务控制是委托给 SqlSession 的方法来实现的。

public abstract class BaseExecutor implements Executor {
    protected Transaction transaction;

    public void commit(boolean required) throws SQLException {
        if (closed) {
            throw new ExecutorException("Cannot commit, transaction is already closed");
        }
        clearLocalCache();
        flushStatements();
        if (required) {
            transaction.commit();
        }
    }

    public void rollback(boolean required) throws SQLException {
        if (!closed) {
            try {
                clearLocalCache();
                flushStatements(true);
            } finally {
                if (required) {
                    transaction.rollback();
                }
            }
        }
    }

    // 省略其他方法、属性
}

继续阅读

MyBatis 缓存

所有 insert/update/delete 语句都会导致缓存被清除。

1. 一级缓存

一级缓存是针对数据库会话的,用于优化在一次数据库会话里多次执行同样的 SQL。

有两种范围:

  • SESSION:会话级,默认的。
  • STATEMENT:语句级,每次执行完 mapper 中的语句后都清除一级缓存,其实就是禁用这个语句的一级缓存。

如果要在全局更改一级缓存的范围,需要在 MyBatis 的配置文件中设置:

<setting name="localCacheScope" value="STATEMENT"/>

2. 二级缓存

二级缓存是针对 Mapper 级别的,默认是启用的,但生效的话要对每个 Mapper 进行配置,Mapper 里没有配置的不使用二级缓存。

注意:如果在多个 Mapper 中存在对同一个表的操作,那么这几个 Mapper 的缓存数据可能会出现不一致现象。

<!-- 不启用的话在配置文件中指定如下 -->
<settings>
<setting name="cacheEnabled" value="false" />
</settings>


<!-- Mapper 文件里配置 cache 元素以生效 -->
<mapper namespace="...UserMapper">
    <!-- 默认对该 Mapper 文件里的所有查询使用二级缓存 -->
    <cache/>

    <!-- 该select语句不使用缓存 -->
    <select id="selectAbc" useCache="false">
        ...
    </select>
</mapper>

3. 源码实现

继续阅读

隐式类型转换导致全表扫描

最近系统改版了一部分(其实就是重做。。。),上线之后又开始出现数据库 IO 告警了。

DBA 抓出一个全表扫描的 SQL,是把范围在两个钟内的数据找出来做更新的,这个查询的时间字段是有索引的,结果却没有用上,
跟另一个表关联的时候,关联字段也是有索引的,但还是没有用上。

在两个钟内的数据量应该是很小的,相对于总量来说,时间字段的索引没理由不用啊;从主表找出来的数据量不大,再去关联表查询也应该走索引的。
感觉就是执行计划不对了,去找 DBA 做固化。

找 DBA 先用 hint 生成一个走索引的执行计划,然后做了固化。

这个 SQL 是两个钟才执行一次的,结果固化之后值班 DBA 说主表还是全表扫描。找 DBA 再做一次固化,还是主表全表扫描。最后开了 tunning ,提示说有隐式类型转换,主表没法走索引。

大致扫了一下代码,Java 里用的数据类型是 Date 的,而表的列的类型也是 Date,应该不需要做隐式类型转换啊。

问题出在 MyBatis 的参数类型指定为 timestamp 而不是 Date;由于查询需要时分秒,而 Date 会抹去这些值,所以被指定为 timestamp,
而这个类型刚好对应到 Oracle 的 timestamp 类型,所以就出现隐式类型转换。

细节吶。。。


欢迎关注我的微信公众号: coderbee笔记,可以更及时回复你的讨论。

mybatis 批量插入 插件

2017-01-07 更新:这个插件做了重命名、梳理,新的 github 地址为 MyBatis-Batch

可直接从 Maven 中央仓库引用:
<dependency>
<groupId>net.coderbee</groupId>
<artifactId>mybatis-batch</artifactId>
<version>1.1.0</version>
</dependency>

背景

项目中有个设计不合理的表,总共 8 个字段,有 5 个索引,有几个索引字段还是 32 位的字符串。该表数据量已达 1 亿,最近每天新增 100 万。根据日志看,有次用户上传一个有 200 行记录的 excel,需要往这个表插入 3940 条记录,耗时 72 秒。这么大延迟是没法接受的。

要分析数据库方面的问题,首先是找 DBA 分析下表的情况,说跟以往没多大区别,只是跟这个表有关的插入的执行计划很多。因为这个表的批量插入是这样的:

<select id="batchSave" parameterType="java.util.List">
    INSERT INTO TABLE_NAME(ID,NAME) 
<foreach collection="list"item="itm" separator="union all">
    (SELECT #{itm.id}, #{itm.name} FROM DUAL)
</foreach></select>

这是用 MyBatis 对 Oracle 做批量插入的唯一方法。副作用是:假定 List 的最大长度是 N,那么 Oracle 服务器端就可能有 N 个插入的执行计划。这么多执行计划,DBA 也不乐意去分析呀,而且确实生成的每个执行计划都是很简单的。

优化

如果放弃 union all 的方式,则每条记录都需要各提交一次到数据库,显然也不好。

为了解决 N 个执行计划的问题,做到真正的批量插入,只能修改 MyBatis 的执行逻辑,因此就有了这个项目:mybatis-batchinsert-plugin

目前可以批量插入,但不支持返回主键等其他的功能,有空再完善。

我做这个插件主要是希望达到:

  • 1、解决 Oracle 服务器端对一个表做插入的执行计划过多的问题;用 union all 的方式还可能导致硬解析增多,比如需要插入的总记录有 123 条,以 100 条为一批拼接成一条 sql A,那么剩下的 23 条也会拼接成一条 B,而 B 的频率肯定比 A 的频率低很多,因为要做批量插入的记录总数不是固定的,所以余数也不是固定的,容易导致 B 的解析过期,被清除出去,然后又来了条余数 23 的,那么就需要硬解析了。
  • 2、避免用 union all 拼接成一条 sql,导致这条 sql 里的绑定变量过多。
  • 3、兼容 MyBatis 的用法,在使用上更简单了,因为不需要用 MyBatis 的 for 循环,由插件来做循环;用 union all 方式的话,Java 代码要控制批的大小,需要一个循环,MyBatis 里需要一个,用于拼接 union all。
  • 4、一个对于性能有益的是:每次调用 MyBatis 的 insert 语句,都需要调用一次 Connection.prepareStatement 方法,也就需要访问数据库一次,如果对语句没有缓存的话;用插件的话,不管多少记录,只需要一次。
  • 这个插件对性能提升不会很显著,在我们的开发数据库上测试时,以 1000 条为 1 批,已有数据量 160w,插入 40w 数据,用 union all 的方式平均一条需要 32ms,用插件大概是 27ms。


    欢迎关注我的微信公众号: coderbee笔记,可以更及时回复你的讨论。