
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的三种核心实现路径实现自定义SQLMP给了我们好几条路。选择哪条路取决于你的查询复杂度、对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 BaseMapperUser { /** * 自定义查询获取用户及其订单统计信息 * param minOrderCount 最小订单数过滤条件 * return 用户统计视图对象列表 */ ListUserOrderStatsVO selectUserWithOrderStats(Param(minOrderCount) Integer minOrderCount); }注意这里使用了Param注解来明确命名SQL中的参数。第二步创建对应的XML映射文件在resources目录下保持与Mapper接口相同的目录结构例如resources/com/example/demo/mapper/创建UserMapper.xml。?xml version1.0 encodingUTF-8 ? !DOCTYPE mapper PUBLIC -//mybatis.org//DTD Mapper 3.0//EN http://mybatis.org/dtd/mybatis-3-mapper.dtd mapper namespacecom.example.demo.mapper.UserMapper !-- 自定义结果集映射用于将复杂的查询结果映射到VO对象 -- resultMap iduserOrderStatsMap typecom.example.demo.vo.UserOrderStatsVO id propertyuserId columnid/ result propertyusername columnusername/ result propertyemail columnemail/ !-- 订单统计字段 -- result propertytotalOrderCount columnorder_count/ result propertytotalAmount columntotal_amount/ /resultMap !-- 自定义SQL查询语句 -- select idselectUserWithOrderStats resultMapuserOrderStatsMap 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 testminOrderCount ! 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 BaseMapperUser { // 示例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 testusername ! null AND username LIKE CONCAT(%, #{username}, %)/if if teststatus ! null AND status #{status}/if /where ORDER BY id DESC /script) ListUser 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 BaseMapperUser { /** * 使用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}) ListMapString, Object selectUserJoinDept(Param(ew) QueryWrapperUser 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}) ListMapString, Object selectUserJoinDeptLambda(Param(ew) LambdaQueryWrapperUser wrapper); }服务层调用示例Service public class UserServiceImpl { Autowired private UserMapper userMapper; public ListMapString, Object getUsersInDept(String deptName) { LambdaQueryWrapperUser 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提供了强大的分页插件PaginationInterceptor3.4版本前或MybatisPlusInterceptor3.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 BaseMapperOrder { /** * 自定义分页查询查询订单及其商品详情 * param page MP分页对象会自动注入分页参数current, size * param queryParam 自定义查询参数 * return 分页结果 */ IPageOrderDetailVO selectOrderDetailPage(IPageOrderDetailVO page, Param(param) OrderQueryParam queryParam); }对应的XML映射文件select idselectOrderDetailPage resultTypecom.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 testparam.createTimeStart ! null AND o.create_time #{param.createTimeStart} /if if testparam.createTimeEnd ! null AND o.create_time #{param.createTimeEnd} /if if testparam.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 IPageOrderDetailVO getOrderDetailPage(int current, int size, OrderQueryParam param) { // 1. 创建分页对象 PageOrderDetailVO page new Page(current, size); // 2. 执行查询。MP插件会拦截此方法自动将分页SQLCOUNT查询和LIMIT拼接到你的自定义SQL上 return orderMapper.selectOrderDetailPage(page, param); // 执行后page对象里不仅包含分页数据列表(page.getRecords()) // 还包含总记录数(page.getTotal())、总页数(page.getPages())等信息。 } }分页插件工作原理与避坑指南两次查询MP的分页插件会进行两次数据库查询。第一次是自动生成COUNT(*)语句查询符合条件的总记录数第二次才是你的自定义SQL加上LIMIT子句查询当前页数据。COUNT查询优化对于非常复杂的联表查询自动生成的COUNT语句可能效率低下。MP允许你自定义COUNT查询!-- 在同一个select标签内通过id后缀为“_COUNT”来指定 -- select idselectOrderDetailPage_COUNT resultTypejava.lang.Long SELECT COUNT(DISTINCT o.id) FROM order o ... !-- 这里可以写一个优化过的、更简单的COUNT查询 -- /select如果Mapper接口中定义了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 testname ! null and name.trim() ! AND username LIKE CONCAT(%, #{name}, %) /if !-- 对于集合判断非null且非空 -- if testids ! null and ids.size() 0 AND id IN foreach collectionids itemid open( separator, close) #{id} /foreach /if /wherewhere标签它会自动处理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 u5. 性能优化与进阶思考当自定义SQL处理大量数据或复杂关联时性能问题不容忽视。1. 避免N1查询问题这是一个在联表查询映射中常见的陷阱。例如在查询订单列表时如果每个订单又要去查一次用户信息就会产生N1次查询。解决方案在编写自定义SQL时尽量使用JOIN一次性将所需数据查询出来并通过resultMap的association或collection进行嵌套结果映射。虽然resultMap配置稍复杂但能从根本上解决N1问题。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的关键。