
MyBatis-Plus 的跨表查询结果返回几乎每接手一个 Spring Boot 项目都会被人问一遍单表 CRUD 用 BaseMapper 确实爽可订单列表要带出用户昵称、角色列表要统计人数这种场景Wrapper 又拼不出 JOINBaseMapper 返回的又永远是当前实体类型那跨表查询到底怎么写这类问题我前前后后在好几个项目里都踩过、也填过这篇文章直接把能落地的四种方案丢出来每一种都有完整思路和可复制的代码顺带把我当时踩过的分页、配置、count 优化这些坑也一起说清楚。无论你是刚把 MyBatis-Plus 引入项目的新手还是被跨表返回结构困扰已久的老人这四种套路过一遍基本就能应付九成需求。1. 先搞清楚边界MyBatis-Plus 跨表查询本质是切回 SQL1.1 BaseMapper 的单表边界很多人一开始会误以为 MyBatis-Plus 既然叫增强版 MyBatis那 BaseMapper 里的selectList、selectPage、selectMaps应该天然支持联表。实际不是。BaseMapper 的所有方法参数是当前实体对应的 Wrapper底层生成的 SQL 永远是SELECT 实体表字段 FROM 当前表 WHERE ...。也就是说MyBatis-Plus 把一个实体映射一张表这件事做得很彻底它负责的是单表 CRUD 的开发效率而不是替代你写多表 SQL。这个边界想清楚了跨表查询的答案其实就一句话跨表部分自己写 SQL让 MyBatis-Plus 继续帮你处理单表条件、分页插件、结果映射这些脏活。四种方案里前两种是自己写 SQL 的两种载体XML 和注解第三种是尽量借用 Wrapper 的自动化能力第四种是把分页插件和自定义 SQL 组合起来解决跨表列表页这个最高频的需求。1.2 跨表返回结果的三种形态动手之前先想清楚你要的返回值长什么样。我见过三种最常见形态领域实体加几个展示字段。比如订单表联用户表要带出userName、userAge订单本身还是核心。这种情况我一般建一个OrderUserVO把订单字段和用户字段平铺在一起。完全新的聚合结构。典型的是报表、统计比如每个用户的订单总金额和订单数这种跟原实体结构关系不大通常也是用 VO/DTO 接收。结构不确定的动态 Map。比如查询条件由前端动态决定返回字段也在变这时候用ListMapString, Object承接最省事。后面四种方案基本就是围绕这三种形态展开。1.3 四种方案的选型地图先给一张我自己常用的选型对照表方便你按场景直接挑方案方案实现载体适合场景维护成本方案一XML Mapper 手写 JOIN SQL复杂动态 SQL、核心报表、团队长期维护的查询中但可控性最高方案二Mapper 接口Select注解两三张表的固定简单查询急着上线时用低但复杂后就很难受方案三QueryWrapper selectMaps子查询条件多变、附带展示字段少、不想写 SQL 文件中需要注意子查询写法方案四分页插件 自定义 SQL 组合跨表列表分页后端管理系统最常用中高分页坑最多需要说明一下方案四和方案一二的关系。方案四是建立在方案一或方案二之上的单独把它拎出来是因为跨表 分页太常见了而且它牵扯到分页插件对 count 语句的改写规则这里面的坑值得专门讲。2. 方案一XML Mapper 手写 JOIN SQL跨表查询的地基2.1 先填热搜里的坑XML 和 Mapper 接口放同一个文件夹怎么配这里先解决一个很多人开篇就被卡住的配置问题。网上最多的教程是把 XM L放在src/main/resources/mapper/下但不少人习惯把OrderMapper.xml直接跟OrderMapper.java放同一个包也就是放在src/main/java/com/example/mapper/目录下。这样放完全没问题但必须做两件事否则运行期会报Invalid bound statement (not found)。第一件在pom.xml里把src/main/java下的 XML 文件也纳入资源复制build resources resource directorysrc/main/java/directory includes include**/*.xml/include /includes /resource resource directorysrc/main/resources/directory /resource /resources /build不配这个IDE 里跑可能没事但打包成 jar 时classes目录下根本没有 XML上线就炸。第二件在application.yml里指定 mapper 位置mybatis-plus: mapper-locations: classpath*:com/example/mapper/*.xml注意classpath*带星号作用是扫描所有 jar 里的匹配路径配上之后同包 XML 和接口才能正确绑定。我见过不少人只配了 pom 忘记配 yml或者配了 yml 没配 pom结果仍然是同样的报错。2.2 场景与 VO 设计后面所有代码我都用一个电商里非常常见的场景订单表orders和用户表user查询订单列表时带出用户昵称和年龄。表结构很简单CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50), age INT ); CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT, total_amount DECIMAL(10, 2), status INT );对应实体Order和User按 MyBatis-Plus 常规写法定义即可。然后建一个 VO 承接两张表的合并结果Data public class OrderUserVO { private Long id; private Long userId; private BigDecimal totalAmount; private Integer status; private String userName; private Integer userAge; }这里我特意不用继承Order而是平铺所有字段。原因是跨表 VO 是给前端或业务层看的展示模型继承会让字段来源变得隐晦而且一旦联表字段增多继承关系会很混乱。平铺字段加Data简单直接。2.3 Mapper 接口与 XML 的完整实现Mapper 接口继承BaseMapperOrder新加一个跨表查询方法public interface OrderMapper extends BaseMapperOrder { ListOrderUserVO selectOrderWithUser(Param(status) Integer status); }对应的 XMLmapper namespacecom.example.mapper.OrderMapper select idselectOrderWithUser resultTypecom.example.vo.OrderUserVO SELECT o.id, o.user_id AS userId, o.total_amount AS totalAmount, o.status, u.name AS userName, u.age AS userAge FROM orders o LEFT JOIN user u ON o.user_id u.id where if teststatus ! null AND o.status #{status} /if /where ORDER BY o.id DESC /select /mapper这里我把别名都写成了小程序驼峰因为OrderUserVO里的字段是userId而不是user_id。如果你项目里开启了map-underscore-to-camel-case: true当然可以不写别名靠自动映射但我个人建议手写 JOIN 查询里的别名因为跨表查询经常出现两个表都有id、name这类同名字段只靠下划线转驼峰很容易映射错列。显式写别名等于把映射规则钉死在 SQL 里排查问题的时候一眼就能看出来。2.4 为什么方案一被称作地基方案一的 XML 里可以使用 MyBatis 最完整的动态 SQL 能力where、if、foreach、choose全都能用。比如查询条件里要支持按多个用户 ID 过滤直接加if testuserIds ! null and userIds.size() 0 AND o.user_id IN foreach collectionuserIds itemuserId open( separator, close) #{userId} /foreach /if这个能力在注解 SQL 里是享受不到的在 QueryWrapper 里虽然能实现但可读性差得多。这也是为什么几乎各家企业的核心报表、复杂查询最终还是落到 XML 上。对比一下另一个热词场景Spring Data JPA 当然也能做联表用Query写 JPQL或者用Specification拼条件但团队里只要有人对 SQL 更熟讨论成本就会明显上升。MyBatis-Plus 的方案一就是把 MP 的单表便利和 MyBatis 的 SQL 控制力接在一起对SQL 直觉型团队最友好。3. 方案二Select 注解 SQL轻量场景的偷懒通道3.1 一行注解搞定简单 JOIN如果查询就几张表、条件也不复杂其实不必开 XML 文件。直接在 Mapper 接口方法上加Select即可public interface OrderMapper extends BaseMapperOrder { Select(SELECT o.id, o.user_id AS userId, o.total_amount AS totalAmount, o.status, u.name AS userName, u.age AS userAge FROM orders o LEFT JOIN user u ON o.user_id u.id WHERE o.status #{status}) ListOrderUserVO selectOrderWithUserByAnno(Param(status) Integer status); }这个方案的好处是零配置文件接口本身就是 SQL 的载体代码跳转非常直接。返回值一样用 VO 承接逻辑和方案一完全相同只是 SQL 从 XML 挪到了注解里。3.2 注解 SQL 配合分页插件一起用注解 SQL 同样可以享受 MyBatis-Plus 的分页插件只要方法签名里有Page参数返回类型写成IPage即可Select(SELECT o.id, o.user_id AS userId, o.total_amount AS totalAmount, o.status, u.name AS userName, u.age AS userAge FROM orders o LEFT JOIN user u ON o.user_id u.id WHERE o.status #{status}) IPageOrderUserVO selectOrderUserPageByAnno(Page? page, Param(status) Integer status);分页插件在拦截器里看到第一个参数是Page会自动改写 SQL先执行 count 再执行带 limit 的查询这部分和 XML 方案没有区别。也就是说方案二完全可以覆盖大部分轻量管理页的需求。3.3 注解方案的硬伤字符串拼接的动态 SQL 会让人崩溃注解方案我用了大概半年后在几个场景里彻底放弃。第一个硬伤是动态条件。想在注解 SQL 里根据条件拼接and status ?只能用script标签包一层Select(script SELECT o.id, o.user_id AS userId, o.total_amount AS totalAmount, u.name AS userName FROM orders o LEFT JOIN user u ON o.user_id u.id where if teststatus ! nullAND o.status #{status}/if if testuserName ! null and userName ! \\AND u.name LIKE CONCAT(%, #{userName}, %)/if /where ORDER BY o.id DESC /script) ListOrderUserVO selectOrderWithUserDynamic(Param(status) Integer status, Param(userName) String userName);一旦条件多起来你就会看到一长串以拼接的 Java 字符串里埋着 XML 标签。转义、引号嵌套、缩进全部失效稍微改一个条件整个 SQL 可读性就崩了。第二个硬伤是foreach这类循环标签在注解里写起来更是噩梦IN 集合稍微复杂点就非常难受。所以我的建议是固定两三条 SQL 的简单场景用注解一旦出现三个以上动态条件或同一个 Mapper 里跨表查询超过两三个方法立刻转 XML。这个判断标准不算严格但可以帮你少走很多弯路。4. 方案三QueryWrapper selectMaps不想写 SQL 时的动态方案4.1 selectMaps 到底返回什么别指望它自动 JOINMyBatis-Plus 的selectMaps返回的是ListMapString, Objectkey 是查询列名或别名value 是对应的值。很多人以为它跟selectList一样能搞出跨表结果其实它同样只查主表不能像 JOIN 那样把另一张表的字段拼进来。但这不代表方案三没戏它真正的价值在于动态 SQL 动态结构。先看一个基础用法。单表查询时selectMaps配合 Wrapper 可以动态选择返回哪些列QueryWrapperOrder wrapper new QueryWrapper(); wrapper.select(id, user_id, status) .eq(status, 1); ListMapString, Object maps orderMapper.selectMaps(wrapper);4.2 两条真正跨表的路关联子查询 EXISTS 过滤要让方案三真正跨表有两种写法。第一种是在select列表里用关联子查询带出目标字段QueryWrapperOrder wrapper new QueryWrapper(); wrapper.select(id, user_id, status, (SELECT u.name FROM user u WHERE u.id orders.user_id) AS userName) .eq(status, 1); ListMapString, Object maps orderMapper.selectMaps(wrapper);注意这里子查询里引用了orders表本身别名写userName结果返回的 Map 里就有userName这个 key。这种写法对返回字段少、只是顺便带个名字的场景非常合适少写一个 XML 文件也不破坏 Wrapper 链式调用的连贯感。第二种是利用 Wrapper 做跨表过滤比如查所有年龄大于 18 岁用户的订单QueryWrapperOrder wrapper new QueryWrapper(); wrapper.apply(EXISTS (SELECT 1 FROM user u WHERE u.id orders.user_id AND u.age 18)) .eq(status, 1); ListOrder orders orderMapper.selectList(wrapper);更常用的是inSqlQueryWrapperOrder wrapper new QueryWrapper(); wrapper.inSql(user_id, SELECT id FROM user WHERE age 18) .eq(status, 1); ListOrder orders orderMapper.selectList(wrapper);两种方式都能实现主表条件复杂、关联表只是过滤条件的跨表查询而整个过程不需要一个 XML也不需要写整段 JOIN。如果你对 MyBatis-Plus 自带的方法边界理解得够清楚会发现在一些轻量场景里方案三反而是最快落地的。4.3 Map 结果转 VO 的落地写法Map 返回结构适合临时给前端供数但业务代码里到处传 Map 会丢失类型安全所以我一般会在 Service 层转成 VO。转换姿势有两种。一种是用 Hutool 的BeanUtilListOrderUserVO voList maps.stream() .map(map - BeanUtil.toBean(map, OrderUserVO.class)) .collect(Collectors.toList());另一种是手动转。手动转看起来啰嗦但在字段重命名、类型转换上有完全控制权OrderUserVO vo new OrderUserVO(); vo.setId((Long) map.get(id)); vo.setUserId((Long) map.get(user_id)); vo.setStatus((Integer) map.get(status)); vo.setUserName((String) map.get(userName));需要特别提醒的是selectMaps返回的 key 跟你写的别名严格一致而BeanUtil.toBean这类工具对下划线转驼峰的处理并不总是符合预期。我建议 Wrapper 的select里直接把需要映射的字段写成 VO 字段对应的别名杜绝转换时对不上号。4.4 方案三的适用边界和明显限制方案三最舒服的场景是主表要动态筛选条件是一大堆eq、like、ge返回的关联字段就一两个不想为此开 XML。但它的天花板也很明显要做两张表的真正 LEFT JOIN 并返回多张表的多个字段Wrapper 表达起来会极其别扭要做GROUP BY分组聚合selectMaps虽然能拼出 SQL但条件构造的复杂度会让你怀疑人生。所以它更适合中间态查询而不是复杂报表。真到了那种需求回到方案一。5. 方案四分页插件加持解决跨表 分页的最后一公里5.1 分页插件为什么能拦截自定义 SQL后端管理系统里跨表查询十有八九要做分页。MyBatis-Plus 的分页插件PaginationInnerInterceptor不是只能作用于 BaseMapper 方法它对任何经过 MyBatis 执行的 SELECT 都有效只要方法参数里有Page。这个机制的底层是 MyBatis 的拦截器它在 Executor 执行前拦截 SQL先生成 count 查询拿到总数再改写原 SQL 拼上LIMIT和OFFSET。先注册插件Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }这里有一个版本相关的坑。如果你是 MyBatis-Plus 3.5.9 及以上版本PaginationInnerInterceptor依赖的 jsqlparser 被抽离到了单独的mybatis-plus-jsqlparser模块只引入mybatis-plus-boot-starter是不够的会直接报类找不到。需要额外加dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-jsqlparser/artifactId version3.5.9/version /dependency这个坑特别隐蔽因为很多老项目用的是 3.5.3.2 之类直接带依赖的版本照着老配置迁移到新版本时才炸。5.2 分页 JOIN 的标准组合结合方案一的 XML 方式给跨表查询加上分页标准姿势分三步。第一步Mapper 方法签名第一个参数放Pagepublic interface OrderMapper extends BaseMapperOrder { IPageOrderUserVO selectOrderUserPage(Page? page, Param(vo) OrderUserVO vo); }第二步XML 里照常写 JOIN 和动态条件select idselectOrderUserPage resultTypecom.example.vo.OrderUserVO SELECT o.id, o.user_id AS userId, o.total_amount AS totalAmount, o.status, u.name AS userName, u.age AS userAge FROM orders o LEFT JOIN user u ON o.user_id u.id where if testvo.status ! null AND o.status #{vo.status} /if if testvo.userName ! null and vo.userName ! AND u.name LIKE CONCAT(%, #{vo.userName}, %) /if /where ORDER BY o.id DESC /select第三步Service 层调用时把Page对象传进去PageOrderUserVO page new Page(current, size); IPageOrderUserVO result orderMapper.selectOrderUserPage(page, queryVO);可以看到除了方法签名多了一个Page参数整个使用方式跟单表分页几乎没区别。分页插件会自动算出 total再把 records 填到Page对象里。这里我专门用了Param(vo) OrderUserVO vo作为条件传入对象而不是用 QueryWrapper。原因是跨表查询里条件可能落在关联表上比如按userName查订单这种条件用 Wrapper 很难表达自定义一个查询 VO 反而最清晰。这个方法在团队协作里也更好维护接口一眼就能看出支持哪些筛选条件。5.3 count 查询的翻车现场以及自救方法分页插件最坑的地方在 count 语句的生成上。JSQLParser 会尝试把原 SQL 改写成SELECT COUNT(*) FROM ...大多数简单 JOIN 没问题但在两类场景下会翻车。第一类是 SQL 里有GROUP BY。比如统计每个用户的订单数据Select(SELECT o.user_id, COUNT(*) AS orderCount, SUM(o.total_amount) AS totalAmount FROM orders o LEFT JOIN user u ON o.user_id u.id GROUP BY o.user_id) IPageMapString, Object selectGroupPage(Page? page);如果这里不做任何处理分页插件生成的 count 在某些版本下会变成SELECT COUNT(*) FROM orders o LEFT JOIN user u ON ... GROUP BY o.user_id执行出来的结果是每个分组一行MyBatis 取第一个值当 total总数就变错了。第二类是 SQL 里带DISTINCT且逻辑较复杂时count 改写也可能不符合预期。自救的办法是关掉 count 优化让插件用包一层子查询的方式兜底。代码里显式指定JsqlParserCountOptimize countOptimize new JsqlParserCountOptimize(); countOptimize.setOptimizeCountSql(false); PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setCountSqlParser(countOptimize);不同小版本 API 名字可能略有差异我测过 3.5.x 是有效的。关闭优化后 count 会变成SELECT COUNT(*) FROM (原SQL去order by) t这种子查询形式结果准确代价是性能稍差。在正确性优先于极致性能的管理系统场景里这个取舍非常值得。还有一个很实际的提醒JOIN 查询里所有条件字段都要写表别名前缀。举一个真实翻车例子——我在一个查询里用status 1没加前缀而orders表和user表恰好都没有歧义但后来某次需求给user表也加了status字段这个 SQL 立刻报Column status is ambiguous。所以从一开始写 JOIN 就要养成全字段带别名的习惯这是避免线上事故的一条铁律。5.4 统一分页返回的封装建议最后给一个分页返回的统一封装。直接往外抛IPage虽然能用但前端拿到的字段名不够友好而且容易把records、total、current、size这些叫法透出去。我习惯在 Service 层组装一个统一的结果Data public class PageResultT { private Long total; private Long pages; private Long current; private Long size; private ListT records; }转换代码很简单PageResultOrderUserVO result new PageResult(); result.setTotal(page.getTotal()); result.setPages(page.getPages()); result.setCurrent(page.getCurrent()); result.setSize(page.getSize()); result.setRecords(page.getRecords());这样前端对接时字段固定后端内部把 IPage 换成 List 也不会造成接口字段变化。到了这一步跨表查询从 SQL 书写、结果映射到分页返回整条链路就完整了。在我自己的项目里最终落地的组合基本是能预判的固定跨表查询全部走方案一 XML查询条件极简单时才用方案二临时取数或轻量动态场景用方案三凡是管理端列表页全部统一走方案四分页链路。这套组合跑了一年多没再为跨表查询挠过头。跨表问题本质上不是 MyBatis-Plus 没给你 API而是你的思维要从对象查询切回SQL 模型想清楚这个四种方案哪个顺手就用哪个。