MyBatis XML Mapper 标签与 CRUD 原理实战

MyBatis XML Mapper 标签与 CRUD 原理实战

文章目录

内容摘要:本文面向使用 MyBatis XML Mapper 编写 SQL 的 Java 开发者,讲清 selectinsertupdatedeleteifchoosewheresetforeach 等标签的职责、组合方式和边界。核心判断是:XML 动态 SQL 的可靠性不取决于标签数量,而取决于参数命名、SQL 结构收敛和 #{} 参数化约束。读完可独立编写常见查询、条件更新、批量操作,并定位空条件、主键回填和注入风险。

读者问题、范围与核心结论

MyBatis XML 的作用不是把 SQL 隐藏在 Java 代码里,而是把 SQL 结构Java 参数绑定 分开管理:Java 负责传入明确的业务参数,Mapper XML 负责根据参数拼出仍然合法、可预期的 SQL。

本文覆盖 MyBatis 3 的 Mapper XML 中最常用的内容:

  • CRUD 语句标签:<select><insert><update><delete>
  • 动态 SQL 标签:<if><choose><when><otherwise><where><set><trim><foreach><bind>
  • 复用与映射标签:<sql><include><resultMap><result><id><association><collection>
  • 常见辅助标签:<selectKey><cache><cache-ref>

不讨论 MyBatis-Plus 的 Wrapper 自动构造 SQL、数据库表设计、分页插件和事务传播;它们与 XML Mapper 可以共存,但不改变本文所述的 XML 语义。

核心结论:

  1. 值参数应优先使用 #{...},它会绑定为 JDBC 预编译参数;${...} 是原样文本替换,只应承载经过白名单校验的 SQL 结构片段。
  2. <if> 只决定片段是否出现,不能自动修复 WHERE AND、多余逗号或空 SET;应由 <where><set><trim> 让 SQL 的边界收敛。
  3. <choose> 表达"多选一"的业务规则,多个互相排斥的 <if> 容易因条件重叠而生成意外 SQL。
  4. 主键回填、批量插入返回值和关联查询的行为受 JDBC 驱动与数据库影响;示例能说明写法,但不能代替目标数据库的集成验证。

先理解一条 SQL 如何从 Java 走到数据库

以下例子假设有 user 表。为避免与部分数据库的保留字冲突,实际项目可按团队规范将表命名为 sys_user;文中的表名仅用于讲解。

java 复制代码
package com.example.user.mapper;

import com.example.user.dto.UserQuery;
import com.example.user.entity.User;
import org.apache.ibatis.annotations.Param;

import java.util.List;

public interface UserMapper {

    List<User> selectByCondition(UserQuery query);

    User selectById(@Param("id") Long id);

    int insert(User user);

    int updateSelective(@Param("user") User user,
                        @Param("currentOrganizationId") Long currentOrganizationId);

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

对应 Mapper 文件的根节点必须使用接口的全限定名作为 namespace

xml 复制代码
<?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.user.mapper.UserMapper">
    <!-- 此处放置 select、insert、update、delete 等语句 -->
</mapper>

调用 userMapper.selectById(1L) 时,MyBatis 用 namespace + statement id 定位到 UserMapper.selectById,读取 XML,解析动态标签,再把 #{id} 绑定到 JDBC PreparedStatement。SQL 关键字和字段名来自 XML;运行时输入通常作为参数值传入驱动。
渲染错误: Mermaid 渲染失败: Parse error on line 5: ...QL 结构] D --> E#{...} 绑定 JDBC 参数 ----------------------^ Expecting 'SQE', 'DOUBLECIRCLEEND', 'PE', '-)', 'STADIUMEND', 'SUBROUTINEEND', 'PIPE', 'CYLINDEREND', 'DIAMOND_STOP', 'TAGEND', 'TRAPEND', 'INVTRAPEND', 'UNICODE_TEXT', 'TEXT', 'TAGSTART', got 'DIAMOND_START'

读图要点:动态标签改变的是 SQL 的结构片段#{} 改变的是 SQL 的参数值。将两者混为一谈,通常就是动态 SQL 难维护或出现注入漏洞的起点。

参数名为什么要显式固定

单参数方法在部分场景可以直接用对象属性或默认参数名,但多参数方法不要依赖编译器是否保留方法参数名。使用 @Param 固定 XML 可见名称,重构 Java 形参时不容易悄悄失效:

java 复制代码
int updateStatus(@Param("id") Long id, @Param("status") Integer status);
xml 复制代码
<update id="updateStatus">
    UPDATE sys_user
    SET status = #{status}, updated_at = CURRENT_TIMESTAMP
    WHERE id = #{id}
</update>
项目 契约
输入 idstatus 必填,名称由 @Param 固定
输出 int,通常为受影响行数
不变量 WHERE id = #{id} 必须存在,避免整表更新
失败 参数名拼错通常在运行时绑定异常;目标行不存在时可能返回 0
验证 用存在和不存在的 id 分别断言返回 10

#{}${}:先决定输入是值还是 SQL 结构

这是 XML Mapper 最重要的安全边界。

#{}:默认用于数据值

xml 复制代码
<select id="selectByLoginName" resultType="com.example.user.entity.User">
    SELECT id, login_name, status
    FROM sys_user
    WHERE login_name = #{loginName}
</select>

这里 loginName 是一个值。MyBatis 会将它作为 JDBC 参数交给驱动,输入中的引号等字符不会改变 SQL 语法。可按需要指定类型处理信息:

xml 复制代码
WHERE deleted_at = #{deletedAt, jdbcType=TIMESTAMP}
  AND status = #{status, javaType=Integer}

jdbcType 常在允许 null 的字段上有价值,尤其是数据库或驱动不能从 null 推断类型时;它不是所有参数都必须写的模板化配置。

${}:原样拼接,只用于已验证的结构

xml 复制代码
<select id="selectPageByOrder" resultType="com.example.user.entity.User">
    SELECT id, login_name, created_at
    FROM sys_user
    ORDER BY ${orderColumn} ${orderDirection}
</select>

${} 不会产生 JDBC 占位符;若将前端原始输入直接传给 orderColumn,攻击者可改变 ORDER BY 之后的 SQL 文本。因此下面这种写法不安全:

xml 复制代码
<!-- 不安全:keyword 可能改变 SQL 语义 -->
WHERE login_name LIKE '%${keyword}%'

安全替代方案是让 Java 侧把排序字段和方向映射为有限枚举,XML 只接收已经确定的值;模糊查询也仍然使用 #{}

xml 复制代码
<select id="selectByKeyword" resultType="com.example.user.entity.User">
    SELECT id, login_name, nickname
    FROM sys_user
    WHERE login_name LIKE CONCAT('%', #{keyword}, '%')
</select>

不同数据库的拼接函数不同。若要让 XML 不依赖 CONCAT,可通过 <bind> 构造参数,后文会说明。

四类 CRUD 语句:返回什么、如何映射、何时回填主键

<select>:查询结果必须有清晰映射契约

最简单的结果映射是 resultType。当数据库列名能映射到 Java 属性名(例如开启下划线转驼峰)或 SQL 明确使用别名时,适合这样写:

xml 复制代码
<select id="selectById" resultType="com.example.user.entity.User">
    SELECT
        id,
        login_name AS loginName,
        nickname,
        status,
        created_at AS createdAt
    FROM sys_user
    WHERE id = #{id}
</select>

<select> 常用属性:

属性 作用 使用建议
id 对应接口方法名 在同一 namespace 内唯一
resultType 指定单行结果元素类型 简单对象、Map、基本类型场景适用
resultMap 引用复杂结果映射 多表嵌套、列名不一致时优先
parameterType 参数类型提示 接口方法已提供类型时常可省略
resultSetType JDBC ResultSet 类型 一般保持默认,特殊游标场景再考虑
fetchSize 给驱动的抓取大小提示 并不等于分页,也不保证每次网络读取数量
timeout 语句超时秒数 需结合数据库/驱动实际支持验证

查询单列时,resultType 可以是 longjava.lang.Longstring 等:

xml 复制代码
<select id="countEnabled" resultType="long">
    SELECT COUNT(*)
    FROM sys_user
    WHERE status = 1
</select>

<insert>:重点是受影响行数和主键生成策略

典型自增主键数据库可尝试 JDBC 主键回填:

xml 复制代码
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO sys_user (
        login_name,
        nickname,
        status
    ) VALUES (
        #{loginName},
        #{nickname},
        #{status}
    )
</insert>

执行成功后,MyBatis 会尝试将驱动返回的主键写回传入对象的 user.iduseGeneratedKeys="true" 是否生效取决于 JDBC 驱动和数据库的 generated keys 支持,因此必须使用目标数据库实测。

对序列型数据库,或主键必须在插入前获取时,可使用 <selectKey>

xml 复制代码
<insert id="insertWithSequence">
    <selectKey keyProperty="id" resultType="long" order="BEFORE">
        SELECT NEXTVAL('sys_user_id_seq')
    </selectKey>
    INSERT INTO sys_user (id, login_name, nickname, status)
    VALUES (#{id}, #{loginName}, #{nickname}, #{status})
</insert>

order="BEFORE" 表示先执行主键查询再插入;一些数据库的语法与获取时机不同,不能照搬该 SQL。keyProperty 是 Java 属性名,多个属性可按框架文档要求配置。

<update>:受影响行数是并发控制的信号

xml 复制代码
<update id="updateNickname">
    UPDATE sys_user
    SET nickname = #{nickname}, updated_at = CURRENT_TIMESTAMP
    WHERE id = #{id}
</update>

接口通常返回 int。返回 1 表示一行被更新,返回 0 可能是记录不存在、逻辑删除条件不满足,或者乐观锁版本过期。不要把 0 一律翻译成"操作成功"。

乐观锁可以将版本条件放在 WHERE 中:

xml 复制代码
<update id="updateNicknameWithVersion">
    UPDATE sys_user
    SET nickname = #{nickname}, version = version + 1
    WHERE id = #{id}
      AND version = #{version}
</update>

此时成功判据不只是 SQL 无异常,而是受影响行数必须为 1。这是一条业务并发契约,应在 Service 层将 0 转换为可识别的"数据已被修改"错误。

<delete>:删除语句与逻辑删除不是同一回事

xml 复制代码
<delete id="deleteById">
    DELETE FROM sys_user
    WHERE id = #{id}
</delete>

物理删除会移除数据;若系统要求保留审计痕迹,通常应使用 <update> 写入删除标记:

xml 复制代码
<update id="logicalDeleteById">
    UPDATE sys_user
    SET deleted = 1, deleted_at = CURRENT_TIMESTAMP
    WHERE id = #{id}
      AND deleted = 0
</update>

两者没有"谁更标准"的结论。选择取决于数据保留、唯一键、审计、恢复和查询过滤约束;但无论哪一种,都应验证 WHERE 条件与返回行数。

动态 SQL:标签解决结构问题,不替你决定业务规则

下面定义一个查询对象,后续示例都基于它:

java 复制代码
package com.example.user.dto;

import java.time.LocalDateTime;
import java.util.List;

public class UserQuery {

    private String keyword;
    private Integer status;
    private List<Long> organizationIds;
    private LocalDateTime createdFrom;
    private LocalDateTime createdTo;
    private Long currentOrganizationId;

    // getter / setter 省略
}

currentOrganizationId 虽然在查询对象中出现,但它应由登录态、服务端权限上下文写入,不能直接信任浏览器提交的组织 ID。把它命名出来的目的,是让 XML 的数据隔离条件可见、可测试;这不等于把权限判断下放给前端。

<if>:可选条件的最小单位

xml 复制代码
<select id="selectByCondition" resultType="com.example.user.entity.User">
    SELECT id, login_name AS loginName, nickname, status, created_at AS createdAt
    FROM sys_user
    <where>
        <if test="keyword != null and keyword != ''">
            AND (login_name LIKE CONCAT('%', #{keyword}, '%')
                 OR nickname LIKE CONCAT('%', #{keyword}, '%'))
        </if>
        <if test="status != null">
            AND status = #{status}
        </if>
        <if test="createdFrom != null">
            AND created_at <![CDATA[ >= ]]> #{createdFrom}
        </if>
        <if test="createdTo != null">
            AND created_at <![CDATA[ < ]]> #{createdTo}
        </if>
    </where>
    ORDER BY id DESC
</select>

test 使用 MyBatis 支持的表达式语法(常见为 OGNL 风格),表达式读取的是参数对象属性或 @Param 名称。<if> 的职责只有一个:条件成立时输出它的文本节点。它不会理解 AND 位置是否正确,也不会检查查询是否过宽。

在 XML 中,<& 有特殊含义:

  • 小于号可写为 &lt;,也可用 <![CDATA[ < ]]>
  • 大于等于号 >= 通常可直接写,示例为一致性使用 CDATA;
  • 逻辑与应写为 and,不要在 XML 文本中直接写 &&

<where>:消除前缀 AND,但不替代查询权限

<where> 会在内部有任何有效内容时输出 WHERE,并删除开头的 ANDOR。因此前面的查询在只有 status 时会得到:

sql 复制代码
WHERE status = ?

而不是 WHERE AND status = ?

但是所有 <if> 都不成立时,<where> 会整体不输出。对于后台列表这可能意味着全表查询;对于数据隔离场景则可能是权限事故。强制组织条件、租户条件和逻辑删除条件不应放在可选 <if> 中:

xml 复制代码
<where>
    deleted = 0
    AND organization_id = #{currentOrganizationId}
    <if test="status != null">
        AND status = #{status}
    </if>
</where>

<choose><when><otherwise>:表达多选一,而不是堆叠条件

<choose> 类似 Java 的 if / else if / else:从上到下选择第一个成立的 <when>,没有命中时才使用 <otherwise>。它适合搜索模式、不同角色字段或互斥筛选规则。

xml 复制代码
<select id="selectBySearchMode" resultType="com.example.user.entity.User">
    SELECT id, login_name AS loginName, nickname, status
    FROM sys_user
    <where>
        deleted = 0
        <choose>
            <when test="searchMode == 'LOGIN_NAME' and keyword != null and keyword != ''">
                AND login_name LIKE CONCAT('%', #{keyword}, '%')
            </when>
            <when test="searchMode == 'NICKNAME' and keyword != null and keyword != ''">
                AND nickname LIKE CONCAT('%', #{keyword}, '%')
            </when>
            <otherwise>
                AND status = 1
            </otherwise>
        </choose>
    </where>
</select>

这里的 otherwise 不是"没有关键词就不过滤"的通用写法,而是明确规定默认只查询启用用户。默认分支是业务规则,应由调用方确认:如果默认查询全量数据,应显式写出,并配合分页与权限控制。

<trim><where><set> 的通用底层形式

<trim> 可增加前后缀,并删除指定开头或结尾文本:

xml 复制代码
<trim prefix="WHERE" prefixOverrides="AND |OR ">
    <if test="status != null">
        AND status = #{status}
    </if>
    <if test="keyword != null and keyword != ''">
        OR nickname LIKE CONCAT('%', #{keyword}, '%')
    </if>
</trim>

常用属性:

属性 作用
prefix 子内容存在时添加到开头,例如 WHERE
suffix 子内容存在时添加到结尾,例如 )
prefixOverrides 删除开头匹配的前缀,例如 AND OR
suffixOverrides 删除结尾匹配的文本,例如 ,

<where> 可理解为常用的前缀清理封装;<set> 可理解为常用的逗号清理封装。只有需要自定义括号、前后缀或清理规则时才直接使用 <trim>,否则优先选择语义更清楚的 <where> / <set>

<set>:选择性更新时避免最后一个逗号

xml 复制代码
<update id="updateSelective">
    UPDATE sys_user
    <set>
        <if test="nickname != null">
            nickname = #{nickname},
        </if>
        <if test="status != null">
            status = #{status},
        </if>
        updated_at = CURRENT_TIMESTAMP,
    </set>
    WHERE id = #{id}
</update>

<set> 会添加 SET 并清理结尾逗号。上例刻意让 updated_at 始终写入,确保至少有一个赋值项;如果所有赋值项都被 <if> 排除,最终会产生 UPDATE ... WHERE ... 这样的非法 SQL。

更重要的是,id 不应是可选条件。选择性更新 API 应在 Java 校验 id 非空,再交给 XML 执行;不要试图用动态标签"容错"掉主键条件。

<foreach>:构造列表、批量值和批量 SQL

批量删除或 IN 查询:

xml 复制代码
<delete id="deleteByIds">
    DELETE FROM sys_user
    WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</delete>
属性 含义
collection 集合参数名,例如 @Param("ids")ids
item 每次循环的元素变量
index 下标或 Map 的键,需要时显式声明
open / close 循环内容前后的文本,如圆括号
separator 元素之间的分隔符,如逗号
nullable 是否允许集合为 null,具体行为须与项目 MyBatis 版本及配置核对

不要把空列表直接交给该 SQL。不同配置下可能生成 IN ()、跳过片段或抛错,且全量删除防护不应依赖这种不确定性。Service 层通常应先拒绝空 ids

java 复制代码
if (ids == null || ids.isEmpty()) {
    throw new IllegalArgumentException("待删除的用户 ID 不能为空");
}

批量插入也可以使用 <foreach>

xml 复制代码
<insert id="batchInsert">
    INSERT INTO sys_user (login_name, nickname, status)
    VALUES
    <foreach collection="users" item="user" separator=",">
        (#{user.loginName}, #{user.nickname}, #{user.status})
    </foreach>
</insert>

但"单条多值 INSERT"并不等于任何数据库都支持批量主键回填,也不等于适合超大批次。批量大小、事务范围、驱动参数上限和失败回滚语义必须用目标数据库验证。

<bind>:把派生值保留为参数,而不是改用 ${}

xml 复制代码
<select id="selectByKeywordWithBind" resultType="com.example.user.entity.User">
    <bind name="keywordPattern" value="'%' + keyword + '%'" />
    SELECT id, login_name AS loginName, nickname
    FROM sys_user
    WHERE login_name LIKE #{keywordPattern}
</select>

<bind> 在动态上下文中创建变量,上例把 %关键词% 命名为 keywordPattern,随后仍通过 #{keywordPattern} 绑定。它的价值是保留参数化边界,并让数据库方言相关的字符串拼接不扩散到 SQL 主体。

如果 keyword 可能为 null,先用 <if> 控制是否输出该条件,或在 Java 层规范化输入。不要把空值语义寄托在表达式的隐式转换上。

结果映射与 SQL 片段复用:避免"能查到"却映射错误

<sql><include>:复用列清单,不要复用不透明的大段 SQL

xml 复制代码
<sql id="BaseUserColumns">
    id,
    login_name AS loginName,
    nickname,
    status,
    organization_id AS organizationId,
    created_at AS createdAt
</sql>

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

适合复用的是稳定且语义单一的列清单、固定过滤条件。不要把多种 JOIN、排序、可选条件和分页都塞入一个 <sql> 片段;这样虽然减少文本重复,却让调用点看不出最终 SQL 的结构与权限条件。

<include>refid 可引用同一 Mapper 的 <sql id="...">,跨 namespace 引用需使用完整标识。片段展开发生在 Mapper 解析期,它不是运行时函数,不能用它代替参数化。

<resultMap>:复杂映射应明确主键和对象边界

简单列别名足够时可用 resultType。当需要嵌套对象、一对多集合,或列到属性映射不规则时,使用 resultMap

xml 复制代码
<resultMap id="UserWithOrganizationMap" type="com.example.user.dto.UserDetail">
    <id property="id" column="user_id" />
    <result property="loginName" column="login_name" />
    <result property="nickname" column="nickname" />
    <association property="organization" javaType="com.example.organization.dto.OrganizationSummary">
        <id property="id" column="organization_id" />
        <result property="name" column="organization_name" />
    </association>
</resultMap>

<select id="selectDetailById" resultMap="UserWithOrganizationMap">
    SELECT
        u.id AS user_id,
        u.login_name,
        u.nickname,
        o.id AS organization_id,
        o.name AS organization_name
    FROM sys_user u
    LEFT JOIN sys_organization o ON o.id = u.organization_id
    WHERE u.id = #{id}
</select>

<id> 不只是"普通字段也能写"的标签。在嵌套映射与结果对象去重时,它帮助 MyBatis 识别对象标识。应将真实主键或稳定唯一键映射为 <id>;把非唯一列误标为 <id> 可能导致集合合并异常或结果缺失。

一对多可使用 <collection>

xml 复制代码
<resultMap id="OrganizationWithUsersMap" type="com.example.organization.dto.OrganizationDetail">
    <id property="id" column="organization_id" />
    <result property="name" column="organization_name" />
    <collection property="users" ofType="com.example.user.dto.UserSummary">
        <id property="id" column="user_id" />
        <result property="loginName" column="login_name" />
    </collection>
</resultMap>

当一个组织没有用户时,LEFT JOIN 仍会返回组织行,user_idnull。应通过测试确认你的 DTO 初始化、集合空值与映射行为符合预期。深层嵌套 JOIN 还可能产生行数乘积,不能仅因 XML 能写就将所有关联一次性查询出来。

<constructor><discriminator>:少用但需要知道边界

  • <constructor> 使用构造函数参数创建不可变 DTO,内部可用 <idArg><arg> 映射;要求构造参数顺序/名称与映射契约一致。
  • <discriminator> 根据某个列值选择不同的 <case> 映射,适合多态结果;若类型数量少、查询语义简单,拆成清晰的查询往往更容易维护。

这两个标签不是 CRUD 日常必需品。引入前应先确认 DTO 是否确实不可变、结果是否确实是多态,而不是为了"XML 标签用得全"。

事务、缓存与语句属性:它们不是动态标签,但会改变运行时语义

<cache><cache-ref>:二级缓存需要数据一致性设计

xml 复制代码
<cache eviction="LRU" flushInterval="600000" size="512" readOnly="true" />

<cache> 为当前 namespace 配置二级缓存;<cache-ref namespace="..." /> 复用另一 namespace 的缓存。缓存是否启用,还受全局配置与语句属性影响。对频繁更新、权限强相关或查询结果随上下文变化的数据,先设计失效策略再启用缓存;缓存命中不等于数据一定是实时的。

flushCacheuseCachestatementType

语句可按需配置:

xml 复制代码
<select id="selectById"
        resultType="com.example.user.entity.User"
        useCache="true"
        flushCache="false">
    SELECT id, login_name AS loginName
    FROM sys_user
    WHERE id = #{id}
</select>
属性 关注点
useCache 当前查询是否使用二级缓存,前提是全局/Mapper 缓存允许
flushCache 执行语句时是否刷新本地/二级缓存,修改语句的默认行为通常更积极
statementType PREPAREDSTATEMENTCALLABLE;普通带参数 SQL 应保持 PREPARED
databaseId 针对特定数据库提供同一 statement 的方言版本,需有清晰覆盖策略

不要为了动态表名或字段名把普通语句改成 STATEMENT。这会绕开 PreparedStatement 的参数化优势,无法解决结构白名单问题。

常见失败路径:标签写对了,SQL 仍可能错

失败一:可选条件导致全表更新或全表删除

xml 复制代码
<!-- 危险:id 为 null 时 WHERE 可能完全消失 -->
<delete id="unsafeDelete">
    DELETE FROM sys_user
    <where>
        <if test="id != null">
            AND id = #{id}
        </if>
    </where>
</delete>

<where> 正确删除了多余 AND,但当 id 为空时也正确地不输出 WHERE,结果变成全表删除。根因不是 <where> 有 bug,而是把不可缺失的安全条件写成了可选条件。

修复原则:在 Service 层校验关键参数;XML 中无条件输出关键 WHERE 条件;高风险操作可额外要求组织范围、状态范围或审批条件。

失败二:多个 <if> 本应互斥,却同时拼进 SQL

xml 复制代码
<!-- 当 role 为空且 isAdmin 为 true 时,两段可能同时出现 -->
<if test="role != null">AND role = #{role}</if>
<if test="isAdmin">AND admin_flag = 1</if>

如果业务要求"按角色查"与"仅管理员"二选一,应改为 <choose>。多个 <if> 合并条件本身没有错,问题在于代码没有表达互斥规则;随后调用者变化时,SQL 语义会静默改变。

失败三:foreach 接收空集合

xml 复制代码
WHERE id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">
    #{id}
</foreach>

ids 为空,得到的 SQL 是否可执行依赖标签配置和版本。更糟的是,若外层再用可选 <if> 包裹,可能连 IN 条件都消失。空集合是业务输入问题,应在调用 XML 前拒绝或定义成明确的"返回空结果",而不是期望数据库兜底。

失败四:把值当成 SQL 文本拼接

xml 复制代码
<!-- 危险:不应用于用户可控输入 -->
ORDER BY ${sortField}

安全做法是 Java 枚举映射,例如只允许 CREATED_ATLOGIN_NAME 两个字段,再传入服务端固定的 created_at / login_name;排序方向也只能是 ASCDESC。白名单应在进入 Mapper 前完成,XML 不应承担输入消毒责任。

失败五:主键回填在本地可用,换数据库后失效

useGeneratedKeys 成功依赖数据库、表定义和 JDBC 驱动。必须验证:插入后 entity.id 是否已赋值、批量插入时每个对象的 ID 是否正确、事务回滚后对象内存中的 ID 如何处理。不要仅看受影响行数为 1 就断言主键已回填。

一套可直接复用的 Mapper:查询、选择性更新、批量删除

以下完整片段将前面的约束放在一起。它是教学模板,不是可直接覆盖现有项目 Mapper 的源码;表名、列名、软删除字段与权限条件必须按你的项目调整。

xml 复制代码
<?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.user.mapper.UserMapper">

    <sql id="BaseColumns">
        id,
        login_name AS loginName,
        nickname,
        status,
        organization_id AS organizationId,
        created_at AS createdAt
    </sql>

    <select id="selectByCondition" resultType="com.example.user.entity.User">
        SELECT <include refid="BaseColumns" />
        FROM sys_user
        <where>
            deleted = 0
            AND organization_id = #{currentOrganizationId}
            <if test="keyword != null and keyword.trim() != ''">
                AND (
                    login_name LIKE CONCAT('%', #{keyword}, '%')
                    OR nickname LIKE CONCAT('%', #{keyword}, '%')
                )
            </if>
            <if test="status != null">
                AND status = #{status}
            </if>
            <if test="organizationIds != null and organizationIds.size() > 0">
                AND organization_id IN
                <foreach collection="organizationIds" item="organizationId"
                         open="(" separator="," close=")">
                    #{organizationId}
                </foreach>
            </if>
        </where>
        ORDER BY id DESC
    </select>

    <insert id="insert" useGeneratedKeys="true" keyProperty="id">
        INSERT INTO sys_user (login_name, nickname, status, organization_id)
        VALUES (#{loginName}, #{nickname}, #{status}, #{organizationId})
    </insert>

    <update id="updateSelective">
        UPDATE sys_user
        <set>
            <if test="user.nickname != null">
                nickname = #{user.nickname},
            </if>
            <if test="user.status != null">
                status = #{user.status},
            </if>
            updated_at = CURRENT_TIMESTAMP,
        </set>
        WHERE id = #{user.id}
          AND organization_id = #{currentOrganizationId}
          AND deleted = 0
    </update>

    <delete id="deleteByIds">
        DELETE FROM sys_user
        WHERE organization_id = #{currentOrganizationId}
          AND id IN
        <foreach collection="ids" item="id" open="(" separator="," close=")">
            #{id}
        </foreach>
    </delete>

</mapper>
项目 说明
输入 查询对象或实体;currentOrganizationIdidids 等安全边界参数应在接口中用 @Param 或对象属性明确暴露
输出 查询返回对象列表;写操作返回受影响行数;插入可能回填 id
不变量 deleted = 0、组织范围和写操作主键条件不应因可选筛选条件而消失
失败 空集合、无更新字段、参数命名不匹配、驱动不支持主键回填、数据库方言不兼容
验证 对每个可选条件组合记录最终 SQL 和绑定参数,并在目标数据库执行集成测试

注意:上例 selectByCondition 同时含有强制 organization_id = #{currentOrganizationId} 和可选 organizationIds IN (...)。如果 organizationIds 可能包含跨组织 ID,两个条件会取交集;这是安全的,但是否符合产品筛选需求需由业务决定。若管理员有跨组织权限,应选择不同 Mapper 语句或通过 <choose> 明确分支,不要无意删除基础隔离条件。

验证清单:从"XML 能解析"到"行为符合预期"

仅能启动应用或 XML 无报错,不能证明动态 SQL 正确。至少覆盖下列场景:

场景 应检查什么
无可选查询条件 固定租户/逻辑删除条件仍存在;不会越权或无界查询
只有一个可选条件 WHERE AND、无重复 WHERE、参数顺序正确
多个可选条件 条件之间按预期为 AND / OR,括号保持语义
<choose> 多分支 每组输入只命中一个分支,默认分支符合业务定义
选择性更新 每种字段组合都没有尾逗号;空更新请求被拒绝或有固定更新项
空/单个/多个集合 空集合被 Service 拒绝;单个和多个 ID 的 IN 语法正确
插入与回填 返回行数、对象主键和事务回滚行为均在目标数据库确认
非法排序/字段输入 白名单拒绝;Mapper 中不直接把外部输入放入 ${}

建议在 MyBatis 日志或测试中查看"预编译 SQL + 参数列表",而不是把参数手工替换进 SQL 直接复制到生产数据库。直接在数据库客户端测试时,#{id} 不是数据库语法,应替换成经过审查的具体值;这只能验证 SQL 方言,不能验证 MyBatis 参数绑定。

取舍与结论:怎样写才可维护

什么时候用 XML,什么时候不要继续堆动态标签

XML 适合复杂联表、数据库方言 SQL、可读的条件分支和需要精细控制的结果映射。它的代价是 SQL 在运行时由多处条件共同决定,代码审查必须同时看接口参数、调用方和最终 SQL。

当一个 statement 出现大量角色分支、十几个可选 JOIN、分页排序规则和动态列时,不要只继续嵌套 <if>。可考虑拆分为职责明确的多个查询,或让 Service 层先将业务条件归一化为有限模式,再由 <choose> 输出清晰分支。拆分会增加 statement 数量,却通常降低"某个参数组合生成非法或越权 SQL"的风险。

最终写法准则

  1. 先在 Java 定义参数对象与 @Param 名称,再写 XML;不要依赖隐式参数名。
  2. 所有外部数据值使用 #{};只有已经白名单化的字段名、排序方向等结构才可使用 ${}
  3. 可选查询条件放 <if>,互斥规则放 <choose>WHERESET、逗号和前缀交给 <where><set><trim> 收敛。
  4. 安全边界条件(主键、租户、逻辑删除、权限范围)不可选,并在 Service 层先校验。
  5. 对插入回填、批量写入、缓存和方言 SQL,在目标数据库做集成测试,不把 XML 解析成功当成生产验收。

官方资料与证据边界

本文对标签职责、参数占位和 Mapper 组织方式的描述,以 MyBatis 官方文档为依据;文中的实体、表结构、权限字段和 SQL 均为教学示例,未连接任何实际数据库执行。

已证明的内容:文档结构检查可证明本文件包含指定标签、CRUD 示例、失败路径与验证清单。未证明的内容:任何示例在你的数据库类型、MyBatis 版本、JDBC 驱动、字段命名和权限模型下的实际执行结果。下一步:将其中一个最小 Mapper 复制到隔离测试库,针对"无条件、单条件、多条件、空集合、写入回填"执行集成测试,并以日志核对最终 SQL 与参数。

相关推荐
程序员黎剑11 分钟前
MySQL-JOIN优化-NLJ与BNL的区别与实战
数据库·mysql
JavaPub-rodert41 分钟前
一个后台系统如何同时支持 MySQL、PostgreSQL、SQLite、SQL Server?从 ShiyuAdmin 的 GORM 多数据库适配说起
数据库·mysql·postgresql
小翰生信41 分钟前
转录组下游分析全流程:DESeq2 差异分析、GO/KEGG 富集与 GSEA 实战
数据库·矩阵·golang
2603_966437261 小时前
拷贝中断文件丢失,电脑资料抢救实操方案
数据库·电脑
harmony&1 小时前
MySQL 数据库从入门到实战:原理、安装与 SQL 详解
数据库·sql·mysql
艺杯羹1 小时前
全栈信创落地实录:基于银河麒麟V10与达梦数据库DM8的SpringBoot工业级适配指南
java·数据库·spring boot·后端·spring
坚定信念,勇往无前6 小时前
mongodb的日志分割
数据库·mongodb
oradh10 小时前
Oracle TX 锁 Mode 4(Share)问题排查总结
数据库·oracle·tx 锁 mode 4·oracle tx 锁
yi.Ist10 小时前
数据定义语言-DDL操作
数据库·学习·mysql·oracle·大海豚