第 4 章 [扩展性] 复杂关系映射与性能优化

第 4 章 [扩展性] 复杂关系映射与性能优化

摘要: 现实世界的业务远不止单表操作那么简单。本章我们将为银行应用引入“客户”实体,并深入探讨如何处理 “一个客户拥有多个账户” (一对多) 以及 “多个账户属于一个客户” (多对一) 这样的关联关系。您将掌握 MyBatis 中最强大的结果映射工具 <resultMap>,以及用于处理关联关系的 <association><collection> 标签。在此基础上,我们将进一步探讨性能优化这一高级主题,揭示著名的 N+1 查询问题,并学习如何通过 延迟加载一、二级缓存 来显著提升应用性能。


4.1. [基础] resultMap:复杂结果映射的基石

到目前为止,我们一直使用 resultType 来自动映射查询结果,这在列名与属性名(或开启驼峰映射后)能对应上的情况下非常方便。但当遇到多表查询、列名与属性名无规律对应时,我们就需要一个更强大的工具——<resultMap>

<resultMap> 允许我们手动定义数据库查询结果(ResultSet)的列与 Java 对象(POJO)的属性之间的映射关系。

核心标签与属性讲解:

  • <resultMap>: 定义一个结果映射集。
    • id: 必须。为此 <resultMap> 指定一个唯一的名称,供 <select> 标签引用。
    • type: 必须。指定要映射到的 POJO 类的全限定名或别名。
  • <id>: 用于映射主键字段,有助于 MyBatis 提高性能。
    • property: POJO 中的属性名。
    • column: 数据库结果集中的列名。
  • <result>: 用于映射普通字段。
    • property: POJO 中的属性名。
    • column: 数据库结果集中的列名。

需求: 假设 t_car 表的列名不规范(例如 car_number, car_brand),我们需要手动将其映射到 Car 对象的 carNum, brand 属性上。

文件路径 (XML): src/main/resources/mappers/CarMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
<resultMap id="carResultMap" type="com.example.bank.pojo.Car">
<id property="id" column="id"/>
<result property="carNum" column="car_num"/>
<result property="brand" column="brand"/>
<result property="guidePrice" column="guide_price"/>
<result property="produceTime" column="produce_time"/>
<result property="carType" column="car_type"/>
</resultMap>

<select id="selectById" resultMap="carResultMap">
select <include refid="carColumns" /> where id = #{id}
</select>

通过这种方式,无论列名多么不规范,我们都能精确地将其映射到正确的 Java 属性上。


4.2. [核心] 处理关联关系

现在,我们来升级我们的银行应用业务模型。

1. 场景与准备工作

一个客户(Customer)可以拥有多个账户(Account)。这是一个典型的“一对多”关系。反过来,一个账户(Account)只属于一个客户(Customer),这是一个“多对一”关系。

  • 数据库准备:
    我们新建 t_customer 表,并为 t_account 表添加外键 customer_id
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 新建客户表
CREATE TABLE `t_customer` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
INSERT INTO `t_customer` (id, name) VALUES (1, '张三'), (2, '李四');

-- 为账户表添加外键
ALTER TABLE `t_account` ADD COLUMN `customer_id` INT NULL;
UPDATE `t_account` SET `customer_id` = 1 WHERE `actno` = 'act-001';
UPDATE `t_account` SET `customer_id` = 2 WHERE `actno` = 'act-002';
-- 假设我们给张三再开一个账户
INSERT INTO `t_account` (actno, balance, customer_id) VALUES ('act-003', 1000.00, 1);
  • POJO 准备:
    文件路径: src/main/java/com/example/bank/pojo/Customer.java (新建)
1
2
3
4
5
6
7
8
9
10
11
12
package com.example.bank.pojo;

import lombok.Data;
import java.io.Serializable;
import java.util.List;

@Data
public class Customer implements Serializable { // 实现 Serializable 为二级缓存做准备
private Integer id;
private String name;
private List<Account> accounts; // 一个客户拥有多个账户
}

文件路径: src/main/java/com/example/bank/pojo/Account.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.bank.pojo;

import lombok.Data;
import java.io.Serializable;

@Data
public class Account implements Serializable { // 实现 Serializable
private Integer id;
private String actno;
private Double balance;
private Integer customerId;
private Customer customer; // 一个账户属于一个客户
}

2. 一对一 (<association>)

需求: 查询账户信息时,同时把所属的客户信息也查询出来。

我们采用“分步查询”的方式,这更灵活,且是实现后续“延迟加载”优化的前提。

  • association 标签讲解:
    • <association> 用于在 resultMap 中处理“拥有一个”(has-one)的关联关系。
    • property: POJO 中关联对象的属性名,例如 customer
    • select: 指定一个外部查询的 statementIdnamespace.id),MyBatis 会执行这个查询来加载关联对象。
    • column: 将主查询结果的某一列的值,作为参数传递给 select 指定的查询。

文件路径 (接口): src/main/java/com/example/bank/mapper/CustomerMapper.java (新建)

1
2
3
4
5
package com.example.bank.mapper;
import com.example.bank.pojo.Customer;
public interface CustomerMapper {
Customer selectById(Integer id);
}

文件路径 (XML): src/main/resources/mappers/CustomerMapper.xml (新建)

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.bank.mapper.CustomerMapper">
<select id="selectById" resultType="com.example.bank.pojo.Customer">
select * from t_customer where id = #{id}
</select>
</mapper>

文件路径 (XML): src/main/resources/mappers/AccountMapper.xml (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.bank.mapper.AccountMapper">

<!-- 为了清晰,可以给ResultMap一个更具描述性的名字,当然,保持原样也可以 -->
<resultMap id="accountWithCustomerResultMap" type="com.example.bank.pojo.Account">
<id property="id" column="id"/>
<result property="actno" column="actno"/>
<result property="balance" column="balance"/>
<!-- 关联查询:根据当前查询出的customer_id,去调用CustomerMapper中的selectById方法 -->
<association property="customer"
select="com.example.bank.mapper.CustomerMapper.selectById"
column="customer_id" />
</resultMap>

<select id="selectByActnoWithCustomer" resultMap="accountWithCustomerResultMap">
<!-- 建议明确写出列名,避免 "select *" 带来的潜在问题,并确保 column="customer_id" 存在 -->
select id, actno, balance, customer_id from t_account where actno = #{actno}
</select>

</mapper>

文件路径 (接口): src/main/java/com/example/bank/mapper/AccountMapper.java (添加方法)

1
Account selectByActnoWithCustomer(String actno);

编写测试

文件路径: src/test/java/com/example/bank/test/AssociationTest.java (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
public class AssociationTest {
@Test
void testSelectByActnoWithCustomer() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
AccountMapper mapper = sqlSession.getMapper(AccountMapper.class);
Account account = mapper.selectByActnoWithCustomer("act-001");

assertThat(account).isNotNull();
assertThat(account.getCustomer()).isNotNull();
assertThat(account.getCustomer().getName()).isEqualTo("张三");
log.info("多对一分步查询成功: {}", account);
}
}
}
// 输出:
// 日志会显示执行了两条 SQL:一条查询 t_account,一条查询 t_customer

3. 一对多 (<collection>)

需求: 查询客户信息时,同时把他名下所有的账户列表也查询出来。

  • collection 标签讲解:
    • <collection> 用于处理“拥有多个”(has-many)的关联关系。
    • property: POJO 中集合属性的名称,例如 accounts
    • ofType: 集合的泛型类型,例如 com.example.bank.pojo.Account
    • selectcolumn 的作用与 <association> 相同。

文件路径 (接口): src/main/java/com/example/bank/mapper/AccountMapper.java (添加方法)

1
List<Account> selectByCustomerId(Integer customerId);

文件路径 (XML): src/main/resources/mappers/AccountMapper.xml (添加)

1
2
3
<select id="selectByCustomerId" resultType="com.example.bank.pojo.Account">
select * from t_account where customer_id = #{customerId}
</select>

文件路径 (XML): src/main/resources/mappers/CustomerMapper.xml (修改)

1
2
3
4
5
6
7
8
9
10
11
12
<resultMap id="customerWithAccountsMap" type="com.example.bank.pojo.Customer">
<id property="id" column="id"/>
<result property="name" column="name"/>
<collection property="accounts"
ofType="com.example.bank.pojo.Account"
select="com.example.bank.mapper.AccountMapper.selectByCustomerId"
column="id" />
</resultMap>

<select id="selectByIdWithAccounts" resultMap="customerWithAccountsMap">
select * from t_customer where id = #{id}
</select>

文件路径 (接口): src/main/java/com/example/bank/mapper/CustomerMapper.java (修改)

1
Customer selectByIdWithAccounts(Integer id);

编写测试

文件路径: src/test/java/com/example/bank/test/CollectionTest.java (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.example.bank.test;

import com.example.bank.mapper.CustomerMapper;
import com.example.bank.pojo.Customer;
import com.example.bank.utils.SqlSessionUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.junit.jupiter.api.Test;


@Slf4j
public class CollectionTest {
@Test
void testSelectByIdWithAccounts() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
CustomerMapper mapper = sqlSession.getMapper(CustomerMapper.class);
Customer customer = mapper.selectByIdWithAccounts(1);
log.info("一对多分步查询成功: {}", customer);
}
}
}
// 输出:
// 日志会显示执行了两条 SQL:一条查询 t_customer,一条查询 t_account

4.3. [性能] 性能优化

分步查询虽然灵活,但可能会引发著名的 N+1 查询问题:查询 1 个主对象(例如 Customer),触发了 N 个查询关联对象(Account)的 SQL。如果我们要查询 100 个客户,就会执行 1 + 100 = 101 条 SQL,效率低下。

1. 延迟加载

延迟加载是解决 N+1 问题的有效手段。它的核心思想是:非必要,不加载。只有当你真正去访问关联对象时(例如调用 customer.getAccounts()),MyBatis 才会去执行加载关联对象的 SQL。

  • 如何开启:
    在全局配置文件中开启即可。

文件路径: src/main/resources/mybatis-config.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<!-- settings 必须在 environments 和 mappers 之前 -->
<settings>
<setting name="lazyLoadingEnabled" value="true"/>
</settings>

<!-- environments 可以放在 settings 之后 -->
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/bank_db"/>
<property name="username" value="root"/>
<property name="password" value="root"/>
</dataSource>
</environment>
</environments>

<!-- mappers 放在最后 -->
<mappers>
<mapper resource="mappers/AccountMapper.xml"/>
<mapper resource="mappers/CarMapper.xml"/>
<mapper resource="mappers/CustomerMapper.xml"/>
</mappers>
</configuration>

  • 演示:
    文件路径: src/test/java/com/example/bank/test/PerformanceTest.java (新建)

2. MyBatis 缓存

缓存是另一种重要的性能优化手段,它能减少与数据库的交互次数。

  • 一级缓存 (SqlSession 级别):

    • 原理: 默认开启,与 SqlSession 绑定。在同一个 SqlSession 的生命周期内,执行完全相同的查询(相同的 statementId 和参数),只有第一次会访问数据库,后续的都将直接从内存缓存中获取。
    • 演示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Test
    void testL1Cache() {
    try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
    CustomerMapper mapper = sqlSession.getMapper(CustomerMapper.class);
    log.info("--- 第一次查询 ---");
    mapper.selectById(1);
    log.info("--- 第二次查询,应命中一级缓存,不会再发 SQL ---");
    mapper.selectById(1);
    }
    }
    // 输出:
    // ... --- 第一次查询 ---
    // ... ==> Preparing: select * from t_customer where id = ?
    // ... --- 第二次查询,应命中一级缓存,不会再发 SQL ---
    // (此处无新的 SQL 日志)
  • 二级缓存 (SqlSessionFactory 级别):

    危险: 二级缓存是跨会话共享的,数据一致性较难保证,不同会话可能获取到旧数据。而且缓存过多数据会占用内存,不适合数据变化频繁的场景,所以默认不开启,让开发者根据实际需求灵活配置

    ✅ 适合开启二级缓存的场景

    查询多、更新少(典型的报表系统、字典表、配置类数据)

    单体应用或已配置了共享缓存方案(如整合 Redis)

    严格管理 Mapper 操作范围,不跨 Mapper 操作同一张表

    原理: 跨 SqlSession 共享的缓存,需要手动开启。当一个 SqlSession 关闭或提交时,它的一级缓存中的数据会被刷新到二级缓存中。

    开启步骤:

    1. mybatis-config.xml 中确保 cacheEnabledtrue (默认就是)。
    2. 在需要开启二级缓存的 Mapper XML 文件中添加 <cache/> 标签。
    3. 所有参与缓存的 POJO 必须实现 java.io.Serializable 接口(我们之前已完成)。

    文件路径: src/main/resources/mappers/CustomerMapper.xml (添加)

    1
    2
    3
    4
    <mapper namespace="com.example.bank.mapper.CustomerMapper">
    <cache/>
    ...
    </mapper>