第 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 { 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); 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(); } } }
当我们执行这个转账方法时,它会在 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; 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); } } public static SqlSession openSession () { SqlSession session = localSession.get(); if (session == null ) { session = sqlSessionFactory.openSession(); localSession.set(session); } return session; } 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) { 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); 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; private String produceTime; private String carType; }
3.2.2. <if> 与 <where> 标签 这是最基础的动态 SQL 组合,用于处理可选的查询条件。
if 标签:用于进行条件判断。test 属性 : 它的值是一个 OGNL 表达式。如果表达式的结果为 true,标签内部的 SQL 语句就会被包含进来。我们通常用它来判断传入的参数是否为 null 或空字符串。where 标签:用于智能地包裹 if 判断。它非常智能,只会在其内部有任何 if 条件成立时,才会生成 WHERE 关键字。 它会自动 移除 第一个 if 条件前多余的 AND 或 OR。 需求 : 根据用户可能输入的 brand 和 carType 进行查询。
文件路径 (接口) : 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); log.info("场景1: brand='比亚迪汉', carType='新能源'" ); List<Car> cars1 = carMapper.selectByCondition("比亚迪汉" , "新能源" ); System.out.println(cars1); log.info("场景2: brand='宝马530Li', carType=null" ); List<Car> cars2 = carMapper.selectByCondition("宝马530Li" , null ); System.out.println(cars2); 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); } }
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); Car carToUpdate = new Car (); carToUpdate.setId(2L ); carToUpdate.setBrand("比亚迪-汉EV" ); int count = carMapper.updateSelective(carToUpdate); assertThat(count).isEqualTo(1 ); sqlSession.commit(); log.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" > <sql id ="carColumns" > id, car_num, brand, guide_price, produce_time, car_type </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 > <select id ="selectById" resultType ="com.example.bank.pojo.Car" > select <include refid ="carColumns" /> from t_car where id = #{id} </select > </mapper >
这个重构不改变任何功能,因此之前的测试用例 testSelectByCondition 依然会通过,这恰好证明了 <sql> 和 <include> 在不影响业务逻辑的前提下,优化了代码结构。