第 3 章 [核心] 事务控制与复杂查询

第 3 章 [核心] 事务控制与复杂查询

摘要: 一个应用程序的价值不仅在于实现功能,更在于其健壮性与灵活性。本章将聚焦于这两个关键品质。首先,我们将解决转账业务中的 原子性 问题,通过引入 事务控制ThreadLocal 模式,确保数据在任何情况下都保持一致。随后,为了满足日益复杂的报表查询需求,我们将深入探索 MyBatis 最强大的功能之一——动态 SQL。您将学会使用 <if>, <where>, <foreach> 等标签,让您的 SQL 语句能够根据不同的输入条件“智能”地变化。


3.1. [核心] MyBatis 的事务管理

当前的转账逻辑看似可行,但隐藏着巨大的风险。一个完整的转账操作包含“A 账户扣款”和“B 账户增款”两个步骤,它们必须 要么同时成功,要么同时失败。这就是事务的 原子性

3.1.1. 问题复现:一个危险的转账实现

为了管理业务逻辑,我们首先创建 Service 层。

文件路径 (接口): src/main/java/com/example/bank/service/AccountService.java

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

import com.example.bank.pojo.Account;

public interface AccountService {
/**
* 转账业务方法
* @param fromActno 转出账号
* @param toActno 转入账号
* @param money 转账金额
*/
void transfer(String fromActno, String toActno, Double money);
}

文件路径 (实现类): src/main/java/com/example/bank/service/impl/AccountServiceImpl.java
下面是一个 错误 的实现,它没有进行任何事务控制。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package com.example.bank.service.impl;

import com.example.bank.mapper.AccountMapper;
import com.example.bank.pojo.Account;
import com.example.bank.service.AccountService;
import com.example.bank.utils.SqlSessionUtil;
import org.apache.ibatis.session.SqlSession;

public class AccountServiceImpl implements AccountService {

@Override
public void transfer(String fromActno, String toActno, Double money) {
// 在一个会话中执行两次更新
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
AccountMapper accountMapper = sqlSession.getMapper(AccountMapper.class);

// 1. 查询转出账户余额
Account fromAct = accountMapper.selectByActno(fromActno);
if (fromAct.getBalance() < money) {
throw new RuntimeException("余额不足");
}
// 2. 查询转入账户
Account toAct = accountMapper.selectByActno(toActno);

// 3. 更新双方余额
fromAct.setBalance(fromAct.getBalance() - money);
toAct.setBalance(toAct.getBalance() + money);

// 4. 执行更新
int count = accountMapper.update(fromAct);

// 模拟一个异常,模拟扣款后、增款前程序突然崩溃
String s = null;
s.toString();

count += accountMapper.update(toAct);

if (count != 2) {
throw new RuntimeException("转账异常,请联系柜台");
}

// 提交事务
sqlSession.commit();
}
}
}

当我们执行这个转账方法时,它会在 s.toString() 处抛出 NullPointerException。结果是 act-001 的钱被扣了,但由于没有 commit,看似没问题。但如果 openSession() 默认开启了自动提交(openSession(true)),或者在两次更新之间是网络 IO 等问题,就会出现数据不一致。

真正的核心问题是:我们需要一个机制,将两次 update 操作绑定在同一个事务中,统一提交或回滚。

3.1.2. 解决方案:ThreadLocal 与事务手动控制

要保证 Service 层中的多次数据库调用在同一个事务里,就必须保证它们使用的是 同一个 SqlSession 实例

原理点 · ThreadLocal

ThreadLocal 是 Java 提供的一个神奇工具,它可以为一个线程 独立 地存储一个变量的副本。在一个线程的生命周期内,无论你在哪个方法中访问这个 ThreadLocal 变量,拿到的都是同一个对象。这完美地解决了我们的问题:我们可以将 SqlSession 存入 ThreadLocal,这样,在同一次请求(即同一个线程)中,无论 Service 调用多少次 DAO,获取到的都是同一个 SqlSession,从而共享同一个数据库连接和事务。

我们来改造 SqlSessionUtil

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

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.example.bank.utils;

import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import java.io.IOException;
import java.io.InputStream;

@Slf4j
public class SqlSessionUtil {

private static final SqlSessionFactory sqlSessionFactory;

// 创建一个 ThreadLocal 对象来存储 SqlSession
private static final ThreadLocal<SqlSession> localSession = new ThreadLocal<>();

static {
try {
InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml");
sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
} catch (IOException e) {
log.error("初始化 SqlSessionFactory 失败!", e);
throw new ExceptionInInitializerError(e);
}
}

/**
* 获取一个新的 SqlSession 对象,并与当前线程绑定
* @return SqlSession 实例
*/
public static SqlSession openSession() {
SqlSession session = localSession.get();
if (session == null) {
session = sqlSessionFactory.openSession();
localSession.set(session); // 将 session 存入 ThreadLocal
}
return session;
}

/**
* 关闭与当前线程绑定的 SqlSession
*/
public static void closeSession() {
SqlSession session = localSession.get();
if (session != null) {
session.close();
localSession.remove(); // 必须移除,防止内存泄漏
}
}
}

3.1.3. 正确的事务处理代码

现在,我们可以在 Service 层通过 try-catch-finally 结构,实现精确的事务控制。

文件路径 (修改): src/main/java/com/example/bank/service/impl/AccountServiceImpl.java

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package com.example.bank.service.impl;

import com.example.bank.mapper.AccountMapper;
import com.example.bank.pojo.Account;
import com.example.bank.service.AccountService;
import com.example.bank.utils.SqlSessionUtil;
import org.apache.ibatis.session.SqlSession;

public class AccountServiceImpl implements AccountService {

@Override
public void transfer(String fromActno, String toActno, Double money) {
// 在 Service 层控制事务
SqlSession sqlSession = SqlSessionUtil.openSession();
try {
AccountMapper accountMapper = sqlSession.getMapper(AccountMapper.class);
Account fromAct = accountMapper.selectByActno(fromActno);
if (fromAct.getBalance() < money) {
throw new RuntimeException("余额不足");
}
Account toAct = accountMapper.selectByActno(toActno);

fromAct.setBalance(fromAct.getBalance() - money);
toAct.setBalance(toAct.getBalance() + money);

int count = accountMapper.update(fromAct);

// 模拟异常
// String s = null;
// s.toString();

count += accountMapper.update(toAct);
if (count != 2) {
throw new RuntimeException("转账异常,请联系柜台");
}

// 所有操作成功,提交事务
sqlSession.commit();
} catch (Exception e) {
// 出现任何异常,回滚事务
sqlSession.rollback();
// 将异常继续向上抛出,通知调用者
throw new RuntimeException(e);
} finally {
// 无论成功与否,都要关闭会话
SqlSessionUtil.closeSession();
}
}
}

现在,如果您取消 String s = null; s.toString(); 的注释并再次运行测试,act-001 的扣款操作将会被回滚,数据库的数据将保持一致,我们的应用变得健壮了。

前瞻提示: 目前手动管理事务的 try-catch-finally 写法虽然能帮助我们理解原理,但在未来的 Spring / Spring Boot 整合开发中,这个过程将被极大地简化。届时,我们只需在一个方法上添加一个 @Transactional 注解,框架就会自动为我们完成事务的开启、提交和回滚。因此,您现在只需理解其原理,无需死记硬背此处的模板代码。


3.2. [核心] 动态 SQL

随着业务的发展,我们常常需要应对各种复杂的查询场景。例如,银行需要一个报表系统,可以根据一个或多个 不确定 的条件来筛选数据。这就是 MyBatis 强大的 动态 SQL 功能的用武之地。

3.2.1. 场景与准备工作

为了更好地演示动态查询,我们引入一个新的业务场景:汽车信息报表

1. 数据库准备

首先,我们在 bank_db 数据库中新建一张 t_car 表,并插入一些测试数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE `t_car` (
`id` bigint NOT NULL AUTO_INCREMENT,
`car_num` varchar(255) DEFAULT NULL,
`brand` varchar(255) DEFAULT NULL,
`guide_price` decimal(10,2) DEFAULT NULL,
`produce_time` varchar(255) DEFAULT NULL,
`car_type` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);

INSERT INTO `t_car` (id, car_num, brand, guide_price, produce_time, car_type) VALUES
(1, '1001', '宝马530Li', 55.00, '2023-10-01', '燃油车'),
(2, '1002', '比亚迪汉', 23.00, '2024-05-11', '新能源'),
(3, '1003', '丰田凯美瑞', 21.00, '2022-01-20', '燃油车'),
(4, '1004', '特斯拉Model Y', 30.00, '2024-02-15', '新能源');

2. 创建实体类 POJO

文件路径: src/main/java/com/example/bank/pojo/Car.java

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

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Car {
private Long id;
private String carNum;
private String brand;
private BigDecimal guidePrice; // 使用 BigDecimal 处理金额,更精确
private String produceTime;
private String carType;
}

3.2.2. <if><where> 标签

这是最基础的动态 SQL 组合,用于处理可选的查询条件。

  • if 标签:用于进行条件判断。
    • test 属性: 它的值是一个 OGNL 表达式。如果表达式的结果为 true,标签内部的 SQL 语句就会被包含进来。我们通常用它来判断传入的参数是否为 null 或空字符串。
  • where 标签:用于智能地包裹 if 判断。
    • 它非常智能,只会在其内部有任何 if 条件成立时,才会生成 WHERE 关键字。
    • 它会自动 移除 第一个 if 条件前多余的 ANDOR

需求: 根据用户可能输入的 brandcarType 进行查询。

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

1
2
3
4
5
6
7
8
9
package com.example.bank.mapper;

import com.example.bank.pojo.Car;
import org.apache.ibatis.annotations.Param;
import java.util.List;

public interface CarMapper {
List<Car> selectByCondition(@Param("brand") String brand, @Param("carType") String carType);
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?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.CarMapper">
<select id="selectByCondition" resultType="com.example.bank.pojo.Car">
select id, car_num, brand, guide_price, produce_time, car_type
from t_car
<where>
<if test="brand != null and brand != ''">
and brand = #{brand}
</if>
<if test="carType != null and carType != ''">
and car_type = #{carType}
</if>
</where>
</select>
</mapper>

请务必在 mybatis-config.xml<mappers> 标签中注册这个新的映射文件:

<mapper resource="mappers/CarMapper.xml"/>

1. 编写测试

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

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
32
33
34
35
36
37
38
39
package com.example.bank.test;

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

import java.util.List;

import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;

@Slf4j
public class CarMapperTest {

@Test
void testSelectByCondition() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
CarMapper carMapper = sqlSession.getMapper(CarMapper.class);
// 场景 1: 两个条件都非空
log.info("场景1: brand='比亚迪汉', carType='新能源'");
List<Car> cars1 = carMapper.selectByCondition("比亚迪汉", "新能源");
// [Car(id = 2, carNum = null, brand = 比亚迪汉, guidePrice = null, produceTime = null, carType = null)]
System.out.println(cars1);

// 场景 2: 只有 brand 非空
log.info("场景2: brand='宝马530Li', carType=null");
List<Car> cars2 = carMapper.selectByCondition("宝马530Li", null);
// [Car(id = 1, carNum = null, brand = 宝马 530Li, guidePrice = null, produceTime = null, carType = null)]
System.out.println(cars2);

// 场景 3: 两个条件都为空,查询所有
log.info("场景3: brand=null, carType=null");
List<Car> cars3 = carMapper.selectByCondition(null, null);
System.out.println(cars3);
}
}
}

3.2.3. <foreach> 标签

需求: 批量删除指定 ID 的汽车。SQL 语句类似 DELETE FROM t_car WHERE id IN (1, 2, 3)

<foreach> 标签用于遍历集合或数组,是实现批量操作(尤其是 IN 子句)的利器。

核心属性讲解:

  • collection: 必须。指定要遍历的参数,值与 Mapper 接口中 @Param 注解定义的值一致。
  • item: 必须。为集合中每个元素指定的变量名,在标签内部通过 #{} 来引用,例如 #{id}
  • open: 可选。表示整个遍历内容开始前要拼接的字符串,例如 (
  • close: 可选。表示整个遍历内容结束后要拼接的字符串,例如 )
  • separator: 可选。表示每次遍历元素之间要拼接的分隔符,例如 ,

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

1
int deleteByIds(@Param("ids") Long[] ids);

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

1
2
3
4
5
6
<delete id="deleteByIds">
delete from t_car where id in
<foreach collection="ids" item="id" separator="," open="(" close=")">
#{id}
</foreach>
</delete>

1. 编写测试

文件路径: src/test/java/com/example/bank/test/CarMapperTest.java (添加测试方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void testDeleteByIds() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
CarMapper carMapper = sqlSession.getMapper(CarMapper.class);
Long[] ids = {1L, 3L};
int count = carMapper.deleteByIds(ids);
assertThat(count).isEqualTo(2);
sqlSession.commit(); // 提交事务
log.info("成功批量删除 {} 条记录", count);
}
}
// 输出:
// 日志将显示: delete from t_car where id in ( ? , ? )
// INFO ... - 成功批量删除 2 条记录

3.2.4. <set> 标签

需求: 更新一辆汽车的信息,但只更新用户传入的字段,未传入的字段保持数据库原样(即“选择性更新”)。

<set> 标签与 <where> 类似,它会智能地生成 SET 关键字,并 移除 最后一个 if 条件后多余的逗号。

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

1
int updateSelective(Car car);

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

1
2
3
4
5
6
7
8
9
10
11
<update id="updateSelective">
update t_car
<set>
<if test="carNum != null and carNum != ''">car_num = #{carNum},</if>
<if test="brand != null and brand != ''">brand = #{brand},</if>
<if test="guidePrice != null">guide_price = #{guidePrice},</if>
<if test="produceTime != null and produceTime != ''">produce_time = #{produceTime},</if>
<if test="carType != null and carType != ''">car_type = #{carType},</if>
</set>
where id = #{id}
</update>

1. 编写测试

文件路径: src/test/java/com/example/bank/test/CarMapperTest.java (添加测试方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
void testUpdateSelective() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
CarMapper carMapper = sqlSession.getMapper(CarMapper.class);
// 我们只想把 id = 2 的车的品牌改为 "比亚迪-汉 EV",其他字段不传值
Car carToUpdate = new Car();
carToUpdate.setId(2L);
carToUpdate.setBrand("比亚迪-汉EV");

int count = carMapper.updateSelective(carToUpdate);
assertThat(count).isEqualTo(1);
sqlSession.commit();
log.info("选择性更新成功");
}
}
// 输出:
// 日志将显示: update t_car SET brand = ? where id = ?
// INFO ... - 选择性更新成功

3.2.5. <sql><include> 标签

需求: 在多个查询中,我们都需要查询相同的字段列表,为了避免重复编写和方便维护,我们可以将其抽取出来。

  • sql 标签:用于定义一个可重用的 SQL 片段。
    • id 属性: 必须。为这个 SQL 片段指定一个唯一的名称。
  • include 标签:用于在其他地方引用 sql 标签定义的片段。
    • refid 属性: 必须。其值必须与要引用的 <sql> 标签的 id 一致。

文件路径 (XML): src/main/resources/mappers/CarMapper.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
32
33
34
35
36
37
38
<?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.CarMapper">

<!-- 1. 定义可重用的SQL片段,用于存放表的列名 -->
<!-- 优点:当表字段变更时,只需修改此处,所有引用该片段的select语句都会生效,便于维护。 -->
<sql id="carColumns">
id, car_num, brand, guide_price, produce_time, car_type
</sql>

<!-- updateSelective 语句保持不变,因为它不涉及查询所有列,其动态性由 <set> 保证 -->

<!-- 2. 重构 selectByCondition,使用 <include> 引用SQL片段 -->
<select id="selectByCondition" resultType="com.example.bank.pojo.Car">
select <include refid="carColumns"/>
from t_car
<where>
<if test="brand != null and brand != ''">
and brand = #{brand}
</if>
<if test="carType != null and carType != ''">
and car_type = #{carType}
</if>
</where>
</select>

<!-- 3. 新增一个根据ID查询的示例,同样使用 <include> -->
<select id="selectById" resultType="com.example.bank.pojo.Car">
select <include refid="carColumns"/>
from t_car
where id = #{id}
</select>

<!-- deleteByIds 语句保持不变,因为它不涉及查询列 -->

</mapper>

这个重构不改变任何功能,因此之前的测试用例 testSelectByCondition 依然会通过,这恰好证明了 <sql><include> 在不影响业务逻辑的前提下,优化了代码结构。