做后端的朋友应该都有这种经历:需求文档上写着“列表页增加一列:下单用户名称”,听起来是个小需求,但你打开 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,一个订单实体越塞越多,最后数据库表字段和查询附加字段混在一起,别人看代码时根本分不清哪些是真实列、哪些是临时数据。
另外,同一个实体如果要在不同模块里返回不同的
