前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >MyBatis的“基于嵌套select”映射的剖析

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

作者头像
疯狂软件李刚
发布2020-06-24 11:42:17
2K0
发布2020-06-24 11:42:17
举报
文章被收录于专栏:疯狂软件李刚

导读

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

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

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

下面是Person类的代码。

代码语言:javascript
复制
public class Person
{
    private Integer id;
    private String name;
    private int age;
    private Address address;
    // 下面省略构造器、setter和getter方法
    ...
}

下面是Address类的代码。

代码语言:javascript
复制
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语句应该写成如下形式:

代码语言:javascript
复制
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语句应该写成如下形式:

代码语言:javascript
复制
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接口的代码。

代码语言:javascript
复制
public interface PersonMapper
{
    Person getPerson(Integer id);
}

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

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

代码语言:javascript
复制
<?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实体,下面是该映射文件的代码。

代码语言:javascript
复制
<?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的方法。

代码语言:javascript
复制
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()方法时会在控制台看到如下日志:

代码语言:javascript
复制
[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()方法时会在控制台看到如下日志:

代码语言:javascript
复制
[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组件的接口代码。

代码语言:javascript
复制
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组件的接口代码。

代码语言:javascript
复制
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中增加如下定义:

代码语言:javascript
复制
    <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()方法,可在控制台看到如下日志输出。

代码语言:javascript
复制
[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实体。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2019-07-22,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 疯狂软件李刚 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档