导读
本文详细分析了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种映射策略:
<association.../>元素支持的属性较多,部分属性专对某种映射策略起作用,下面这些属性是所有映射策略都支持的通用属性。
对于基于嵌套select的映射策略来说,MyBatis需要使用额外的select语句来查询关联实体,因此这种策略需要为<association.../>元素指定如下3个额外的属性:
对于这种映射策略,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分别写成:
再看“先加载了从表实体”的情形,此时MyBatis已经加载了从表中id为101的记录,接下来MyBatis需要使用一条额外的select语句从主表中抓取它关联的实体。那么这条select语句应该写成如下形式:
select * from 主表where id = #{id}
对于上面select语句,我们必须让MyBatis将“4”作为参数传给它——这个4来自哪里?来自已加载的实体(从表实体)的refid列的值,故此时select和column分别写成:
认真理解上面讲解之后,接下来即可使用<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语句。
使用延迟加载的好处很明显:
上面程序中的第二行粗体字代码通过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属性的值。该注解只能指定如下两个属性:
至于<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实体。