恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
MyBatis-Plus自定义SQL实战:XML映射与Wrapper结合应对复杂查询
首页
资讯中心
/
MyBatis-Plus自定义SQL实战:XML映射与Wrapper结合应对复杂查询
MyBatis-Plus自定义SQL实战:XML映射与Wrapper结合应对复杂查询
发布时间:2026/8/8 4:50:37
1. 项目概述为什么我们需要自定义SQL在项目里用MyBatis-Plus后面简称MP的朋友估计都享受过它带来的便利单表CRUD基本不用写SQL一个LambdaQueryWrapper就能搞定大部分查询。但干过几个真实项目你就会发现现实远比理想骨感。我见过不少团队项目初期图快所有查询都硬套MP的Wrapper结果遇到多表关联、复杂子查询、数据库特定函数比如Oracle的LISTAGG或者MySQL的窗口函数时代码就变得又臭又长性能还上不去。最后要么在Wrapper里拼接一堆apply和last要么干脆绕开MP直接回MyBatis的老路写XML。所以“实现自定义SQL”这个事绝不是为了炫技。它的核心价值在于平衡在享受MP自动化单表操作便利性的同时为复杂业务场景保留最灵活、最直接的SQL控制权。这就像给你的工具箱里既配了电动螺丝刀MP的Wrapper也留了一把精密的瑞士军刀自定义SQL。什么时候该用哪把工具考验的就是我们对框架的理解和项目边界的把控。最近社区里关于MP的讨论比如“分页查询怎么配”、“若依框架怎么集成MP”背后其实都指向同一个问题当标准方案不够用时我们如何优雅地扩展今天我就结合自己踩过的坑把MP里玩转自定义SQL的几种主流姿势、各自的适用场景以及那些官方文档里没明说的细节给你一次讲透。2. 核心思路MP自定义SQL的三种武器库实现自定义SQLMP其实给了我们三条清晰度不同的路径。选哪条路取决于你对“自定义”程度的要求以及团队的技术习惯。2.1 武器一Wrapper Select注解轻度自定义这是最轻量、最快速的方式适合在简单的多条件查询中嵌入一小段固定的SQL片段。核心逻辑利用QueryWrapper生成大部分WHERE条件对于Wrapper无法表达的复杂部分如一个特定的函数或子查询通过Select注解直接在Mapper接口方法上写完整SQL并将Wrapper作为参数传入。MP会智能地将Wrapper生成的条件拼接到你的SQL中。实操示例假设我们要查询用户列表但有一个特殊条件只筛选出积分points大于所在部门平均积分的用户。这个“大于部门平均分”的条件用Lambda表达式很难直接写。// 1. 在UserMapper接口中定义方法 public interface UserMapper extends BaseMapperUser { Select(SELECT u.* FROM user u ${ew.customSqlSegment}) ListUser selectUsersWithComplexCondition(Param(Constants.WRAPPER) WrapperUser wrapper); } // 2. 在Service或Controller中构造Wrapper并调用 public void queryUsers() { QueryWrapperUser wrapper new QueryWrapper(); // 可以添加Wrapper能处理的常规条件 wrapper.like(name, 张) .eq(status, 1); // 关键使用 apply 方法注入自定义的SQL片段 // 注意apply 内的片段会直接拼接需注意SQL注入和安全 wrapper.apply(u.points (SELECT AVG(points) FROM user WHERE dept_id u.dept_id)); // 调用自定义方法 ListUser userList userMapper.selectUsersWithComplexCondition(wrapper); }为什么这么用这里的${ew.customSqlSegment}是MP提供的占位符它会被替换成Wrapper生成的WHERE关键字及其后的条件语句。apply方法里的字符串则会原样拼接到WHERE条件中。这种方式下你只定义了SELECT ... FROM部分WHERE条件由你和Wrapper共同动态生成。注意事项SQL注入风险apply方法直接拼接字符串如果前端参数未经严格过滤就传入apply风险极高。绝对不要写成wrapper.apply(“points “ userInput)。对于动态值应使用{0}占位符并通过apply的重载方法传入参数wrapper.apply(“date_column {0}”, someDate);表别名在Select注解的SQL中如果涉及多表或子查询最好显式使用表别名如示例中的u并在apply的片段中也使用相同的别名确保SQL语法正确。适用场景局限这种方式最适合在WHERE条件中插入固定或相对简单的自定义片段。对于整个SQL语句结构都复杂的情况如多层嵌套子查询、UNION查询就显得力不从心了。2.2 武器二XML映射文件中度到重度自定义这是MyBatis的“正统”也是MP完全兼容的“大杀器”。当你需要编写完整的、复杂的SQL语句时XML映射文件提供了最强大的能力和最清晰的SQL与Java代码的分离。核心逻辑将SQL写在独立的Mapper.xml文件中MP的BaseMapper接口及其方法会自动与之关联。你可以在XML中编写任意复杂度的SQL并利用MyBatis强大的动态SQL标签if,choose,foreach等来构建灵活的逻辑。实操示例实现一个分页的多表关联查询查询用户信息及其部门名称并且支持根据多个动态条件筛选。!-- 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.mapper.UserMapper !-- 自定义结果映射关联查询必备 -- resultMap idUserDeptResultMap typecom.example.entity.User id propertyid columnid/ result propertyname columnname/ result propertyemail columnemail/ result propertydeptId columndept_id/ !-- 关联部门信息 -- association propertydepartment javaTypecom.example.entity.Department id propertyid columndept_id/ result propertydeptName columndept_name/ /association /resultMap !-- 自定义分页查询SQL -- select idselectUserPageWithDept resultMapUserDeptResultMap SELECT u.id, u.name, u.email, u.dept_id, d.id as dept_id, d.name as dept_name FROM user u LEFT JOIN department d ON u.dept_id d.id where !-- 动态条件姓名模糊查询 -- if testquery.name ! null and query.name ! AND u.name LIKE CONCAT(%, #{query.name}, %) /if !-- 动态条件部门ID精确匹配 -- if testquery.deptId ! null AND u.dept_id #{query.deptId} /if !-- 动态条件状态多选 -- if testquery.statusList ! null and query.statusList.size() 0 AND u.status IN foreach collectionquery.statusList itemstatus open( separator, close) #{status} /foreach /if !-- 甚至可以嵌入复杂的子查询 -- if testquery.minPoints ! null AND u.points (SELECT AVG(points) FROM user WHERE dept_id u.dept_id) /if /where ORDER BY u.create_time DESC /select /mapper对应的Mapper接口和Service// UserMapper.java public interface UserMapper extends BaseMapperUser { // 方法名与XML中的id对应 IPageUserDeptVO selectUserPageWithDept(PageUser page, Param(“query”) UserQueryDTO query); } // Service层调用 public IPageUserDeptVO getUserPage(PageUser page, UserQueryDTO query) { return userMapper.selectUserPageWithDept(page, query); }为什么这是最推荐的方式关注点分离SQL集中在XML里Java代码干净便于DBA或后端开发者单独Review和优化SQL。功能强大可以利用MyBatis全部的动态SQL能力处理极其复杂的条件分支和循环。易于调试可以直接在数据库客户端测试写好的SQL再粘贴到XML中。天然防注入所有参数都通过#{}占位符绑定安全无忧。实操心得XML文件位置确保你的Mapper.xml文件放在resources目录下对应的包路径中如resources/com/example/mapper/并与Mapper接口的包名一致。这是MyBatis的默认扫描约定在Spring Boot中通常无需额外配置。分页插件配置要使自定义XML分页查询生效必须在Spring Boot配置类中配置MP的分页插件。这是很多新手会掉的坑。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 添加分页插件 interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 根据数据库类型调整 return interceptor; } }参数传递XML中通过Param注解指定的名称如“query”来引用参数。对于复杂对象使用query.propertyName的方式访问其属性。2.3 武器三自定义SQL注入器深度定制高阶玩法这是MP最灵活、也最复杂的扩展方式。它允许你完全定义一个新的Mapper方法并为其注入自定义的SQL执行逻辑。当你需要创造一种MP本身不支持的通用操作模式时比如批量Upsert、逻辑删除的扩展行为就需要用到它。核心逻辑通过实现com.baomidou.mybatisplus.core.injector.ISqlInjector接口或继承AbstractSqlInjector类向MP的Mapper中注入全新的方法。你需要自己编写该方法的SQL模板和运行时逻辑。适用场景比如MP默认提供的insert是单条插入虽然也有insertBatch但某些数据库如MySQL的批量插入语法有优化空间。我们可以注入一个更高效的批量插入方法。由于实现一个完整的SQL注入器步骤较多这里概述其关键步骤定义自定义方法接口创建一个接口声明你的新方法。编写SQL模板创建一个类继承AbstractMethod在injectMappedStatement方法中定义SQL语句。创建SQL注入器创建一个类继承DefaultSqlInjector重写getMethodList方法将你的自定义方法添加到方法列表中。注册注入器将你的SQL注入器配置为Spring Bean替换MP默认的注入器。为什么用这种方式它实现了框架级别的复用。一旦配置好所有Mapper都能使用这个新方法就像使用MP自带的selectById一样自然。但这属于框架底层扩展除非有强烈的、通用的定制需求否则不建议轻易使用因为维护成本较高。3. 实战精讲XML方式实现分页联表查询让我们聚焦于最常用、也最强大的XML方式通过一个完整的实战案例把每一步的细节和坑点都捋清楚。这个案例将覆盖多表关联、动态条件、分页查询、结果集映射ResultMap以及排序。3.1 环境准备与依赖确认首先确保你的pom.xml依赖正确。以Spring Boot 2.7.x 和 MyBatis-Plus 3.5.17为例这也是当前社区搜索的热点版本parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version !-- 与MP 3.5.17兼容性较好的版本 -- /parent dependencies dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.17/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency !-- 其他依赖... -- /dependencies版本匹配要点MP 3.5.x 与 Spring Boot 2.7.x 是黄金搭档。如果你用的是Spring Boot 3.x则需要使用MP的更高版本如5.x。社区里“mybatis-plus version3.5.17对应的springboot版本”的疑问答案就是Spring Boot 2.7.x系列。3.2 定义查询参数DTO与返回VO清晰的参数和返回对象是写好复杂查询的第一步。避免直接在Controller层用Map接收参数或在SQL里用Object。// 查询参数DTO Data public class UserQueryDTO { private String name; // 用户名模糊查询 private Long deptId; // 部门ID精确匹配 private ListInteger statusList; // 状态多选 private Integer minPoints; // 最低积分 // 可以加入分页参数但更推荐用Page对象单独传递 } // 返回结果VO (View Object) Data public class UserDeptVO { private Long id; private String name; private String email; private Long deptId; // 关联的部门信息 private String deptName; // 其他需要返回的字段... }为什么用VO而不是Entity实体类EntityUser通常与数据库user表严格对应。而联表查询的结果字段可能来自多张表如dept_name。用一个专门的VO来接收语义更清晰也避免了给实体类增加不属于它本身业务的属性保持实体类的纯净。3.3 编写Mapper接口与XML映射文件Mapper接口public interface UserMapper extends BaseMapperUser { /** * 分页查询用户及其部门信息 * param page 分页参数对象MP会自动处理 * param query 查询条件对象 * return 分页结果 */ IPageUserDeptVO selectUserPageWithDept(Param(“page”) PageUser page, Param(“query”) UserQueryDTO query); }注意这里返回类型是IPageUserDeptVO泛型是VO不是Entity。Param注解至关重要它定义了参数在XML中的名称。XML映射文件 (UserMapper.xml) 关键点在于resultMap的定义和动态SQLwhere标签的使用。?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.mapper.UserMapper !-- 重点1定义结果映射 -- resultMap idUserDeptVOResultMap typecom.example.vo.UserDeptVO id propertyid columnid/ result propertyname columnuser_name/ !-- 注意字段别名 -- result propertyemail columnemail/ result propertydeptId columndept_id/ result propertydeptName columndept_name/ /resultMap !-- 重点2编写包含动态条件的完整SQL -- select idselectUserPageWithDept resultMapUserDeptVOResultMap SELECT u.id, u.name as user_name, !-- 使用别名避免字段名冲突或歧义 -- u.email, u.dept_id, d.name as dept_name FROM user u LEFT JOIN department d ON u.dept_id d.id where !-- 使用 if 标签实现动态条件 -- if testquery.name ! null and query.name.trim() ! AND u.name LIKE CONCAT(%, #{query.name}, %) /if if testquery.deptId ! null AND u.dept_id #{query.deptId} /if if testquery.statusList ! null and query.statusList.size() 0 AND u.status IN foreach collectionquery.statusList itemstatus open( separator, close) #{status} /foreach /if !-- 一个相对复杂的子查询条件示例 -- if testquery.minPoints ! null AND u.points #{query.minPoints} /if !-- 可以继续添加其他条件 -- /where !-- 排序 -- ORDER BY u.create_time DESC /select /mapper关键细节解析resultMap它是连接SQL结果集和Java VO对象的桥梁。column属性对应SQL查询结果的列名或别名property对应VO对象的属性名。当数据库字段名如u.name与VO属性名name不一致或者存在多表同名字段时必须使用别名并在resultMap中明确指定。例如上例中将u.name别名为了user_name并在resultMap中映射到name属性。where标签这个标签非常智能。它会自动处理WHERE关键字并且去掉开头多余的AND或OR。如果where标签内所有if条件都不成立它会自动省略整个WHERE子句避免SQL语法错误。foreach标签处理IN查询的利器。collection指定集合参数名item是遍历的每个元素的变量名open和close是包装括号separator是元素间的分隔符。参数访问在if的test表达式或#{}占位符中使用query.xxx的格式来访问UserQueryDTO对象的属性。Param(“query”)注解让query这个名称在XML中可用。3.4 Service层与Controller层调用Service层Service public class UserServiceImpl extends ServiceImplUserMapper, User implements UserService { Override public IPageUserDeptVO getUserPage(PageUser page, UserQueryDTO queryDTO) { // 直接调用自定义的Mapper方法 return baseMapper.selectUserPageWithDept(page, queryDTO); } }这里baseMapper是MP在ServiceImpl中为我们注入的当前实体对应的Mapper即UserMapper可以直接使用其所有方法包括我们自定义的。Controller层RestController RequestMapping(“/user”) public class UserController { Autowired private UserService userService; GetMapping(“/page”) public RIPageUserDeptVO getUserPage( RequestParam(defaultValue “1”) long current, RequestParam(defaultValue “10”) long size, UserQueryDTO queryDTO) { // 查询条件对象 // 构建MP的分页对象 PageUser page new Page(current, size); // 调用Service IPageUserDeptVO result userService.getUserPage(page, queryDTO); return R.ok(result); } }MP的Page对象包含了当前页码、每页大小、排序信息等。它作为参数传入后MP的分页插件会拦截查询自动计算总记录数并执行分页逻辑最终IPage对象里会包含分页数据records和分页信息total,size,current等。4. 避坑指南与性能优化在实际使用中尤其是从纯MyBatis迁移过来或在复杂场景下会遇到一些典型问题。4.1 分页插件失效与总数为-1的问题问题描述调用自定义的XML分页查询方法后返回的IPage对象中分页数据正确但total总记录数为0或-1。根本原因MP的分页插件是通过拦截器PaginationInnerInterceptor实现的。它会在执行你的查询SQL前自动生成一条COUNT(*)语句来查询总数。如果插件没有正确配置或没有生效就不会执行这一步。解决方案确认插件已配置如2.2节所述必须在配置类中声明MybatisPlusInterceptor并添加PaginationInnerInterceptor。检查SQL兼容性分页插件会自动优化COUNT语句。但对于极其复杂的SQL如带有WITH子句的公共表表达式、UNION等插件可能无法正确解析。此时你需要自定义count查询。方法一在XML中单独提供一个count查询推荐。select id“selectUserPageWithDeptCount” resultType“java.lang.Long” SELECT COUNT(*) FROM user u LEFT JOIN department d ON u.dept_id d.id !-- 这里需要复制与主查询完全一致的WHERE条件 -- where if test“query.name ! null and query.name.trim() ! ‘’“ AND u.name LIKE CONCAT(‘%’, #{query.name}, ‘%’) /if !-- ... 其他条件 ... -- /where /select然后在Mapper接口中增加一个返回Long的selectUserPageWithDeptCount方法并使用Param(“query”)。MP插件会优先使用这个自定义的count方法。方法二在Page对象中设置不优化count语句简单但可能影响性能。PageUser page new Page(current, size); page.setOptimizeCountSql(false); // 关闭自动优化4.2 字段映射失败与别名使用问题描述查询结果中某些字段尤其是VO中新增的或来自关联表的字段值为null。排查步骤检查resultMap确认result标签的property和column是否一一对应特别是大小写。数据库字段名通常是下划线风格dept_nameJava属性是驼峰风格deptName。MP默认开启了驼峰映射但如果你的字段别名不是标准下划线或者resultMap中明确指定了column则以resultMap为准。检查SQL中的别名在多表关联查询时如果两张表有同名字段如user表和department表都有name字段必须在SELECT语句中为它们起不同的别名并在resultMap中引用这些别名。这是最常见的错误来源。开启MyBatis日志在application.yml中设置日志级别查看最终执行的SQL语句和返回的结果集字段名这是最直接的调试手段。logging: level: com.example.mapper: debug # 将你的Mapper包路径设置为debug4.3 动态SQL条件中的空字符串与集合判断在if标签的test表达式中判断字符串为空和判断集合为空有细微差别。test“query.name ! null and query.name ! ‘’“这是标准写法判断不为null且不是空字符串。test“query.name ! null and query.name.trim() ! ‘’“更严谨先去除首尾空格再判断避免用户输入一堆空格导致条件失效。判断集合test“query.statusList ! null and query.statusList.size() 0“。也可以使用MyBatis内置的_parameter关键字或Param注解的名称但直接使用参数名更直观。4.4 性能考量N1查询问题在定义resultMap时除了association一对一还有collection一对多。警惕在collection中嵌套过深的查询这可能导致著名的“N1查询”问题主查询1次获取N条主记录然后每条主记录再执行一次子查询。优化建议尽量使用单次联表查询就像我们上面的例子通过JOIN一次性将主表和关联表的数据查出来通过结果映射resultMap组装对象。这是最高效的方式。如果关联数据过多或过于复杂考虑拆分成两次查询。第一次查询主列表分页第二次根据主列表的ID集合批量查询关联数据然后在内存中Service层进行组装。这通常比在SQL中多层嵌套JOIN或触发N1查询要快。使用MP的TableField注解进行关联查询对于简单的一对一关联MP支持在实体类中使用TableField的select属性指定一个子查询但这本质上仍然是N1不推荐在列表查询中使用。5. 进阶在若依等现有框架中集成MP自定义SQL社区里很多朋友在问“若依框架不分离版4.8.3版本 想将mybatis 改为mybatis-plus”。对于若依这类已经成熟的框架集成MP并启用自定义SQL需要系统性地替换。核心步骤替换依赖在pom.xml中将mybatis-spring-boot-starter依赖替换为mybatis-plus-boot-starter。修改配置将application.yml中所有mybatis开头的配置项改为mybatis-plus开头。例如mybatis.mapper-locations改为mybatis-plus.mapper-locations。MP完全兼容MyBatis的配置。修改基类若依的BaseEntity、BaseMapper、BaseService、BaseServiceImpl等需要调整。让BaseMapper继承MP的BaseMapper让BaseServiceImpl继承MP的ServiceImpl。这是一个细致活需要对照MP的API修改方法签名和实现。处理XML原有的MyBatis XML映射文件绝大部分可以直接使用因为MP 100%兼容MyBatis。只需注意如果原来XML里用了${}进行字符串拼接有注入风险建议借机改为安全的#{}绑定。添加分页插件这是必须的在若依的配置类如RuoYiConfig或新建一个配置类中添加MybatisPlusInterceptor和PaginationInnerInterceptor的Bean定义。逐步重构不要试图一次性重写所有DAO方法。可以先从简单的单表查询开始用MP的Wrapper替换原有的Example或XML中的简单语句。对于复杂的多表查询保留原有XML方式这正是“自定义SQL”的价值所在。这个过程的关键是平滑迁移保证原有功能不受影响。自定义SQLXML能力的存在使得你可以先完成框架替换再逐步优化具体的SQL实现风险可控。自定义SQL不是MP的短板反而是它设计哲学中“强大且灵活”的体现。它没有试图用一个Wrapper封装所有SQL场景而是明智地选择了“放手”将复杂场景交还给最专业的MyBatis XML去处理。掌握好这几种自定义方式尤其是XML映射文件你就能在项目里真正做到游刃有余既享受了MP的便捷也不失应对复杂业务的底气。