做后端的朋友应该都有这种经历:需求文档上写着“列表页增加一列:下单用户名称”,听起来是个小需求,但你打开 MyBatis-Plus 的 BaseMapper,找遍所有方法都发现没有一个支持 JOIN。再看看实体类,里面根本没有 username 这个字段,一时间不知道这个跨表查询的结果到底该用什么来接。这其实就是 MyBatis-Plus 跨表查询最让人头疼的地方——SQL 大家都会写,难点全在“结果怎么返回”上。这篇文章就围绕这个痛点,把我自己项目中验证过、改过坑的 4 种方案完整整理出来:实体类扩展字段、VO+XML、Map 接收、@Select+Wrapper,每种都给出实战代码、适用场景和避坑要点,适合正在用 MyBatis-Plus 做业务开发、又被多表查询卡住的 Java 后端同学。
1. 先想清楚:MyBatis-Plus 跨表查询到底难在哪
1.1 真正的痛点不是“写不出SQL”,而是“结果怎么接”
MyBatis-Plus 的定位是单表 CRUD 增强,它生成的 selectById、selectList、page 这类方法,都是基于当前实体类映射的哪一张表。一旦牵涉到第二张表、第三张表,MP 自动生成的 SQL 就完全没法用了。很多人第一反应是“那我手写 SQL 不就行了”,可写完 SQL 之后马上会卡住:这个查询结果返回什么类型?
举个例子。订单表 tb_order 里只有 user_id,页面要显示用户名 username。你可以很轻松写出 LEFT JOIN 的 SQL,但查出来的结果里有 username,这个字段并不存在于 Order 实体里。如果直接返回 Order,username 必然丢失;如果返回 Map,代码里到处都是 map.get("username"),类型还不安全;如果新建一个 VO,又要考虑分页、条件构造器、字段映射,工作量一下子变大了。
所以说白了,MyBatis-Plus 跨表查询真正难的不是 SQL 本身,而是“结果返回”这套方案怎么设计。下面 4 种方案,本质上都是在回答同一个问题:多表查询的结果,用什么对象来接收、怎么接收最合理。
1.2 4种方案怎么选:先看懂场景再抄作业
这 4 种方案覆盖了从“临时能用”到“长期规范”的所有需求,各有各的适用面。我自己在项目里的选择逻辑基本是这样:
- 临时查个数据、报表统计、字段不固定:直接返回 Map,省事。
- 老模块小改动,只想少建一个类:在实体类里加
@TableField(exist = false)扩展字段。 - 新功能、要长期维护、列表要分页:老老实实建 VO,写 XML。
- SQL 就一两条、不想建 XML 文件:用 @Select 注解,配合 Wrapper 条件。
下面每种方案我都会给出能直接复制跑的代码,也会把“为什么这么写”和“哪里容易踩坑”讲清楚。先提醒一句:别急着抄,先看完每个方案的边界,因为你今天觉得“方便”的方案,可能过两周就变成维护噩梦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方法一:实体类扩展字段,简单联查填进去
2.1 用@TableField(exist = false)给实体“加列”
第一种方案最容易理解:既然 Order 实体里没有 username,那就在实体类里手动加一个 username 字段,再打上 @TableField(exist = false)。这个注解的含义是告诉 MyBatis-Plus:这个字段在数据库表里不存在,你生成任何自动 CRUD SQL 时都忽略它,但它依然是一个普通的 Java 属性,可以正常赋值、读取。
java复制@Data
@TableName("tb_order")
public class Order {
@TableId
private Long id;
private Long userId;
private BigDecimal amount;
/**
* 非数据库字段,仅用于接收跨表查询结果
*/
@TableField(exist = false)
private String username;
}
这么做的优势很直接:不用新建任何类,在原来实体上加一个字段,联查结果直接映射到实体属性里,Service 层返回对象时前端拿到的 JSON 里就自然带着 username。如果你的项目里跨表查询就一两个字段,这个方案确实能让你在十分钟内搞定问题。
2.2 实操演示:订单联查用户名,XML里怎么写
实体类准备好了,接下来就要写一个自定义 SQL。注意,MP 的 BaseMapper 里所有自动方法都不会去关联用户表,所以必须自己写一个 Mapper 方法。
先在 OrderMapper 里加一个接口方法:
java复制public interface OrderMapper extends BaseMapper<Order> {
Order selectOrderWithUser(@Param("id") Long id);
}
然后在 resources/mapper 目录下建 OrderMapper.xml:
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.OrderMapper">
<select id="selectOrderWithUser" resultType="com.example.entity.Order">
SELECT o.*, u.username AS username
FROM tb_order o
LEFT JOIN tb_user u ON o.user_id = u.id
WHERE o.deleted = 0
AND u.deleted = 0
AND o.id = #{id}
</select>
</mapper>
这里有两个重点要特别说明。第一,resultType 直接写 Order 实体,MyBatis 会按照字段名自动映射,u.username AS username 就是为了让查询结果里的列名和实体属性名保持一致。第二,SQL 里千万不要漏掉 o.deleted = 0 和 u.deleted = 0,因为一旦用了自定义 SQL,MP 的逻辑删除自动过滤就失效了,这一点后面专门会讲。
2.3 这种方案的边界:一眼能看穿的小问题
这个方案虽然简单,但它的坑也很明显。最突出的问题是实体类会被“撑大”:今天加 username,明天可能加 userPhone,后天再加 orderCount,一个订单实体越塞越多,最后数据库表字段和查询附加字段混在一起,别人看代码时根本分不清哪些是真实列、哪些是临时数据。
另外,同一个实体如果要在不同模块里返回不同的附加字段,就没法处理了。比如订单列表页要带用户名,导出报表要带用户手机号,订单详情页要带商品名称。如果你都用 Order 实体来接,那这些字段只能全部堆在 Order 里,查询接口要么一次查全,要么还得再写其他 VO。到了这一步,第一个方案就开始变得非常别扭。
所以我的建议是:这个方案只适合“字段少、场景单一、没有长期维护压力”的临时需求。稍微正规一点的项目,建议直接看第二种方案。
3. 方法二:VO类 + XML,跨表查询的标准姿势
3.1 为什么要搞VO:实体类不该背锅
实体类的职责是映射数据库表结构,它应该跟表字段一一对应。而跨表查询的结果是多个表拼出来的“展示数据”,本质上是给前端接口用的,不应该强行塞进单表实体里。所以更规范的做法是单独建一个 VO(View Object),把列表页、详情页需要展示的字段全部定义清楚。
这样做有几个现实的好处:第一,Order 实体保持干净,不会被无关字段污染;第二,VO 的字段可以完全按前端需求来设计,比如金额格式化之后放到 String 里、用户名和手机号拼成一个字段;第三,不同接口可以用不同 VO,互不影响。对长期维护的项目来说,这个收益是实打实的。
我见过很多项目初期偷懒用方案一,到后期实体类膨胀到几十个字段,加需求都不敢动老代码。与其后面返工,不如一开始就建一个 VO。
3.2 自定义XML + mapper-locations配置
先定义一个订单和用户信息的 VO:
java复制@Data
public class OrderUserVO {
private Long orderId;
private Long userId;
private BigDecimal amount;
private String username;
private String userPhone;
}
然后在 OrderMapper 里声明方法:
java复制public interface OrderMapper extends BaseMapper<Order> {
List<OrderUserVO> selectOrderUserList(@Param("userId") Long userId);
}
在 OrderMapper.xml 里写联查 SQL:
xml复制<mapper namespace="com.example.mapper.OrderMapper">
<select id="selectOrderUserList" resultType="com.example.vo.OrderUserVO">
SELECT o.id AS orderId,
o.user_id AS userId,
o.amount AS amount,
u.username AS username,
u.phone AS userPhone
FROM tb_order o
LEFT JOIN tb_user u ON o.user_id = u.id
WHERE o.deleted = 0
AND u.deleted = 0
<if test="userId != null">
AND o.user_id = #{userId}
</if>
</select>
</mapper>
这里值得多说一句的是 resultType 和列别名。数据库列名通常是小写加下划线,比如 user_id,而 Java 属性是驼峰,比如 userId。MyBatis-Plus 默认开启了 map-underscore-to-camel-case,理论上你可以不写别名让它自动转换,但多表联查时很容易出现两个表都有 id、都有 name 这类同名字段,一旦 SELECT * 或者列名不明确,映射就会错乱。所以我习惯在跨表 SQL 里把每一列都用别名明确指定,宁可多写几个字,也别让映射靠猜。
使用这个自定义查询时,还需要确保 Spring Boot 能找到 XML 文件。在 application.yml 里这样配置:
yaml复制mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
只要你的 XML 文件放在 resources/mapper 目录下,这个配置就能让 MyBatis 在启动时自动加载。如果你把 XML 和 Mapper 接口放在同一个 java 包下面,配置方式不一样,这个问题放到后面“两个热搜问题”章节专门讲。
3.3 加分页:MP分页插件怎么和自定义SQL一起用
跨表查询十有八九要分页。MP 的分页插件对自定义 SQL 是支持的,前提是你把插件配置好。先加一个配置类:
java复制@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
然后在 Mapper 方法里把返回类型和参数都改成 IPage:
java复制IPage<OrderUserVO> selectOrderUserPage(Page<?> page, @Param("userId") Long userId);
XML 里 SQL 不用写 LIMIT,分页插件会自动拦截并生成分页 SQL:
xml复制<select id="selectOrderUserPage" resultType="com.example.vo.OrderUserVO">
SELECT o.id AS orderId,
o.user_id AS userId,
o.amount AS amount,
u.username AS username,
u.phone AS userPhone
FROM tb_order o
LEFT JOIN tb_user u ON o.user_id = u.id
WHERE o.deleted = 0
AND u.deleted = 0
<if test="userId != null">
AND o.user_id = #{userId}
</if>
</select>
Service 层调用的时候,把页码、页大小传进去就行:
java复制Page<OrderUserVO> page = new Page<>(1, 10);
IPage<OrderUserVO> result = orderMapper.selectOrderUserPage(page, userId);
分页插件会先执行一条 count 查询计算总数,再执行带 LIMIT 的列表查询。需要注意一点:如果 JOIN 的表特别多、SQL 很复杂,插件自动生成的 count SQL 有可能出问题,这个我在第 7 章会专门讲排查方法。
3.4 结果映射的三个常见坑
用 VO + XML 方案,我踩过的坑主要有三个。
第一个,resultType 写错。XML 里 resultType 必须写 VO 类的全限定名,比如 com.example.vo.OrderUserVO,少写包名会直接报 ClassNotFoundException。如果不确定,可以先在 IDE 里按住 Ctrl 点进去确认路径。
第二个,VO 没有无参构造或者字段没有 setter。MyBatis 默认通过无参构造创建对象,再调用 setter 填充属性。如果你在 VO 里写了带参构造却没写无参构造,映射的时候就会报错或者全是 null。Lombok 的 @Data 本身会生成全参、无参吗?其实 @Data 不生成构造器,只有 @NoArgsConstructor 才生成无参构造,所以 VO 上我习惯同时加 @Data 和 @NoArgsConstructor。
第三个,数据库字段和 VO 字段对不上。跨表查询经常出现两个表都有 name、id 这种列,如果不用别名区分,最终结果要么覆盖、要么报错。比如查询结果里同时有 u.id 和 o.id,两个列名都叫 id,MyBatis 映射到 VO 时后面一个会覆盖前面一个,数据就乱了。所以我在联查 SQL 里基本不用 SELECT *,每一列都用别名明确指定。
4. 方法三:返回Map,临时表和报表场景的利器
4.1 Map当返回类型,能有多方便
第三种方案是返回 List<Map<String, Object>>。SQL 的 resultType 直接写成 map,MyBatis 会把查询结果按“列名 -> 值”封装成一个 Map。这个方案在写统计报表、数据看板、临时导出这类需求时特别方便,因为结果列不固定,今天要加一列统计字段,明天要改一个聚合维度,用 POJO 封装反而要跟着改类。
举个例子,我们要统计每个用户的订单数和订单总金额:
xml复制<select id="selectOrderStats" resultType="map">
SELECT u.username AS username,
COUNT(o.id) AS orderCount,
IFNULL(SUM(o.amount), 0) AS totalAmount
FROM tb_order o
LEFT JOIN tb_user u ON o.user_id = u.id
WHERE o.deleted = 0
AND u.deleted = 0
GROUP BY u.id, u.username
</select>
对应的 Mapper 方法:
java复制List<Map<String, Object>> selectOrderStats();
Service 层拿到结果后直接用就行:
java复制List<Map<String, Object>> stats = orderMapper.selectOrderStats();
for (Map<String, Object> row : stats) {
String username = (String) row.get("username");
Long orderCount = (Long) row.get("orderCount");
BigDecimal totalAmount = (BigDecimal) row.get("totalAmount");
}
这种写法在报表需求里非常灵活,SQL 查出来是什么列,Map 里就有什么 key,不需要预定义任何类。
4.2 Map模式避坑:驼峰、null、类型转换
Map 返回看着方便,坑也不少,这里挑三个最常见的说。
第一,key 的问题。MySQL 驱动返回的列名默认是你在 SQL 里写的别名,大小写基本按原始定义。如果你不写别名,返回的 key 可能是 user_id 而不是 userId,因为 Map 结果集一般不会自动帮你做驼峰转换。所以用 Map 方案时,SQL 里的列名一定要自己起好别名,并且拿到 key 时要严格匹配,否则 get 回来就是 null。这一点我在项目里被坑过一次,排查了半天才发现是别名没统一。
第二,null 值问题。用了 SUM、AVG 这类聚合函数时,如果分组内没有数据,结果可能是 null,而不是 0。比如上面那个语句,一旦某用户没有任何有效订单,SUM(o.amount) 返回 null,Map 里这个 key 的值就是 null,前端序列化后要么缺字段、要么显示 null。我的习惯是在 SQL 层用 IFNULL 处理,或者在代码里统一判空。
第三,类型转换问题。COUNT 的结果在 MyBatis 里通常是 Long,SUM 的结果一般是 BigDecimal,拿到 Map 里如果直接强转 Integer 就会报 ClassCastException。不要嫌麻烦,该转的转,该判断的判断。Map 方案最大的缺陷就在这里:没有编译期类型检查,任何字段改动都可能要到运行时才暴露。
4.3 什么情况下别用Map
Map 虽好,但不能滥用。如果你的接口是给前端页面用的,返回结构相对固定,我强烈不建议用 Map。原因很简单:可读性太差。前端同事拿到一个 Map 类型的 VO,根本不知道里面有哪些 key、值是什么类型,只能靠后端口头告知或者翻 SQL;后端自己维护代码时,想找到一个字段的来源也很费劲。
另外,涉及动态条件构造时,Map 方案通常要配合 XML 里的 <if> 标签手动拼条件,条件一多 SQL 会变得非常难维护。所以我的原则是:Map 只用于内部的数据统计、报表导出、临时排查等场景,凡是要进接口文档、要长期维护的跨表查询,一律用 VO。
5. 方法四:注解SQL + Wrapper,轻量联查的隐藏玩法
5.1 @Select里写JOIN,省掉XML文件
除了 XML,MyBatis 还支持在 Mapper 接口的方法上用 @Select、@Insert 等注解直接写 SQL。这个方案适合 SQL 条数少、不想建 XML 文件、项目结构比较轻量的情况。比如还是那个订单联查用户名的需求,可以这样写:
java复制public interface OrderMapper extends BaseMapper<Order> {
@Select("SELECT o.*, u.username AS username " +
"FROM tb_order o " +
"LEFT JOIN tb_user u ON o.user_id = u.id " +
"WHERE o.deleted = 0 AND u.deleted = 0 AND o.id = #{id}")
Order selectOrderWithUser(@Param("id") Long id);
}
效果和 XML 是一模一样的,只是省了一个文件。对于一两条固定 SQL 来说,这种方式确实干净利落。不过一旦 SQL 变长、条件变多,注解里写字符串拼接会非常痛苦,没有 XML 的格式化、提示,更要命的是动态条件在注解里写起来极其别扭,需要套 <script> 标签,可读性会急剧下降。
5.2 把 QueryWrapper 传进自定义SQL
注解方案真正的进阶玩法是配合 MyBatis-Plus 的 Wrapper 条件构造器,把动态查询条件传进自定义 SQL。做法是 Mapper 方法加一个 @Param(Constants.WRAPPER) 参数,然后在 SQL 里拼 ${ew.customSqlSegment}:
java复制@Select("SELECT o.*, u.username AS username " +
"FROM tb_order o " +
"LEFT JOIN tb_user u ON o.user_id = u.id " +
"${ew.customSqlSegment}")
List<Order> selectOrderWithUser(@Param(Constants.WRAPPER) Wrapper<Order> wrapper);
调用的时候,条件全部通过 QueryWrapper 构造:
java复制QueryWrapper<Order> wrapper = new QueryWrapper<>();
wrapper.eq("o.deleted", 0)
.eq("u.deleted", 0)
.eq("o.user_id", userId)
.ge("o.amount", 100);
List<Order> list = orderMapper.selectOrderWithUser(wrapper);
这里最关键的一点是 ${ew.customSqlSegment} 会自带 WHERE 关键字,所以 SQL 里不要再手动写 WHERE,否则会生成 WHERE WHERE 这种语法错误。另外,多表查询时 QueryWrapper 里传的列名要带表别名,比如 o.user_id 而不是 user_id,否则 SQL 里出现歧义列时数据库会直接报错。我建议这种情况下直接用普通 QueryWrapper 配合字符串列名,别硬上 LambdaQueryWrapper,因为 Lambda 方式在多表场景下很难表达表别名。
5.3 注解方案什么时候会翻车
注解方案在简单场景下很香,但我也见过不少项目因为滥用注解而翻车的。第一种情况是动态条件复杂,比如需要 <foreach> 遍历集合、需要 choose 多分支判断,这时候注解里套 <script> 标签,SQL 和脚本混在一起,维护体验非常差。第二种情况是 SQL 太长,一个 JOIN 查询写了十几行,没有格式化,肉眼根本没法快速看出逻辑。第三种情况是团队协作,有经验的后端看一眼注解方法可能还行,新手接手这种代码,第一反应往往是崩溃。
所以我给团队定的规矩是:超过 3 行或者有条件分支的 SQL,一律用 XML;只有那种一个方法一行 SQL 就能讲清楚的固定查询,才允许用注解。这条规矩执行下来,后期代码维护省了非常多力气。
6. 两个热搜问题一起答:deleted过滤和XML配置
6.1 自定义联查时deleted=0千万别忘了
MyBatis-Plus 的逻辑删除是通过 @TableLogic 注解实现的,框架会在自动生成的 select、update、delete 语句中自动追加 deleted = 0 之类的条件。但请注意:这个自动追加只对 MP 自动生成的方法有效,一旦你写了自定义 SQL,不管是 XML 还是注解,MP 都不会帮你在 JOIN 语句里自动加逻辑删除条件。
很多新手都会在这里踩坑:直接用 MP 的 selectList 查订单没问题,一到自定义联查就把已删除的数据也带出来了。原因就是你自己写的 SQL 里没有加 deleted 过滤。所以一旦涉及多表联查,每张参与查询的表都要在 WHERE 里显式加上对应的逻辑删除条件:
sql复制WHERE o.deleted = 0
AND u.deleted = 0
如果用的是方案四那种传 Wrapper 的方式,也可以把这两个条件写进 Wrapper:
java复制wrapper.eq("o.deleted", 0).eq("u.deleted", 0);
但这里有个坑:如果你同时在 SQL 里写了 o.deleted = 0,又在 Wrapper 里写了同样的条件,虽然 SQL 不会报错,但条件重复、代码看着很啰嗦。所以自定义联查时逻辑删除条件的加法和位置,最好在项目里统一一种写法,不要这次写 SQL、下次写 Wrapper,否则真的很难排查。
6.2 xml与mapper在同一个文件夹下的配置正确姿势
这个问题是很多初学者都会问的:“我不想把 XML 放到 resources 目录,想和 Mapper 接口放在同一个 java 包里,该怎么配置?”
先解释一下为什么会有这个问题。Spring Boot 项目默认打包时,只会处理 src/main/resources 目录下的资源文件,而 src/main/java 目录下编译器只会把 .java 文件编译成 .class,XML 文件不会自动复制到 target/classes 里。结果就是你本地 IDEA 跑着好好的,一打成 jar 包部署到服务器,就报 Invalid bound statement (not found)。
解决方案有两种。
第一种,最推荐,把 XML 放到 src/main/resources/mapper/ 目录下,然后在 application.yml 里配置:
yaml复制mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
这样 XML 会被正常打进 jar 包,扫描也没问题。项目结构清晰,大家都容易理解,强烈建议默认都用这种方式。
第二种,如果你非得把 XML 和 Mapper 接口放同一个目录,需要在 pom.xml 里手动把 XML 作为资源打包:
xml复制<build>
<resources>
<resource>
<directory>src/main/java</directory>
<includes>
<include>**/*.xml</include>
</includes>
</resource>
</resources>
</build>
打完包之后,mapper-locations 可以写成 classpath*:com/example/mapper/**/*.xml,具体包名按你项目实际路径写。需要注意,加了这段配置之后,一定要重新 mvn clean package,然后去 target/classes 里确认 XML 到底有没有被复制进去,这是最直接的验证方式。我见过不少人清了缓存、改了配置还是不生效,最后发现就是 target 目录里的旧文件压根没刷新。
6.3 顺带提一嘴:多表时逻辑删除的“漏网之鱼”
还有一个很容易被忽略的点:多表联查时,不仅要考虑主表的逻辑删除,还要考虑关联表的逻辑删除策略。还是订单查用户那个例子,主表订单没删除,但关联的用户已经注销了(deleted=1),这时候 JOIN 结果该怎么处理?如果需求是“订单列表始终显示所有订单,用户注销了显示‘已注销’”,那你应该用 LEFT JOIN,并且不要过滤 u.deleted,或者换个业务字段标记。如果需求是“已经注销的用户下的单不用显示”,那才需要加 u.deleted = 0。
不少人写联查 SQL 时就一股脑把所有表的 deleted=0 都加上,结果把本来该显示的数据过滤掉了。到底过滤哪张表的逻辑删除,取决于业务语义,这个选择一定要在写 SQL 之前想清楚。我一般会先在需求里确认主表和关联表之间的保留策略,再决定 SQL 怎么写。
7. 常见问题与排查技巧实录
7.1 联查出来的字段全是null
如果 SQL 单独在数据库客户端跑能查出数据,但在项目里通过 Mapper 查出来全是 null,优先排查这几个点:
第一,resultType 是否写对。XML 里 resultType 要写 VO 的全限定名,别简写。第二,VO 是否有无参构造和 setter。用 Lombok 记得加 @NoArgsConstructor 或者直接 @Data 加 @NoArgsConstructor。第三,数据库列名和 Java 属性名是否能对上。如果你没在 SQL 里写别名,就依赖驼峰转换,一定要确认 map-underscore-to-camel-case 是否开启。第四,查一下 target/classes 里编译出来的 VO 是不是旧版本,IDEA 热部署有时候会坑人,重启一遍往往就好了。
排查这类问题时,最有效的方法是先在日志里打开 MyBatis SQL 输出,看 SQL 实际查出了哪些列,再对比实体字段,问题基本一目了然:
yaml复制mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
7.2 分页总数不对或SQL报错
自定义 SQL 用 MP 分页插件时,最常见的报错是 Unknown column 或者 count 总数不对。原因大多是分页插件自动生成的 count SQL 无法正确处理复杂的多表 JOIN。比如你给多个表都用了同样名字的列,插件生成的 count 语句可能带上歧义列,数据库就会报错。
解决方法有两个。一是尽量控制 JOIN 数量,不要一张查询关联五六张表,太多表时优化 SQL 本身、拆成多次查询可能更好。二是如果 count SQL 确实跑不对,可以在 Mapper 里自己写一个 count 方法,或者用 page 的 setSearchCount(false) 关掉自动查询总数,再手动查总数设置回去。不过 setSearchCount 关掉后总数就没了,所以更稳妥的办法还是自己写 count 查询:
xml复制<select id="countOrderUser" resultType="long">
SELECT COUNT(*)
FROM tb_order o
LEFT JOIN tb_user u ON o.user_id = u.id
WHERE o.deleted = 0 AND u.deleted = 0
</select>
另外,如果报错信息是 There is no getter for property named 'ew',那基本可以确定是 Mapper 方法里没有加 @Param(Constants.WRAPPER),或者 XML 里写的是 ew 而参数名不是 ew。注意 Constants.WRAPPER 的值就是字符串 "ew",两者要一致。
7.3 联表后字段冲突
多表联查很常见的问题是两个表都有 id、name 这种通用字段。当 resultType 是 VO 时,MyBatis 映射同名列时后面的值会覆盖前面的值,导致数据显示错乱。我们项目里出现过订单 ID 被用户 ID 覆盖的案例,查了半天发现是 SELECT o., u. 惹的祸。
解决方法就一条:跨表查询不要用 SELECT *,所有列都显式写并起别名。你可以在 SQL 里写成:
sql复制SELECT o.id AS orderId,
u.id AS userId,
o.amount AS amount
这样既解决了映射冲突,又让 SQL 意图一目了然。尤其是在返回 Map 这种场景,别名就是 Map 里的 key,写好别名等于定义了接口文档。
7.4 性能:跨表查询不背锅,N+1才要命
最后说一个经验层面的问题。很多同学遇到性能问题,第一反应是“跨表 JOIN 太慢”,其实真正拖垮系统的往往是 N+1 查询。什么叫 N+1?就是你先查了 10 条订单,然后在循环里再逐个查用户信息,一次列表请求发出 11 条、甚至 11 条以上的 SQL。这种写法的伤害比一条 JOIN 大得多。
正确做法是把 SQL 合并成一次 JOIN,或者在需要查询用户信息时,先用订单表拿到去重后的 user_id 集合,再用 IN 查询一次用户表,最后在内存里组装。这两种方式都比循环查询好。
再有一个性能细节是 JOIN 的时候,关联字段上一定要有索引。tb_order.user_id、tb_user.id 这种关联字段如果没建索引,数据量一大,JOIN 查询会直接锁死整张表或者非常慢。建了索引之后,同样的 SQL 可能从几秒降到几十毫秒。
最后分享一个我自己的使用习惯
如果让我只保留一种方案长期用,我肯定选 VO + XML,因为跨表查询一旦多了,只有 VO 能把各种查询结果隔离开,代码结构最清晰。Map 我只在报表和临时统计里用,而且一定在 SQL 里把每个别名都写清楚。实体类扩展字段这种玩法,基本只出现在历史代码里,新代码我尽量不碰。至于 @Select + Wrapper,适合那种逻辑很简单的固定查询,复杂一点就乖乖用 XML。踩过几次坑之后你会明白:MyBatis-Plus 的跨表查询,工作量从来不在 SQL 上,而在于你选的结果返回方式够不够“扛造”。
