MyBatis 整合实战:Mapper、动态 SQL 与分页

bee2026-10-0877 分钟0 次阅读
从 mybatis-spring-boot-starter 起步:#{} 与 ${} 的安全分界、动态 SQL 标签全家桶、关联查询与分页插件,配一套可直接抄用的 XML + 注解双写法。
1 / 165
小节
〇、30 秒看懂
2 / 165

先破一个误解:MyBatis 不替你写 SQL。它只做一件很朴素的事——你写好 SQL,它负责把方法参数搬进 SQL、把查询回来的行搬进 Java 对象。中间那些重复劳动(拿连接、预编译、setXxx 传参、遍历 ResultSet、逐字段赋值、异常包装、归还连接)全被它接管了。所以学 MyBatis 的真正重点是两件事:SQL 写在哪、怎么让它和 Java 对得上。

3 / 165

六个词一句话解释(后面全文都用):

4 / 165
  • Mapper 接口:你只声明方法、不写实现的那个 Java 接口,例如 UserMapper.findById(Long id)。它是「点菜单」,不是厨房
  • XML 映射文件:一段段带 namespace 的 SQL 清单,namespace 必须是接口的全限定名,id 必须与方法名一致——两者靠这两条线缝在一起
  • SqlSession:MyBatis 的执行入口对象,可以理解成「这次会话期间的窗口柜员」,selectOne / insert / update 都从它走
  • Executor:SqlSession 背后的调度工,决定要不要查缓存、要不要复用语句、批量还是逐条执行
  • resultMap / resultType:结果怎么装回对象的两种写法。列名与字段能对上就用 resultType,对不上(或有关联对象)就得写 resultMap
  • #{} 与 ${}:前者是「参数」(预编译,安全),后者是「字符串拼接」(改 SQL 结构,危险)。这是本篇最重要的一条分界线
5 / 165
类比

MyBatis 是一位翻译官。客户(Java 代码)说的是中文,柜台(数据库)只听英文,翻译官的责任就是在两边之间搬运信息,并且绝不替客户编造诉求。对应关系很整齐:Mapper 接口 = 你递过去的那张「要翻译什么」的单子;XML 里的 SQL = 翻译官起草好的英文稿;#{} = 稿件里留的空格,最后由专人填数字(谁也改不了句式);${} = 直接把客户原话抄进句式里(他要是塞一句「顺便删表」,句子就真的变了);resultMap = 对方说完后逐条记回你的表格;驼峰开关没开 = 翻译官把 user_name 直译成「用户名」,可你实体里存的是 userName,于是这一栏永远空白。翻译官只管搬运,不负责替你思考——这条心法能让你避开 MyBatis 八成的坑。

6 / 165
架构图
图 · 动态 SQL 标签家族
图 · 动态 SQL 标签家族
7 / 165

上面这张「标签家族树」就是本篇的地图:日常你能遇到的拼接需求,几乎都落在这六个分支里。第五节会逐个给可抄片段。

8 / 165

学完这一篇,你应该能回答三个问题:

9 / 165
  • 我只写了一个接口、连实现类都没有,userMapper.findById(1L) 到底是谁在执行?
  • Invalid bound statement (not found) 这句话出现时,我应该按哪三条线索去查?
  • <if test="keyword != null"> 为什么还不够?空串会让 SQL 变成什么样子?
10 / 165

先用沙盘感受一下「映射」这件事有多容易翻车——切换两个开关,右侧的控制台输出立刻变:

11 / 165
沙盘
沙盘同一个查询,映射开关决定对象里有没有值
运行结果
===> Preparing: SELECT id, user_name, status FROM t_user WHERE id = ?
===> Parameters: 1(Long)
<=== Row: 1 | alice | 1
User{id=1, userName='alice', status=1}
# 列名 user_name 经驼峰规则命中 userName,不用写一行映射代码
最省事的正确组合:列名符合下划线规范 + 开关打开。新手项目请一开始就把这个开关设为 true
12 / 165
提示

这四个格子里,只有右下角那种「一半对一半空」最难自查——因为它既不报错也不为空。真实工程里请养成一个习惯:看日志里的 <=== Row 那一行和对象的打印结果是否吻合,不吻合就先怀疑映射,而不是 SQL。

13 / 165
小节
一、MyBatis 的定位:半自动 ORM 的取舍
14 / 165

做数据访问层,几乎绕不开一个灵魂拷问:SQL 到底该不该由框架替你写? 完全交给框架(JPA/Hibernate),CRUD 飞快,但一旦遇到复杂报表、多表联查、性能调优,你就得和框架生成的 SQL 搏斗;完全手写(JdbcTemplate),SQL 尽在掌握,可 ResultSet 到对象的搬运能把人累死。

15 / 165

MyBatis 的答案是「半自动」:它不替你写 SQL,只负责把 SQL 与 Java 对象之间那道繁琐的搬运缝起来。你给出 SQL,它负责预编译、参数绑定、结果映射、缓存、事务衔接。

16 / 165
架构图
图 1 · MyBatis 的工作组件
图 1 · MyBatis 的工作组件
17 / 165
对照表
方案SQL 控制力开发效率学习成本典型场景
JdbcTemplate100% 手写,最强低(样板代码多)低报表、批量、极简工具
MyBatis高(SQL 自己写,可控)中高中复杂查询、性能敏感、需要改 SQL
JPA / Hibernate低(框架生成 SQL)高(CRUD 几乎零代码)高领域模型清晰、以 CRUD 为主
18 / 165
说明

选型没有绝对优劣,只有匹配度。SQL 复杂、要调优、团队熟 SQL 的项目,MyBatis 往往更顺手;模型稳定、以增删改查为主 的项目,JPA 的开发效率更高。下面先看清 MyBatis 这条链上每一层在干什么,再动手整合。

19 / 165
小节
二、整合起步:依赖、配置与 @MapperScan
20 / 165

Spring Boot 不会自动带上 MyBatis,需要官方 starter。三件套一次配齐,之后基本不用再碰配置:

21 / 165
xml
<dependency>    <groupId>org.mybatis.spring.boot</groupId>    <artifactId>mybatis-spring-boot-starter</artifactId>    <version>3.0.3</version>   <!-- 适配 Spring Boot 3.x / JDK 17+ --></dependency><dependency>    <groupId>com.mysql</groupId>    <artifactId>mysql-connector-j</artifactId>    <scope>runtime</scope></dependency>
22 / 165

接着是 application.yml。这三个配置项是新手最容易漏、又最容易出诡异 bug 的地方:

23 / 165
yaml
mybatis:  mapper-locations: classpath:mapper/*.xml     # XML 放哪,按需扫描  type-aliases-package: com.example.demo.entity # 实体包名,XML 里可省略全限定名  configuration:    map-underscore-to-camel-case: true          # user_name → userName,必开    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl  # 控制台打印 SQL# 可选:想用 PageHelper 分页插件,见第七节
24 / 165

启动类上加上扫描注解,让容器知道去哪个包找 Mapper 接口:

25 / 165
代码对照
代码java
@SpringBootApplication@MapperScan("com.example.demo.mapper")   // 一次扫全,免得每个接口都写 @Mapperpublic class DemoApplication {    public static void main(String[] args) {        SpringApplication.run(DemoApplication.class, args);    }}
解读
  • mapper-locations 默认是空,XML 必须放对位置才会被加载,否则报 Invalid bound statement (not found)
  • map-underscore-to-camel-case: true 开启后,数据库的 user_name 自动映射到实体 userName,不写 @Results 也能对上
  • @MapperScan 与逐个接口标 @Mapper 二选一即可,前者更省事

坑:XML 放在 src/main/java 下的包目录里,编译后默认不会被复制到 target/classes。请把 XML 放到 src/main/resources/mapper/ 下,或给 pom 加 resources 配置,否则一定报 Invalid bound statement。

26 / 165

依赖和配置各有三四种组合,别背——勾一遍,看它们各自生成什么。先勾 pom:只留 MyBatis + MySQL 能得到什么,加上 H2 和 Test 之后测试类能少写几行,scope 又分别落在哪一档:

27 / 165
生成器
生成器MyBatis 项目该引哪几个依赖pom.xml3 / 8
勾 MyBatis / MySQL / H2 / Test,对照生成出来的三行 scope:runtime 是跑起来才需要、test 只活在测试里、compile 才会传下去——第 2 篇讲过的那条规则在这里第一次用上
产物
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>demo-service</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>3.0.3</version>
            <!-- 第三方 starter:必须写版本 -->
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
勾了这些,代价与理由在这里
parent继承 3.3.4 的 starter-parent 之后,所有 spring-boot-starter-* 都不用写版本号;一旦有人手写给某个 starter 加 version,就以那条为准——这是依赖版本漂移最常见的原因。
Web做接口就绕不开它: DispatcherServlet、内嵌 Tomcat、JSON 序列化全在这个 starter 里。
MyBatis 3.x第三方 starter,版本不受 Boot BOM 管,必须自己写 <version>。
MySQL 驱动scope=runtime:编译期不需要它,运行期靠 SPI 反射装载,别写成 compile。
28 / 165

再勾 yml:只勾「数据源」是能让 Mapper 跑起来的最小集,加上「日志」才看得见 ===> Preparing 那几行(第八、十一节全都靠它),「profile」则演示开发库与生产库怎么分文件配:

29 / 165
生成器
生成器一份能跑起来又能看见 SQL 的配置application.yml2 / 4
datasource 给 url / 账号 / 池参数;logging 把 org.apache.ibatis 与 Mapper 包的级别调出来,日志里才有 SQL 与参数;profile 让 dev 与 prod 各拿一份配置——注意生产那份千万别把 log-impl 设成 StdOutImpl
产物
server:
  port: 8080

spring:
  application:
    name: demo-service
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: ${DB_USER:root}          # ${} 占位符:环境变量优先,冒号后是默认值
    password: ${DB_PASS:}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000
      max-lifetime: 1740000            # 必须小于 MySQL 的 wait_timeout
      pool-name: beeHikari

logging:
  level:
    root: INFO
    com.example.demoservice: DEBUG
    org.springframework.jdbc.core.JdbcTemplate: DEBUG   # 打 SQL 与参数
  file:
    name: logs/app.log
  logback:
    rollingpolicy: { max-file-size: 50MB, max-history: 14 }
勾了这些,代价与理由在这里
datasource池参数写在这里才生效;写在代码里 new HikariDataSource() 就白配了。
logging级别可按包精细控制;logging.level.root=DEBUG 会把三方库全打爆,别在生产这么干。
30 / 165
说明

这两份产物加起来就是本节那三件套的完整答案。留一个自检动作:每勾一项都问一句「去掉它,哪一行日志或哪条报错会消失」——这比记住配置名有用得多。

31 / 165
小节
三、第一个 Mapper:#{} 与 ${} 的安全分界
32 / 165

先看实体、接口、XML 三件套的完整样子。这是后面所有内容的骨架:

33 / 165
java
// 实体:字段用驼峰,数据库列名可不一样,靠 map-underscore 或 resultMap 对上public class User {    private Long id;    private String userName;   // DB 列 user_name    private Integer status;    // getter / setter 省略}
34 / 165
java
public interface UserMapper {    User findById(Long id);    List<User> findByStatus(@Param("status") Integer status);    int insert(User user);}
35 / 165
xml
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"        "http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.demo.mapper.UserMapper">    <select id="findById" resultType="User">        SELECT id, user_name, status FROM t_user WHERE id = #{id}    </select>    <select id="findByStatus" resultType="User">        SELECT id, user_name, status FROM t_user WHERE status = #{status}    </select>    <insert id="insert" useGeneratedKeys="true" keyProperty="id">        INSERT INTO t_user (user_name, status) VALUES (#{userName}, #{status})    </insert></mapper>
36 / 165
原理动画
动图 · 一条 SQL 的执行旅程
动图 · 一条 SQL 的执行旅程
37 / 165

上一张讲的是「一条 SQL 走了几步」,这一张回答新手更关心的问题:接口里没有实现类,那到底是谁在替我干活? 七棒接力,把 MapperProxy、SqlSession、Executor、StatementHandler、ResultSetHandler 五个名字一次认全。

38 / 165
原理动画
动图 · 接口没有实现类,谁来干活
动图 · 接口没有实现类,谁来干活
39 / 165

接口方法名必须与 XML 里的 id 一致,namespace 必须是接口全限定名——这两条对不上,启动就直接失败。

40 / 165

现在进入本节重点:#{} 与 ${} 的区别不是「一个带引号一个不带」,而是「预编译」与「字符串拼接」的区别。

41 / 165
对照表
写法底层行为防注入正确用途
#{name}生成 PreparedStatement 的 ?,参数走 setXxx安全99% 的参数传值
${name}直接把值拼接进 SQL 字符串有风险表名 / 列名 / 排序字段等结构片段
42 / 165

看一个真实的注入现场。下面这段想按前端传来的字段排序:

43 / 165
java
@Select("SELECT * FROM t_user ORDER BY ${orderBy}")List<User> listOrderBy(@Param("orderBy") String orderBy);
44 / 165

如果 orderBy 直接来自请求参数,攻击者传 id; DROP TABLE t_user--,拼接后的 SQL 就变成 ... ORDER BY id; DROP TABLE t_user--。${} 不会预编译,SQL 结构被篡改,注入就此发生。

45 / 165
代码对照
代码java
// 安全写法:排序字段走白名单校验,只允许固定集合里的值,再用 ${} 拼结构private static final Set<String> ALLOWED = Set.of("id", "user_name", "status", "created_at");public List<User> listOrderBy(String raw) {    if (raw == null || !ALLOWED.contains(raw)) {        raw = "id";              // 非法输入直接回退到安全默认值    }    return userMapper.listOrderBy(raw);   // 此时 raw 已在白名单内}
解读
  • #{}:参数化查询,数据库把值当「数据」处理,永远无法改变 SQL 语法
  • ${}:字符串替换,值会被当作「SQL 结构」的一部分,只能用于表名、列名、排序方向这类无法参数化的位置
  • 结论一句话:能用 #{} 就绝不写 ${};非用 ${} 不可时,先过白名单
46 / 165

上面这三行是结论,「分岔发生在哪一步」得看动图。注意第 ③④ 帧——SQL 结构在 PreparedStatement 那一刻就已经定死了,之后进来的值只能填进槽位,永远改不动句式;而 ${} 是在第 ① 帧之前就把值抄进了句式本身:

47 / 165
原理动画
动图 · #{} 与 ${} 的分岔路
动图 · #{} 与 ${} 的分岔路
48 / 165

再亲手跑一遍这个实验:切到「注入现场」,看一句 ' OR 1=1 -- 是怎么把 WHERE 条件整段吃掉的;切到「必须拼接的时候」,看 ORDER BY 那个位置为什么 #{} 无能为力——这是本节唯一一处 #{} 真正用不了的地方:

49 / 165
内核实验
TeaVM预编译与注入:#{ 与 }$ 到底差在哪一步未启动
按 hash → dollar → inject → order 的顺序切:hash 看 ? 与 setXxx 的配合,dollar 看字符串替换,inject 看一条真实注入语句,order 看那句「有些位置只能拼接」
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
50 / 165

规则只有一条,但能用在哪、不能用在哪才是新手真正会卡住的地方——因为「这里能不能参数化」取决于这个位置在 SQL 语法里扮演什么角色。来玩一局配对:左边是 SQL 里的位置,右边点它该用的写法;配错了当场告诉你原因。

51 / 165
配对闯关
闯关这个位置该用 #{} 还是 ${}已配对 0/6 · 配错 0
六组都是真实写法。判据只有一条:这个位置在语法里是「一个值」还是「一段结构」
先点左边一个
52 / 165
小节
四、参数传递与结果映射
53 / 165

单参数时 MyBatis 直接取,多个参数就得用 @Param 起名,否则只能靠 arg0/param1 这种难记的别名:

54 / 165
java
// 多参数必须 @Param:否则 XML 里只能用 #{arg0} #{arg1} 或 #{param1} #{param2}List<User> search(@Param("keyword") String keyword,                  @Param("status") Integer status,                  @Param("offset") int offset,                  @Param("size") int size);
55 / 165

字段名与列名对不上时,用 resultMap 显式声明;关联查询则用 association(一对一)和 collection(一对多):

56 / 165
代码对照
代码xml
<resultMap id="OrderWithItems" type="Order">    <id     property="id"         column="order_id"/>    <result property="orderNo"    column="order_no"/>    <result property="createdAt"  column="created_at"/>    <!-- 一对一:订单 → 用户 -->    <association property="user" javaType="User">        <id     property="id"       column="u_id"/>        <result property="userName" column="u_name"/>    </association>    <!-- 一对多:订单 → 明细列表 -->    <collection property="items" ofType="OrderItem">        <id     property="id"       column="item_id"/>        <result property="productId" column="product_id"/>        <result property="quantity"  column="quantity"/>    </collection></resultMap><select id="findOrderDetail" resultMap="OrderWithItems">    SELECT o.id AS order_id, o.order_no, o.created_at,           u.id AS u_id, u.user_name AS u_name,           i.id AS item_id, i.product_id, i.quantity    FROM t_order o    JOIN t_user u ON u.id = o.user_id    LEFT JOIN t_order_item i ON i.order_id = o.id    WHERE o.id = #{id}</select>
解读
  • association 处理「一个订单属于一个用户」,collection 处理「一个订单包含多条明细」
  • 关键在 SQL:用别名把不同表的同名列区分开(u_id / item_id),resultMap 再按别名归位
  • 若字段与列一致且驼峰能对上,完全可以省掉 resultMap,直接 resultType
57 / 165
内核实验
TeaVM看数据访问如何嵌进请求链路未启动
以 /users/42 为例,理解 Controller → Service → Mapper 的调用位置
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
58 / 165
提示

上面的交互演示跑的是 /users/42 这条请求。看一遍你会发现,Mapper 只是链路最末端的一环:Controller 接参 → Service 编排业务和事务 → Mapper 执行 SQL。理解了这条链,你才能想清楚「事务该加在哪一层」。

59 / 165

那条链读起来像七个名词,但它其实是一段可以被单步执行的代码。下面把 userMapper.findById(1L) 摊开:左边七行是真实走过的顺序,右边同步刷新「此刻手里拿着什么」。连点下一步,重点盯第 3 步——那句 Invalid bound statement (not found) 就诞生在那一格:

60 / 165
单步调试台
单步台单步跟一遍:接口没有实现类,这行代码到底谁在执行1 / 7
按「下一步」走七拍。第 2 步你手里还只有一个代理对象,第 4 步才第一次碰到 SQL——这中间没有任何你写的代码
被调试的代码
1User u = userMapper.findById(1L); // 你手里只有接口,没有实现类
2// MapperProxy.invoke(proxy, method, args) // JDK 动态代理是唯一入口
3// mapperInterface + methodName 拼出 msId // 形如 com.example.UserMapper.findById
4MappedStatement ms = cfg.getMappedStatement(msId); // 拿这条钥匙去找 SQL
5// SqlSession.selectOne(ms, param) // 执行入口,你平时看不到它
6// Executor.query(ms, param) // 先问一级缓存,没命中才发 SQL
7// ResultSetHandler 把行装回 User // 驼峰或 resultMap 在这一格接手
此刻的变量
userMapper 实际是JDK 代理对象
方法名findById
有没有实现类没有
调用栈
1UserService.list
2UserMapper.findById
1容器启动时 @MapperScan 把这些接口注册成 Bean,注入进来的从来不是某个 UserMapperImpl,而是 Proxy.newProxyInstance 造出来的代理。所以你找不到实现类不是记错了,是它确实不存在。
61 / 165
小节
五、动态 SQL 全家桶
62 / 165

动态 SQL 是 MyBatis 最实用的特性:同一段 XML 能根据参数拼出不同 SQL。下面按标签逐个给片段,可以直接抄。

63 / 165
类比

动态 SQL 就像填空式的公文模板。你写好一份「兹有 ___ 同志,因 ___ 请假」的模板,盖章前只把当天真正需要的句子填进去;而 <where> / <set> 是那个特别较真的校对员——开头多出来的「AND」、结尾多出来的逗号,它都帮你删掉,免得整份文件语法不通(SQL 报 syntax error)。<foreach> 则像把一串名字用顿号连起来写成「张三、李四、王五」,一旦一个名字都没有,写出来就是空的「()」,读的人当场看不懂(IN () 语法错)。记住这三个角色,标签名就再也不用背了。

64 / 165

随堂第一题,很轻:

65 / 165
随堂自测
随堂自测下面这段查询,当 keyword 和 status 都为 null 时,最终生成的 SQL 是什么? SELECT * FROM t_user <where> <if test="keyword != null">AND user_name LIKE #{keyword}</if> <if test="status != null">AND status = #{status}</if> </where>
先自己选一个,选中立刻告诉你对不对
66 / 165
小节
5.1 if:按条件追加
67 / 165
代码对照
代码xml
<select id="search" resultType="User">    SELECT * FROM t_user    WHERE 1 = 1    <if test="keyword != null and keyword != ''">        AND user_name LIKE CONCAT('%', #{keyword}, '%')    </if>    <if test="status != null">        AND status = #{status}    </if></select>
解读
  • test 里是 OGNL 表达式,字符串判空要同时判 != null 和 != ''
  • WHERE 1 = 1 是老写法,用来兜住第一个 AND;下一节的 <where> 会把它优雅地干掉
68 / 165
小节
5.2 choose / when / otherwise:多选一
69 / 165
xml
<choose>    <when test="orderBy == 'price'">ORDER BY price ASC</when>    <when test="orderBy == 'sales'">ORDER BY sales DESC</when>    <otherwise>ORDER BY id DESC</otherwise>   <!-- 兜底,避免无排序 --></choose>
70 / 165
小节
5.3 where / set / trim:自动增删关键字
71 / 165
代码对照
代码xml
<select id="search2" resultType="User">    SELECT * FROM t_user    <where>        <if test="keyword != null and keyword != ''">            AND user_name LIKE CONCAT('%', #{keyword}, '%')        </if>        <if test="status != null">AND status = #{status}</if>    </where></select><update id="updateSelective">    UPDATE t_user    <set>        <if test="userName != null">user_name = #{userName},</if>        <if test="status != null">status = #{status},</if>    </set>    WHERE id = #{id}</update>
解读
  • <where>:有内容才加 WHERE,且自动剥掉开头的 AND / OR
  • <set>:有内容才加 SET,且自动剥掉结尾多余的逗号
  • <trim prefix="(" suffix=")" prefixOverrides="AND |OR "> 是二者的通用形态,需要自定义前后缀时用它
72 / 165
小节
5.4 foreach:in 查询与批量插入
73 / 165
代码对照
代码xml
<!-- in 查询:把集合展开成 (1, 2, 3) --><select id="findByIds" resultType="User">    SELECT * FROM t_user    WHERE id IN    <foreach collection="ids" item="id" open="(" separator="," close=")">        #{id}    </foreach></select><!-- 批量插入:一次 INSERT 多条 VALUES --><insert id="batchInsert">    INSERT INTO t_user (user_name, status) VALUES    <foreach collection="list" item="u" separator=",">        (#{u.userName}, #{u.status})    </foreach></insert>
解读
  • collection:接口传 List 时写 list,传数组写 array,@Param 命名后写参数名
  • open / close / separator 分别控制包裹符号与分隔符,#{id} 在 foreach 内会自动映射到当前项
74 / 165
小节
5.5 sql / include:片段复用
75 / 165
代码对照
代码xml
<sql id="baseColumns">id, user_name, status, created_at</sql><select id="findById" resultType="User">    SELECT <include refid="baseColumns"/> FROM t_user WHERE id = #{id}</select>
解读
  • 把重复的列清单抽成 <sql>,改一处全局生效
  • <include> 里还能塞 <property> 做参数化,适合更复杂的复用场景
76 / 165
小节
六、注解写法对照:@Select / @Insert / @Results
77 / 165

简单 SQL 直接写在接口上更直观,快速原型阶段很香:

78 / 165
java
public interface UserMapper {    @Select("SELECT id, user_name, status FROM t_user WHERE id = #{id}")    @Results(id = "userMap", value = {        @Result(column = "id",        property = "id", id = true),        @Result(column = "user_name", property = "userName"),        @Result(column = "status",    property = "status")    })    User findById(Long id);    @Insert("INSERT INTO t_user (user_name, status) VALUES (#{userName}, #{status})")    @Options(useGeneratedKeys = true, keyProperty = "id")    int insert(User user);    @Update("UPDATE t_user SET status = #{status} WHERE id = #{id}")    int updateStatus(@Param("id") Long id, @Param("status") Integer status);}
79 / 165

注解与 XML 各自的主场很清楚:

80 / 165
对照表
维度注解写法XML 写法
可读性简单 SQL 直观复杂 SQL 层次分明
动态 SQL需 <script> 包裹,很别扭原生标签,天然好用
复用片段难以复用<sql> / <include> 随手抽
关联映射@Results 能写但很挤resultMap 清晰
改 SQL 是否要重新编译要不用,改 XML 即可
81 / 165
要点

动态 SQL、复杂关联、需要复用片段 这三种情况,一律用 XML;只有「一两行的简单查询」才考虑注解。别为了少一个文件把三种情况硬塞进注解,那只会让维护成本翻倍。

82 / 165
小节
七、分页方案:从手写 limit 到插件
83 / 165

最原始的方式是手动把分页参数拼进 SQL:

84 / 165
xml
<select id="page" resultType="User">    SELECT * FROM t_user ORDER BY id DESC LIMIT #{offset}, #{size}</select>
85 / 165

能跑,但每次都得多写一条 COUNT(*) 查询,参数、偏移量全靠手算,不推荐。更常用的是 PageHelper 插件。

86 / 165

PageHelper 的原理很巧妙:调用查询前一秒写下分页参数,它用一个 ThreadLocal 把参数存起来,再通过 MyBatis 的拦截器改写即将执行的 SQL(自动追加 LIMIT 并额外跑一次 COUNT),查询结束后清理 ThreadLocal。

87 / 165
代码对照
代码java
// 1. 依赖:pagehelper-spring-boot-starter(版本与 Boot 对齐)// 2. 用法:紧挨着查询,中间不能夹别的 SQLPageHelper.startPage(1, 10);              // 第 1 页,每页 10 条List<User> list = userMapper.search(null, 1, null, null);PageInfo<User> pageInfo = new PageInfo<>(list);   // 包装出总记录数、总页数System.out.println(pageInfo.getTotal());System.out.println(pageInfo.getPages());
解读
  • startPage 与真正的查询必须紧挨,中间插入任何别的查询都会让分页参数「串味」
  • PageInfo 会读 ThreadLocal 里那次 COUNT 的结果,算出 total / pages
  • 切记:PageHelper.startPage 只对紧随其后的第一条 SQL 生效
88 / 165

「只对第一条生效」这句话,只有把改写过程摊开看才会真的相信。下面这五格点着走一遍,重点在第 ① 格(参数存在哪)和第 ⑤ 格(什么时候被清掉)——它俩共同决定了「中间夹一条别的 SQL 会发生什么」:

89 / 165
交互图解
流程PageHelper 到底改写了什么(点着看)1 / 5
从 ① 点到 ⑤;这条链解释了「为什么 startPage 必须紧贴查询」
→
→
→
→
① 参数进 ThreadLocal
startPage(1, 10) 什么都不查,只是把分页对象挂到当前线程上。此刻 SQL 还没开始执行,参数在「等你」。
全部看懂了这条链只有一个软肋:ThreadLocal 与「下一条 SQL」之间的距离。所以 startPage 与查询之间不要夹任何别的查询。
90 / 165

当数据量到百万级以上,LIMIT offset, size 的深分页会越来越慢(要扫过前面所有行)。这时用游标分页,靠主键做「下一页」的锚点:

91 / 165
代码对照
代码xml
<!-- 游标分页:WHERE id < #{lastId} ORDER BY id DESC LIMIT #{size}     永远只扫 size 行,翻到第几页都一样快 --><select id="nextPage" resultType="User">    SELECT * FROM t_user    <where>        <if test="lastId != null">AND id &lt; #{lastId}</if>    </where>    ORDER BY id DESC    LIMIT #{size}</select>
解读

坑:游标分页换不来「跳页」。它适合信息流、订单列表这种「一直往下看」的场景;需要精确跳到第 N 页的后台表格,仍得用 LIMIT offset 或 PageHelper。

92 / 165

分页之外,MyBatis 还有一个真数值旋钮,而且它的代价上一篇刚见过:一条没人管的慢 SQL 会一直攥着连接,直到连接池被拖干(第 28 篇第十二节那个画面)。default-statement-timeout 就是给每条语句上的一道保险丝,单位是秒、默认 0 表示完全不设限。拖一遍看它在哪个区间最有用:

93 / 165
参数调节台
调节台一条 SQL 允许跑多久
mybatis.configuration.default-statement-timeout
5秒当前 0 – 60
常见正解:慢的先死,快的 unaffected
  • 5~10 秒是绝大多数在线系统的落点
  • 超过就中断:连接立刻归还,池子不会被一条语句拖干
  • 失败是显式的、带语句的,日志里能直接看到是哪条 SQL 超时
  • 配套还要有 MySQL 侧的 long_query_time,两边一起看才有因果
被拖住的连接12%
误杀正常查询8%
这一格与上一篇那条 connectionTimeout 是一对:一个决定「慢的 SQL 允许占多久」,一个决定「等连接的人允许站多久」。两头都要有上限。
94 / 165
小节
八、一级 / 二级缓存的真相
95 / 165

MyBatis 有两级缓存,默认行为差异极大,踩坑和它俩脱不了干系:

96 / 165
对照表
级别作用域默认开关典型坑
一级缓存同一个 SqlSession开启无法关闭(仅可调整级别)同会话读到旧数据
二级缓存同一个 namespace(跨 SqlSession)关闭<cache/> 或 @CacheNamespace分布式多节点各缓存一份,改库后互相脏读
97 / 165

两级缓存的差别全在「作用域」这一个词上:一级跟着 SqlSession 走(Spring 下≈一个事务),二级跟着 namespace 走(跨会话、但只在本进程内)。把左右两栏分清楚,下面那两类「诡异行为」就各自有了归属:

98 / 165
架构图
图 · 两级缓存的作用域
图 · 两级缓存的作用域
99 / 165

一级缓存藏在 SqlSession 里。同一个 SqlSession 中,相同 SQL 与参数只查一次,第二次直接命中缓存。这在同一个事务里通常没问题,但有三个触发失效/脏读的场景要牢记:

100 / 165
java
// 场景:同一 SqlSession(Spring 中同一事务)内User u1 = mapper.findById(1L);      // 查库,写入一级缓存User u2 = mapper.findById(1L);      // 命中一级缓存,不再查库// 该缓存会被清除的情况:// 1) 执行了任意 insert / update / delete(flushCache)// 2) 显式调用 sqlSession.clearCache()// 3) SqlSession 关闭(事务结束)
101 / 165

真正的坑在于:你以为每次调用 Mapper 都会重新查库,其实在同一个事务里,第二次读的是缓存。如果中间有别的线程改了这行数据,你会读到旧值——这就是所谓「同一 SqlSession 脏读」。

102 / 165

二级缓存默认关闭。开启后,同一 namespace 的查询结果会被缓存到跨 SqlSession 的地方,能省下跨请求的重复查询。但在集群部署下,每个节点各有一份缓存,A 节点改了数据,B 节点的二级缓存不会自动失效,于是读到脏数据。所以:

103 / 165
  • 单机、读多写少、且能接受短时不一致的字典类数据,才考虑开二级缓存
  • 集群环境下,优先用 Redis 这类集中式缓存,而不是本地二级缓存
  • 无论哪级缓存,只要涉及「必须读到最新值」的写后立即读,就显式清缓存或直接绕过
104 / 165

随堂第二题,这题要求你把「事务边界」和「缓存边界」对上(很多人工作三年也没意识到它们是同一件事):

105 / 165
随堂自测
随堂自测同一个 service 方法上标了 @Transactional。方法里先 userMapper.findById(1L) 拿到 u1,接着另一段代码直接改了库里这条记录(外部线程已提交),然后再次调用 findById(1L) 拿到 u2。结果是?
先自己选一个,选中立刻告诉你对不对
106 / 165
小节
九、两个必须记住的坑
107 / 165

坑一:map-underscore-to-camel-case 没开,字段全是 null。

108 / 165

数据库列是 user_name,实体属性是 userName。开关没打开时,MyBatis 按列名精确匹配,找不到 userName 这个列,属性就一直是 null。表里有数据、SQL 也查到了行,但对象字段全空——排查半天以为是 SQL 问题,其实是映射开关。记住:统一开启该开关,或老老实实写 resultMap。

109 / 165

坑二:排序字段用 #{} 无效、用 ${} 有风险。

110 / 165

ORDER BY #{orderBy} 会被预编译成 ORDER BY ?,数据库把 ? 当成一个「常量值」,排序直接失效(甚至报错)。可换成 ${orderBy} 又暴露了注入面。唯一正解是白名单:只允许提前定义好的列名集合通过,其余一律回退默认值,见第三节的 ALLOWED 写法。这条规则同样适用于动态表名、动态查询列。

111 / 165
警告

任何时候看到 ${},都要立刻自问一句「这个值用户能控制吗」。能,就必须白名单;不能,才勉强可用。

112 / 165
小节
十、把 MyBatis 拆开看:四个内核实验
113 / 165

这一节的四个实验跑的是真内核(WASM),点一下参数就重跑一遍。建议顺序:先用第一个建立「接口 → SQL」的整体感觉,再用第二个看动态 SQL 怎么拼,第三个看缓存为什么会在同一个事务里骗你,第四个看分页插件到底改写了什么。

114 / 165
小节
10.1 一次查询的全链路,以及动态 SQL / 缓存 / 分页(`mybatis`)
115 / 165
内核实验
TeaVMMapper 代理到结果映射:五个可切参数未启动
先跑「一次查询全链路」认全那五个类名,再依次切 dynamic / l1 / l2 / page 看四种改写
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
116 / 165

每个参数你要盯住的东西不一样:

117 / 165
对照表
参数你会看到对应正文小节
一次查询全链路MapperProxy → SqlSession → Executor → StatementHandler → ResultSetHandler 的完整接力第三节的动图
动态 SQL 拼装<if> / <where> 根据参数决定片段进不进最终 SQL第五节
一级缓存同一 SqlSession 内第二次相同查询不再发 SQL第八节
二级缓存跨 SqlSession 命中 namespace 级缓存,写入会整块清空第八节
分页插件改写 SQL原 SQL 被追加 LIMIT,并额外跑一次 COUNT第七节
118 / 165
提示

把「一级缓存」和「分页插件」连着看两遍,你会顺手理解两件常被混在一起的事——PageHelper 的 ThreadLocal 只在第一条 SQL 上生效,而一级缓存的作用域是 SqlSession(≈一个事务)。这两条性质解释了 MyBatis 大部分「诡异行为」。

119 / 165
小节
10.2 没有 MyBatis 时这些搬运是谁做的(`jdbc`)
120 / 165

MyBatis 接管的工作,在 JdbcTemplate 时代是你自己写的。切到「ResultSet → 对象」看逐列取值的样板代码,再切到「忘记释放会怎样」看连接怎么漏——这就是「半自动 ORM」到底帮你省掉了哪几层。

121 / 165
内核实验
TeaVMJdbcTemplate 执行骨架:MyBatis 替你扛了哪些活未启动
对比 map(手写映射)与 leak(漏还连接)两个参数,体会 MyBatis 的价值在哪一层
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
122 / 165
小节
10.3 SQL 慢的时候,池子会替你把病显出来(`pool`)
123 / 165

Mapper 只负责产生 SQL,SQL 还得借一条连接才能跑。把这条实验切到「达到上限排队」和「等待超时」,你会看到:只要有一条 foreach 展开成上千个 IN 值的查询长期占着连接,整个池就开始排队、最后大面积超时。MyBatis 的性能问题,症状常常出现在连接池上。

124 / 165
内核实验
TeaVM连接池借还与排队:慢 SQL 的连锁反应未启动
切到「泄漏检测」看带堆栈的警告——很多 Mapper 里手动 getConnection 忘了 close 就是这个画面
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
125 / 165
小节
10.4 更新丢了吗?先看回滚规则(`txprop`)
126 / 165

<update> 返回的影响行数是 0,不代表「SQL 失败」——它可能只是没匹配到行;而如果外层方法抛异常回滚,你前面成功的更新也会一起消失。切到「回滚规则」这个参数,观察 rollbackFor 与受检异常的组合:

127 / 165
内核实验
TeaVM七种传播行为与回滚规则未启动
切到「回滚规则」:为什么抛 IOException 时事务默认不回滚,Mapper 的写入却已经发出去了
场景参数
点「运行演示」,在浏览器内真实执行 Java 编译出的内核算法,逐步看它怎么跑。
128 / 165
说明

这三层关系值得记牢——Mapper 产生 SQL → 连接池提供连接 → 事务决定这些改动是否算数。诊断任何数据访问问题,都按这三层从上往下剥。

129 / 165

实验按到最后,换成命令行自己敲一遍。下面这台控制台连着浏览器里的同一个内核,回答全部由内核算出来:先 beans 看 Mapper 是不是真的被注册成了 Bean,再用 cond 把条件开关拨下来看它如何让位,最后逐条 lab 把本篇那三个主题(预编译、动态 SQL、一级缓存)敲出来:

130 / 165
内核控制台
131 / 165
说明

lab mybatis l1 之后紧接着 lab pool timeout 是值得的一组:前者演示「同一个 SqlSession 里第二条相同查询不再发 SQL」,后者演示「一条长期占住连接的 SQL 怎么把整个池拖干」。同一个「少发一条 SQL」的愿望,一个在帮你省,一个在替你埋雷。

132 / 165
小节
十一、常见报错速查
133 / 165
对照表
报错原文(片段)真实原因30 秒自救深挖看第几篇
org.apache.ibatis.binding.BindingException: Invalid bound statement (not found)接口方法与 XML 没绑上:namespace 写错、id 与方法名不一致、XML 没被 mapper-locations 扫到,或 XML 放在 src/main/java 下没被打包三查:① namespace 是否等于接口全限定名 ② id 是否等于方法名 ③ 编译产物 target/classes/mapper/ 里到底有没有那个 XML本篇第二、三节
java.sql.SQLSyntaxErrorException: ... near 'DROP TABLE' / 语法错误${} 把外部输入拼进了 SQL 结构(SQL 注入现场)把该处改成 #{};确需动态表名/列名则先在服务端过白名单,见第三节 ALLOWED本篇第三节
SQLSyntaxErrorException: ... near ')',日志里出现 WHERE id IN ()<foreach> 收到的集合为空,open="(" close=")" 照样输出,于是生成空的括号调用前判空并短路返回空列表;或用 <if test="ids != null and ids.size() > 0"> 包住整个 IN (...) 段本篇 5.4
不报错,但实体字段全是 null(表里明明有数据)map-underscore-to-camel-case 没开,或 resultMap 里 column 写成了实体属性名先开驼峰开关;跨表/别名场景检查每个 <result column="..."/> 是否与 SQL 里的列别名完全一致本篇第九节
TooManyResultsException: Expected one result (or null) to be returned by selectOne(), but found: N方法返回单个对象,可 SQL 实际返回多行(常见于 join 后行数放大或忘了加唯一条件)返回类型改成 List<T>,或让 SQL 保证至多一行(唯一索引 / LIMIT 1)本篇第四节
org.apache.ibatis.exceptions.PersistenceException: Parameter 'status' not found. Available parameters are [arg1, arg0, param1, param2]多参数方法没加 @Param,XML 里只能拿到 arg0/param1 这类别名给每个参数补 @Param("status");用对象作参数则直接写属性名本篇第四节
BindingException: Error evaluating expression 'keyword != null'. Cause: ... OgnlException<if test> 里引用了未声明的参数名,OGNL 找不到变量核对 @Param 名称与 test 表达式中的名字大小写;字符串判空要写 keyword != null and keyword != ''本篇第五节
集群部署后读到旧数据,重启某个节点又对了本地二级缓存各存一份,A 节点写入不会失效 B 节点的缓存关掉 <cache/>,改用 Redis 等集中式缓存;字典类数据也建议加过期时间本篇第八节
134 / 165
提示

这张表里最阴的两行根本不报错(字段全 null、读到旧数据)。它们的共同解法是同一件事:打开 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl,永远以「SQL 真的发了几条、参数是什么、回来的行是什么」为准。

135 / 165

表里第一行的报错,新手往往读不懂它在说什么——它其实已经把答案写在文案里了。下面这段是真实堆栈,先别看解析,点出你认为的凶手行:

136 / 165
报错急救
报错急救BindingException: Invalid bound statement (not found)
接口方法找不到 SQL:报错文案里就藏着那把钥匙

照着教程写完 Mapper 与 XML,启动正常、注入也没问题,一调 findById 就抛 BindingException。新人第一反应是「MyBatis 有 bug」。

org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): com.example.demo.mapper.UserMapper.findById
at org.apache.ibatis.binding.MapperMethod$SqlCommand.<init>(MapperMethod.java:235)
at org.apache.ibatis.binding.MapperMethod.<init>(MapperMethod.java:53)
at org.apache.ibatis.binding.MapperProxy.lambda$cachedMapperMethod$0(MapperProxy.java:98)
at org.apache.ibatis.binding.MapperProxy.invoke(MapperProxy.java:92)
at jdk.proxy2/jdk.proxy2.$Proxy87.findById(Unknown Source)
at com.example.demo.service.UserService.getUser(UserService.java:29)
点你认为的「凶手行」(可反复试)
不会也没关系:先猜异常名,再猜哪一行在做决定。
137 / 165
小节
十二、动手练习
138 / 165
小节
第一档 · 照做:一个 CRUD + 动态查询 + 批量插入的最小工程
139 / 165

目标:跑通「接口只有一行、SQL 全在 XML」的标准结构,并亲眼看见动态 SQL 拼出三种不同语句。四份文件全部给出,可直接抄。

140 / 165
java
package com.example.demo.entity;public class User {    private Long id;    private String userName;   // DB 列 user_name    private Integer status;    // getter / setter / toString 省略}
141 / 165
java
package com.example.demo.mapper;import com.example.demo.entity.User;import org.apache.ibatis.annotations.Mapper;import org.apache.ibatis.annotations.Param;import java.util.List;@Mapperpublic interface UserMapper {    User findById(Long id);    List<User> search(@Param("keyword") String keyword, @Param("status") Integer status);    List<User> findByIds(@Param("ids") List<Long> ids);    int batchInsert(@Param("list") List<User> list);    int updateSelective(User user);}
142 / 165

src/main/resources/mapper/UserMapper.xml(注意:一定放 resources 下,放 java 目录里就会 Invalid bound statement):

143 / 165
xml
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"        "http://mybatis.org/dtd/mybatis-3-mapper.dtd"><mapper namespace="com.example.demo.mapper.UserMapper">    <sql id="baseColumns">id, user_name, status</sql>    <select id="findById" resultType="User">        SELECT <include refid="baseColumns"/> FROM t_user WHERE id = #{id}    </select>    <select id="search" resultType="User">        SELECT <include refid="baseColumns"/> FROM t_user        <where>            <if test="keyword != null and keyword != ''">                AND user_name LIKE CONCAT('%', #{keyword}, '%')            </if>            <if test="status != null">AND status = #{status}</if>        </where>        ORDER BY id DESC    </select>    <select id="findByIds" resultType="User">        SELECT <include refid="baseColumns"/> FROM t_user        WHERE id IN        <foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>    </select>    <insert id="batchInsert">        INSERT INTO t_user (user_name, status) VALUES        <foreach collection="list" item="u" separator=",">(#{u.userName}, #{u.status})</foreach>    </insert>    <update id="updateSelective">        UPDATE t_user        <set>            <if test="userName != null">user_name = #{userName},</if>            <if test="status != null">status = #{status},</if>        </set>        WHERE id = #{id}    </update></mapper>
144 / 165

驱动它的测试类:

145 / 165
java
@SpringBootTestclass UserMapperTest {    @Autowired UserMapper mapper;    @Test    void dynamicSql() {        mapper.search(null, null);      // ① 无筛选        mapper.search("ali", null);     // ② 只有关键词        mapper.search(null, 1);         // ③ 只有状态    }}
146 / 165

预期控制台 SQL 日志(开了 log-impl 才有;形状必须一致):

147 / 165
text
===>  Preparing: SELECT id, user_name, status FROM t_user ORDER BY id DESC===> Parameters: <>===>  Preparing: SELECT id, user_name, status FROM t_user WHERE user_name LIKE ? ORDER BY id DESC===> Parameters: %ali%(String)===>  Preparing: SELECT id, user_name, status FROM t_user WHERE status = ? ORDER BY id DESC===> Parameters: 1(Integer)===>  Preparing: INSERT INTO t_user (user_name, status) VALUES (?, ?), (?, ?), (?, ?)===> Parameters: alice(String), 1(Integer), bob(String), 1(Integer), cindy(String), 0(Integer)===>  Preparing: UPDATE t_user SET status = ? WHERE id = ?===> Parameters: 2(Integer), 1(Long)
148 / 165

对着日志确认四件事:

149 / 165
  • ①里 连 WHERE 都没有 —— 这就是 <where> 的行为,不是 bug
  • ②③里开头的 AND 被剥掉了,且参数一律是 ?(#{} 的功劳)
  • <sql> + <include> 让三处列清单只维护一份
  • UPDATE 只出现了 status,因为 <set> 只拼非空字段并删掉了尾逗号
150 / 165
小节
第二档 · 变体:每次只改一处,记录你观察到的现象
151 / 165
  1. 把 map-underscore-to-camel-case 改成 false 重跑 → 你会观察到:<=== Row 明明有数据,但 toString() 里 userName=null。这就是坑一的现场,比读十遍文字都管用。
  2. 把 search 的 <where> 换成硬写的 WHERE 1 = 1 → 你会观察到:结果一样,但如果哪天把 AND 前缀忘了加,SQL 立刻语法错。体会 <where> 到底替你兜了什么。
  3. 给 findByIds(List.of()) 传一个空集合 → 你会观察到:日志出现 WHERE id IN () 并抛 SQLSyntaxErrorException。修法自己试:加 <if test="ids != null and ids.size() > 0"> 包住,或在 Service 里判空短路。
  4. 把 ORDER BY ${orderBy} 加进 search,然后传入 id; DROP TABLE t_user-- → 你会观察到:日志里 SQL 结构真的变了。跑完请立刻删掉这行代码,并在笔记里写下「${} 只能接白名单值」这条铁律。
  5. 把 findById 的返回类型从 User 改成 List<User> 却仍写 SELECT * FROM t_user(不加 WHERE)→ 反过来观察 TooManyResultsException 长什么样,以后见到就能一眼认出。
152 / 165
小节
第三档 · 造一个:给订单列表做一个「真正的后台查询接口」
153 / 165

需求:实现 GET /admin/orders?keyword=&status=&page=1&size=20&sortField=id&order=desc,用 MyBatis 完成多条件筛选 + 分页 + 排序。验收清单:

154 / 165
  • [ ] Mapper 接口只有一个方法,SQL 全在 XML;筛选条件用 <where> + <if>,不许出现 WHERE 1=1
  • [ ] 排序字段走服务端白名单(Set<String> ALLOWED),非法值回退 id;顺序只允许 ASC / DESC 两个字面量,用 <choose> 表达
  • [ ] 分页有两种实现并各自说明适用场景:PageHelper(startPage 紧贴查询)与游标分页(WHERE id < #{lastId}),文档里写清「跳页」需求该选哪个
  • [ ] 状态多选参数用 <foreach> 展开,并且空集合时不发 SQL,直接返回空页
  • [ ] 打开 log-impl,把上面每种组合实际产生的 SQL 贴进 README(至少 4 条:无条件 / 单条件 / 多条件+排序 / 游标翻页)
  • [ ] 压测 100 并发调这个接口,同时抓 hikaricp.connections.pending 与 MySQL 慢查询日志,判断瓶颈在 SQL、映射还是连接池,并写 5 行结论
  • [ ] 额外加分:把某条查询配成二级缓存 <cache/>,然后在两个实例间改一次数据,复现「另一个节点读到旧值」的现象,再解释你为什么在生产选择 Redis 而不是本地二级缓存
155 / 165

做完这三档,你对 MyBatis 的掌握就从「会写标签」升级到「知道它每一步在干什么、出错时知道往哪一层查」。

156 / 165
小节
十三、要点自查
157 / 165
自检

Invalid bound statement (not found) 出现时,你的三条排查线索分别是什么?答:namespace 是否等于接口全限定名、id 是否等于方法名、编译后的 target/classes 里有没有那个 XML。

158 / 165
自检

#{} 和 ${} 的本质差别用一句话说?答:前者是预编译参数(值进不了句式),后者是字符串拼接(值能改句式)。

159 / 165
自检

<where> 在没有一个 <if> 成立时输出什么?答:什么都不输出,连 WHERE 关键字都不加——所以不需要 WHERE 1=1。

160 / 165
自检

同一个事务里第二次调用 findById(1L) 会不会发 SQL?答:默认不会,一级缓存在 SqlSession 内命中;这也是「读不到别人刚提交的值」的根因。

161 / 165
自检

集群环境下为什么不敢开二级缓存?答:每个 JVM 各存一份,A 节点写入不会失效 B 节点的缓存,于是脏读。集中式缓存才是答案。

162 / 165
口诀

接口点菜、XML 掌勺,驼峰不开字段全空;#{} 填数、${} 改句,非用不可先过白名单;<where> 清头、<set> 去尾,<foreach> 遇空别硬撑;一级缓存跟事务走,二级缓存别上集群。

163 / 165
小节
十四、决策卡与总结
164 / 165
决策
决策新项目需要大量多表联查和报表 SQL,团队熟悉 SQL,又希望保留随时改 SQL 的自由度。数据访问层选 MyBatis 还是 Spring Data JPA?
165 / 165
总结

MyBatis 的全部功力可以浓缩成四句话——能 #{} 就别 ${},非用 ${} 先过白名单;动态 SQL 靠标签拼,复杂查询交给 XML;关联映射用 resultMap,字段对不上先查驼峰开关;缓存默认只信一级,集群下别指望二级。把这四句记牢,绝大多数 MyBatis 的坑你都能提前绕开。