深入浅出 MyBatis XML:从入门到精通

0 阅读22分钟

一、前言

在 Java 后端开发中,操作数据库是绕不开的核心环节。早期的 JDBC 编程需要手动拼接 SQL、处理结果集、管理连接,代码繁琐且容易出错。ORM(对象关系映射)框架的出现彻底改变了这一局面,而 MyBatis 凭借其灵活性和轻量级特性,已成为国内 Java 生态中最主流的持久层框架之一。

MyBatis 的核心思想是将 SQL 从 Java 代码中剥离出来,写在 XML 配置文件中,由框架负责参数映射和结果集封装。这种"SQL 与代码分离"的设计,让开发者既能享受 ORM 的便利,又能完全掌控每一条 SQL 的执行逻辑。

本文将系统地介绍 MyBatis XML 的方方面面——从基础概念到高级特性,从语法原理到实战示例,力求让每一位读者都能真正理解和掌握这门"数据库操作的描述语言"。

MyBatis XML 图片生成

二、简介

2.1 什么是 MyBatis

MyBatis 是一款优秀的半自动化 ORM 框架,前身为 Apache 的 iBatis 项目,2010 年迁移到 GitHub 后更名为 MyBatis。它通过 XML 或注解的方式将 Java 对象与数据库表进行映射,屏蔽了底层 JDBC 的繁琐操作。

image

2.2 什么是 MyBatis XML

MyBatis XML 是 MyBatis 框架定义的一套基于 XML 标准的 SQL 映射描述语言。开发者在 XML 文件中编写 SQL 语句、定义参数映射规则和结果集映射规则,MyBatis 框架在运行时解析这些 XML 文件,自动完成以下工作:

image

Java 方法调用
    ↓
MyBatis 解析 XML 中对应的 SQL
    ↓
将 Java 参数映射到 SQL 占位符
    ↓
执行 SQL
    ↓
将数据库返回的结果映射为 Java 对象
    ↓
返回给调用者

一个最简单的 MyBatis 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.mapper.UserMapper">

    <select id="selectById" resultType="com.example.entity.User">
        SELECT * FROM user WHERE id = #{id}
    </select>

</mapper>

2.3 MyBatis 与 MyBatis-Plus 的关系

image

| 维度 | MyBatis | MyBatis-Plus | | --- | --- | --- | | 定位 | 基础 ORM 框架 | MyBatis 的增强工具 | | XML 语法 | 自身定义 | 完全兼容 MyBatis,无新增 XML 标签 | | CRUD | 需手写 SQL | 内置通用 Mapper,简单 CRUD 零 SQL | | 动态 SQL | 提供 if/where/foreach 等 | 直接继承,同时提供 Java API 的条件构造器 | | 额外能力 | 无 | 分页插件、代码生成器、逻辑删除等 |

结论:MyBatis-Plus 的 XML 写法和 MyBatis 完全一致。 学好 MyBatis XML,MyBatis-Plus 的 XML 部分自然就会了。

三、特点

3.1 SQL 与代码分离

SQL 写在 XML 中,Java 代码只负责调用接口方法,修改 SQL 不需要重新编译 Java 代码。

3.2 真正的 SQL 可控

不像 Hibernate 等全自动化 ORM 框架会自动生成 SQL,MyBatis 让开发者直接编写原生 SQL,对每一条语句拥有完全的控制权。这意味着:

  • • 可以充分利用数据库特有的语法和函数

  • • 可以针对慢查询做精细的 SQL 优化

  • • 可以执行复杂的多表关联和子查询

3.3 动态 SQL 能力

MyBatis XML 提供了 <if><where><choose><foreach><trim><set> 等动态标签,可以根据参数的不同动态生成不同的 SQL 语句,避免了大量字符串拼接的痛苦。

3.4 强大的结果映射

通过 <resultMap> 可以将复杂的数据库查询结果(包括多表关联、嵌套对象、集合)精确映射为 Java 对象,支持一对一、一对多、鉴别器等高级映射关系。

3.5 学习成本低

语法直观,本质上就是"在 XML 里写 SQL",加上少量的框架标签。对于已经熟悉 SQL 的开发者来说,上手非常快。

3.6 插件生态丰富

MyBatis 提供了插件(Interceptor)机制,可以拦截 SQL 执行的各个阶段。MyBatis-Plus 在此基础上提供了分页插件、乐观锁插件、防全表更新插件等开箱即用的能力。

四、功能

MyBatis XML 提供的核心功能可以归纳为以下几类:

┌──────────────────────────────────────────────────────┐
│                    MyBatis XML 功能全景图              │
├──────────────────────────────────────────────────────┤
│                                                      │
│  📌 基础 CRUD                                        │
│  ├── SELECT 查询(单条 / 列表 / 分页)                │
│  ├── INSERT 插入(单条 / 批量 / 返回主键)            │
│  ├── UPDATE 更新(全量更新 / 选择性更新)             │
│  └── DELETE 删除(单条 / 批量)                       │
│                                                      │
│  📌 动态 SQL                                         │
│  ├── 条件判断     <if>                               │
│  ├── 多选一       <choose><when><otherwise>          │
│  ├── 智能 WHERE   <where>                            │
│  ├── 智能 SET     <set>                              │
│  ├── 自定义裁剪   <trim>                             │
│  └── 循环遍历     <foreach>                          │
│                                                      │
│  📌 结果映射                                         │
│  ├── 基本映射     resultType / resultMap             │
│  ├── 一对一关联   <association>                      │
│  ├── 一对多关联   <collection>                       │
│  └── 鉴别器      <discriminator>                    │
│                                                      │
│  📌 复用与引用                                       │
│  ├── SQL 片段     <sql> / <include>                  │
│  └── 缓存配置     <cache> / <cache-ref>             │
│                                                      │
│  📌 参数处理                                         │
│  ├── 预编译参数   #{}                                │
│  ├── 字符串替换   ${}                                │
│  └── 特殊符号     CDATA                              │
│                                                      │
│  📌 分页查询                                         │
│  ├── 物理分页     LIMIT / ROW_NUMBER()              │
│  └── 逻辑分页     RowBounds / PageHelper            │
│                                                      │
└──────────────────────────────────────────────────────┘

五、这种 XML 语法的归属——MyBatis 特有的,还是通用标准?

这是一个非常好的问题,也是很多初学者容易混淆的地方。答案是:需要分层理解

5.1 第一层:XML 本身 — 通用国际标准

XML(eXtensible Markup Language,可扩展标记语言)是由 W3C(万维网联盟) 于 1998 年发布的国际标准。它只规定了标签的书写规则

image

<!-- 开始标签和结束标签 -->
<标签名 属性名="属性值">内容</标签名>

<!-- 自闭合标签 -->
<标签名 属性名="属性值"/>

<!-- 注释 -->
<!-- 这是 XML 注释 -->

XML 本身不定义任何具体的业务标签,它只是一套「容器格式规范」,就像一张标准的白纸——在上面写中文还是英文,取决于书写者。

5.2 第二层:MyBatis 标签体系 — MyBatis 自己定义的私有词汇

MyBatis 通过 DTD 文件(Document Type Definition,文档类型定义)定义了自己的一套 XML 标签。你在 XML 文件头部看到的声明,就是引用了这个 DTD:

<!DOCTYPE mapper PUBLIC
    "-//mybatis.org//DTD Mapper 3.0//EN"
    "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

这个 DTD 文件规定了:

  • • 允许使用哪些标签:<select><insert><if><where> 等

  • • 每个标签有哪些属性:idresultTypeparameterType 等

  • • 标签之间的嵌套关系:<if> 必须写在 <where> 里面

这些标签不是 XML 标准的一部分,纯粹是 MyBatis 框架自己定义的。 把 <select> 写到 Spring 配置文件里,Spring 是不认识的。

MyBatis 的设计非常聪明,它内置了一套 “本地优先” 的加载策略-,流程如下:

需要注意:官网曾公告过,mybatis.org 这个网站曾出现过短暂的服务不可用情况-35。但官方也强调,只要配置正确,MyBatis 会从本地的 Jar 包读取 DTD,不会受到网站宕机的影

5.3 第三层:OGNL 表达式 — 来自 Apache 的表达式语言

MyBatis XML 中还有一个隐藏层:

<if test="name != null and name != ''">

test="" 里面的内容既不是 XML 语法,也不是 MyBatis 发明的,而是 OGNL(Object-Graph Navigation Language) ,一个由 Apache 维护的开源表达式语言,专门用于在 Java 对象上进行属性访问和逻辑判断。

5.4 总结:三层合一

MyBatis XML 文件 = XML 语法规范(W3C 标准)
                 + MyBatis 自定义标签(MyBatis DTD)
                 + OGNL 表达式(Apache 开源项目)

用一个类比来理解:

| 层次 | 类比 | | --- | --- | | XML 语法(W3C) | 纸张规格(A4、方格纸) | | MyBatis 标签(DTD) | 一套专用的公文模板 | | OGNL 表达式 | 写在模板里的逻辑判断语言 |

5.5 拓展:Java 生态中到处都是这种模式

利用 XML 容器 + 私有标签的模式在 Java 世界中非常普遍:

| 框架 | 自定义标签 | 用途 | | --- | --- | --- | | MyBatis | <select><if><foreach> | SQL 映射 | | Spring | <bean><property>[aop:config](aop:config) | IoC/AOP 配置 | | Maven | <dependency><plugin><build> | 项目构建 | | Tomcat | <Server><Connector><Context> | 服务器配置 | | Android | <LinearLayout><TextView> | UI 布局 | | MyBatis-Plus | 无新增 ,完全复用 MyBatis 的标签 | 增强工具 |

它们都遵守 XML 的通用语法规则,但标签体系各自独立,互不通用。

六、语法详细介绍

6.1 文件基本结构

每个 MyBatis 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.mapper.UserMapper">

    <!-- 在此编写 SQL 映射语句 -->

</mapper>

| 组成部分 | 说明 | | --- | --- | | XML 声明 | 指定版本和编码,固定写法 | | DOCTYPE 声明 | 引用 MyBatis DTD,提供标签校验和 IDE 补全 | | <mapper>  根标签 | namespace  必须对应 Mapper 接口的全限定类名 |

6.2 四大基础标签

<select> — 查询

<select id="方法名" resultType="返回类型" parameterType="参数类型">
    SQL 语句
</select>

| 属性 | 含义 | 是否必填 | | --- | --- | --- | | id | 对应 Mapper 接口中的方法名 | 是 | | resultType | 查询结果映射的 Java 类型 | 与 resultMap 二选一 | | resultMap | 引用自定义的结果映射 | 与 resultType 二选一 | | parameterType | 参数的 Java 类型 | 否(通常省略,MyBatis 自动推断) |

<insert> — 插入

<insert id="方法名" useGeneratedKeys="true" keyProperty="id">
    SQL 语句
</insert>

| 属性 | 含义 | | --- | --- | | useGeneratedKeys | 是否使用数据库自增主键 | | keyProperty | 自增主键回填到实体类的哪个字段 |

<update> — 更新

<update id="方法名">
    SQL 语句
</update>

<delete> — 删除

<delete id="方法名">
    SQL 语句
</delete>

6.3 参数占位符:#{} 与 ${}

这是 MyBatis 中最容易混淆也最容易出错的知识点:

#{}${}
本质
预编译参数,生成 ? 占位符
字符串直接替换拼接
安全性
防止 SQL 注入存在 SQL 注入风险
底层实现
PreparedStatementStatement
适用场景
参数值(绝大多数场景)
动态表名、动态列名、ORDER BY 字段
<!-- 安全写法:参数值统一用 #{} -->
SELECT * FROM user WHERE id = #{id}
-- 实际执行:SELECT * FROM user WHERE id = ?
-- 参数:[10]

<!-- ${} 仅用于无法使用占位符的场景 -->
SELECT * FROM ${tableName} ORDER BY ${sortColumn} ${sortOrder}
-- 实际执行:SELECT * FROM user ORDER BY age DESC

黄金法则:能用**#{}的地方就用#{},只在动态表名、列名、排序方向等场景才用${}****。**


6.4 特殊符号处理与 CDATA

XML 解析器有一套自己的语法规则,以下字符在 XML 中具有特殊含义:

| 字符 | XML 中的含义 | 直接使用的后果 | | --- | --- | --- | | < | 标签开始 | 解析器误认为新标签开头,解析报错 | | > | 标签结束 | 部分情况下解析异常 | | & | 实体引用开头 | 后续内容被当成实体名解析 | | " | 属性值界定符 | 截断属性值 | | ' | 属性值界定符 | 截断属性值 |

6.4.1 为什么 SQL 中会遇到这些符号

SQL 中大量使用这些符号作为运算符:

-- 小于、大于
WHERE age > 18
WHERE age < 60
WHERE price >= 100 AND price <= 500

-- 尖括号(动态 SQL 中的比较)
WHERE score > #{minScore}

如果直接写在 XML 中,解析器会把 > 当成标签的结束符,把 < 当成新标签的开始,导致解析失败。

6.4.2 方案一:转义字符

XML 预定义了 5 个转义实体:

| 字符 | 转义写法 | 示例 | | --- | --- | --- | | < | &lt;** | age &lt; 60 | | > | &gt; | age &gt; 18 | | & | &amp; | name &amp; age | | " | &quot; | 用于属性值中 | | ' | &apos; | 用于属性值中 |

<select id="selectByAgeRange" resultType="User">
    SELECT * FROM user
    WHERE age &gt;= #{ageMin} AND age &lt;= #{ageMax}
</select>

优点:标准 XML 写法,所有 XML 工具都支持。

缺点:SQL 可读性下降,尤其是条件多的时候满屏 &gt;``&lt;,维护困难。

6.4.3 方案二:CDATA 段(推荐)

CDATA(Character Data)段告诉 XML 解析器: "这段内容全部当做纯文本,不要做任何解析" 。

语法格式:

<![CDATA[  这里面的内容全部是纯文本,原样保留  ]]>

实际使用:

<select id="selectByAgeRange" resultType="User">
    SELECT * FROM user
    WHERE age <![CDATA[ >= ]]> #{ageMin}
      AND age <![CDATA[ <= ]]> #{ageMax}
</select>

生成的 SQL:

SELECT * FROM user WHERE age >= 10 AND age <= 60

CDATA 内部可以自由书写特殊符号,不会被 XML 解析器误解析。

6.4.4 方案三:巧妙利用 SQL 关键字替代

对于比较运算符,可以换一种等价的 SQL 写法来完全避免特殊符号:

<!-- 方式 1:使用 BETWEEN(推荐用于范围查询) -->
<select id="selectByAgeRange" resultType="User">
    SELECT * FROM user
    WHERE age BETWEEN #{ageMin} AND #{ageMax}
</select>

<!-- 方式 2:使用不涉及特殊符号的 SQL 函数 -->
<select id="selectByAge" resultType="User">
    SELECT * FROM user
    WHERE GREATEST(age, #{minAge}) = age
</select>

BETWEEN 完全不需要 <``> 符号,是最干净的方案。

6.4.5 CDATA 的注意事项

1. CDATA 不能嵌套

<!-- 错误!CDATA 不支持嵌套 -->
<![CDATA[
    age > 18 <![CDATA[ AND ]]> name = #{name}
]]>

2. CDATA 内不能出现**]]>**字符串

因为 ]]> 是 CDATA 段的结束标记。如果 SQL 中恰好包含 ]]>(极少见),需要拆分 CDATA 段或使用转义。

3. CDATA 与动态 SQL 标签共存时要注意

动态 SQL 标签(<if><where><foreach> 等)必须在 CDATA 外部,因为它们需要被 XML 解析器识别:

<!-- 正确:CDATA 只包裹包含特殊符号的纯 SQL 部分 -->
<where>
    <if test="minAge != null">
        AND age <![CDATA[ > ]]> #{minAge}
    </if>
    <if test="maxAge != null">
        AND age <![CDATA[ < ]]> #{maxAge}
    </if>
</where>

<!-- 错误!整个 SQL 被 CDATA 包裹后,<if> 等标签变成了纯文本,不会被解析 -->
<![CDATA[
    <where>
        <if test="minAge != null">
            AND age > #{minAge}
        </if>
    </where>
]]>

最佳实践:CDATA 的包裹范围要尽可能小,只包裹包含特殊符号的那部分 SQL 片段。

6.4.6 三种方案对比

| 方案 | 可读性 | 安全性 | 适用场景 | | --- | --- | --- | --- | | 转义字符 &gt;``&lt; | 差 | 好 | 特殊符号少且分散时 | | CDATA 段 | 好 | 好 | 含大量比较运算符的复杂 SQL | | SQL 关键字替代 | 最好 | 好 | BETWEENIN 等可替代的场景 |

实际项目中的常见做法:优先用 SQL 关键字替代(如 BETWEEN),无法替代时用 CDATA 包裹,简单场景可用转义字符。


6.5 动态 SQL 标签

动态 SQL 是 MyBatis 最强大、最核心的特性。它解决了传统开发中大量字符串拼接 SQL 的痛点。

6.5.1 <if> — 条件判断

根据条件决定是否拼接某段 SQL:

<select id="selectByCondition" resultType="User">
    SELECT * FROM user
    WHERE 1 = 1
    <if test="name != null and name != ''">
        AND name = #{name}
    </if>
    <if test="age != null">
        AND age = #{age}
    </if>
    <if test="email != null and email != ''">
        AND email = #{email}
    </if>
</select>

test 属性中使用 OGNL 表达式 进行判断。常用写法:

<!-- 判空 -->
<if test="name != null">

<!-- 判空 + 空字符串 -->
<if test="name != null and name != ''">

<!-- 判断集合非空 -->
<if test="list != null and list.size() > 0">

<!-- 比较数值 -->
<if test="age != null and age >= 18">

<!-- 判断字符串相等(注意用双引号包裹单引号) -->
<if test="status == '1'">

<!-- 逻辑组合 -->
<if test="(name != null or email != null) and deleted == 0">

6.5.2 <where> — 智能 WHERE 关键字

<if> 示例中的 WHERE 1 = 1 写法不够优雅,<where> 可以完美替代:

<select id="selectByCondition" resultType="User">
    SELECT * FROM user
    <where>
        <if test="name != null and name != ''">
            AND name = #{name}
        </if>
        <if test="age != null">
            AND age = #{age}
        </if>
        <if test="email != null and email != ''">
            AND email = #{email}
        </if>
    </where>
</select>

<where> 的智能行为:

| 情况 | 行为 | | --- | --- | | 内部有内容 | 自动添加 WHERE 关键字 | | 第一个条件带 AND | 自动去掉开头多余的 AND 或 OR | | 内部无内容 | 整个 <where> 标签不输出任何内容 |

6.5.3 <set> — 智能 SET 关键字

更新语句专用,自动处理末尾多余的逗号:

<update id="updateSelective">
    UPDATE user
    <set>
        <if test="name != null">name = #{name},</if>
        <if test="age != null">age = #{age},</if>
        <if test="email != null">email = #{email},</if>
    </set>
    WHERE id = #{id}
</update>

当只传入 name 和 email 时,生成的 SQL:

UPDATE user SET name = '张三', email = 'zs@qq.com' WHERE id = 1

末尾多余的逗号被自动去掉了。

6.5.4 <choose>``<when>``<otherwise> — 多选一

类似 Java 的 switch-case 结构,只执行第一个满足条件的分支

<select id="selectByPriority" resultType="User">
    SELECT * FROM user
    <where>
        <choose>
            <when test="name != null and name != ''">
                AND name = #{name}
            </when>
            <when test="email != null and email != ''">
                AND email = #{email}
            </when>
            <otherwise>
                AND status = 1
            </otherwise>
        </choose>
    </where>
</select>

执行逻辑:

  • • 传了 name → 按名字查询

  • • 没传 name,但传了 email → 按邮箱查询

  • • 都没传 → 查所有状态为 1 的记录

6.5.5 <trim> — 自定义裁剪

<where> 和 <set> 本质上是 <trim> 的特化形式。当你需要更灵活的控制时,可以直接使用 <trim>

<!-- 等价于 <where> -->
<trim prefix="WHERE" prefixOverrides="AND |OR ">
    <if test="name != null">AND name = #{name}</if>
    <if test="age != null">AND age = #{age}</if>
</trim>

<!-- 等价于 <set> -->
<trim prefix="SET" suffixOverrides=",">
    <if test="name != null">name = #{name},</if>
    <if test="age != null">age = #{age},</if>
</trim>

<!-- 自定义:给内容加前缀和后缀 -->
<trim prefix="(" suffix=")" suffixOverrides=",">
    <if test="name != null">#{name},</if>
    <if test="age != null">#{age},</if>
</trim>

四个属性的含义:

| 属性 | 作用 | 示例 | | --- | --- | --- | | prefix | 在整体内容前添加 | WHERESET( | | suffix | 在整体内容后添加 | )END | | prefixOverrides | 去掉内容开头的指定字符 | ANDOR | | suffixOverrides | 去掉内容结尾的指定字符 | , |

6.5.6 <foreach> — 循环遍历

处理集合/数组参数的利器,最常见的两个场景是 IN 查询 和 批量插入

IN 查询:

<select id="selectByIds" resultType="User">
    SELECT * FROM user WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>

生成的 SQL:

SELECT * FROM user WHERE id IN (12358)

批量插入:

<insert id="batchInsert">
    INSERT INTO user (name, age, email) VALUES
    <foreach collection="list" item="u" separator=",">
        (#{u.name}, #{u.age}, #{u.email})
    </foreach>
</insert>

生成的 SQL:

INSERT INTO user (name, age, email) VALUES
('张三'20'zs@qq.com'),
('李四'25'ls@qq.com'),
('王五'30'ww@qq.com')

**<foreach>**属性详解:

| 属性 | 含义 | | --- | --- | | collection | 要遍历的集合。如果参数用 @Param("ids") 修饰则写 ids;如果直接传 List 则写 list;如果传数组则写 array | | item | 遍历时每个元素的变量名 | | index | 遍历时的索引变量名(可选) | | open | 循环开始前拼接的字符串 | | close | 循环结束后拼接的字符串 | | separator | 每个元素之间的分隔符 |

6.5.7 <bind> 标签

在 XML 中创建变量,可以简化复杂表达式:

<select id="selectByNameLike" resultType="User">
    <bind name="pattern" value="'%' + name + '%'"/>
    SELECT * FROM user WHERE user_name LIKE #{pattern}
</select>

<bind> 还可以用来做数据库兼容——不同数据库的拼接函数不同:

<!-- MySQL 用 CONCAT,Oracle 用 ||,通过 bind 统一处理 -->
<bind name="pattern" value="'%' + name + '%'"/>
SELECT * FROM user WHERE user_name LIKE #{pattern}

6.5.8 <sql> 和 <include> — SQL 片段复用

将重复出现的 SQL 片段抽取出来,避免代码冗余:

<!-- 定义片段 -->
<sql id="baseColumns">
    id, user_name, age, email, create_time
</sql>

<sql id="queryCondition">
    <if test="name != null and name != ''">
        AND user_name LIKE CONCAT('%', #{name}, '%')
    </if>
    <if test="age != null">
        AND age = #{age}
    </if>
    <if test="status != null">
        AND status = #{status}
    </if>
</sql>

<!-- 引用片段 -->
<select id="selectByCondition" resultType="User">
    SELECT <include refid="baseColumns"/>
    FROM user
    <where>
        <include refid="queryCondition"/>
    </where>
</select>

<select id="countByCondition" resultType="int">
    SELECT COUNT(*)
    FROM user
    <where>
        <include refid="queryCondition"/>
    </where>
</select>

6.6 结果映射 <resultMap>

6.6.1 何时需要 resultMap

当数据库字段名和 Java 属性名不一致时,或者需要做关联映射时。

<resultMap id="userResultMap" type="com.example.entity.User">
    <!-- 主键用 id 标签 -->
    <id property="id" column="id"/>
    <!-- 普通字段用 result 标签 -->
    <result property="userName"   column="user_name"/>
    <result property="age"        column="age"/>
    <result property="email"      column="email"/>
    <result property="createTime" column="create_time"/>
</resultMap>

<select id="selectAll" resultMap="userResultMap">
    SELECT id, user_name, age, email, create_time
    FROM user
</select>

快捷方式:在 MyBatis 配置中开启 mapUnderscoreToCamelCase,下划线自动转驼峰,大部分情况就不需要手动写 resultMap 了。

6.6.2 一对一关联 <association>

一个用户属于一个部门:

<resultMap id="userWithDept" type="User">
    <id property="id" column="id"/>
    <result property="userName" column="user_name"/>
    <result property="age" column="age"/>

    <!-- 一对一:关联部门 -->
    <association property="dept" javaType="Dept">
        <id property="id" column="dept_id"/>
        <result property="deptName" column="dept_name"/>
    </association>
</resultMap>

<select id="selectUserWithDept" resultMap="userWithDept">
    SELECT
        u.id, u.user_name, u.age,
        d.id AS dept_id, d.dept_name AS dept_name
    FROM user u
    LEFT JOIN dept d ON u.dept_id = d.id
    WHERE u.id = #{id}
</select>

6.6.3 一对多关联 <collection>

一个部门有多个用户:

<resultMap id="deptWithUsers" type="Dept">
    <id property="id" column="id"/>
    <result property="deptName" column="dept_name"/>

    <!-- 一对多:关联用户列表 -->
    <collection property="users" ofType="User">
        <id property="id" column="user_id"/>
        <result property="userName" column="user_name"/>
        <result property="age" column="age"/>
    </collection>
</resultMap>

<select id="selectDeptWithUsers" resultMap="deptWithUsers">
    SELECT
        d.id, d.dept_name,
        u.id AS user_id, u.user_name, u.age
    FROM dept d
    LEFT JOIN user u ON d.id = u.dept_id
    WHERE d.id = #{id}
</select>

associationjavaTypecollectionofType,不要搞混。

6.6.4 嵌套查询(延迟加载)

除了上面的 JOIN 一次性查出所有数据外,还可以分步查询:

<!-- 第一步:查询用户 -->
<resultMap id="userWithDeptLazy" type="User">
    <id property="id" column="id"/>
    <result property="userName" column="user_name"/>

    <!-- 第二步:根据 dept_id 触发另一条 SQL 查询部门 -->
    <association property="dept"
                 column="dept_id"
                 select="com.example.mapper.DeptMapper.selectById"/>
</resultMap>

默认是立即加载(查用户时同时查部门)。开启延迟加载后,只有真正访问 user.getDept() 时才执行第二条 SQL:

mybatis:
  configuration:
    lazy-loading-enabled: true
    aggressive-lazy-loading: false

6.7 分页查询

分页是实际项目中最常见的需求之一。MyBatis 本身没有内置分页功能,但提供了多种实现方案。

6.7.1 物理分页 vs 逻辑分页

| 类型 | 原理 | 优点 | 缺点 | | --- | --- | --- | --- | | 物理分页 | 在 SQL 层面限制返回行数(如 LIMIT) | 性能好,只查需要的数据 | 依赖数据库方言 | | 逻辑分页 | 查出全部数据,在 Java 内存中截取 | 与数据库无关 | 数据量大时内存溢出、性能差 |

生产环境几乎只用物理分页,逻辑分页(如 RowBounds)仅在小数据量场景下适用。

6.7.2 原生 SQL 分页(MySQL)

最基础的方式——直接在 SQL 中写 LIMIT

<!-- MySQL 分页 -->
<select id="selectByPage" resultType="User">
    SELECT * FROM user
    <where>
        <if test="name != null and name != ''">
            AND name LIKE CONCAT('%', #{name}, '%')
        </if>
    </where>
    ORDER BY create_time DESC
    LIMIT #{offset}, #{pageSize}
</select>

对应的 Mapper 接口和分页参数:

List<User> selectByPage(@Param("name") String name,
                        @Param("offset") int offset,
                        @Param("pageSize") int pageSize);

调用时计算 offset:

int pageNum = 3;   // 第 3 页
int pageSize = 10;  // 每页 10 条
int offset = (pageNum - 1) * pageSize;  // offset = 20

List<User> users = userMapper.selectByPage("张", offset, pageSize);

生成的 SQL:

SELECT * FROM user WHERE name LIKE '%张%' ORDER BY create_time DESC LIMIT 2010

配合总数查询实现完整分页:

<!-- 查询总数(配合分页使用) -->
<select id="countByCondition" resultType="int">
    SELECT COUNT(*) FROM user
    <where>
        <if test="name != null and name != ''">
            AND name LIKE CONCAT('%', #{name}, '%')
        </if>
    </where>
</select>
// 完整分页逻辑
int total = userMapper.countByCondition("张");
int totalPages = (total + pageSize - 1) / pageSize;
List<User> records = userMapper.selectByPage("张", offset, pageSize);

// 封装分页结果
PageResult<User> pageResult = new PageResult<>();
pageResult.setTotal(total);
pageResult.setPages(totalPages);
pageResult.setPageNum(pageNum);
pageResult.setPageSize(pageSize);
pageResult.setRecords(records);

6.7.3 不同数据库的分页语法对比

| 数据库 | 分页语法 | 示例 | | --- | --- | --- | | MySQL | LIMIT offset, size | LIMIT 0, 10 | | PostgreSQL | LIMIT size OFFSET offset | LIMIT 10 OFFSET 0 | | Oracle 12c+ | OFFSET n ROWS FETCH NEXT m ROWS ONLY | OFFSET 0 ROWS FETCH NEXT 10 ROWS ONLY | | Oracle 旧版 | 嵌套 ROWNUM 子查询 | 见下方示例 | | SQL Server | OFFSET n ROWS FETCH NEXT m ROWS ONLY | 同 Oracle 12c |

Oracle 旧版分页写法(嵌套子查询):

<select id="selectByPageOracle" resultType="User">
    SELECT * FROM (
        SELECT t.*, ROWNUM rn FROM (
            SELECT * FROM user ORDER BY create_time DESC
        ) t
        WHERE ROWNUM <![CDATA[ <= ]]> #{endRow}
    )
    WHERE rn <![CDATA[ > ]]> #{startRow}
</select>

注意这里用到了 CDATA 来处理 <= 和 > 这些特殊符号。

6.7.4 MyBatis 的 RowBounds 逻辑分页

MyBatis 内置了 RowBounds 参数,实现内存级逻辑分页:

// Mapper 接口(不需要 XML 中写分页逻辑)
List<User> selectAll(RowBounds rowBounds);
// 调用:跳过前 20 条,取 10 条
RowBounds rowBounds = new RowBounds(2010);
List<User> users = userMapper.selectAll(rowBounds);

XML 中无需任何分页相关 SQL:

<select id="selectAll" resultType="User">
    SELECT * FROM user ORDER BY create_time DESC
</select>

原理:MyBatis 会执行完整 SQL 查出所有数据,然后在 Java 层截取需要的那一页。

注意:数据量大时性能极差,仅适用于小数据量场景。

6.7.5 MyBatis-Plus 分页插件(推荐)

MyBatis-Plus 提供了开箱即用的物理分页插件,支持多种数据库自动适配。

第一步:配置分页插件

@Configuration
public class MybatisPlusConfig {

    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        // 添加分页插件,指定数据库类型
        interceptor.addInnerInterceptor(
            new PaginationInnerInterceptor(DbType.MYSQL)
        );
        return interceptor;
    }
}

第二步:使用内置分页方法(零 XML)

// 直接调用 BaseMapper 的 selectPage
Page<User> page = new Page<>(110);  // 第 1 页,每页 10 条
IPage<User> result = userMapper.selectPage(page, null);

// 带条件的分页
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.like("name""张")
       .eq("status"1)
       .orderByDesc("create_time");
IPage<User> result = userMapper.selectPage(page, wrapper);

第三步:自定义 XML 分页查询

当需要写复杂 SQL 时,可以在 XML 中使用分页:

<!-- Mapper 接口方法 -->
<!-- IPage<User> selectUserPage(IPage<User> page, @Param("query") UserQuery query); -->

<select id="selectUserPage" resultType="User">
    SELECT u.id, u.user_name, u.age, u.email, d.dept_name
    FROM user u
    LEFT JOIN dept d ON u.dept_id = d.id
    <where>
        <if test="query.name != null and query.name != ''">
            AND u.user_name LIKE CONCAT('%', #{query.name}, '%')
        </if>
        <if test="query.status != null">
            AND u.status = #{query.status}
        </if>
    </where>
    ORDER BY u.create_time DESC
</select>
// 调用时只需要传入 Page 对象,MyBatis-Plus 自动追加 LIMIT 语句
Page<User> page = new Page<>(110);
UserQuery query = new UserQuery();
query.setName("张");
query.setStatus(1);

IPage<User> result = userMapper.selectUserPage(page, query);

// 获取结果
result.getRecords();  // 当前页数据
result.getTotal();     // 总记录数
result.getPages();     // 总页数
result.getCurrent();   // 当前页码
result.getSize();      // 每页大小

MyBatis-Plus 的分页插件会自动执行两条 SQL:先查总数,再查当前页数据。你不需要手动写 COUNT(*) 和 LIMIT

6.7.6 PageHelper 分页插件(第三方)

如果不使用 MyBatis-Plus,也可以使用 PageHelper:

第一步:引入依赖

<dependency>
    <groupId>com.github.pagehelper</groupId>
    <artifactId>pagehelper-spring-boot-starter</artifactId>
    <version>1.4.7</version>
</dependency>

第二步:使用(通过 ThreadLocal 自动拦截)

// 开启分页:紧接在查询方法之前调用
PageHelper.startPage(110);

// 执行普通查询(不需要写分页 SQL)
List<User> users = userMapper.selectAll();

// 结果自动被包装为 Page 类型
Page<User> page = (Page<User>) users;
page.getTotal();     // 总记录数
page.getPages();     // 总页数
page.getResult();    // 当前页数据

原理PageHelper.startPage() 通过 ThreadLocal 设置分页参数,MyBatis 执行 SQL 时,PageHelper 的拦截器会自动修改 SQL,追加 LIMIT 子句和 COUNT 查询。

6.7.7 分页方案对比

| 方案 | 类型 | 侵入性 | 数据库兼容 | 推荐程度 | | --- | --- | --- | --- | --- | | 原生 SQL LIMIT | 物理 | 无 | 单一数据库 | 简单场景可用 | | RowBounds | 逻辑 | 低 | 全兼容 | 不推荐生产使用 | | MyBatis-Plus 分页插件 | 物理 | 低 | 多数据库自动适配 | 强烈推荐 | | PageHelper | 物理 | 低 | 多数据库自动适配 | 推荐(非 Plus 项目) |

选型建议:使用 MyBatis-Plus 的项目首选其内置分页插件;纯 MyBatis 项目推荐 PageHelper。

七、使用示例

下面通过一个完整的「用户管理系统」场景,把所有语法串联起来。

7.1 实体类

@Data
public class User {
    private Long id;
    private String userName;
    private Integer age;
    private String email;
    private Long deptId;
    private Integer status;
    private Date createTime;
    private Date updateTime;
}

7.2 Mapper 接口

@Mapper
public interface UserMapper {

    List<User> selectUserList(UserQuery query);

    int countUser(UserQuery query);

    User selectById(Long id);

    List<User> selectByIds(@Param("ids") List<Long> ids);

    int insertUser(User user);

    int batchInsert(@Param("list") List<User> users);

    int updateUser(User user);

    int deleteById(Long id);

    int deleteByIds(@Param("ids") List<Long> ids);
}

7.3 完整的 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.mapper.UserMapper">

    <!-- ==================== 可复用片段 ==================== -->

    <!-- 通用查询字段 -->
    <sql id="baseColumns">
        id, user_name, age, email, dept_id, status, create_time, update_time
    </sql>

    <!-- 通用查询条件 -->
    <sql id="queryCondition">
        <if test="userName != null and userName != ''">
            AND user_name LIKE CONCAT('%', #{userName}, '%')
        </if>
        <if test="ageMin != null">
            AND age <![CDATA[ >= ]]> #{ageMin}
        </if>
        <if test="ageMax != null">
            AND age <![CDATA[ <= ]]> #{ageMax}
        </if>
        <if test="email != null and email != ''">
            AND email = #{email}
        </if>
        <if test="deptId != null">
            AND dept_id = #{deptId}
        </if>
        <if test="status != null">
            AND status = #{status}
        </if>
    </sql>

    <!-- ==================== 结果映射 ==================== -->

    <resultMap id="userResultMap" type="com.example.entity.User">
        <id     property="id"         column="id"/>
        <result property="userName"   column="user_name"/>
        <result property="age"        column="age"/>
        <result property="email"      column="email"/>
        <result property="deptId"     column="dept_id"/>
        <result property="status"     column="status"/>
        <result property="createTime" column="create_time"/>
        <result property="updateTime" column="update_time"/>
    </resultMap>

    <!-- ==================== 查询 ==================== -->

    <!-- 条件分页查询 -->
    <select id="selectUserList" resultMap="userResultMap">
        SELECT <include refid="baseColumns"/>
        FROM user
        <where>
            <include refid="queryCondition"/>
        </where>
        <choose>
            <when test="orderBy != null and orderBy != ''">
                ORDER BY ${orderBy} ${orderDir}
            </when>
            <otherwise>
                ORDER BY create_time DESC
            </otherwise>
        </choose>
        LIMIT #{offset}, #{pageSize}
    </select>

    <!-- 统计总数 -->
    <select id="countUser" resultType="int">
        SELECT COUNT(*)
        FROM user
        <where>
            <include refid="queryCondition"/>
        </where>
    </select>

    <!-- 根据 ID 查询 -->
    <select id="selectById" resultMap="userResultMap">
        SELECT <include refid="baseColumns"/>
        FROM user
        WHERE id = #{id}
    </select>

    <!-- 根据 ID 列表查询 -->
    <select id="selectByIds" resultMap="userResultMap">
        SELECT <include refid="baseColumns"/>
        FROM user
        WHERE id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </select>

    <!-- ==================== 插入 ==================== -->

    <!-- 单条插入(返回自增主键) -->
    <insert id="insertUser" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO user
        <trim prefix="(" suffix=")" suffixOverrides=",">
            <if test="userName != null">user_name,</if>
            <if test="age != null">age,</if>
            <if test="email != null">email,</if>
            <if test="deptId != null">dept_id,</if>
            <if test="status != null">status,</if>
            create_time,
        </trim>
        <trim prefix="VALUES (" suffix=")" suffixOverrides=",">
            <if test="userName != null">#{userName},</if>
            <if test="age != null">#{age},</if>
            <if test="email != null">#{email},</if>
            <if test="deptId != null">#{deptId},</if>
            <if test="status != null">#{status},</if>
            NOW(),
        </trim>
    </insert>

    <!-- 批量插入 -->
    <insert id="batchInsert" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO user (user_name, age, email, dept_id, status, create_time)
        VALUES
        <foreach collection="list" item="u" separator=",">
            (#{u.userName}, #{u.age}, #{u.email},
             #{u.deptId}, #{u.status}, NOW())
        </foreach>
    </insert>

    <!-- ==================== 更新 ==================== -->

    <!-- 选择性更新 -->
    <update id="updateUser">
        UPDATE user
        <set>
            <if test="userName != null and userName != ''">
                user_name = #{userName},
            </if>
            <if test="age != null">
                age = #{age},
            </if>
            <if test="email != null and email != ''">
                email = #{email},
            </if>
            <if test="deptId != null">
                dept_id = #{deptId},
            </if>
            <if test="status != null">
                status = #{status},
            </if>
            update_time = NOW(),
        </set>
        WHERE id = #{id}
    </update>

    <!-- ==================== 删除 ==================== -->

    <!-- 根据 ID 删除 -->
    <delete id="deleteById">
        DELETE FROM user WHERE id = #{id}
    </delete>

    <!-- 根据 ID 列表批量删除 -->
    <delete id="deleteByIds">
        DELETE FROM user WHERE id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </delete>

</mapper>

7.4 调用示例

@Service
public class UserService {

    @Autowired
    private UserMapper userMapper;

    public void demo() {
        // 1. 条件查询
        UserQuery query = new UserQuery();
        query.setUserName("张");
        query.setStatus(1);
        query.setOffset(0);
        query.setPageSize(10);
        List<User> users = userMapper.selectUserList(query);

        // 2. 根据 ID 查询
        User user = userMapper.selectById(1L);

        // 3. 插入
        User newUser = new User();
        newUser.setUserName("赵六");
        newUser.setAge(28);
        newUser.setEmail("zl@example.com");
        userMapper.insertUser(newUser);
        System.out.println("新用户 ID: " + newUser.getId()); // 主键已回填

        // 4. 批量插入
        List<User> batchUsers = new ArrayList<>();
        // ... 添加多个 User 对象
        userMapper.batchInsert(batchUsers);

        // 5. 选择性更新(只更新传了值的字段)
        User updateUser = new User();
        updateUser.setId(1L);
        updateUser.setEmail("newemail@example.com");
        userMapper.updateUser(updateUser); // 只更新 email

        // 6. 批量删除
        userMapper.deleteByIds(Arrays.asList(1L2L3L));
    }
}

八、XML 与注解如何选择

MyBatis 同时支持 XML 和注解两种方式来编写 SQL 映射。很多开发者会纠结到底该用哪种,下面从多个维度进行对比分析。

8.1 注解方式快速回顾

先看一个用注解实现的 Mapper 接口:

@Mapper
public interface UserMapper {

    // 简单查询
    @Select("SELECT * FROM user WHERE id = #{id}")
    User selectById(Long id);

    // 简单插入
    @Insert("INSERT INTO user (name, age) VALUES (#{name}, #{age})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int insertUser(User user);

    // 简单更新
    @Update("UPDATE user SET name = #{name}, age = #{age} WHERE id = #{id}")
    int updateUser(User user);

    // 简单删除
    @Delete("DELETE FROM user WHERE id = #{id}")
    int deleteById(Long id);

    // 动态 SQL(注解方式,用 <script> 包裹)
    @Select("<script>" +
            "SELECT * FROM user" +
            "<where>" +
            "  <if test='name != null'>AND name = #{name}</if>" +
            "  <if test='age != null'>AND age = #{age}</if>" +
            "</where>" +
            "</script>")
    List<User> selectByCondition(UserQuery query);
}

8.2 全方位对比

| 对比维度 | XML 方式 | 注解方式 | | --- | --- | --- | | SQL 可读性 | 高,SQL 格式清晰,有语法高亮 | 低,长 SQL 挤在一行字符串里 | | 动态 SQL | 原生支持,标签语法直观 | 需用 <script> 包裹,写在字符串中,无 IDE 提示 | | 复杂查询 | 多表关联、子查询清晰可读 | 嵌套复杂 SQL 时代码几乎不可维护 | | SQL 与代码分离 | 完全分离,修改 SQL 不动 Java 代码 | SQL 和 Java 代码混在一起 | | IDE 支持 | XML 中 SQL 有语法高亮和校验 | 字符串中的 SQL 基本无提示 | | 维护成本 | SQL 多了容易文件膨胀,但有 <sql> 复用 | 简单场景维护方便,复杂场景维护困难 | | 版本管理 | XML 文件独立,git diff 清晰 | SQL 修改和 Java 代码混在同一个 diff 中 | | 学习门槛 | 需要了解 XML 和 MyBatis 标签语法 | 零额外学习成本 | | 执行性能 | 完全相同 | 完全相同 |

8.3 选择决策树

你的 SQL 复杂吗?
│
├── 不复杂(单表 CRUD,无动态条件)
│   └── ✅ 用注解,简洁高效
│
├── 有动态 SQL(if/where/foreach 等)
│   └── ✅ 用 XML,注解写动态 SQL 痛苦且易错
│
├── 多表关联查询
│   └── ✅ 用 XML,可读性和维护性远胜注解
│
└── 需要 resultMap 映射(一对一、一对多)
    └── ✅ 用 XML,注解方式实现复杂映射非常笨拙

8.4 业界主流做法

大多数企业项目的实际做法是两者结合使用:

@Mapper
public interface UserMapper {

    // 简单查询 → 注解
    @Select("SELECT * FROM user WHERE id = #{id}")
    User selectById(Long id);

    // 简单插入 → 注解
    @Insert("INSERT INTO user (name, age) VALUES (#{name}, #{age})")
    @Options(useGeneratedKeys = true, keyProperty = "id")
    int insertUser(User user);

    // 复杂查询 → XML(方法签名声明在这里,SQL 写在 XML 中)
    List<User> selectUserList(UserQuery query);

    // 复杂关联查询 → XML
    User selectUserWithDept(Long id);

    // 批量操作 → XML
    int batchInsert(@Param("list") List<User> users);
}

| 场景 | 推荐方式 | 理由 | | --- | --- | --- | | 单表简单查询/插入/删除 | 注解 | 一目了然,无需跳转 XML | | 带条件的动态查询 | XML | <if> <where> 标签比字符串拼接清晰百倍 | | 多表关联 / 子查询 | XML | 复杂 SQL 在字符串中无法维护 | | 需要 resultMap 映射 | XML | 注解方式对复杂映射支持很差 | | 批量操作 | XML | <foreach>  是批量操作的最佳选择 | | 需要 SQL 片段复用 | XML | <sql> <include> 天然支持复用 |

8.5 使用 MyBatis-Plus 时的最佳实践

如果项目使用了 MyBatis-Plus,情况又有所不同:

简单 CRUD → MyBatis-Plus 内置方法(零 SQL、零 XML、零注解)
中等复杂   → MyBatis-Plus 条件构造器(Java API,无需 XML)
高度复杂   → XML 自定义 SQL
// 简单 CRUD:直接继承 BaseMapper,什么都不用写
@Mapper
public interface UserMapper extends BaseMapper<User> {
}

// 使用时
userMapper.selectById(1L);                              // 单条查询
userMapper.selectBatchIds(Arrays.asList(1L2L3L));   // 批量查询
userMapper.insert(user);                                // 插入
userMapper.updateById(user);                            // 更新
userMapper.deleteById(1L);                              // 删除

// 中等复杂:条件构造器
QueryWrapper<User> wrapper = new QueryWrapper<>();
wrapper.like("name""张")
       .between("age"1830)
       .eq("status"1)
       .orderByDesc("create_time");
List<User> users = userMapper.selectList(wrapper);

// 高度复杂:自定义 XML
List<User> users = userMapper.selectComplexReport(params);

总结:简单场景用注解或框架内置方法,复杂场景用 XML,不纠结、不教条,按需选择。

九、进阶技巧与最佳实践

9.1 防止全表更新

MyBatis-Plus 提供了防全表更新插件,但理解原理很重要:

<!-- 危险!缺少 WHERE 条件会导致全表更新 -->
<update id="updateAll">
    UPDATE user SET status = #{status}
    <!-- 忘了写 WHERE ... -->
</update>

MyBatis-Plus 的防全表更新插件:

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
    return interceptor;
}

9.2 #{} 中指定 JDBC 类型

<!-- 指定 JDBC 类型可以避免某些数据库驱动的兼容问题 -->
#{age, jdbcType=INTEGER}
#{name, jdbcType=VARCHAR}

9.3 嵌套查询的两种方式对比

| 方式 | 优点 | 缺点 | | --- | --- | --- | | 嵌套结果(JOIN) | 一次 SQL 完成,性能好 | SQL 较长,resultMap 较复杂 | | 嵌套查询(分步) | SQL 简洁,可延迟加载 | N+1 查询问题 |

十、常见问题排查

| 问题 | 原因 | 解决方案 | | --- | --- | --- | | BindingException: Invalid bound statement | XML 的 namespace 或 id 与接口不匹配 | 检查 namespace 是否为接口全限定名,id 是否与方法名一致 | | XML 修改后不生效 | XML 没有编译到 target 目录 | 检查文件路径是否在 resources 对应包下,或 clean 后重新构建 | | Parameter 'xxx' not found | 参数名不匹配 | 给方法参数加 @Param("xxx") 注解 | | 字段值为 null | 字段名映射不正确 | 检查 resultMap 或开启驼峰映射 | | 动态 SQL 条件不生效 | OGNL 表达式写错 | 字串用 != '',数值只用 != null | | XML 中 < 导致解析错误 | 特殊符号未转义 | 使用 &lt; 转义或 CDATA 包裹 | | 分页插件不生效 | 未配置分页插件 | 检查是否注册了 MybatisPlusInterceptor | | $  导致 SQL 注入 | 错误使用了 ${} | 参数值应统一用 #{} |

十一、速记口诀

查询 SELECT,插入 INSERT,
更新 UPDATE,删除 DELETE。
参数统一用 #{号井花括号},防注入保安全。
${} 只用于动态列名和排序,
滥用必有 SQL 注入风险。

特殊符号莫忘记,CDATA 来帮你。
< > & 直接写,解析报错找转义。

动态条件 <where> 帮你加 WHERE,
动态更新 <set> 帮你去逗号。
<if> 做判断,<choose> 做多选,
<foreach> 循环,批量操作显神通。
<sql> 抽片段,<include> 做复用,
代码整洁不重复。

<association> 一对一关联映射,
<collection> 一对多关联映射。
主键标签 <id>,字段标签 <result>,
<resultMap> 让映射不再困扰。

分页查询选方案,物理分页是首选。
LIMIT 最基础,插件更省心。
简单查询用注解,复杂查询用 XML,
灵活搭配效率高,不纠结来不教条。

十二、结束语

MyBatis XML 本质上并不复杂——它的核心思想就是把 SQL 写在 XML 文件里,然后通过一些框架标签实现参数映射和动态生成。但正是这种简单的设计,赋予了它极高的灵活性:你可以在 XML 中写任意复杂的 SQL,可以精确控制每一条语句的执行行为,可以根据业务条件动态组装查询逻辑。

掌握了本文介绍的这些知识点——从基础的四大标签、参数占位符,到动态 SQL 的七种标签,再到特殊符号处理、分页查询方案、结果映射技巧,以及 XML 与注解的选择策略——你已经具备了应对绝大多数业务场景的能力。

剩下的就是在实际项目中多写、多练、多踩坑——真正的熟练来自于实践,而非记忆


公众号:技海拾贝

专注于 Java 后端技术分享,涵盖 Spring 全家桶、MyBatis、数据库优化、微服务架构、分布式系统等核心技术领域。每一篇都是精心整理的实战干货,助你在技术之路上稳步前行。

关注「技海拾贝」,让碎片化的学习变成系统化的成长。技术的海洋浩瀚无边,愿与你一同拾取那些闪闪发光的贝壳。

点击关注收藏