☰
MyBatis-Plus复杂查询实战:自定义SQL、多表联查与分页
2026/10/2 13:50:02 网站建设 项目流程

用 MyBatis-Plus 做单表 CRUD 确实很爽,一行代码不写就能完成绝大部分增删改查,很多项目的持久层因此干净得只剩一堆 Wrapper。但只要你开始碰多表联查、复杂统计、动态更新某些字段,或者某个查询在 MySQL 里怎么想都拼不出 LambdaQueryWrapper 能表达的样子,通用的 BaseMapper 就顶不住了。这里说的是 MyBatis-Plus 自定义 SQL 和复杂查询,核心解决的就是一件事:把 Wrapper 的便利和原生 SQL 的灵活性打通,在既有的一套 MyBatis-Plus 体系里继续干复杂的活,而不是急急忙忙引入别的 ORM 或者去写一堆重复的 XML 配置。

我最初入坑时以为自定义 SQL 就是换个地方写 SQL,无非注解或者 XML,后来才意识到真正值钱的部分是 MyBatis-Plus 为你预留的几个钩子,比如自定义 SQL 里拼接 Wrapper 的${ew.customSqlSegment}、分页插件对自定义查询的识别、还有 resultMap 在处理多表映射时和注解的配合。这些要是没搞清楚,写出来的自定义 SQL 要么没法分页,要么参数对不上,要么只能在本地跑跑、一上生产就各种注入的隐患。

这篇东西适合正在用 MyBatis-Plus、但遇到 Wrapper 满足不了的需求的开发者和架构师。我会按自己的实操顺序来写:先讲清楚哪些场景必须自定义 SQL、哪些场景是假需求,再讲注解方式和 XML 方式分别怎么选,然后把分页、多表联查、统计查询这三个高频场景完整跑一遍,最后把踩过的坑整理成一张排查表。全程有代码、有为什么这么写的解释,你拿去就能改造成自己项目里的写法。

1. 什么时候必须上自定义 SQL:先从 IService 和 BaseMapper 的边界说起

网上不少 MyBatis-Plus 教程喜欢一上来就贴 LambdaQueryWrapper 的各种链式写法,好像所有查询都能用它一行搞定。但写多了就会碰壁。我先把我这边的判断标准说清楚:只要 SQL 里出现 JOIN、GROUP BY、HAVING、子查询、UNION,或者返回的字段不是单表实体而是某个统计结果,通用 CRUD 就基本帮不上忙,这时候就该换自定义 SQL。

1.1 所谓“无状态增删改查”到底是什么

热搜里出现了一句很典型的代码注释“基于 mybatis-plus 工具类实现无状态增删改查”,很多人看到“无状态”三个字容易懵。说白了就是在 Service 层不保存任何 SqlSession、不手动管理数据库连接、也不依赖具体的 DAO 实现类,只需要注入一个接口,然后直接调用 IService 预设的方法。例如:

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

调用的时候:

userService.save(user); userService.updateById(user); userService.list(new LambdaQueryWrapper<User>().eq(User::getStatus, 1));

这种模式的好处是业务代码里看不到任何 SQL 语句,增删改查全是方法调用,也没有“先取连接再关闭连接”的样板代码,所以叫无状态。它适合单表的常规操作,这是它的主战场。

但问题也随之而来:一旦你要按订单聚合统计每个用户的消费总额,或者联查三张表字段,这条漂亮的链路就断了。你没法让 UserMapper.selectList 去操作两张表,也没法让 IService 自己构建一个 JOIN。于是你会试着用 SQL 片段拼进 Wrapper 里,试一次就发现不是那么回事。

1.2 从真实业务里筛出自定义 SQL 的典型场景

我把自己经手的项目里需要自定义 SQL 的情况整理成了一张表,你可以先对照着看,别听见“复杂查询”三个字就觉得必须自定义:

场景通用 CRUD 能不能做需要的手段
单表等值查询、范围查询、排序能,Wrapper 很顺手不需要自定义 SQL
单表模糊查询且需要防注入勉强能,但 like 拼接容易踩坑自定义 SQL 配合 concat 或 wrapper
多表 JOIN 联查返回 VO不能XML 或注解写 JOIN
分组统计、聚合函数不能自定义 SQL + resultMap/DTO
只更新某些字段,且字段是动态拼接的能,但 SQL 可读性差自定义 SQL 更直观
子查询、UNION、复杂嵌套不能,Wrapper 表达能力不够自定义 SQL
大分页深分页优化不理想,普通 limit 可能性能差自定义 SQL + 分页插件 + 延迟关联等

注意表格里那句“单表模糊查询其实也能做”,但我后来几乎都改成了自定义 SQL。原因是 LambdaQueryWrapper 的 like 方法在默认情况下是直接拼接整个字符串的,一旦用户输入里带了%或_,这两个符号会被当成通配符,查出来的结果和预期完全不一样。与其在 Wrapper 上做各种转义,不如在 XML 里写清楚:

WHERE name LIKE CONCAT('%', #{name}, '%') ESCAPE '/'

这样参数里出现的%转义逻辑由自己控制,出问题也容易排查。这就是从“能用”到“好用”的差别。

2. 注解式自定义 SQL:适合轻量场景,但有几个细节必须知道

如果你只是为某个 Mapper 方法补一段简单的多表查询,又不愿意建 XML 文件,MyBatis-Plus 继承自 MyBatis 的注解式 SQL 是最快的路径。

2.1 @Select、@Insert、@Update、@Delete 的基础用法

在 Mapper 接口里直接写:

public interface UserMapper extends BaseMapper<User> { @Select("SELECT id, name, age, dept_id FROM user WHERE age > #{age}") List<User> selectUsersOlderThan(Integer age); @Update("UPDATE user SET status = #{status} WHERE id = #{id}") int updateStatusById(@Param("id") Long id, @Param("status") Integer status); @Delete("DELETE FROM user_log WHERE create_time < #{createTime}") int deleteLogsBefore(@Param("createTime") LocalDateTime createTime); }

这里第一要注意的是参数注解。如果你只有一个参数,MyBatis 会自动把参数作为#{age}的值;一旦你有两个及以上参数,务必给每个参数加@Param,否则 MyBatis 会报参数找不到,或者只能用诡异的#{param1}取值。这不是 MyBatis-Plus 的毛病,是底层 MyBatis 的规则,但很多人都在这里卡过。

第二点,返回类型。注解里我没写 resultType,MyBatis 会自动把查询结果映射到方法返回类型上。对于字段名和实体属性名一致的场景,这没问题;一旦返回的是 JOIN 后的 VO,且字段名对不上,一定要用 resultMap,见后面 3.2 节。

2.2 在注解 SQL 里拼接 Wrapper 的神器:${ew.customSqlSegment}

这是 MyBatis-Plus 自定义 SQL 里最容易被忽略的一招。它解决的问题是:我想自定义一段 SQL,但又想保留 Controller 层传过来的 Wrapper 条件,比如前端传了个筛选条件,我要在后面拼一个age > ? AND status = ?。如果写死在注解里就失去灵活性了。

MyBatis-Plus 给出的答案是在注解 SQL 里写ew.customSqlSegment,然后在方法参数里接收Wrapper:

@Select("SELECT id, name, age, dept_name FROM user u LEFT JOIN dept d ON u.dept_id = d.id ${ew.customSqlSegment}") List<UserDeptVO> selectUserDeptList(@Param(Constants.WRAPPER) Wrapper<User> wrapper);

调用方可以正常用 Wrapper 传条件:

List<UserDeptVO> list = userMapper.selectUserDeptList( Wrappers.<User>lambdaQuery().eq(User::getAge, 30).orderByDesc(User::getId) );

这里有几个硬性要求,少一个都不行:

  1. 参数名必须写@Param(Constants.WRAPPER),这个常量值就是"ew",别自己随手写个wrapper变量名,MyBatis-Plus 默认只认ew。
  2. ${ew.customSqlSegment}用的是${},不是#{}。它会把 Wrapper 里生成的 SQL 片段直接拼进主 SQL。因为 Wrapper 本身是程序员在代码里构造的,条件值已经做了参数化,所以这里不会产生 SQL 注入,但条件片段是由 MyBatis-Plus 生成的,你别再往里直接拼接任何外部字符串做变量。
  3. 如果 Wrapper 里的条件是orderByDesc,拼接出来的片段是ORDER BY id DESC,注意你主 SQL 里不能自己再写一个 ORDER BY,不然会拼出两个 ORDER BY 导致 SQL 语法错误。

我自己最常用这个特性的是写一个通用数据权限过滤:在 XML 里留一个ew.customSqlSegment,然后在 Service 层往 Wrapper 上 eq 上当前用户的数据范围条件。这样数据权限逻辑可以统一收敛到 Service,SQL 模板保持干净。

2.3 注解方式的边界:动态 SQL 一多,维护成本就上来了

注解里写动态 SQL 分分钟能把 SQL 可读性干没。比如下面这个:

@Select("<script>" + "SELECT * FROM user WHERE deleted = 0 " + "<if test='name != null and name != \"\"'> AND name LIKE CONCAT('%', #{name}, '%')</if>" + "<if test='deptId != null'> AND dept_id = #{deptId}</if>" + "</script>") List<User> selectByCondition(@Param("name") String name, @Param("deptId") Long deptId);

看起来还好,一旦条件超过四五个,字符串拼接里全是转义引号和<if>标签,代码又乱又容易漏引号。我的习惯是:动态 SQL 少于两三个标签,就在注解里写;再多一点,无脑选择 XML。真正的复杂查询、长 SQL、需要复用 SQL 片段的地方,XML 是唯一靠谱的选择。

3. XML 方式管理复杂 SQL:把 SQL 和 Java 代码分离,维护性才拉得起来

如果你的复杂查询要长期维护,或者一个 SQL 可能被好几个方法复用,我强烈建议直接上 XML Mapper。XML 的好处不仅仅是能写更复杂的动态标签,还包括 SQL 片段复用<sql>、结果映射<resultMap>、以及在<script>里写可读性更高的判断语句。

3.1 从一个多表查询的 XML 配置说起

假设我现在要做用户-部门-角色三张表联查,返回一个UserInfoVO。对应的 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"> <resultMap id="userInfoMap" type="com.example.vo.UserInfoVO"> <id property="id" column="id" /> <result property="name" column="name" /> <result property="deptName" column="dept_name" /> <result property="roleNames" column="role_names" /> </resultMap> <select id="selectUserInfoList" resultMap="userInfoMap"> SELECT u.id, u.name, d.dept_name, GROUP_CONCAT(r.role_name ORDER BY r.role_name SEPARATOR ',') AS role_names FROM user u LEFT JOIN dept d ON u.dept_id = d.id LEFT JOIN user_role ur ON ur.user_id = u.id LEFT JOIN role r ON r.id = ur.role_id WHERE u.deleted = 0 GROUP BY u.id, u.name, d.dept_name </select> </mapper>

注意几点:

  • namespace必须和 Mapper 接口全限定名一致。
  • 方法名和<select>的 id 必须一致,MyBatis 靠这个绑定方法。
  • 返回的是 VO,而不是实体,就一定要用resultMap,不能用resultType。resultType需要字段名和 Java 属性名能靠 map-underscore-to-camel-case 自动对应,但role_names这种聚合字段或者多表同名字段根本没法自动映射。

对应 Java Mapper 接口:

List<UserInfoVO> selectUserInfoList();

就这么简单。没有@Select,也没有任何 SQL 字符串,SQL 全在 XML 里。

3.2 resultMap 和复杂关联映射的实战理解

很多人会把 resultMap 想得很复杂,以为要配 N 个 association、collection。实际业务里,我绝大多数情况只需要一个扁平 resultMap,因为返回的 VO 基本都是平铺字段。只有当你把对象嵌套进另一个对象时,才需要<association>和<collection>。

嵌套场景示例:

<resultMap id="userRoleMap" type="com.example.vo.UserWithRolesVO"> <id property="id" column="id" /> <result property="name" column="name" /> <collection property="roleList" ofType="com.example.vo.RoleVO"> <id property="id" column="role_id" /> <result property="roleName" column="role_name" /> </collection> </resultMap>

这种写法配合一条 JOIN 是一条仙路,但有一个极其隐蔽的坑:如果一个人有多个角色,联查出来的结果集中会有多行,如果使用Nested Select或者 ResultHandler 的方式不同,部分集合数据可能丢失或重复。MyBatis 的默认行为是用 id 字段去重,你在<collection>里一定要有正确的<id>列,否则 MyBatis 会把这一行当成另一个对象处理。排查这类问题时,眼睛盯着 resultMap 的 id 和 result column 的匹配,往往比打日志高效得多。

3.3 动态 SQL:复杂查询最锋利的武器

MyBatis 的动态 SQL 标签就那几个:<if>、<choose>、<when>、<otherwise>、<foreach>、<where>、<set>、<trim>。看起来简单,但组合起来几乎能覆盖所有业务查询。

写一个带条件的多表查询:

<select id="selectUserInfoListByCondition" resultMap="userInfoMap"> SELECT u.id, u.name, d.dept_name FROM user u LEFT JOIN dept d ON u.dept_id = d.id <where> <if test="userName != null and userName != ''"> AND u.name LIKE CONCAT('%', #{userName}, '%') </if> <if test="deptId != null"> AND d.id = #{deptId} </if> <if test="statusList != null and statusList.size() > 0"> AND u.status IN <foreach collection="statusList" item="status" open="(" separator="," close=")"> #{status} </foreach> </if> <choose> <when test="ageMin != null"> AND u.age &gt;= #{ageMin} </when> <when test="ageMax != null"> AND u.age &lt;= #{ageMax} </when> <otherwise> AND u.age IS NOT NULL </otherwise> </choose> </where> ORDER BY u.create_time DESC </select>

几个细节:

  • <where>标签会自动去掉第一个条件前的AND,所以每个<if>里的 AND 可以放心写。
  • XML 里>>=写&gt;&gt;=,<写&lt;&lt;=。有些人偷懒直接写<,一旦出现<if这种结构,XML 解析就崩了。稳妥做法是遇到比较符号用 CDATA 包住或者转义。
  • <foreach>里 collection 名字对照方法参数名,如果参数是List,默认叫list,要指定名字就得加@Param("statusList"),见下方方法签名。
  • <choose>和 Java 的 switch 一样,只会命中第一个条件,别指望它 fall-through。

对应方法:

List<UserInfoVO> selectUserInfoListByCondition( @Param("userName") String userName, @Param("deptId") Long deptId, @Param("statusList") List<Integer> statusList);

动态 SQL 看起来简单,实际项目里最常见的错误是<if test>里写了错误的属性名或者类型判断。statusList != null and statusList.size() > 0我很少用,更稳的是:

<if test="statusList != null and statusList.size() > 0">

如果你的实体属性是 List,直接list != null and list.size() > 0也有效。关键点在于 test 表达式里的属性名必须和参数/实体属性名一致,否则报的异常信息还特别隐晦,只有There is no getter for property named ...。

4. 复杂查询三大高频场景实战:分页、多表联查、统计查询

很多教程会把注解和 XML 分开讲,好像是很割裂的两套东西。但实际项目里我会把它们组合起来用。这里集中把三个高频场景完整跑一遍,这几个场景我几乎在每个项目里都会遇到。

4.1 自定义 SQL 怎么用 MyBatis-Plus 分页插件

MyBatis-Plus 的分页插件叫PaginationInnerInterceptor。先注册:

@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }

注册了分页插件后,最关键的一个规则:分页插件只对实现了 IPage 参数的方法做分页,不是对所有 SQL 都分页。所以自定义 SQL 要分页,方法签名必须带上Page或IPage参数:

IPage<UserInfoVO> selectUserInfoPage(Page<?> page, @Param("userName") String userName); // 或者返回 IPage IPage<UserInfoVO> selectUserInfoPage(IPage<UserInfoVO> page, @Param("userName") String userName);

配套 XML:

<select id="selectUserInfoPage" resultMap="userInfoMap"> SELECT u.id, u.name, d.dept_name FROM user u LEFT JOIN dept d ON u.dept_id = d.id <where> <if test="userName != null and userName != ''"> AND u.name LIKE CONCAT('%', #{userName}, '%') </if> </where> ORDER BY u.create_time DESC </select>

有意思的地方是:分页插件会把这条 SQL 改写成两个动作。一个是带上LIMIT ?, ?的查询,另一个是把原 SQL 包一层SELECT COUNT(*) FROM (...)去做 count 查询。这对多表 JOIN 来说很危险,因为 count 阶段也会带着全部 JOIN 去统计,性能会非常差。

我通常在左侧表是两个大表 JOIN 做列表时,不直接分页,而是让自定义 SQL 只返回主键,再用主键拼第二段 SQL 查询详情。这也是俗称的“先分页后 JOIN”。示意:

<select id="selectPageIds" resultType="java.lang.Long"> SELECT u.id FROM user u LEFT JOIN dept d ON u.dept_id = d.id WHERE ...条件... ORDER BY u.create_time DESC </select>

然后第二段:

<select id="selectByIds" resultMap="userInfoMap"> SELECT ... FROM user u LEFT JOIN dept d ON u.dept_id = d.id WHERE u.id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> ORDER BY FIELD(u.id, <foreach collection="ids" item="id" separator=",">#{id}</foreach>) </select>

这种方式能让深分页的性能好很多,代价是多一条 SQL。但是当数据量真的超过几百万时,这几乎是必须的选择,教科书里说的“先取主键再查详情”不是空话。

4.2 多表联查实操:JOIN 方式、VO 设计、分页参数传递

我见过很多同事一上来就把所有字段选出来,然后让 MyBatis 映射到一个巨型 VO 里。这种做法短期内很爽,一旦接口增多,VO 字段膨胀,一个 SQL 前端能取到十几个字段,Mapper 回归测试没人敢动。

我的做法是:每个列表页按需查字段,能少查就少查。下面是一个典型的多表联查分页方法:

IPage<UserDeptPageVO> selectUserDeptPage( Page<UserDeptPageVO> page, @Param("keyword") String keyword, @Param("deptId") Long deptId);

XML:

<select id="selectUserDeptPage" resultType="com.example.vo.UserDeptPageVO"> SELECT u.id, u.username, u.age, d.dept_name AS deptName, d.id AS deptId FROM user u LEFT JOIN dept d ON u.dept_id = d.id <where> <if test="keyword != null and keyword != ''"> AND (u.username LIKE CONCAT('%', #{keyword}, '%') OR d.dept_name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="deptId != null"> AND d.id = #{deptId} </if> </where> ORDER BY u.create_time DESC </select>

这里我特意用了resultType而不是resultMap,前提是被查字段的所有别名都和后端 VO 的属性对应上,比如dept_name AS deptName。如果个别字段对不上,宁可用 resultMap,也不要在 Java 代码里再写一段字段映射。字段映射放 Java 里,每次改动要动两处,维护成本直接翻倍。

JOIN 的选型也要注意:Inner Join、Left Join、Right Join 的业务语义不同,但你主要关心会不会丢数据。联查过滤条件写在 JOIN 的 ON 后面和写在 WHERE 后面是有区别的:

  • 写在 ON 后:保留左表所有行,即使条件不满足也会返回左表记录,右边字段为 NULL。
  • 写在 WHERE 后:会过滤掉左右两边不匹配的行,效果类似于 INNER JOIN。

我见过一个跨天排查的数据 bug:统计部门人数时,把“部门启用状态”条件写在 WHERE 后,结果没有启用部门的员工也被过滤掉了,人数对不上业务。后来把它移到 ON 后,数据就符合预期了。

4.3 统计查询:聚合结果必须用 DTO/Map 接收

统计查询是日常开发里最容易被忽略的领域。很多人图省事,直接在 Mapper 接口返回List<Map<String, Object>>,然后在 Service 层一层一层从 Map 里取数据,类型转换能写出一堆 if-else。这在一个临时统计接口里勉强能用,一旦统计逻辑复杂,代码可读性就会崩掉。

我的建议是引入一个带聚合字段的 DTO。比如:

@Data public class UserCountByDeptDTO { private Long deptId; private String deptName; private Long count; private BigDecimal avgAge; }

XML:

<select id="selectUserCountByDept" resultType="com.example.dto.UserCountByDeptDTO"> SELECT d.id AS deptId, d.dept_name AS deptName, COUNT(u.id) AS count, AVG(u.age) AS avgAge FROM dept d LEFT JOIN user u ON u.dept_id = d.id WHERE d.deleted = 0 GROUP BY d.id, d.dept_name HAVING COUNT(u.id) &gt; 0 </select>

这里有两个坑:

  1. GROUP BY后面除了聚合函数以外出现的列,必须全部出现在 group by 中,否则 MySQL 在ONLY_FULL_GROUP_BY模式下直接抛异常。这个问题在生产环境极常见,开发环境默认配置宽松反而不报,一上线才炸。
  2. HAVING 条件里如果用了聚合函数别名,在 MySQL 里虽然可以用别名,但为了兼容性,我建议 HAVING 后面写完整表达式HAVING COUNT(u.id) > 0。

统计查询一般不推荐上分页插件,因为统计结果集通常只有几十行。真要是上亿数据维度聚合,那就不是 MyBatis-Plus 的职责范围了,应该考虑 ES 或 ClickHouse 这类专门的存储。

4.4 在 Service 层把复杂 SQL 的结果封装成业务对象

当 SQL 已经返回了 DTO,Service 层剩下的活就是把它组装成最终响应。这里要提一个和 MyBatis-Plus 关系不大的经验:不要在 Controller 里直接暴露 Mapper 查询结果,哪怕是简单列表,也最好走 Service 层。为什么?因为一旦你后面要加数据权限、过滤字段、改响应结构,Service 层是唯一需要改动的地方。

我在实际项目里会在 Service 层写一个专门的方法:

@Transactional(readOnly = true) public PageResult<UserDeptPageVO> pageUserDept(UserDeptQuery query) { Page<UserDeptPageVO> page = new Page<>(query.getPageNum(), query.getPageSize()); LambdaQueryWrapper wrapper = buildQueryWrapper(query); // 如果走 wrapper 分支 IPage<UserDeptPageVO> result = userMapper.selectUserDeptPage(page, query.getKeyword(), query.getDeptId()); return PageResult.of(result); }

这种做法让事务边界和读操作语义都很清晰。@Transactional(readOnly = true)在 MySQL 等数据库中主要起到标记作用,对 JDBC 层的优化有限,但代码可读性和团队规范意义是实实在在的。

5. 经验清单:自定义 SQL 过程中最容易踩的坑和排查思路

这一节不按教程顺序讲,完全是我自己踩坑记录整理出来的速查表。每一个坑都真实发生在项目里,排查时对照着看能省很多时间。

5.1 查询列表为空但 SQL 能查出数据

常见原因有两个:

  • 分页插件 count 查询和自己手动加了条件之间冲突,导致 count 和 list 的 SQL 不一致,列表不返回数据。解决办法是检查日志里打印的两条 SQL,重点对比 WHERE 部分。
  • 实体类逻辑删除字段,MyBatis-Plus 默认在查询时自动追加deleted = 0,但自定义 SQL 写在 XML 里时,MyBatis-Plus 的自动逻辑删除拼接逻辑不会生效。也就是说,你自己写SELECT * FROM user WHERE age > 18会连已删除用户一起查出来。这个问题特别隐蔽,因为单表 BaseMapper 查不会踩。解决方式是手动在 SQL 里加deleted = 0条件,或者统一用 logic-delete 搭配 BaseMapper 的现成方法。

5.2#{}和${}的注入问题,绝不只是语法区别

自定义 SQL 里,#{}会使用 PreparedStatement 参数占位,安全;${}是直接字符串替换,有注入风险。MyBatis-Plus 封装的ew.customSqlSegment里值部分已经参数化,所以能用。但你自己写 SQL 时,如果为了拼表名、列名、排序字段用了${},一定要保证传入内容是白名单。

我一般这样处理排序字段:

String orderBy = "create_time"; if ("age".equals(sortField)) { orderBy = "age"; } else if ("name".equals(sortField)) { orderBy = "name"; } // 然后再拼到 SQL

前端传什么就拼什么,是最常见的安全漏洞。任何字段只要是用户可控的,都按“不可信输入”处理。

5.3 XML 里的&lt;和 CDATA,出错率最高的写法

XML 中小于号<必须转义,否则<会被解析为标签开始。常见写法:

WHERE u.age < 18

这段在 XML 里会直接报错。改成:

WHERE u.age &lt; 18

或者用 CDATA:

WHERE u.age <![CDATA[ < ]]> 18

CDATA 更适合包含大量比较符号的复杂表达式,比如日期范围判断:

WHERE u.create_time <![CDATA[ >= ]]> #{startTime} AND u.create_time <![CDATA[ < ]]> #{endTime}

记住:CDATA 只包裹<或表达式整体,别把整个 SQL 用 CDATA 包起来,一旦包裹范围太大,里面的<if>标签就无法被 MyBatis 解析了。

5.4 resultType 自动映射 DTO 时的小驼峰匹配

MyBatis 开启下划线转驼峰配置后,SQL 查询结果里dept_name可以自动映射到 DTO 的deptName。但是带前后缀或者别名有特殊命名时,很容易失败。为了避免这个问题,我个人的习惯是:所有自定义 SQL 的查询列全部显式加别名,并且别名直接用驼峰风格。这样即使哪天关闭了 map-underscore-to-camel-case,代码也不会出问题。

例如:

SELECT u.id AS id, u.username AS username, d.dept_name AS deptName

而不是写d.dept_name后指望自动转。多写几个别名,换来的是排查字段不匹配问题时的爽快。

5.5 Mapper 方法重载的坑

MyBatis 的 Mapper 方法不能被重载,不要试图写两个同名方法只是参数列表不同。Mapper 接口是通过方法名绑定 XML 里的 id,重载会让绑定冲突。我在项目里见过有人写:

List<User> selectUserList(Page page, @Param("name") String name); List<User> selectUserList(@Param("name") String name);

启动直接报Mapper method ... has multiple definitions。解决办法是方法名要唯一,比如selectUserPage和selectUserListByCondition。

5.6 Mapper XML 里的delete和update也需要逻辑删除保护

如果你给表配置了逻辑删除,但自定义 SQL 里用DELETE FROM user WHERE id = #{id},那逻辑删除同样不生效,会真实删掉记录。最稳妥的自定义删除写法:

UPDATE user SET deleted = 1 WHERE id = #{id}

如果你确认要物理删除,除非表本身不需要逻辑删除,否则一定要在 SQL 里写明白deleted = 0,避免误删。

5.7 深分页和 count 性能:不是所有场景都靠 LIMIT 硬扛

深分页问题在任何 ORM 里都存在,MyBatis-Plus 分页插件只是拼一个 LIMIT,不会为你做优化。当页码特别大、偏移量很深时,SQL 的性能会直线下降。这里的排查步骤我建议按顺序来:

  1. 看 count SQL 是否慢,慢就把 LEFT JOIN 改成 INNER JOIN,或者把 count 改成只查主表主键。
  2. 看主 SQL 的 EXPLAIN 是否走对索引,重点是 ORDER BY 和 WHERE 的组合。
  3. 如果仍然慢,用“先查主键再查详情”或者用延迟关联(deferred join)。

零基础理解延迟关联:不是直接查一页完整行,而是先查这一页的主键,再用主键去回表查完整行。这样数据库扫描的页数据量最小,查询深度越大,优势越明显。上面 4.1 节展示的两段 SQL 就是延迟关联的一种表达。

5.8 多数据源和事务场景下的自定义 SQL 注意事项

如果你的项目引入了多数据源,自定义 SQL 执行在哪个数据源,看的是它所属的 Mapper 绑定的数据源,和 XML 内容无关。这个坑我踩过一次:动态数据源切到从库,但某个自定义 update 方法依然走到主库,排查半天发现是事务注解把数据源锁住了。MyBatis-Plus 加多数据源之后,事务和数据源的切换顺序得靠规范保证:先切数据源再开启新事务,通常做法是单独写一个切库服务,在事务外调用。

6. 最后聊一点个人习惯:什么时候别写自定义 SQL

虽然这篇写了很多自定义 SQL 的用法,但我想提一个反向建议:不要为了让 SQL 看起来“高级”就去自定义。如果你只需要查一个表,能用 LambdaQueryWrapper 表达的查询,尽量用 Wrapper。Wrapper 的优势是类型安全,字段名写错了编译期就报错,而 SQL 写错了要等运行期或者静态扫描才能发现。

我见过有些项目组喜欢把简单查询也写成 XML,理由是“风格统一”。结果一条SELECT * FROM user WHERE status = 1都要写 5 行 XML,维护成本白白增加。我的建议是分两条线走:

  • 单表简单查询:用 MyBatis-Plus 内置 CRUD 和 Wrapper,尽量让代码保持精简。
  • 多表复杂查询、统计查询、动态条件特别多的查询:考虑自定义 SQL,并且按照这篇的方式组织。

这个平衡点在每个项目里略有不同。早期我倾向于所有 SQL 都写 XML,觉得可读;后来发现 Wrapper 和 ServiceImpl 的简洁性在业务简单的模块里确实省力。真正该执着的不是“用哪种方式”,而是“查询意图是否清晰、变更是否容易控制”。当你某天发现自己在一个自定义 SQL 里塞了 20 个<if>,不如停下来想想这个查询是不是被过度设计了,能不能拆成多个更小的查询。把一个 20 个<if>的 SQL 拆成三个职责单一的查询,往往比在 SQL 里给每个字段都加一个判断更容易维护,也更不容易让后续接手的同事在心里骂你。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询