上一篇 下一篇 回到顶部 目录 返回首页
目录

MyBatis-Plus 从入门到精通:XML 复杂 SQL 实战全指南

发表于
更新于
18 66.1~85.0 分钟 29757

前言

MyBatis-Plus(简称 MP)是国内 Java 项目中使用最广泛的 ORM 增强框架。它在 MyBatis 的基础上做了增强却不改变原生 MyBatis 的使用方式。很多教程只讲 MP 的 LambdaQueryWrapper 和内置 CRUD,但实际项目中复杂的联表查询、动态 SQL、批量操作几乎都必须靠 XML 来完成

本文从零开始,逐步深入到 XML 高级用法,覆盖实际开发中 90% 以上的场景。


第一部分:入门篇

1.1 什么是 MyBatis-Plus

MyBatis-Plus 是 MyBatis 的增强工具,核心卖点:

  • 零侵入:只做增强,不改变原生 MyBatis 行为

  • 内置 CRUD:继承 BaseMapper 即可获得单表增删改查

  • 条件构造器QueryWrapper / LambdaQueryWrapper 链式组装条件

  • 分页插件:开箱即用的物理分页

  • 代码生成器:从数据库表直接生成 Entity / Mapper / Service / Controller

  • XML 完全兼容:所有原生 MyBatis 的 XML 语法都能用

1.2 快速搭建

依赖(以 Spring Boot + MySQL 为例):

<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
    <version>3.5.9</version>
</dependency>
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
</dependency>

配置(application.yml):

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mydb?useSSL=false&serverTimezone=Asia/Shanghai
    username: root
    password: root

mybatis-plus:
  mapper-locations: classpath*:/mapper/**/*.xml
  type-aliases-package: com.example.entity
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl  # 开发环境打印 SQL

实体类

@Data
@TableName("user")
public class User {
    @TableId(type = IdType.AUTO)
    private Long id;
    private String username;
    private String email;
    private Integer age;
    private Integer status;    // 0=禁用 1=启用
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}

Mapper 接口

@Mapper
public interface UserMapper extends BaseMapper<User> {
    // 继承 BaseMapper 后自动拥有 insert/update/delete/selectById 等方法
    // 自定义复杂方法在这里声明,XML 中实现
}

Service 接口与实现

public interface UserService extends IService<User> {}

@Service
@RequiredArgsConstructor
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {}

1.3 内置 CRUD 基础用法

// 插入
User user = new User();
user.setUsername("Alice");
user.setEmail("alice@test.com");
userMapper.insert(user);

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

// 条件查询
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, "Alice")
       .ge(User::getAge, 18)
       .orderByDesc(User::getCreateTime);
List<User> users = userMapper.selectList(wrapper);

// 分页查询
Page<User> page = new Page<>(1, 10);
userMapper.selectPage(page, wrapper);

为什么内置 CRUD 不够用?

  • 只能做单表操作,无法 JOIN

  • 复杂子查询、窗口函数写不出来

  • 动态 SQL 能力有限

  • 批量更新/删除场景下可读性差

  • 多表聚合统计(SUM/COUNT/GROUP BY)无法优雅表达

这就是为什么你需要 XML。


第二部分:XML 基础篇

2.1 XML 文件的约定

文件位置src/main/resources/mapper/UserMapper.xml

命名空间:必须与 Mapper 接口的全限定类名一致:

<?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">

</mapper>

2.2 基础 CRUD 的 XML 写法

<!-- 查询 -->
<select id="selectById" resultType="com.example.entity.User">
    SELECT id, username, email, age, status, create_time, update_time
    FROM user
    WHERE id = #{id}
</select>

<!-- 插入 -->
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO user (username, email, age, status, create_time, update_time)
    VALUES (#{username}, #{email}, #{age}, #{status}, #{createTime}, #{updateTime})
</insert>

<!-- 更新 -->
<update id="updateById">
    UPDATE user
    SET username = #{username},
        email = #{email},
        age = #{age},
        status = #{status},
        update_time = #{updateTime}
    WHERE id = #{id}
</update>

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

2.3 参数传递的三种方式

方式一:单个参数 — 直接用 #{参数名}

<select id="findByUsername" resultType="User">
    SELECT * FROM user WHERE username = #{username}
</select>
User findByUsername(String username);

方式二:多个参数用 @Param

<select id="findByAgeAndStatus" resultType="User">
    SELECT * FROM user
    WHERE age >= #{minAge}
      AND status = #{status}
    ORDER BY create_time DESC
</select>
List<User> findByAgeAndStatus(@Param("minAge") Integer minAge,
                              @Param("status") Integer status);

方式三:传入对象 — 直接用对象的属性名:

<select id="findByCondition" resultType="User">
    SELECT * FROM user
    WHERE age >= #{minAge}
      AND age <= #{maxAge}
      AND status = #{status}
</select>
@Data
public class UserQueryDTO {
    private Integer minAge;
    private Integer maxAge;
    private Integer status;
}

List<User> findByCondition(UserQueryDTO dto);

2.4 结果映射:resultType vs resultMap

resultType — 列名自动映射到 JavaBean 属性(开启下划线转驼峰后):

<select id="selectById" resultType="User">
    SELECT id, username, email FROM user WHERE id = #{id}
</select>

resultMap — 用于复杂映射(关联对象、自定义列名):

<resultMap id="UserDetailMap" type="com.example.entity.UserDetailVO">
    <id column="id" property="id"/>
    <result column="username" property="username"/>
    <result column="email" property="email"/>
    <!-- 关联对象 -->
    <association property="profile" javaType="com.example.entity.UserProfile">
        <id column="profile_id" property="id"/>
        <result column="bio" property="bio"/>
        <result column="avatar" property="avatar"/>
    </association>
    <!-- 关联集合 -->
    <collection property="orders" ofType="com.example.entity.Order">
        <id column="order_id" property="id"/>
        <result column="order_no" property="orderNo"/>
        <result column="amount" property="amount"/>
    </collection>
</resultMap>

<select id="selectUserDetail" resultMap="UserDetailMap">
    SELECT u.id, u.username, u.email,
           p.id AS profile_id, p.bio, p.avatar,
           o.id AS order_id, o.order_no, o.amount
    FROM user u
    LEFT JOIN user_profile p ON u.id = p.user_id
    LEFT JOIN `order` o ON u.id = o.user_id
    WHERE u.id = #{id}
</select>

第三部分:XML 进阶篇 — 动态 SQL

动态 SQL 是 MyBatis 最强大的特性之一。它让你在 XML 中用条件逻辑组装 SQL,彻底告别 Java 代码中拼接 SQL 字符串的噩梦。

3.1 <if> — 条件判断

最常用的标签,根据参数是否有值决定是否拼接 SQL:

<select id="searchUsers" resultType="User">
    SELECT id, username, email, age, status, create_time
    FROM user
    <where>
        <if test="username != null and username != ''">
            AND username LIKE CONCAT('%', #{username}, '%')
        </if>
        <if test="minAge != null">
            AND age >= #{minAge}
        </if>
        <if test="maxAge != null">
            AND age <= #{maxAge}
        </if>
        <if test="status != null">
            AND status = #{status}
        </if>
        <if test="email != null and email != ''">
            AND email LIKE CONCAT('%', #{email}, '%')
        </if>
    </where>
    ORDER BY create_time DESC
</select>

<where> 标签会自动处理第一个 AND / OR — 如果所有 <if> 都不满足,<where> 不生成任何 WHERE 子句;如果有满足的,它自动去掉开头的 AND

3.2 <choose> / <when> / <otherwise> — 多分支选择

类似 Java 的 switch-case:

<select id="searchByType" resultType="User">
    SELECT * FROM user
    <where>
        <choose>
            <when test="searchType == 'name'">
                username LIKE CONCAT('%', #{keyword}, '%')
            </when>
            <when test="searchType == 'email'">
                email LIKE CONCAT('%', #{keyword}, '%')
            </when>
            <when test="searchType == 'phone'">
                phone = #{keyword}
            </when>
            <otherwise>
                <!-- 默认按用户名搜索 -->
                username LIKE CONCAT('%', #{keyword}, '%')
            </otherwise>
        </choose>
    </where>
</select>

3.3 <foreach> — 批量操作

IN 查询

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

批量插入

<insert id="batchInsert" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO user (username, email, age, status, create_time, update_time)
    VALUES
    <foreach collection="list" item="user" separator=",">
        (#{user.username}, #{user.email}, #{user.age}, #{user.status},
         #{user.createTime}, #{user.updateTime})
    </foreach>
</insert>

批量更新(用 CASE WHEN 方式):

<update id="batchUpdateStatus">
    UPDATE user
    SET status = CASE id
        <foreach collection="list" item="item">
            WHEN #{item.id} THEN #{item.status}
        </foreach>
        ELSE status
    END,
    update_time = NOW()
    WHERE id IN
    <foreach collection="list" item="item" open="(" separator="," close=")">
        #{item.id}
    </foreach>
</update>

批量插入OrUpdate

<insert id="batchUpsert">
    INSERT INTO user (id, username, email, age, status, update_time)
    VALUES
    <foreach collection="list" item="user" separator=",">
        (#{user.id}, #{user.username}, #{user.email}, #{user.age},
         #{user.status}, #{user.updateTime})
    </foreach>
    ON DUPLICATE KEY UPDATE
        username = VALUES(username),
        email = VALUES(email),
        age = VALUES(age),
        status = VALUES(status),
        update_time = VALUES(update_time)
</insert>

3.4 <set> — 动态更新

<update id="updateSelective">
    UPDATE user
    <set>
        <if test="username != null and username != ''">
            username = #{username},
        </if>
        <if test="email != null">
            email = #{email},
        </if>
        <if test="age != null">
            age = #{age},
        </if>
        <if test="status != null">
            status = #{status},
        </if>
        update_time = NOW()
    </set>
    WHERE id = #{id}
</update>

<set> 自动去掉最后一个逗号。

3.5 <trim> — 自定义前缀/后缀

<where><set> 底层都是 <trim> 的快捷方式。你完全可以自定义:

<!-- 等价于 <where> -->
<trim prefix="WHERE" prefixOverrides="AND |OR ">
    ...
</trim>

<!-- 等价于 <set> -->
<trim prefix="SET" suffixOverrides=",">
    ...
</trim>

<!-- 自定义:去掉首尾的逗号,加上括号 -->
<trim prefix="(" suffix=")" suffixOverrides=",">
    <foreach collection="list" item="id">
        #{id},
    </foreach>
</trim>

3.6 <sql> 片段 — 复用列定义

避免在每个 SQL 中都写一长串列名:

<sql id="Base_Column_List">
    id, username, email, age, status, create_time, update_time
</sql>

<select id="selectById" resultType="User">
    SELECT <include refid="Base_Column_List"/> FROM user WHERE id = #{id}
</select>

<select id="selectAll" resultType="User">
    SELECT <include refid="Base_Column_List"/> FROM user
</select>

<!-- 带别名的列片段 -->
<sql id="User_Detail_Columns">
    u.id, u.username, u.email, u.age, u.status,
    u.create_time, u.update_time,
    p.id AS profile_id, p.bio, p.avatar,
    r.id AS role_id, r.role_name
</sql>

3.7 复杂动态条件 — 嵌套 AND/OR

<select id="complexSearch" resultType="User">
    SELECT * FROM user
    <where>
        <if test="keyword != null and keyword != ''">
            AND (
                username LIKE CONCAT('%', #{keyword}, '%')
                OR email LIKE CONCAT('%', #{keyword}, '%')
                OR phone = #{keyword}
            )
        </if>
        <if test="statusList != null and statusList.size() > 0">
            AND status IN
            <foreach collection="statusList" item="s" open="(" separator="," close=")">
                #{s}
            </foreach>
        </if>
        <if test="dateRange != null">
            AND create_time BETWEEN #{dateRange.startDate} AND #{dateRange.endDate}
        </if>
        <if test="excludeIds != null and excludeIds.size() > 0">
            AND id NOT IN
            <foreach collection="excludeIds" item="id" open="(" separator="," close=")">
                #{id}
            </foreach>
        </if>
    </where>
    ORDER BY
    <choose>
        <when test="orderBy == 'age'">age</when>
        <when test="orderBy == 'name'">username</when>
        <otherwise>create_time</otherwise>
    </choose>
    <choose>
        <when test="orderDir == 'asc'">ASC</when>
        <otherwise>DESC</when>
    </choose>
</select>

第四部分:XML 高阶篇 — 复杂 SQL 实战

4.1 多表 JOIN 查询

场景:查询用户及其角色、部门信息

<resultMap id="UserVOMap" type="com.example.vo.UserVO">
    <id column="id" property="id"/>
    <result column="username" property="username"/>
    <result column="email" property="email"/>
    <result column="age" property="age"/>
    <result column="status" property="status"/>
    <result column="dept_name" property="deptName"/>
    <result column="create_time" property="createTime"/>
    <!-- 角色列表 -->
    <collection property="roles" ofType="com.example.entity.Role">
        <id column="role_id" property="id"/>
        <result column="role_name" property="roleName"/>
        <result column="role_code" property="roleCode"/>
    </collection>
</resultMap>

<select id="selectUserWithDeptAndRoles" resultMap="UserVOMap">
    SELECT u.id, u.username, u.email, u.age, u.status,
           d.name AS dept_name,
           u.create_time,
           r.id AS role_id, r.role_name, r.role_code
    FROM user u
    LEFT JOIN dept d ON u.dept_id = d.id
    LEFT JOIN user_role ur ON u.id = ur.user_id
    LEFT JOIN role r ON ur.role_id = r.id
    <where>
        <if test="username != null and username != ''">
            AND u.username LIKE CONCAT('%', #{username}, '%')
        </if>
        <if test="deptId != null">
            AND u.dept_id = #{deptId}
        </if>
        <if test="roleCode != null and roleCode != ''">
            AND r.role_code = #{roleCode}
        </if>
    </where>
    ORDER BY u.create_time DESC
</select>
@Data
public class UserVO {
    private Long id;
    private String username;
    private String email;
    private Integer age;
    private Integer status;
    private String deptName;
    private LocalDateTime createTime;
    private List<Role> roles;  // 一对多
}

注意:这种 LEFT JOIN + collection 的方式会在结果集中产生多行(每个角色一行),MyBatis 会自动按 <id> 列合并。如果数据量很大,建议拆成两次查询。

4.2 分页查询

配置分页插件

@Configuration
@MapperScan("com.example.mapper")
public class MybatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        PaginationInnerInterceptor paginationInterceptor = new PaginationInnerInterceptor(DbType.MYSQL);
        paginationInterceptor.setMaxLimit(500L);  // 限制最大页码
        interceptor.addInnerInterceptor(paginationInterceptor);
        return interceptor;
    }
}

XML 中正常写 SQL,分页由插件自动处理

<select id="pageSearch" resultType="com.example.vo.UserListVO">
    SELECT u.id, u.username, u.email, d.name AS dept_name,
           GROUP_CONCAT(r.role_name SEPARATOR ', ') AS role_names,
           u.status, u.create_time
    FROM user u
    LEFT JOIN dept d ON u.dept_id = d.id
    LEFT JOIN user_role ur ON u.id = ur.user_id
    LEFT JOIN role r ON ur.role_id = r.id
    <where>
        <if test="query.keyword != null and query.keyword != ''">
            AND (u.username LIKE CONCAT('%', #{query.keyword}, '%')
                 OR u.email LIKE CONCAT('%', #{query.keyword}, '%'))
        </if>
        <if test="query.deptId != null">
            AND u.dept_id = #{query.deptId}
        </if>
        <if test="query.status != null">
            AND u.status = #{query.status}
        </if>
        <if test="query.startTime != null">
            AND u.create_time >= #{query.startTime}
        </if>
        <if test="query.endTime != null">
            AND u.create_time &lt;= #{query.endTime}
        </if>
    </where>
    GROUP BY u.id, u.username, u.email, d.name, u.status, u.create_time
    ORDER BY u.create_time DESC
</select>
@Data
public class UserPageQuery {
    private String keyword;
    private Long deptId;
    private Integer status;
    private LocalDateTime startTime;
    private LocalDateTime endTime;
}

// 调用
Page<UserListVO> page = new Page<>(current, size);
// 注意:XML 分页需要传一个特殊的 IPage 参数
IPage<UserListVO> result = userMapper.pageSearch(page, query);

重要:XML 中的分页需要 Mapper 方法的第一个参数是 IPage 类型,插件会自动拦截并加上 LIMIT 子句,同时自动执行 COUNT 查询。

注意:XML 中包含 GROUP BY 时,MP 默认生成的 COUNT SQL 可能不正确。此时需要手动指定 count SQL:

Page<UserListVO> page = new Page<>(current, size);
page.setOptimizeCountSql(false);  // 关闭 COUNT 优化,使用默认 COUNT
// 或者自定义 COUNT SQL
page.setCountId("pageSearchCount");
<select id="pageSearchCount" resultType="long">
    SELECT COUNT(DISTINCT u.id)
    FROM user u
    LEFT JOIN dept d ON u.dept_id = d.id
    <where>
        <!-- 同上 -->
    </where>
</select>

4.3 分组统计查询

场景:按部门统计用户数量、平均年龄、活跃用户数

<select id="deptStatistics" resultType="com.example.vo.DeptStatisticsVO">
    SELECT d.id            AS dept_id,
           d.name          AS dept_name,
           COUNT(u.id)     AS total_count,
           AVG(u.age)      AS avg_age,
           SUM(CASE WHEN u.status = 1 THEN 1 ELSE 0 END) AS active_count,
           SUM(CASE WHEN u.status = 0 THEN 1 ELSE 0 END) AS inactive_count,
           MIN(u.create_time) AS earliest_create
    FROM dept d
    LEFT JOIN user u ON d.id = u.dept_id
    GROUP BY d.id, d.name
    HAVING COUNT(u.id) > 0
    ORDER BY total_count DESC
</select>
@Data
public class DeptStatisticsVO {
    private Long deptId;
    private String deptName;
    private Integer totalCount;
    private Double avgAge;
    private Integer activeCount;
    private Integer inactiveCount;
    private LocalDateTime earliestCreate;
}

4.4 窗口函数

场景:查询每个部门薪资排名前三的员工

<select id="topSalaryPerDept" resultType="com.example.vo.EmployeeRankVO">
    SELECT username, dept_name, salary, dept_rank
    FROM (
        SELECT u.username,
               d.name AS dept_name,
               u.salary,
               DENSE_RANK() OVER (PARTITION BY u.dept_id ORDER BY u.salary DESC) AS dept_rank
        FROM user u
        LEFT JOIN dept d ON u.dept_id = d.id
        WHERE u.status = 1
    ) ranked
    WHERE dept_rank &lt;= 3
    ORDER BY dept_name, dept_rank
</select>
<!-- 场景:计算累计销售额 -->
<select id="cumulativeSales" resultType="com.example.vo.SalesTrendVO">
    SELECT DATE(order_time) AS sale_date,
           SUM(amount) AS daily_amount,
           SUM(SUM(amount)) OVER (ORDER BY DATE(order_time)) AS cumulative_amount
    FROM `order`
    WHERE status = 'completed'
      AND order_time >= #{startDate}
      AND order_time &lt; #{endDate}
    GROUP BY DATE(order_time)
    ORDER BY sale_date
</select>

4.5 递归查询(CTE)

场景:查询部门树(自关联表)

<select id="selectDeptTree" resultType="com.example.entity.Dept">
    WITH RECURSIVE dept_tree AS (
        -- 锚点:查询根部门
        SELECT id, name, parent_id, 1 AS level,
               CAST(id AS CHAR(200)) AS path
        FROM dept
        WHERE parent_id IS NULL

        UNION ALL

        -- 递归:查询子部门
        SELECT d.id, d.name, d.parent_id, dt.level + 1,
               CONCAT(dt.path, '-', d.id)
        FROM dept d
        INNER JOIN dept_tree dt ON d.parent_id = dt.id
    )
    SELECT id, name, parent_id, level
    FROM dept_tree
    ORDER BY path
</select>

4.6 子查询

场景:查询最近有订单的用户

<select id="selectRecentOrderUsers" resultType="User">
    SELECT * FROM user
    WHERE id IN (
        SELECT DISTINCT user_id FROM `order`
        WHERE create_time >= DATE_SUB(NOW(), INTERVAL 30 DAY)
          AND status = 'completed'
    )
    ORDER BY create_time DESC
</select>

场景:查询订单金额超过平均值的用户

<select id="selectAboveAvgOrderUsers" resultType="com.example.vo.UserOrderVO">
    SELECT u.id, u.username, SUM(o.amount) AS total_amount
    FROM user u
    INNER JOIN `order` o ON u.id = o.user_id
    WHERE o.status = 'completed'
    GROUP BY u.id, u.username
    HAVING SUM(o.amount) > (
        SELECT AVG(total) FROM (
            SELECT SUM(amount) AS total
            FROM `order`
            WHERE status = 'completed'
            GROUP BY user_id
        ) t
    )
    ORDER BY total_amount DESC
</select>

4.7 EXISTS 替代 IN(性能优化)

当子查询结果集很大时,EXISTSIN 性能更好:

<!-- IN 方式(子查询结果多时慢) -->
<select id="selectActiveUsers" resultType="User">
    SELECT * FROM user
    WHERE id IN (SELECT user_id FROM user_role WHERE role_id = #{roleId})
</select>

<!-- EXISTS 方式(更高效) -->
<select id="selectActiveUsersByExists" resultType="User">
    SELECT * FROM user u
    WHERE EXISTS (
        SELECT 1 FROM user_role ur
        WHERE ur.user_id = u.id AND ur.role_id = #{roleId}
    )
</select>

4.8 条件化 JOIN

根据不同条件决定是否 JOIN 某张表:

<select id="searchWithOptionalJoin" resultType="com.example.vo.OrderVO">
    SELECT o.id, o.order_no, o.amount, o.status, o.create_time,
    <choose>
        <when test="includeUserInfo">
            u.username, u.email,
        </when>
    </choose>
           d.name AS dept_name
    FROM `order` o
    LEFT JOIN user u ON o.user_id = u.id
    LEFT JOIN dept d ON u.dept_id = d.id
    <where>
        <if test="orderNo != null and orderNo != ''">
            AND o.order_no LIKE CONCAT('%', #{orderNo}, '%')
        </if>
        <if test="status != null">
            AND o.status = #{status}
        </if>
        <if test="minAmount != null">
            AND o.amount >= #{minAmount}
        </if>
    </where>
    ORDER BY o.create_time DESC
</select>

4.9 批量操作进阶

批量删除

<delete id="batchDelete">
    DELETE FROM user WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</delete>

按条件批量更新

<update id="batchUpdateByCondition">
    UPDATE user
    SET status = #{newStatus},
        update_time = NOW()
    WHERE status = #{oldStatus}
      AND id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</update>

批量插入并返回自增 ID

<insert id="batchInsertReturnId" useGeneratedKeys="true" keyProperty="id">
    INSERT INTO user (username, email, age, status, create_time, update_time)
    VALUES
    <foreach collection="list" item="user" separator=",">
        (#{user.username}, #{user.email}, #{user.age}, #{user.status},
         NOW(), NOW())
    </foreach>
</insert>

插入完成后,list 中每个 user 对象的 id 字段会被自动填充。

4.10 UNION / UNION ALL

场景:合并多个来源的数据

<select id="searchAllContent" resultType="com.example.vo.SearchResultVO">
    SELECT 'article' AS content_type,
           a.id, a.title AS display_title,
           SUBSTRING(a.content, 1, 200) AS snippet,
           a.create_time
    FROM article a
    WHERE a.title LIKE CONCAT('%', #{keyword}, '%')
       OR a.content LIKE CONCAT('%', #{keyword}, '%')

    UNION ALL

    SELECT 'product' AS content_type,
           p.id, p.name AS display_title,
           SUBSTRING(p.description, 1, 200) AS snippet,
           p.create_time
    FROM product p
    WHERE p.name LIKE CONCAT('%', #{keyword}, '%')
       OR p.description LIKE CONCAT('%', #{keyword}, '%')

    UNION ALL

    SELECT 'user' AS content_type,
           u.id, u.username AS display_title,
           u.email AS snippet,
           u.create_time
    FROM user u
    WHERE u.username LIKE CONCAT('%', #{keyword}, '%')

    ORDER BY create_time DESC
    LIMIT #{limit} OFFSET #{offset}
</select>

第五部分:XML 精通篇 — 性能优化与高级技巧

5.1 SQL 注入防护

MyBatis 的 #{} 会自动做预编译参数绑定,防止 SQL 注入。但 ${} 是直接字符串拼接,有注入风险

<!-- ❌ 危险:SQL 注入 -->
<select id="dangerousQuery" resultType="User">
    SELECT * FROM user ORDER BY ${orderBy}
</select>

<!-- ✅ 安全:用 <choose> 白名单 -->
<select id="safeQuery" resultType="User">
    SELECT * FROM user ORDER BY
    <choose>
        <when test="orderBy == 'name'">username</when>
        <when test="orderBy == 'age'">age</when>
        <when test="orderBy == 'time'">create_time</when>
        <otherwise>id</otherwise>
    </choose>
</select>

何时必须用 ${}

  • 动态表名 / 列名(如分表场景)

  • ORDER BY 的列名和方向

  • LIMIT 的值(某些数据库)

必须用 ${}:确保值是白名单校验过的,或是从可信来源获取的。

5.2 动态表名(分表场景)

<select id="selectByShardedTable" resultType="User">
    SELECT * FROM user_${shardSuffix}
    WHERE status = #{status}
    <if test="keyword != null and keyword != ''">
        AND username LIKE CONCAT('%', #{keyword}, '%')
    </if>
</select>
// 动态计算分表后缀
String shardSuffix = "202401";  // 根据月份/用户ID等计算
List<User> users = userMapper.selectByShardedTable(shardSuffix, status, keyword);

5.3 XML 与 Wrapper 混合使用

在 XML 中引用 Wrapper:

<select id="searchWithWrapper" resultType="User">
    SELECT * FROM user
    ${ew.customSqlSegment}
</select>
// Java 端组装条件
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.like(User::getUsername, keyword)
       .eq(User::getStatus, 1)
       .orderByDesc(User::getCreateTime);

List<User> users = userMapper.searchWithWrapper(wrapper);

这种方式适合 SQL 主体固定,但条件部分需要灵活组装的场景。

5.4 嵌套查询(N+1 问题与优化)

嵌套查询写法(MyBatis 自动执行子查询):

<resultMap id="UserWithOrdersMap" type="com.example.vo.UserWithOrdersVO">
    <id column="id" property="id"/>
    <result column="username" property="username"/>
    <collection property="orders"
                column="id"
                select="com.example.mapper.OrderMapper.selectByUserId"/>
</resultMap>

<select id="selectUserWithOrders" resultMap="UserWithOrdersMap">
    SELECT id, username FROM user WHERE status = 1
</select>
<!-- OrderMapper.xml -->
<select id="selectByUserId" resultType="Order">
    SELECT id, order_no, amount, status, create_time
    FROM `order` WHERE user_id = #{userId}
</select>

问题:如果查出 100 个用户,会额外执行 100 次订单查询(N+1 问题)。

优化方案:改为 JOIN 一次查询,用 <collection> 合并结果:

<resultMap id="UserWithOrdersMap" type="com.example.vo.UserWithOrdersVO">
    <id column="id" property="id"/>
    <result column="username" property="username"/>
    <collection property="orders" ofType="Order">
        <id column="order_id" property="id"/>
        <result column="order_no" property="orderNo"/>
        <result column="amount" property="amount"/>
    </collection>
</resultMap>

<select id="selectUserWithOrders" resultMap="UserWithOrdersMap">
    SELECT u.id, u.username,
           o.id AS order_id, o.order_no, o.amount
    FROM user u
    LEFT JOIN `order` o ON u.id = o.user_id
    WHERE u.status = 1
</select>

5.5 二级缓存

MP 默认开启一级缓存(SqlSession 级别)。开启二级缓存(Mapper 级别):

<mapper namespace="com.example.mapper.UserMapper">
    <!-- 开启二级缓存 -->
    <cache eviction="LRU" flushInterval="600000" size="512" readOnly="true"/>

    <!-- 或者用 MP 提供的 -->
    <!-- <cache type="org.mybatis.caches.ehcache.EhcacheCache"/> -->
</mapper>

生产建议:大多数项目用 Redis 做二级缓存更合适,而不是 MyBatis 自带的缓存:

@Component
public class MybatisRedisCache implements Cache {
    // 实现 Cache 接口,用 Redis 存储缓存数据
}

5.6 批量操作的性能对比

方式

1000 条耗时

适用场景

循环单条 insert

~3s

数据量 < 50

XML foreach 批量 insert

~0.3s

数据量 50-2000

JDBC batch

~0.2s

数据量 > 2000

saveBatch(MP)

~0.5s

通用批量插入

XML foreach 的坑:MySQL 默认 max_allowed_packet 有限制,单次 INSERT 语句过大(几百 KB)可能报错。超过 2000 条建议分批:

public void batchInsert(List<User> users) {
    int batchSize = 1000;
    for (int i = 0; i < users.size(); i += batchSize) {
        List<User> batch = users.subList(i, Math.min(i + batchSize, users.size()));
        userMapper.batchInsert(batch);
    }
}

5.7 复杂 UPDATE 场景

多字段条件更新

<update id="updateByComplexCondition">
    UPDATE user
    SET status = #{newStatus},
        update_time = NOW()
    WHERE status = #{oldStatus}
      AND age BETWEEN #{minAge} AND #{maxAge}
      AND create_time &lt; DATE_SUB(NOW(), INTERVAL #{days} DAY)
      AND id NOT IN (
          SELECT user_id FROM vip_user WHERE expire_time > NOW()
      )
</update>

多表关联更新

<update id="updateUserAndDept">
    UPDATE user u
    INNER JOIN dept d ON u.dept_id = d.id
    SET u.status = 0
    WHERE d.status = 0  -- 部门禁用时,自动禁用所有用户
</update>

CASE WHEN 批量更新多字段

<update id="batchUpdateMultiFields">
    UPDATE user
    SET
        username = CASE id
            <foreach collection="list" item="user">
                WHEN #{user.id} THEN #{user.username}
            </foreach>
            ELSE username
        END,
        email = CASE id
            <foreach collection="list" item="user">
                WHEN #{user.id} THEN #{user.email}
            </foreach>
            ELSE email
        END,
        update_time = NOW()
    WHERE id IN
    <foreach collection="list" item="user" open="(" separator="," close=")">
        #{user.id}
    </foreach>
</update>

5.8 XML 中的常量与配置

定义常量

<!-- 在 mybatis-config.xml 中 -->
<configuration>
    <properties>
        <property name="default_status" value="1"/>
    </properties>
</configuration>

<!-- XML 中引用 -->
<select id="selectActiveUsers" resultType="User">
    SELECT * FROM user WHERE status = ${default_status}
</select>

5.9 事务控制

XML 中的 SQL 默认在 Spring 事务管理下。如果需要在一个方法中执行多个 XML SQL 操作并保证事务:

@Transactional(rollbackFor = Exception.class)
public void complexOperation(List<User> newUsers, List<Long> deleteIds) {
    if (!newUsers.isEmpty()) {
        userMapper.batchInsert(newUsers);
    }
    if (!deleteIds.isEmpty()) {
        userMapper.batchDelete(deleteIds);
    }
    // 任何一步抛出异常,全部回滚
}

5.10 调试技巧

打印完整 SQL(开发环境):

mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

开启 Mapper 级别的 debug 日志

logging:
  level:
    com.example.mapper: DEBUG

查看实际执行的 SQL 和参数值:使用 MP 的 SQL 性能分析插件(仅开发环境):

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
    interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
    // 开发环境:SQL 性能分析
    // interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor()); // 防止全表更新/删除
    return interceptor;
}

第六部分:实战项目模板

6.1 推荐的 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">

    <!-- 1. 列片段(复用) -->
    <sql id="Base_Column_List">
        id, username, email, age, status, create_time, update_time
    </sql>

    <!-- 2. 结果映射(复杂查询用) -->
    <resultMap id="UserDetailMap" type="com.example.vo.UserDetailVO">
        <id column="id" property="id"/>
        <result column="username" property="username"/>
        <result column="dept_name" property="deptName"/>
        <collection property="roles" ofType="Role">
            <id column="role_id" property="id"/>
            <result column="role_name" property="roleName"/>
        </collection>
    </resultMap>

    <!-- 3. 基础查询 -->
    <select id="selectById" resultMap="UserDetailMap">
        SELECT <include refid="Base_Column_List"/>
        FROM user WHERE id = #{id}
    </select>

    <!-- 4. 条件查询 -->
    <select id="pageSearch" resultType="com.example.vo.UserListVO">
        ...
    </select>

    <!-- 5. 统计查询 -->
    <select id="deptStatistics" resultType="com.example.vo.DeptStatisticsVO">
        ...
    </select>

    <!-- 6. 批量操作 -->
    <insert id="batchInsert">...</insert>
    <update id="batchUpdate">...</update>
    <delete id="batchDelete">...</delete>

    <!-- 7. 复杂业务 SQL -->
    <select id="complexBusinessQuery" resultType="...">
        ...
    </select>

</mapper>

6.2 Mapper 接口模板

@Mapper
public interface UserMapper extends BaseMapper<User> {

    // === 查询 ===
    UserDetailVO selectUserDetail(@Param("id") Long id);

    IPage<UserListVO> pageSearch(IPage<UserListVO> page, @Param("query") UserPageQuery query);

    List<DeptStatisticsVO> deptStatistics();

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

    int batchUpdateStatus(@Param("list") List<UserStatusUpdate> updates);

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

    // === 复杂业务 ===
    List<User> selectRecentOrderUsers();

    List<EmployeeRankVO> topSalaryPerDept();
}

第七部分:常见坑与避坑指南

7.1 XML 中特殊字符需要转义

字符

转义

说明

<

&lt;

小于号

>

&gt;

大于号(某些场景)

&

&amp;

与符号

'

&apos;

单引号

"

&quot;

双引号

替代方案:用 <![CDATA[]]> 包裹整个 SQL:

<select id="dateRangeQuery" resultType="User">
    <![CDATA[
    SELECT * FROM user
    WHERE create_time >= #{startDate}
      AND create_time <= #{endDate}
    ]]>
</select>

7.2 foreach 中 collection 参数的坑

  • 传入 List 时,collection 默认叫 list

  • 传入 Collection 时,collection 默认叫 collection

  • 传入数组时,collection 默认叫 array

  • 最佳实践:始终用 @Param("xxx") 显式命名

// ❌ 依赖默认名,容易搞混
List<User> selectByIds(List<Long> ids);

// ✅ 显式命名
List<User> selectByIds(@Param("ids") List<Long> ids);
<!-- 对应上面 ✅ 的写法 -->
<foreach collection="ids" item="id">...</foreach>

7.3 分页 COUNT SQL 不正确

当 XML 中有 GROUP BY / DISTINCT / 子查询时,MP 自动生成的 COUNT SQL 可能错误。

解决方案

  1. page.setOptimizeCountSql(false) — 关闭 COUNT 优化,用默认 SELECT COUNT(1)

  2. 手动编写 count SQL:page.setCountId("myCountQuery")

7.4 批量更新没生效

MySQL 默认不允许在一条 SQL 中执行多条更新。需要确保 JDBC URL 中有:

jdbc:mysql://localhost:3306/mydb?allowMultiQueries=true

但 XML 中的 CASE WHEN 批量更新不需要这个参数,因为它本质是一条 UPDATE 语句。

7.5 嵌套查询的 N+1 问题

如前所述,<collection select="..."> 会为每个父记录额外执行一次查询。数据量大时一定要改为 JOIN 方式。

7.6 @TableName 和 XML 中的表名

如果实体类使用了 @TableName("sys_user"),XML 中的 SQL 仍然需要手动写完整表名:

<!-- XML 不会自动识别 @TableName,必须手动写 -->
<select id="selectByName" resultType="User">
    SELECT * FROM sys_user WHERE username = #{name}
</select>

7.7 逻辑删除字段

如果配置了逻辑删除:

mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: deleted
      logic-delete-value: 1
      logic-not-delete-value: 0

MP 的 deleteById 会自动转为 UPDATE ... SET deleted = 1。但XML 中手写的 DELETE 语句不会走逻辑删除

<!-- ❌ 这是物理删除,不经过逻辑删除过滤器 -->
<delete id="physicalDelete">
    DELETE FROM user WHERE id = #{id}
</delete>

第八部分:从 MyBatis 迁移到 MyBatis-Plus 的注意事项

  1. Mapper 接口改为继承 BaseMapper<T> — 立即获得内置 CRUD

  2. XML 中的 namespace 不需要改 — MP 完全兼容原生 namespace

  3. 所有已有 XML SQL 不需要改 — 逐文件迁移即可

  4. 分页插件必须手动注册 — 否则 selectPage 无效

  5. IServiceServiceImpl 可选 — 如果不需要 Service 层的增强方法,可以继续用原生 MyBatis 的 DAO 层


结语

MyBatis-Plus 的强大之处在于简单场景用内置 CRUD,复杂场景用 XML,两者无缝衔接。实际项目中:

  • 80% 的单表增删改查:用 BaseMapperLambdaQueryWrapper

  • 20% 的复杂查询:用 XML — 联表 JOIN、动态 SQL、分组统计、窗口函数

XML 动态 SQL 是 MyBatis 的看家本领,也是 MyBatis-Plus 完全保留的核心能力。掌握好 <if><foreach><choose><sql> 这几个标签,90% 以上的业务 SQL 都能优雅地写出来。