MyBatis-Plus 从入门到精通:XML 复杂 SQL 实战全指南
前言
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 <= #{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 <= 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 < #{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(性能优化)
当子查询结果集很大时,EXISTS 比 IN 性能更好:
<!-- 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 批量操作的性能对比
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 < 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 中特殊字符需要转义
替代方案:用 <![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 可能错误。
解决方案:
page.setOptimizeCountSql(false)— 关闭 COUNT 优化,用默认SELECT COUNT(1)手动编写 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 的注意事项
Mapper 接口改为继承
BaseMapper<T>— 立即获得内置 CRUDXML 中的 namespace 不需要改 — MP 完全兼容原生 namespace
所有已有 XML SQL 不需要改 — 逐文件迁移即可
分页插件必须手动注册 — 否则
selectPage无效IService和ServiceImpl可选 — 如果不需要 Service 层的增强方法,可以继续用原生 MyBatis 的 DAO 层
结语
MyBatis-Plus 的强大之处在于简单场景用内置 CRUD,复杂场景用 XML,两者无缝衔接。实际项目中:
80% 的单表增删改查:用
BaseMapper和LambdaQueryWrapper20% 的复杂查询:用 XML — 联表 JOIN、动态 SQL、分组统计、窗口函数
XML 动态 SQL 是 MyBatis 的看家本领,也是 MyBatis-Plus 完全保留的核心能力。掌握好 <if>、<foreach>、<choose>、<sql> 这几个标签,90% 以上的业务 SQL 都能优雅地写出来。