专栏首页疯狂软件李刚MyBatis的“基于嵌套select”映射的剖析

MyBatis的“基于嵌套select”映射的剖析

导读

本文详细分析了MyBatis中“基于嵌套select”映射策略的性能缺陷、并给出了具体的实施建议,本文适合对MyBatis有一定使用经验的读者阅读,对MyBatis小白不适合。

对于1-1关联关系而言,无论从哪一端来看,关联实体都是单个的,因此两边都使用<association.../>或@One映射即可。

下面一个Person与Address的关联关系为例,本例假设每个Person只有一个对应的Address,每个Address也只有一个对应的Person,也就是Person与Address之间存在1-1关联关系。

下面是Person类的代码。

public class Person
{
    private Integer id;
    private String name;
    private int age;
    private Address address;
    // 下面省略构造器、setter和getter方法
    ...
}

下面是Address类的代码。

public class Address
{
    private Integer id;
    // 定义地址详细信息的成员变量
    private String detail;
    private Person person;
    // 下面省略构造器、setter和getter方法
    ...
}

对于关联实体是单个的情况,MyBatis使用<association.../>元素进行映射,MyBatis为关联实体是单个的情况提供3种映射策略:

  • 基于嵌套select的映射策略。
  • 基于连接查询的映射策略。
  • 基于多结果集的映射策略。

<association.../>元素支持的属性较多,部分属性专对某种映射策略起作用,下面这些属性是所有映射策略都支持的通用属性。

  • property:指定关联属性的属性名。该属性名可支持表达式,例如ower.address。
  • javaType:指定该属性的Java类型。通常而言,如果该属性是Java Bean类,MyBatis可推断出该属性值,因此可省略该属性值;但如果该属性映射到HashMap类型,则应该明确指定javaType属性。
  • jdbcType:指定该属性对应的JDBC类型,通常来说,只需在可能执行插入、更新和删除操作、且允许空值的列上指定 JDBC 类型,这完全是JDBC编程的要求。
  • typeHandler:为该属性指定局部的类型处理器。

对于基于嵌套select的映射策略来说,MyBatis需要使用额外的select语句来查询关联实体,因此这种策略需要为<association.../>元素指定如下3个额外的属性:

  • select:指定Mapper定义的一个select语句的id,MyBatis会使用该select语句来查询关联实体,当前实体对应的column列的值将作为参数传给该select语句。
  • column:指定当前实体对应数据表的列名,当前实体对应的column列的值将作为参数传给select属性指定的查询语句。如果底层数据表采用了复合主键的设计,该属性还可通过 column="{prop1=col1,prop2=col2}"的形式来指定多个列名,这样prop1和prop2将作为参数传给select属性指定的查询语句。
  • fetchType:指定是否使用延迟加载。该属性可支持lazy(延迟加载)和eager(立即加载)。如果该属性指定为lazy,MyBatis会等到程序实际访问关联实体时才会执行select属性指定的查询语句去抓取实体;如果该属性指定为eager,MyBaits会在加载当前实体时,立即执行select属性指定的查询语句去抓取实体。

对于这种映射策略,column属性稍微有点难以理解,下面以一个具体的示例进行吸详细讲解。

假设有如图1所示的主从表设计:

图1 主从表设计

提示

在数据表设计中,主从表是最常见的关联设计,从表增加外键列(如图3.1中的refid列),外键列的值引用(references)主表记录,比如图3.1中从表id为101的记录,起外键列的值为4,表明引用了主表中id为4的记录。简单一句话:从表通过外键列引用对用的主表记录。形象来记:就像一对情侣,如果其中一人在自己身上纹上对方的名字,那ta肯定是从属的一方。

另:国内大部分数据库理论资料喜欢将references翻译为“参照”——这都是早期的胡乱翻译。

对于基于嵌套select的映射策略,它可分为两种情况:第一种是先加载了主表实体,接下来MyBatis需要使用额外的select语句来抓取关联的从表实体;第二种是先加载了从表实体,接下来MyBatis需使用额外的select语句来抓取关联的主表实体。

先看“先加载了主表实体”的情形,此时MyBatis已经加载了主表中id为4的记录,接下来MyBatis需要使用一条额外的select语句从从表中抓取它关联的实体。那么这条select语句应该写成如下形式:

select * from 从表 where refid=#{id}

对于上面select语句,我们必须让MyBatis将“4”作为参数传给它——这个4来自哪里?来自已加载的实体(主表实体)的id列的值,故此时select和column分别写成:

  • select = "select * from 从表 where refid=#{id}"
  • column = "id"

再看“先加载了从表实体”的情形,此时MyBatis已经加载了从表中id为101的记录,接下来MyBatis需要使用一条额外的select语句从主表中抓取它关联的实体。那么这条select语句应该写成如下形式:

select * from 主表where id = #{id}

对于上面select语句,我们必须让MyBatis将“4”作为参数传给它——这个4来自哪里?来自已加载的实体(从表实体)的refid列的值,故此时select和column分别写成:

  • select = "select * from 主表 where id=#{id}"
  • column = "refid"

认真理解上面讲解之后,接下来即可使用<association.../>元素定义Person与Address之间的1—1关联,下面是PersonMapper接口的代码。

public interface PersonMapper
{
    Person getPerson(Integer id);
}

该Mapper接口中定义了一个getPerson()方法,该方法根据id获取Person实体(主表实体),如果采用“基于嵌套select”的映射策略,MyBatis必须使用额外的select语句去抓取Address实体(从表实体)。

该PersonMapper的XML Mapper文件的代码如下:

<?xml version="1.0" encoding="UTF-8" ?>
<!-- MyBatis Mapper文件的DTD -->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.crazyit.app.dao.PersonMapper">
    <select id="getPerson" resultMap="personMap">
        select * from person_inf where person_id=#{id}
    </select>
    <resultMap id="personMap" type="person">
        <id column="person_id" property="id"/>
        <result column="person_name" property="name"/>
        <result column="person_age" property="age"/>
        <!-- 使用select指定的select语句去抓取关联实体,
        当前实体的person_id列的值作为参数传给select语句 -->
        <association property="address" javaType="Address"
            column="person_id" select="org.crazyit.app.dao.AddressMapper.findAddressByOwner"
            fetchType="eager"/>
    </resultMap>
</mapper>

该XML Mapper文件的重点就是<association.../>元素配置代码,该配置代码的select属性为AddressMapper中的findAddressByOwner——也就是AddressMapper中定义的select语句。column属性为person_id,这意味着Person实体对应的数据表记录的person_id列的值将作为参数传给select语句。

此外,上面<association.../>元素还指定了fetchType="eager",这表明MyBatis会在加载Person实体时,立即执行select属性指定的select语句去抓取关联的Addresss实体。

AddressMapper的接口同样很简单,它只是定义了一个简单的getAddress(Integer id)方法,此处不再给出该接口的代码。

AddressMapper的XML文件同样使用<association.../>元素来定义关联的Person实体,下面是该映射文件的代码。

<?xml version="1.0" encoding="UTF-8" ?>
<!-- MyBatis Mapper文件的DTD -->
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="org.crazyit.app.dao.AddressMapper">
    <select id="getAddress" resultMap="addressMap">
        select * from address_inf where addr_id=#{id}
    </select>
    <resultMap id="addressMap" type="address">
        <id column="addr_id" property="id"/>
        <result column="addr_detail" property="detail"/>
        <result column="news_content" property="content"/>
        <!-- 使用select指定的select语句去抓取关联实体,
        当前实体的owner_id列的值作为参数传给select语句 -->
        <association property="person" javaType="Person"
            column="owner_id" select="org.crazyit.app.dao.PersonMapper.getPerson"
            fetchType="lazy"/>
    </resultMap>
    <select id="findAddressByOwner" resultMap="addressMap">
        select * from address_inf where owner_id=#{id}
    </select>
</mapper>

上面<association .../>配置代码的select属性为PersonMapper中的getPerson——也就是PersonMapper中定义的select语句。column属性为owner_id,这意味着Address实体对应的数据表记录的owner_id列的值将作为参数传给select语句。

此外,<association .../>配置代码还指定了fetchType="fetch",这表明MyBatis会在加载Address实体时,不会立即执行select属性指定的select语句去抓取关联的Person实体,而是等到程序实际访问关联的Person实体时才会执行select语句去抓取。

本例将两个关联实体的fetchType分别设为eager和lazy,只是为了向读者演示延迟加载和立即加载的差异。就实际运行性能来说,如果采用“基于嵌套select”的映射策略,通常建议采用延迟加载的抓取策略。

开发完上面Mapper组件之后,分别使用如下两个方法来调用Mapper的方法。

public static void selectAddress(SqlSession sqlSession)
{
    // 获取Mapper对象
    AddressMapper addrMapper = sqlSession.getMapper(AddressMapper.class);
    // 调用Mapper对象的方法执行持久化操作
    Address addr = addrMapper.getAddress(2);
    System.out.println(addr.getDetail());    // ①
    System.out.println("------------------");
    // 访问关联实体
    System.out.println(addr.getPerson());
    // 提交事务
    sqlSession.commit();
    // 关闭资源
    sqlSession.close();
}
public static void selectPerson(SqlSession sqlSession)
{
    // 获取Mapper对象
    PersonMapper personMapper = sqlSession.getMapper(PersonMapper.class);
    // 调用Mapper对象的方法执行持久化操作
    Person person = personMapper.getPerson(2);
    System.out.println(person.getName());
    System.out.println("------------------");
    // 访问关联实体
    System.out.println(person.getAddress());
    // 提交事务
    sqlSession.commit();
    // 关闭资源
    sqlSession.close();
}

上面程序中的第10行字代码通过Address实体访问它的关联实体:Person对象,由于Address实体采用延迟加载策略来获取关联的Person实体,因此将看到MyBatis会输出横线之后才执行select语句去抓取关联的Person对象。运行selectAddress()方法时会在控制台看到如下日志:

[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.getAddress ==>  Preparing: select * from address_inf where addr_id=?
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.getAddress ==> Parameters: 2(Integer)
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.getAddress <==      Total: 1
[java] 花果山水帘洞
[java] ------------------
[java] DEBUG [main] org.crazyit.app.dao.PersonMapper.getPerson ==>  Preparing: select * from person_inf where person_id=?
[java] DEBUG [main] org.crazyit.app.dao.PersonMapper.getPerson ==> Parameters: 1(Integer)
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner ====>  Preparing: select * from address_inf where owner_id=?
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner ====> Parameters: 1(Integer)
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner <====      Total: 1
[java] DEBUG [main] org.crazyit.app.dao.PersonMapper.getPerson <==      Total: 1
[java] Person[id=1, name=孙悟空, age=500]

从上面第6行日志可以看到:程序先输出了横线,然后再输出MyBatis抓取Address实体关联的Person实体的select语句——这就是延迟加载的效果:只有等到程序实际访问Address关联的Person时,程序才去真正执行select语句。

使用延迟加载的好处很明显:

  • 程序可能只是要使用Address对象的普通属性,可能永远都不需要访问它关联的Person对象,这样程序就可以减少数据库的连接、执行select交互;
  • 即使程序后面需要访问Address关联的Person对象,但MyBatis等到程序真正需要使用Person实体时才把它加载到内存中,这样减少了Person对象在内存中的驻留时间,这也是节省内存空间的一种方式。

上面程序中的第二行粗体字代码通过Person实体访问它的关联实体:Address对象,由于Person实体采用立即加载策略来获取关联的Address实体,因此将看到MyBatis会在加载Person实体时、立即执行select语句去抓取关联的Address对象。运行selectAddress()方法时会在控制台看到如下日志:

[java] DEBUG [main] org.crazyit.app.dao.PersonMapper.getPerson ==>  Preparing: select * from person_inf where person_id=?
[java] DEBUG [main] org.crazyit.app.dao.PersonMapper.getPerson ==> Parameters: 2(Integer)
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner ====>  Preparing: select * from address_inf where owner_id=?
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner ====> Parameters: 2(Integer)
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner <====      Total: 1
[java] DEBUG [main] org.crazyit.app.dao.PersonMapper.getPerson <==      Total: 1
[java] 猪八戒
[java] ------------------
[java] Address[id=3, detail=福陵山云栈洞, person=Person[id=2, name=猪八戒, age=280]]

从上面第3行日志可以看到:程序获取Person实体后,立即输出MyBatis抓取该Person实体关联的Address实体的select语句——这就是立即加载的效果。

如果要将该示例改为使用注解,则需要使用@One注解来代替<association.../>元素——严格来说@One并不等于<association.../>元素,而是@Result+@One才等于<association.../>元素。

@One注解根本不能单独使用(它不能修饰任何程序单元),它只能作为@Result的one属性的值。该注解只能指定如下两个属性:

  • select:等同于<association.../>元素的select属性。
  • fetchType:等同于<association.../>元素的fetchType属性。

至于<association.../>元素支持的property、javaType、jdbcType、typeHandler、column等属性,直接放在@Result注解中指定。

总结起来,可以得到如下等式:

<association.../> = @Result + @One

下面是采用注解后的PersonMapper组件的接口代码。

public interface PersonMapper
{
    @Select("select * from person_inf where person_id=#{id}")
    @Results({
        @Result(column = "person_id", property = "id", id=true),
        @Result(column = "person_name", property = "name"),
        @Result(column = "person_age", property = "age"),
        @Result(property = "address", javaType = Address.class, column = "person_id",
            one = @One(select = "org.crazyit.app.dao.AddressMapper.selectAddressByOwner",
             fetchType = FetchType.EAGER))
    })
    Person getPerson(Integer id);
}

上面第4个@Result注解代码就等同于PersonMapper.xml中<association.../>元素的配置。

下面是采用注解后的AddressMapper组件的接口代码。

public interface AddressMapper

{
    @Select("select * from address_inf where addr_id=#{id}")
    @Results(id = "addressMap", value = {
        @Result(column = "addr_id", property = "id", id = true),
        @Result(column = "addr_detail", property = "detail"),
        @Result(column = "news_content", property = "content"),
        @Result(property = "person", javaType = Person.class, column = "owner_id",
            one = @One(select = "org.crazyit.app.dao.PersonMapper.getPerson",
            fetchType = FetchType.LAZY))
    })
    Address getAddress(Integer id);

    @Select("select * from address_inf where owner_id=#{id}")
    @ResultMap("addressMap")
    Address selectAddressByOwner(Integer ownerId);
}

上面第4个@Result注解代码就等同于AddressMapper.xml中<association.../>元素的配置。

基于嵌套select映射策略的性能缺陷

对于这种基于嵌套select的映射策略,它有一个很严重的性能问题:MyBatis总需要使用额外的select语句去抓取关联实体,这个问题被称为“N+1”查询问题”。具体来说,比如你希望获取一个Person列表,MyBatis的执行过程可概括为两步:

(1)执行了一条select语句来查询person_inf表中的记录,该查询语句返回的结果的一个列表。这是N+1中1条select语句。

(2)对于列表的每个Person实体,MyBatis都需要额外执行一条select查询语句来为它抓取关联的Address实体,这是N+1中N条select语句。

假如在PersonMapper.xml中增加如下定义:

    <select id="findPersonById" resultMap="personMap">
        select * from person_inf where person_id>#{id}
    </select>

上面粗体字select语句(N+1中的1)会从person_inf表中选出多条记录,接下来MyBatis会为每个Person对象生成一条额外的select语句来抓取关联的Address实体(N+1中的N)。

使用程序调用PersonMapper组件中的findPersonById()方法,可在控制台看到如下日志输出。

[java] DEBUG [main] org.crazyit.app.dao.PersonMapper.findPersonById ==>  Preparing: select * from person_inf where person_id>?
[java] DEBUG [main] org.crazyit.app.dao.PersonMapper.findPersonById ==> Parameters: 2(Integer)
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner ====>  Preparing: select * from address_inf where owner_id=?
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner ====> Parameters: 3(Integer)
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner <====      Total: 1
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner ====>  Preparing: select * from address_inf where owner_id=?
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner ====> Parameters: 4(Integer)
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner <====      Total: 1
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner ====>  Preparing: select * from address_inf where owner_id=?
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner ====> Parameters: 5(Integer)
[java] DEBUG [main] org.crazyit.app.dao.AddressMapper.findAddressByOwner <====      Total: 1
[java] DEBUG [main] org.crazyit.app.dao.PersonMapper.findPersonById <==      Total: 3

从上面日志可以看到:select * from person_inf where person_id>?从person_inf表中查询出符合条件的Person实体(此处的测试数据只有3条符合条件的记录),接下来MyBatis会额外执行3条select语句——幸好此处的测试数据只有3条符合条件的记录,因此只需额外执行3条select语句。对于实际运行的项目,符合条件的数据记录可能是几十万、几百万,这样MyBatis会额外生成几十万、几百万条记录,这样会导致严重的性能缺陷。

注意

实际运行并没有那么糟糕,由于MyBatis缓存机制的缘故,当多个实体的关联实体相同时,只有第一个实体加载它的关联实体时需要执行select语句,如果后面的实体要加载的关联实体之前已被加载过(处于缓存中),MyBatis会直接使用缓存中的关联实体,不需要重新执行select语句。

那么,基于嵌套select映射策略是否完全没有价值呢?这倒不是,如果将这种映射策略与延迟加载结合使用,也许会有不错的效果。

例如,将上面Person实体获取关联的Address实体的加载策略改为延迟加载,假如MyBatis执行第一条select语句获取了1000个Person实体,此时MyBatis并不会立即为每个Person实体抓取关联的Address实体,因此不会额外生成N条select语句。

极端情况下,程序也许永远不会访问这1000个Person实体所关联的Address实体,这样MyBatis将永远不需要生成额外的select语句;更常见的情况下,这1000个Person实体中也许只有3个需要访问它关联的Address实体,这样MyBatis最多只需额外生成3条select语句——考虑到延迟加载的在内存开销方面的优势,额外执行3条select语句的开销也许可以忽略。

总结:如果将基于嵌套select映射策略与立即加载策略结合使用,几乎是一个非常糟糕的设计。建议:基于嵌套select映射策略总是和延迟加载策略结合使用。

注意

基于嵌套select映射策略需要和延迟加载策略结合使用。

延迟加载的原理

MyBatis这种延迟加载在底层是如何实现的呢?比如,本例中Address实体采用了延迟加载策略获取关联的Person实体,那MyBatis加载Address实体时如何来处理它的person变量呢?

在selectAddress()方法的①号代码处添加一个端点,然后使用Eclispse来调试该程序,当Eclipse执行到①号代码处时可在变量窗口看到如图2所示的信息。

图2 延迟加载的底层处理

从图2可以看到,当设置MyBatis采用延迟加载策略处理关联实体时,程序加载主实体时,它的代表关联实体的变量会被设为null,正如图2所看到person变量为null。但addr实体多出了一个handler变量,如图2黑框所示。

可是我们的Address类并没有定义handler变量啊?仔细查看图2中addr变量的类型,它并不是Address类的实力,而是Address_$$_jvs...类的实例——这个类是MyBatis调用Javassist库中动态生成的代理类。

MyBatis提供了一个proxyFactory设置,该设置用于指定MyBatis的代理工厂,如果不改变该设置,MyBatis默认使用Javassist作为代理工厂,此处就看到了MyBatis使用Javassist为Address生成的代理。

提示

Java领域常用的有3种代理技术:JDK动态代理(详细用法参考《疯狂Java讲义》第18章),CGLIB和Javassist,其中JDK动态代理存在一个很大的限制:它要求被代理类必须实现了接口;而CGLIB和Javassist的动态代理则不存在该限制,它们生成代理类是目标类的子类。

由于Javassist和CGLIB生成的代理类是目标类的子类,因此无论是使用CGLIB作为代理工厂,还是使用Javassist作为代理工厂,被代理类都不能是final类,否则MyBatis的延迟加载就要引发异常!因此此处的Address类不能有final修饰,否则程序会引发异常。

当程序通过Address实体去获取它关联的Person实体时,Address对象的handler对象就会起作用了,该对象负责执行select语句、并查询的结果来填充关联的Person实体。

本文分享自微信公众号 - 疯狂软件李刚(fkbooks),作者:疯狂软件李刚

原文出处及转载信息见文内详细说明,如有侵权,请联系 yunjia_community@tencent.com 删除。

原始发表时间:2019-07-22

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • MyBatis一级缓存的脏数据——MyBatis迷信者,清醒点之二

    本文详细分析了MyBatis中“一级缓存”在实际项目中如何产生脏数据,并并给出了具体的实施建议,本文适合对MyBatis有1年以上使用经验的开发者阅读,对MyB...

    疯狂软件李刚
  • MyBatis二级缓存的脏数据——MyBatis迷信者,清醒点之三

    为了避免二级缓存产生脏数据,MyBatis已经做了预防:Mapper组件执行DML语句(这些语句会更新底层数据)时默认会flush二级缓存,因此在同一个Mapp...

    疯狂软件李刚
  • Python的双端队列deque

    Python的强大并不在于它的语法,而在于它的库,当你对各种数据结构感到苦恼时,Python提供了各种开箱即用的数据结构。

    疯狂软件李刚
  • snowflake升级版全局id生成

    1. 背景 分布式系统或者微服务架构基本都采用了分库分表的设计,全局唯一id生成的需求变得很迫切。 传统的单体应用,使用单库,数据库中自增id可以很方便实现。分...

    aoho求索
  • Vue动画之列表过渡

            首先我们定义一个列表循环的元素放在transition-group中

    十月梦想
  • CentOS 8 上MySQL 8.0 安装部署与配置教程

    MySQL 8 新增了安全设置向导,这对于在服务器部署MySQL来说,简化了安全设置的操作,非常棒。

    KenTalk
  • mysql 统计join数据的条数

    当mysql  left join 或者 right join 时,有时候会发现count(*)是无法统计正确数据的

    仙士可
  • SQL优化:紧急情况下提高SQL性能竟是这样实现的!

    作者 | 黄堋 ,多年一线 Oracle DBA 经验,长期服务电信、电网、医院、政府等行业客户。擅长数据库优化、数据库迁移升级、数据库故障处理。

    数据和云
  • 【DB笔试面试733】在Oracle中,RAC中REMOTE_LISTENER的作用是什么?

    REMOTE_LISTENER参数主要用于RAC环境中监听器的远程注册,监听器的远程注册主要用于实现负载均衡。通常情况下,客户端发出的连接请求会首先被LOCAL...

    小麦苗DBA宝典
  • SQL Tuning 基础概述07 - SQL Joins

    N多年之前,刚刚接触SQL的时候,就被多表查询中的各种内连接,外连接,左外连接,右外连接等各式各样的连接弄的晕头转向。

    Alfred Zhao

扫码关注云+社区

领取腾讯云代金券