1. 项目概述:为什么需要自定义SQL?
在Java后端开发里,MyBatis-Plus(简称MP)几乎成了操作数据库的标配。它封装了大量单表CRUD操作,一个lambdaQuery()链式调用就能解决大部分查询,save()、updateById()这些方法更是让开发效率飞起。但用久了,尤其是项目进入深水区,你会发现一个绕不开的坎:当业务逻辑变得复杂,涉及到多表关联查询、复杂的聚合统计,或者需要用到数据库特定的高级函数时,MP提供的那些“开箱即用”的通用方法就有点力不从心了。
这时候,自定义SQL就成了必须掌握的技能。它不是对MP的否定,恰恰相反,是让MP这把“瑞士军刀”变得更锋利的关键模块。自定义SQL让你能在享受MP便捷性的同时,又能精准、灵活地处理那些通用Mapper无法覆盖的复杂场景。比如,你需要从用户表、订单表、商品表里联查出一个用户最近半年的消费明细和商品分类统计,这种涉及多表JOIN和分组聚合的查询,手写SQL往往是最高效、最清晰的选择。
最近社区里关于MP的讨论,很多都集中在分页、与特定Spring Boot版本的适配,以及如何在像若依这样的成熟框架中集成MP。这些话题的背后,其实都隐含着一个共同需求:如何更自如地驾驭MP,尤其是它的自定义SQL能力,来应对真实项目中千变万化的数据查询需求。所以,今天我们就抛开那些简单的增删改查,深入聊聊MyBatis-Plus中实现自定义SQL的几种核心方式、各自的适用场景,以及我踩过的一些坑和总结的实战技巧。
2. 自定义SQL的三种核心实现路径
实现自定义SQL,MP给了我们好几条路。选择哪条路,取决于你的查询复杂度、对MP特性的依赖程度以及个人或团队的编码习惯。下面这张表可以帮你快速理清思路:
| 实现方式 | 核心机制 | 优点 | 缺点/适用场景 |
|---|---|---|---|
| XML映射文件 | 在Mapper.xml中编写完整的SQL语句,通过<select>等标签与Mapper接口方法绑定。 | 1.功能最强大、最正统,支持所有MyBatis原生特性(动态SQL、结果集映射等)。 2. SQL与Java代码分离,结构清晰,便于DBA评审和优化。 3. 复杂的多表查询、动态SQL的首选。 | 1. 需要维护额外的XML文件。 2. 对于极其简单的自定义查询,略显“重”。 |
注解方式(@Select,@Update等) | 在Mapper接口的方法上直接使用注解编写SQL。 | 1.简单快捷,无需XML文件,SQL与接口方法紧耦合。 2. 适合短小、固定的SQL语句。 | 1. SQL较长时,注解内字符串难以阅读和维护。 2. 不支持MyBatis所有动态SQL标签(需结合 <script>标签)。3. 复杂SQL可读性差。 |
Wrapper +@Select注解 | 使用MP的QueryWrapper或LambdaQueryWrapper构建条件,在注解SQL中通过${ew.customSqlSegment}引入。 | 1.融合了MP的Wrapper条件构造优势与自定义SQL的灵活性。 2. 可以复用Wrapper强大的条件API,避免在XML或注解中拼接 WHERE条件。 | 1. 主要适用于WHERE条件动态,但SELECT字段和FROM表固定的场景。2. 复杂的JOIN或GROUP BY仍需完整SQL。 |
2.1 路径一:使用XML映射文件(推荐用于复杂场景)
这是MyBatis系最经典、最强大的方式。你的SQL语句写在独立的XML文件里,MP(底层是MyBatis)负责解析并执行它。
第一步:创建Mapper接口假设我们有一个UserMapper接口,需要定义一个查询用户及其订单总数的方法。
package com.example.demo.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.demo.entity.User; import com.example.demo.vo.UserOrderStatsVO; import org.apache.ibatis.annotations.Param; import java.util.List; public interface UserMapper extends BaseMapper<User> { /** * 自定义查询:获取用户及其订单统计信息 * @param minOrderCount 最小订单数过滤条件 * @return 用户统计视图对象列表 */ List<UserOrderStatsVO> selectUserWithOrderStats(@Param("minOrderCount") Integer minOrderCount); }注意这里使用了@Param注解来明确命名SQL中的参数。
第二步:创建对应的XML映射文件在resources目录下,保持与Mapper接口相同的目录结构(例如resources/com/example/demo/mapper/),创建UserMapper.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.demo.mapper.UserMapper"> <!-- 自定义结果集映射,用于将复杂的查询结果映射到VO对象 --> <resultMap id="userOrderStatsMap" type="com.example.demo.vo.UserOrderStatsVO"> <id property="userId" column="id"/> <result property="username" column="username"/> <result property="email" column="email"/> <!-- 订单统计字段 --> <result property="totalOrderCount" column="order_count"/> <result property="totalAmount" column="total_amount"/> </resultMap> <!-- 自定义SQL查询语句 --> <select id="selectUserWithOrderStats" resultMap="userOrderStatsMap"> SELECT u.id, u.username, u.email, COUNT(o.id) AS order_count, IFNULL(SUM(o.amount), 0) AS total_amount FROM user u LEFT JOIN `order` o ON u.id = o.user_id <where> <!-- MyBatis动态SQL:根据传入参数决定是否添加此条件 --> <if test="minOrderCount != null"> AND ( SELECT COUNT(*) FROM `order` o2 WHERE o2.user_id = u.id ) >= #{minOrderCount} </if> </where> GROUP BY u.id HAVING total_amount > 0 <!-- 示例:对分组后的结果进行过滤 --> ORDER BY total_amount DESC </select> </mapper>关键点解析与实操心得:
namespace必须精确:它的值必须是Mapper接口的全限定名(包含包名)。这是MyBatis将XML文件与接口方法绑定的唯一依据,写错会导致Invalid bound statement (not found)错误。- 结果映射(
resultMap)是灵魂:对于返回字段名与实体类属性名不一致,或者像上面这种需要聚合计算(COUNT,SUM)的复杂查询,必须定义resultMap。它清晰地描述了数据库列(column)和Java对象属性(property)的映射关系。对于简单映射,也可以用@Result注解,但在XML中resultMap更清晰强大。 - 动态SQL标签:
<where>、<if>、<foreach>等标签让你能构建灵活的SQL。<where>标签会智能地处理AND/OR开头的问题,避免WHERE后面直接跟AND的语法错误。 - 参数使用
#{}:始终使用#{}进行参数占位,它能有效防止SQL注入。${}是字符串替换,有安全风险,除非你非常清楚自己在做什么(比如动态指定表名、列名)。
踩坑提醒:在Spring Boot项目中,默认的XML映射文件扫描路径是
classpath*:mapper/**/*.xml。如果你的XML文件放在其他目录,务必在application.yml中配置mybatis-plus.mapper-locations。例如,如果你把XML放在resources/sqlmapper/下,就需要配置:mybatis-plus.mapper-locations: classpath*:/sqlmapper/**/*.xml。
2.2 路径二:使用@Select等注解(适合简单场景)
对于非常简单的、固定的SQL,直接在方法上写注解会更快捷。
public interface UserMapper extends BaseMapper<User> { // 示例1:简单查询 @Select("SELECT * FROM user WHERE email = #{email}") User selectByEmail(@Param("email") String email); // 示例2:更新操作 @Update("UPDATE user SET status = #{status} WHERE id = #{id}") int updateUserStatus(@Param("id") Long id, @Param("status") Integer status); // 示例3:结合简单动态SQL(需要使用<script>标签) @Select("<script>" + "SELECT * FROM user " + "<where>" + " <if test='username != null'> AND username LIKE CONCAT('%', #{username}, '%')</if>" + " <if test='status != null'> AND status = #{status}</if>" + "</where>" + "ORDER BY id DESC" + "</script>") List<User> selectByCondition(@Param("username") String username, @Param("status") Integer status); }实操心得:
- 可读性陷阱:当SQL超过三行,在注解里拼接字符串就会变得难以阅读和维护,尤其是包含动态SQL时。此时应毫不犹豫地切换到XML方式。
<script>标签:如上例所示,如果需要在注解中使用<if>、<where>等动态SQL标签,必须将整个SQL语句包裹在<script>标签内。这是很多新手容易忽略的地方。
2.3 路径三:Wrapper与自定义SQL结合(条件构造的优雅之道)
这是MP非常有特色的一个功能。当你需要复用MP强大的QueryWrapper来构造复杂的WHERE条件,但SELECT的字段或FROM的表又比较特殊时,这种混合模式就非常有用。
public interface UserMapper extends BaseMapper<User> { /** * 使用Wrapper构造条件,自定义SELECT和FROM部分 * ${ew.customSqlSegment} 会被替换为Wrapper生成的WHERE条件(包含参数占位符) * 注意:这里使用 ${},因为ew是MP的表达式,需要被解析替换,而非参数值。 * MP内部对ew的替换做了处理,相对安全,但仅限于使用ew。 */ @Select("SELECT u.id, u.name, d.dept_name FROM user u LEFT JOIN dept d ON u.dept_id = d.id ${ew.customSqlSegment}") List<Map<String, Object>> selectUserJoinDept(@Param("ew") QueryWrapper<User> wrapper); // 更推荐使用Lambda表达式,避免字段名魔法值 @Select("SELECT u.id, u.name, d.dept_name FROM user u LEFT JOIN dept d ON u.dept_id = d.id ${ew.customSqlSegment}") List<Map<String, Object>> selectUserJoinDeptLambda(@Param("ew") LambdaQueryWrapper<User> wrapper); }服务层调用示例:
@Service public class UserServiceImpl { @Autowired private UserMapper userMapper; public List<Map<String, Object>> getUsersInDept(String deptName) { LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>(); wrapper.like(User::getName, "张") // 查询姓张的用户 .eq(User::getStatus, 1) // 状态为启用 .isNotNull(User::getDeptId); // 部门ID不为空 // 注意:这里wrapper的条件会自动拼接到自定义SQL的${ew.customSqlSegment}处 // 生成的SQL大概是:... FROM user u ... WHERE u.name LIKE ? AND u.status = ? AND u.dept_id IS NOT NULL return userMapper.selectUserJoinDeptLambda(wrapper); } }核心要点:
${ew.customSqlSegment}:这是一个固定写法,ew是Param注解中定义的参数名,customSqlSegment是Wrapper对象中存储生成的SQL条件片段(如WHERE name = ? AND age > ?)的属性。MP在执行时会用这个片段替换掉${ew.customSqlSegment}。- 为什么用
${}而不是#{}?因为这里需要的是SQL片段(字符串)的直接替换,而不是参数值的预编译填充。MP确保了这个替换过程的安全性,但切记不要用${}去拼接用户输入。 - Lambda的优势:使用
LambdaQueryWrapper,如User::getName,是类型安全的,编译器会检查,重构友好。而普通的QueryWrapper需要用字符串"name",容易出错。
3. 高级应用:分页插件与自定义SQL的联姻
分页是高频需求。MP提供了强大的分页插件PaginationInterceptor(3.4版本前)或MybatisPlusInterceptor(3.4版本后),它与自定义SQL能完美协作。
第一步:配置分页插件(以Spring Boot + MP 3.5.17为例)
@Configuration public class MybatisPlusConfig { /** * 新版拦截器配置方式 */ @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加分页插件 PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor(DbType.MYSQL); // 设置请求的页面大于最大页后操作, true调回到首页,false 继续请求 默认false paginationInnerInterceptor.setOverflow(false); // 设置最大单页限制数量,默认 500 条,-1 不受限制 paginationInnerInterceptor.setMaxLimit(1000L); interceptor.addInnerInterceptor(paginationInnerInterceptor); // 还可以添加其他插件,如乐观锁插件 // interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }第二步:在自定义查询方法中使用IPage参数Mapper接口定义:
public interface OrderMapper extends BaseMapper<Order> { /** * 自定义分页查询:查询订单及其商品详情 * @param page MP分页对象,会自动注入分页参数(current, size) * @param queryParam 自定义查询参数 * @return 分页结果 */ IPage<OrderDetailVO> selectOrderDetailPage(IPage<OrderDetailVO> page, @Param("param") OrderQueryParam queryParam); }对应的XML映射文件:
<select id="selectOrderDetailPage" resultType="com.example.demo.vo.OrderDetailVO"> SELECT o.order_no, o.create_time, u.username, GROUP_CONCAT(g.goods_name) AS goods_names, SUM(oi.quantity * oi.unit_price) AS total_amount FROM `order` o JOIN `user` u ON o.user_id = u.id JOIN order_item oi ON o.id = oi.order_id JOIN goods g ON oi.goods_id = g.id <where> <if test="param.createTimeStart != null"> AND o.create_time >= #{param.createTimeStart} </if> <if test="param.createTimeEnd != null"> AND o.create_time <= #{param.createTimeEnd} </if> <if test="param.username != null and param.username != ''"> AND u.username LIKE CONCAT('%', #{param.username}, '%') </if> </where> GROUP BY o.id ORDER BY o.create_time DESC </select>服务层调用:
@Service public class OrderService { public IPage<OrderDetailVO> getOrderDetailPage(int current, int size, OrderQueryParam param) { // 1. 创建分页对象 Page<OrderDetailVO> page = new Page<>(current, size); // 2. 执行查询。MP插件会拦截此方法,自动将分页SQL(COUNT查询和LIMIT)拼接到你的自定义SQL上 return orderMapper.selectOrderDetailPage(page, param); // 执行后,page对象里不仅包含分页数据列表(page.getRecords()), // 还包含总记录数(page.getTotal())、总页数(page.getPages())等信息。 } }分页插件工作原理与避坑指南:
- 两次查询:MP的分页插件会进行两次数据库查询。第一次是自动生成
COUNT(*)语句查询符合条件的总记录数,第二次才是你的自定义SQL加上LIMIT子句查询当前页数据。 COUNT查询优化:对于非常复杂的联表查询,自动生成的COUNT语句可能效率低下。MP允许你自定义COUNT查询:
如果Mapper接口中定义了<!-- 在同一个<select>标签内,通过id后缀为“_COUNT”来指定 --> <select id="selectOrderDetailPage_COUNT" resultType="java.lang.Long"> SELECT COUNT(DISTINCT o.id) FROM `order` o ... <!-- 这里可以写一个优化过的、更简单的COUNT查询 --> </select>IPage参数的方法名为selectOrderDetailPage,那么MP会优先寻找selectOrderDetailPage_COUNT这个id的查询语句来执行计数。这是一个非常实用的高级技巧。ORDER BY与分页:一定要在自定义SQL中明确指定ORDER BY。没有稳定的排序,分页结果可能是随机、混乱的,导致“翻页时数据重复或丢失”的经典问题。通常使用唯一或高区分度的字段组合排序,如id DESC或create_time DESC。
4. 实战中的典型问题与排查技巧
即使掌握了方法,在实际编码和运行时还是会遇到各种问题。下面是我总结的几个高频问题及解决方案。
4.1 问题一:Invalid bound statement (not found)
这是最常见的问题,意味着MyBatis找不到SQL语句的映射。
排查步骤:
- 检查XML文件路径和
namespace:确保resources下的XML文件路径与Mapper接口包名对应,且namespace属性值一字不差地等于接口的全限定名。 - 检查方法名和id:确保XML中SQL标签的
id属性值(如selectUserWithOrderStats)与Mapper接口中的方法名完全一致。 - 检查配置文件:在
application.yml中确认mybatis-plus.mapper-locations配置是否正确指向了你的XML文件位置。如果是默认位置classpath*:mapper/**/*.xml,请检查XML是否放在resources/mapper/或其子目录下。 - 检查文件是否被编译:清理项目(
mvn clean或gradle clean)后重新编译,确保XML文件被正确复制到target/classes或build/classes目录下。 - 检查注解冲突:如果你在接口方法上同时使用了
@Select注解,又在XML中定义了相同id的语句,可能会导致冲突。通常XML优先级更高,但最好保持唯一。
4.2 问题二:参数绑定失败或为null
在XML或注解中引用方法参数时,发现参数值为null。
解决方案:
- 使用
@Param注解:这是最稳妥的方式。在Mapper接口方法的参数前加上@Param("key"),然后在XML中用#{key}引用。特别是当方法有多个参数时,必须使用@Param。 - 使用默认参数名:在Spring Boot项目中,如果编译时开启了
-parameters参数(Spring Boot默认开启),且方法只有一个参数时,可以直接用#{参数名}引用。但为了代码清晰和可移植性,强烈建议始终使用@Param。 - 检查参数类型:确保传入的参数类型与SQL中
#{}期望的类型匹配。例如,日期时间类型可能需要特殊处理。
4.3 问题三:动态SQL拼接错误
使用<if>等标签时,可能因为null值或空字符串判断不当,导致生成的SQL语法错误。
最佳实践:
<where> <!-- 判断字符串非空,推荐同时判断 null 和 空字符串 --> <if test="name != null and name.trim() != ''"> AND username LIKE CONCAT('%', #{name}, '%') </if> <!-- 对于集合,判断非null且非空 --> <if test="ids != null and ids.size() > 0"> AND id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </if> </where><where>标签:它会自动处理WHERE关键字,并去除开头多余的AND或OR,是写动态WHERE条件的最佳搭档。test表达式:里面是OGNL表达式,可以调用Java方法,如trim()、size()。
4.4 问题四:结果映射(ResultMap)异常
查询返回了数据,但对象的某些属性为null,或者出现类型转换错误。
排查与解决:
- 检查数据库列名与对象属性名:默认的自动映射(
autoMappingBehavior默认为PARTIAL)要求列名与属性名遵循“下划线转驼峰”规则(如user_name->userName)。如果不匹配,需要在resultMap中显式指定,或者在实体类字段上使用@TableField(value = “db_column_name”)注解。 - 复杂类型与类型处理器:如果数据库字段是
JSON、枚举等复杂类型,需要注册或自定义MyBatis的TypeHandler。MP对常用类型(如Java 8的LocalDateTime)有内置处理器,但自定义类型需要额外配置。 - 使用别名:在自定义SQL中,如果使用了函数或计算字段,务必使用
AS赋予一个别名,且别名要与resultMap中的column或返回类型的属性名对应。SELECT u.id, DATE_FORMAT(u.create_time, '%Y-%m-%d') AS createDate -- 别名createDate对应VO属性 FROM user u
5. 性能优化与进阶思考
当自定义SQL处理大量数据或复杂关联时,性能问题不容忽视。
1. 避免N+1查询问题这是一个在联表查询映射中常见的陷阱。例如,在查询订单列表时,如果每个订单又要去查一次用户信息,就会产生N+1次查询。
- 解决方案:在编写自定义SQL时,尽量使用
JOIN一次性将所需数据查询出来,并通过resultMap的<association>或<collection>进行嵌套结果映射。虽然resultMap配置稍复杂,但能从根本上解决N+1问题。MP本身并不自动解决此问题,需要你在SQL层面做好设计。
2. 善用索引与SQL优化自定义SQL意味着你需要对最终执行的SQL负责。
- 使用
EXPLAIN:在开发阶段,将生成的SQL放到数据库客户端中,用EXPLAIN命令分析执行计划,查看是否用上了合适的索引。 - 警惕
SELECT *:在自定义SQL中,尤其是联表查询,明确写出需要的字段列表,而不是用SELECT *。这能减少网络传输的数据量,也可能让覆盖索引发挥作用。 - 分页优化:如前所述,对于超复杂查询,考虑自定义
COUNT语句。对于深度分页(如LIMIT 100000, 20),可以考虑使用“游标分页”或“基于上次最大ID查询”等优化方案。
3. 代码组织与维护
- 统一管理:将所有的自定义SQL集中放在对应的
Mapper.xml文件中,而不是分散在注解里。这样便于DBA进行SQL评审和优化。 - 使用
<sql>片段:对于重复使用的SQL片段(如复杂的WHERE条件、字段列表),可以使用<sql id=”baseColumn”>…</sql>定义,然后在各个<select>中使用<include refid=”baseColumn”/>引入,提高复用性和可维护性。 - 版本兼容性注意:如网络热词中提到的,注意MP版本与Spring Boot版本的对应关系。MP 3.5.x 通常需要 Spring Boot 2.7.x 或 3.x。在升级时,要仔细查看官方发布的版本兼容说明,避免因依赖冲突导致自定义SQL的解析或执行出现意外错误。
自定义SQL是MyBatis-Plus从“好用”到“强大”的桥梁。它没有削弱MP的便利性,反而通过赋予开发者精准控制SQL的能力,弥补了通用Mapper在复杂场景下的不足。掌握XML映射、注解、Wrapper结合这三种主要方式,并理解分页、结果映射等高级特性,你就能在面对任何复杂数据查询时游刃有余。记住,清晰的SQL、恰当的索引、以及避免常见的性能陷阱,是写出高质量自定义SQL的关键。