1. 为什么单靠QueryWrapper搞不定多表查询
1.1 一个真实的需求场景
先说一个我最近接到的需求。后台管理系统要做一个订单列表页,展示字段包括订单号、下单时间、客户姓名、客户手机号、商品名称、商品单价、购买数量、订单总金额。数据分散在四张表里:t_order、t_customer、t_order_item、t_product。前端还要支持按客户姓名模糊搜索、按下单时间范围筛选、按订单状态过滤,并且要分页。
如果用最原始的方式,要么手写一大段 JOIN 的 XML,要么在 Java 里查完订单再循环查客户和商品——前者维护起来痛苦,后者就是经典的 N+1 问题。MyBatis-Plus 的QueryWrapper单表用起来很爽,但一碰到多表 JOIN,很多人第一反应是"这玩意儿不支持吧",然后乖乖回去写 XML。
其实不是不支持,而是需要换一个思路:把 QueryWrapper 当作条件构造器,把多表 SQL 的骨架交给@Select注解,两者通过${ew.customSqlSegment}这个占位符缝合起来。这套方案我在三个项目里用过,稳定、可维护,而且分页插件照样生效。
1.2 核心思路一句话讲清
传统 XML 写法是这样的:在 Mapper 接口里定义方法,在 XML 里写<select>标签,用<if test="...">拼条件。这套东西能用,但条件一多,XML 就变成了一棵巨大的 if 树,改一个字段要在 Java 和 XML 之间来回跳。
新思路是把"条件拼接"这件事完全交给 QueryWrapper,SQL 的 SELECT 部分和 JOIN 部分写死在@Select注解里,条件部分用${ew.customSqlSegment}占位。MyBatis-Plus 在执行时会把这个占位符替换成 QueryWrapper 生成的条件片段(包括 WHERE、ORDER BY 等)。这样 Java 代码里只操作 Wrapper,SQL 骨架保持干净。
打个比方:@Select注解负责画好一张表格的框架和表头,QueryWrapper 负责往格子里填筛选条件。两者分工明确,谁也不越界。
1.3 这套方案适合谁、解决什么问题
适合的人群很明确:已经在用 MyBatis-Plus、不想为了多表查询退回纯 XML、又希望条件构造保持类型安全和链式调用的开发者。如果你还在用 JPA 或者纯 MyBatis,这套方案的核心思想(SQL 骨架与条件分离)同样有参考价值。
它解决的核心痛点有三个:一是多表 JOIN 条件下 XML 维护成本高;二是分页插件在自定义 SQL 里容易失效;三是条件复用困难,同一个查询条件在不同接口里要重复写。下面我会逐个拆解怎么解决。
2. 方案整体设计与关键选型考量
2.1 为什么选 @Select 注解而不是 XML
MyBatis-Plus 支持两种写自定义 SQL 的方式:XML 映射文件和注解。多表查询场景下我优先选注解,原因有三。
第一,注解和 Mapper 接口方法定义在一起,看方法签名就能看到 SQL,不用在两个文件之间跳转。对于中等复杂度的多表查询(JOIN 三到五张表),注解的可读性完全够用。
第二,注解方式天然支持${ew.customSqlSegment}占位符,和 QueryWrapper 配合是无缝的。XML 里虽然也能用,但配置起来多一层。
第三,注解方式下分页插件的兼容性更好处理。MyBatis-Plus 的分页拦截器会识别方法参数里的IPage对象,自动改写 SQL 加上 LIMIT。注解方式下这个机制工作得很稳定。
当然,如果 SQL 超过 50 行、嵌套子查询特别多,那还是老老实实写 XML。注解适合的是"骨架清晰、条件多变"的场景,这恰好是多表查询的典型特征。
2.2 ${ew.customSqlSegment} 到底做了什么
这个占位符是整个方案的核心,必须讲清楚它的行为,否则很容易踩坑。
当 Mapper 方法里有一个名为ew的Wrapper参数时,MyBatis-Plus 会拦截这次查询,调用 Wrapper 的getCustomSqlSegment()方法,生成一段类似WHERE (customer_name LIKE ? AND status = ?) ORDER BY create_time DESC的 SQL 片段,然后替换掉${ew.customSqlSegment}。
注意几个关键点:
- 占位符名字里的
ew不是固定的,它对应的是方法参数里 Wrapper 的参数名。如果你把参数命名为wrapper,那占位符就得写${wrapper.customSqlSegment}。我习惯统一用ew,因为这是 MyBatis-Plus 官方示例里的约定,团队协作时不容易乱。 - 生成的条件片段自带 WHERE 关键字。所以你的
@Select里不要再写 WHERE,否则会变成WHERE WHERE。 - 如果 Wrapper 里没有任何条件,
customSqlSegment会返回空字符串,SQL 依然合法。这一点很关键,意味着你可以放心地传一个空 Wrapper。 - 它生成的是
${}拼接而非#{}预编译,但因为 Wrapper 内部的值最终是通过#{}参数化的,所以不存在 SQL 注入风险。真正被拼接进 SQL 的只有字段名和关键字,值都是占位符。
2.3 分页为什么容易失效,怎么保证不失效
这是热词里出现频率很高的问题:"mybatisplus分页失效"。在多表自定义 SQL 场景下,分页失效通常有三个原因。
第一个原因是方法签名里没有IPage参数。分页拦截器靠识别IPage类型的参数来决定是否改写 SQL。如果你只传了 Wrapper 没传 IPage,拦截器根本不知道你要分页。
第二个原因是@Select里的 SQL 被拦截器判定为"无法安全改写"。比如 SQL 里有UNION、复杂的子查询、或者SELECT后面跟了聚合函数,拦截器可能放弃改写。解决办法是尽量让外层 SQL 结构简单,把复杂逻辑放到子查询里。
第三个原因是count查询慢。热词里也提到了"select count 就会很慢"。多表 JOIN 的 count 查询会扫描所有关联表,数据量大时非常慢。我的做法是给 count 查询单独写一个简化版的 SQL,只 JOIN 必要的表,或者用optimizeCountSql配置让插件自动优化。
// 分页配置,关键是开启 count 优化 @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); // 开启 count 的 join 优化,只针对部分 left join pagination.setOptimizeJoin(true); interceptor.addInnerInterceptor(pagination); return interceptor; }2.4 参数命名与返回类型的约定
为了让这套方案在团队里可复制,我定了几条约定,实测下来能减少大量沟通成本。
Mapper 方法里,Wrapper 参数统一命名为ew,分页参数统一命名为page。返回类型如果是分页,用IPage<VO>;如果是列表,用List<VO>。VO 类专门用于接收多表查询结果,字段名和 SQL 里的别名一一对应,不要复用 Entity。
@Select("SELECT o.order_no, o.create_time, c.customer_name, c.phone, " + "p.product_name, i.price, i.quantity " + "FROM t_order o " + "LEFT JOIN t_customer c ON o.customer_id = c.id " + "LEFT JOIN t_order_item i ON i.order_id = o.id " + "LEFT JOIN t_product p ON i.product_id = p.id " + "${ew.customSqlSegment}") IPage<OrderDetailVO> selectOrderDetailPage(IPage<OrderDetailVO> page, @Param("ew") QueryWrapper<OrderDetailVO> ew);这段代码是整个方案的模板,后面所有变体都从这里衍生。
3. 核心细节解析与实操要点
3.1 Wrapper 里字段名必须写数据库列名
这是新手最容易踩的坑。用QueryWrapper单表查询时,你可以写eq("customerName", name),MyBatis-Plus 会自动帮你做驼峰转下划线。但在${ew.customSqlSegment}场景下,这个自动转换不会发生,因为插件不知道你的字段属于哪张表,无法推断映射关系。
所以多表查询时,Wrapper 里的字段名必须直接写数据库列名,而且要带上表别名。
QueryWrapper<OrderDetailVO> ew = new QueryWrapper<>(); ew.like("c.customer_name", keyword); ew.eq("o.status", status); ew.ge("o.create_time", startTime); ew.le("o.create_time", endTime); ew.orderByDesc("o.create_time");如果你写ew.like("customerName", keyword),生成的 SQL 会是WHERE (customerName LIKE ?),数据库直接报"未知列"。这个错误我第一次用的时候卡了半小时,因为报错信息不会告诉你"应该写列名",只会说列不存在。
提示:团队里最好在 VO 类的字段上加注释,标明对应的数据库列名和表别名,避免有人写错。
3.2 条件为空时的处理逻辑
实际业务里,筛选条件经常是"用户填了才筛,没填就不筛"。QueryWrapper 的链式调用天然支持这个:条件方法都有重载版本,第一个参数是boolean condition,为 false 时该条件不加入 SQL。
ew.like(StringUtils.isNotBlank(keyword), "c.customer_name", keyword); ew.eq(status != null, "o.status", status); ew.ge(startTime != null, "o.create_time", startTime);这样写比在外面套一堆if干净得多。我见过有人这么写:
// 不推荐 if (StringUtils.isNotBlank(keyword)) { ew.like("c.customer_name", keyword); } if (status != null) { ew.eq("o.status", status); }两种写法功能一样,但前者更紧凑,而且条件一多优势就明显了。不过要注意,condition为 false 时,后面的参数依然会被求值,所以如果参数是方法调用(比如getStatus()),要确保它不会抛异常。
3.3 动态排序与多字段排序
排序也是多表查询的常见需求。前端可能传sortField和sortOrder,后端要动态拼 ORDER BY。这里有个安全考量:排序字段不能直接拼接用户输入,否则就是 SQL 注入。
我的做法是维护一个白名单,把前端传的字段名映射到数据库列名。
private static final Map<String, String> SORT_FIELD_MAP = Map.of( "createTime", "o.create_time", "amount", "o.total_amount", "customerName", "c.customer_name" ); String column = SORT_FIELD_MAP.get(sortField); if (column != null) { ew.orderBy(true, "asc".equalsIgnoreCase(sortOrder), column); }orderBy方法的签名是orderBy(boolean condition, boolean isAsc, String column),第二个参数控制升序还是降序。多字段排序就多次调用,会按调用顺序拼接。
3.4 用 LambdaQueryWrapper 还是 QueryWrapper
MyBatis-Plus 提供了LambdaQueryWrapper,用方法引用代替字符串字段名,编译期就能发现拼写错误。但在多表场景下,它有个致命限制:Lambda 表达式只能引用实体类的字段,无法表达表别名。
比如你想写c.customer_name,Lambda 写法是Customer::getCustomerName,生成的 SQL 是customer_name,没有表别名。如果多张表有同名列(比如都有create_time),就会报"列名歧义"。
所以多表查询我统一用普通QueryWrapper,接受字符串字段名带来的拼写风险,用单元测试来兜底。单表查询才用 Lambda 版本。
3.5 参数传递的几种方式对比
@Select注解里引用参数有几种写法,各有适用场景。
| 写法 | 示例 | 适用场景 | 注意事项 |
|---|---|---|---|
@Param命名 | @Param("ew") QueryWrapper ew | Wrapper 参数 | 占位符名必须与 Param 值一致 |
@Param命名 | @Param("status") Integer status | 固定条件参数 | 用#{status}引用 |
| 对象属性 | @Param("query") QueryDTO query | 多参数封装 | 用#{query.status}引用 |
| 集合 | @Param("ids") List<Long> ids | IN 查询 | 配合<foreach>或 Wrapper 的 in |
Wrapper 参数必须用@Param显式命名,否则 MyBatis 无法确定参数名,${ew.customSqlSegment}会解析失败。这一点和普通参数不同,普通参数在单参数时可以省略@Param。
4. 完整实操过程与核心环节实现
4.1 环境准备与依赖配置
先确认依赖版本。这套方案依赖 MyBatis-Plus 3.4.0 以上版本,customSqlSegment在更早版本里行为不一致。我用的是 3.5.3.1,稳定。
<dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency>分页插件必须配置,否则IPage参数不生效。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination = new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(500L); // 单页最大 500 条 pagination.setOptimizeJoin(true); interceptor.addInnerInterceptor(pagination); return interceptor; } }setMaxLimit(500L)是热词里"接触 mybatisplus 单页 500 条限制"的来源。这个配置是主动设的,防止前端传个size=100000把数据库拖垮。如果不设,默认是不限制的。
4.2 定义 VO 与 Mapper 接口
VO 类的字段要和 SQL 里的别名严格对应。我习惯用@Data加 Lombok,字段名用驼峰,SQL 别名用下划线,MyBatis 的mapUnderscoreToCamelCase会自动转换。
@Data public class OrderDetailVO { private String orderNo; private LocalDateTime createTime; private String customerName; private String phone; private String productName; private BigDecimal price; private Integer quantity; private BigDecimal totalAmount; }Mapper 接口定义方法。注意IPage参数要放在第一个,这是 MyBatis-Plus 的约定,虽然放后面也能工作,但放第一个可读性最好。
public interface OrderMapper extends BaseMapper<Order> { @Select("SELECT o.order_no, o.create_time, c.customer_name, c.phone, " + "p.product_name, i.price, i.quantity, " + "(i.price * i.quantity) AS total_amount " + "FROM t_order o " + "LEFT JOIN t_customer c ON o.customer_id = c.id " + "LEFT JOIN t_order_item i ON i.order_id = o.id " + "LEFT JOIN t_product p ON i.product_id = p.id " + "${ew.customSqlSegment}") IPage<OrderDetailVO> selectOrderDetailPage(IPage<OrderDetailVO> page, @Param("ew") QueryWrapper<OrderDetailVO> ew); }这里有个细节:total_amount是计算字段,用(i.price * i.quantity)算出来。如果 Wrapper 里要按这个字段排序,直接写ew.orderByDesc("total_amount")就行,因为它是 SELECT 里的别名,MySQL 允许在 ORDER BY 里用别名。
4.3 Service 层组装查询条件
Service 层负责把前端参数翻译成 Wrapper。我习惯把条件组装抽成一个私有方法,方便复用和测试。
@Service public class OrderServiceImpl implements OrderService { @Autowired private OrderMapper orderMapper; @Override public IPage<OrderDetailVO> queryOrderDetail(OrderQueryDTO query) { Page<OrderDetailVO> page = new Page<>(query.getPageNum(), query.getPageSize()); QueryWrapper<OrderDetailVO> ew = buildWrapper(query); return orderMapper.selectOrderDetailPage(page, ew); } private QueryWrapper<OrderDetailVO> buildWrapper(OrderQueryDTO query) { QueryWrapper<OrderDetailVO> ew = new QueryWrapper<>(); ew.like(StringUtils.isNotBlank(query.getKeyword()), "c.customer_name", query.getKeyword()); ew.eq(query.getStatus() != null, "o.status", query.getStatus()); ew.ge(query.getStartTime() != null, "o.create_time", query.getStartTime()); ew.le(query.getEndTime() != null, "o.create_time", query.getEndTime()); ew.eq(query.getCustomerId() != null, "o.customer_id", query.getCustomerId()); // 动态排序,白名单校验 String column = SORT_FIELD_MAP.get(query.getSortField()); if (column != null) { ew.orderBy(true, "asc".equalsIgnoreCase(query.getSortOrder()), column); } else { ew.orderByDesc("o.create_time"); // 默认排序 } return ew; } }注意默认排序的处理:如果前端没传排序字段,给一个默认的o.create_time DESC。这很重要,否则分页时数据顺序不稳定,翻页可能出现重复或遗漏。
4.4 生成的 SQL 长什么样
假设前端传了keyword="张"、status=1、startTime=2024-01-01,排序按createTime降序,页码 1,每页 10 条。最终执行的 SQL 大致是:
SELECT o.order_no, o.create_time, c.customer_name, c.phone, p.product_name, i.price, i.quantity, (i.price * i.quantity) AS total_amount FROM t_order o LEFT JOIN t_customer c ON o.customer_id = c.id LEFT JOIN t_order_item i ON i.order_id = o.id LEFT JOIN t_product p ON i.product_id = p.id WHERE (c.customer_name LIKE ? AND o.status = ? AND o.create_time >= ?) ORDER BY o.create_time DESC LIMIT 10count 查询会被分页插件自动改写为:
SELECT COUNT(*) FROM t_order o LEFT JOIN t_customer c ON o.customer_id = c.id LEFT JOIN t_order_item i ON i.order_id = o.id LEFT JOIN t_product p ON i.product_id = p.id WHERE (c.customer_name LIKE ? AND o.status = ? AND o.create_time >= ?)注意 count 查询里 SELECT 部分被替换成了COUNT(*),但 JOIN 和 WHERE 都保留了。这就是为什么 count 可能慢——它要扫描所有关联表。如果数据量大,可以考虑给 count 单独写一个简化 SQL,去掉不必要的 JOIN。
4.5 分页参数与返回结构
Page对象的构造是new Page<>(pageNum, pageSize),页码从 1 开始。返回的IPage里包含records(当前页数据)、total(总条数)、pages(总页数)、current、size。
前端需要的通常是records和total。我习惯在 Controller 层再包一层统一响应结构,把IPage转成{ list, total, pageNum, pageSize },避免把 MyBatis-Plus 的内部结构暴露给前端。
@GetMapping("/order/detail") public Result<PageResult<OrderDetailVO>> queryOrderDetail(OrderQueryDTO query) { IPage<OrderDetailVO> page = orderService.queryOrderDetail(query); PageResult<OrderDetailVO> result = new PageResult<>( page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize()); return Result.ok(result); }4.6 多表查询的另一种变体:子查询 + Wrapper
有些场景下,条件需要作用在子查询上。比如"查询购买了某类商品的订单",商品类别在t_product表里,但订单和商品是多对多关系。这时候可以在@Select里写子查询,Wrapper 只作用于外层。
@Select("SELECT o.order_no, o.create_time, c.customer_name " + "FROM t_order o " + "LEFT JOIN t_customer c ON o.customer_id = c.id " + "WHERE o.id IN (" + " SELECT oi.order_id FROM t_order_item oi " + " LEFT JOIN t_product p ON oi.product_id = p.id " + " WHERE p.category_id = #{categoryId}" + ") " + "${ew.customSqlSegment}") IPage<OrderDetailVO> selectByCategory(IPage<OrderDetailVO> page, @Param("categoryId") Long categoryId, @Param("ew") QueryWrapper<OrderDetailVO> ew);注意这里外层已经有 WHERE 了,所以${ew.customSqlSegment}生成的条件会变成AND (...)追加在后面。MyBatis-Plus 会自动处理这个衔接,生成的条件片段以AND开头而不是WHERE。这个行为很贴心,但前提是外层 WHERE 后面要有内容,否则会变成WHERE AND (...)。
注意:如果外层 WHERE 是空的,而 Wrapper 有条件,SQL 会出错。解决办法是外层 WHERE 里放一个恒真条件,比如
WHERE 1=1,或者把子查询条件也放进 Wrapper。
5. 常见问题与排查技巧实录
5.1 分页失效问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 返回全部数据,不分页 | 方法签名缺 IPage 参数 | 检查 Mapper 方法 | 加上 IPage 参数并放第一位 |
| 分页插件未生效 | 未配置拦截器 | 检查是否有 MybatisPlusInterceptor Bean | 添加分页拦截器配置 |
| count 查询报错 | SQL 含 UNION 或复杂子查询 | 看日志里的 count SQL | 单独写 count 方法或简化 SQL |
| 分页数据重复 | 排序字段不唯一 | 检查 ORDER BY | 加唯一字段(如 id)做次级排序 |
| 单页超过 500 条 | 未设 maxLimit | 检查分页配置 | setMaxLimit(500L) |
5.2 字段名写错导致的报错
最常见的报错是Unknown column 'xxx' in 'where clause'。原因几乎都是 Wrapper 里写了 Java 字段名而不是数据库列名。排查方法很简单:把日志级别调到 DEBUG,看 MyBatis 打印的最终 SQL,一眼就能看出哪个字段名不对。
logging: level: com.example.mapper: debug我习惯在开发阶段一直开着 Mapper 包的 DEBUG 日志,上线前再关掉。这样每次查询的 SQL 和参数都能看到,排查问题效率翻倍。
5.3 count 查询慢的优化思路
多表 JOIN 的 count 慢是常态。我总结了几种优化手段,按优先级排列。
第一,开启optimizeJoin。分页插件会尝试去掉 count SQL 里不影响结果的 LEFT JOIN。比如某个 LEFT JOIN 的表在 WHERE 里没用到,且不影响行数,就可以安全去掉。
第二,给 WHERE 条件涉及的字段加索引。c.customer_name如果要做 LIKE 查询,前缀匹配(LIKE '张%')能用索引,全模糊(LIKE '%张%')用不上。如果业务允许,尽量用前缀匹配。
第三,数据量特别大时,考虑用"延迟关联"。先只查主表 ID 和分页,再用 ID 去关联其他表。
SELECT o.order_no, c.customer_name, ... FROM (SELECT id FROM t_order WHERE ... LIMIT 0, 10) t JOIN t_order o ON t.id = o.id LEFT JOIN t_customer c ON o.customer_id = c.id这种写法 count 只扫主表,快很多。但实现起来要拆成两次查询,复杂度上升,只在性能瓶颈明显时才用。
5.4 Wrapper 条件不生效的几种情况
有时候明明加了条件,SQL 里却没有。常见原因有三个。
一是condition参数传了 false。比如ew.eq(status != null, "o.status", status),如果status是 null,条件就不加。这是预期行为,但容易忘。
二是字段名拼写错误,但恰好数据库里有同名列,条件加上了却作用在错误的表上。这种最隐蔽,只能靠仔细核对。
三是 Wrapper 被复用了。QueryWrapper 是有状态的,同一个实例多次传入不同方法,条件会累积。我见过有人在循环里复用同一个 Wrapper,结果条件越加越多。正确做法是每次查询都 new 一个新的。
5.5 排序字段注入的防护
前面提过排序字段白名单,这里再强调一下。orderBy方法接收的是字符串列名,如果直接拼接用户输入,就是 SQL 注入漏洞。比如用户传sortField="1; DROP TABLE t_order",后果不堪设想。
白名单是最简单有效的防护。如果字段太多维护不过来,可以用正则校验,只允许字母、数字、下划线,且必须以字母开头。
private static final Pattern SAFE_COLUMN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9_]*$"); if (SAFE_COLUMN.matcher(column).matches()) { ew.orderBy(true, isAsc, column); }但正则只能防注入,防不了"排序字段不存在"的报错。所以白名单还是首选。
5.6 多数据源下的注意事项
如果项目用了多数据源,${ew.customSqlSegment}的行为不受影响,但分页插件的 DbType 要配对。MySQL 和 Oracle 的分页语法不同,配错了会生成错误的 SQL。多数据源场景下,每个数据源要配独立的MybatisPlusInterceptor,或者用动态数据源插件自动识别。
我在一个同时用 MySQL 和 PostgreSQL 的项目里踩过这个坑:分页插件配了 MySQL,查 PostgreSQL 时生成的LIMIT语法虽然能用,但 count 查询的优化策略不对,性能很差。后来改成按数据源分别配置才解决。
6. 几个进阶玩法与扩展思路
6.1 条件复用:把 Wrapper 构建抽成工具类
如果多个接口共用同一套筛选条件,可以把 Wrapper 构建逻辑抽成静态方法。
public class OrderWrapperBuilder { public static QueryWrapper<OrderDetailVO> build(OrderQueryDTO query) { QueryWrapper<OrderDetailVO> ew = new QueryWrapper<>(); // ... 条件组装 return ew; } }这样不同 Service 调用同一个构建器,条件逻辑只有一份,改一处全生效。但要注意,构建器返回的是新实例,不能缓存复用。
6.2 与 XML 混用的场景
有些团队规范要求复杂 SQL 必须写 XML。这种情况下,${ew.customSqlSegment}在 XML 里同样能用。
<select id="selectOrderDetailPage" resultType="OrderDetailVO"> SELECT o.order_no, c.customer_name, ... FROM t_order o LEFT JOIN t_customer c ON o.customer_id = c.id ${ew.customSqlSegment} </select>Mapper 接口方法签名不变。这样既满足了团队规范,又保留了 Wrapper 的灵活性。我现在的项目就是注解和 XML 混用:简单的多表查询用注解,超过 30 行的用 XML。
6.3 动态表名的处理
极少数场景下,表名也需要动态。比如按月分表,t_order_202401、t_order_202402。这种需求@Select注解搞不定,因为表名在编译期就固定了。
解决办法是用 MyBatis 的动态 SQL 插件,或者用${tableName}占位符配合@Param传入。但表名拼接有注入风险,必须严格校验,只允许白名单里的表名。
@Select("SELECT ... FROM ${tableName} o " + "LEFT JOIN t_customer c ON o.customer_id = c.id " + "${ew.customSqlSegment}") IPage<OrderDetailVO> selectFromTable(IPage<OrderDetailVO> page, @Param("tableName") String tableName, @Param("ew") QueryWrapper<OrderDetailVO> ew);这种写法我一般不用,除非分表是硬需求。能用分区表解决的,优先用分区表。
6.4 性能监控与慢 SQL 记录
多表查询上线后,一定要有慢 SQL 监控。MyBatis-Plus 自带性能分析插件,但功能有限。我一般用 p6spy 或者 Druid 的监控功能,把执行超过 1 秒的 SQL 记录下来。
# p6spy 配置示例 decorator: datasource: p6spy: log-slow-sql: true slow-sql-millis: 1000记录下来的慢 SQL 定期 review,看是否需要加索引或改写。多表查询的性能问题往往在数据量涨到百万级后才暴露,提前监控能避免线上事故。
6.5 单元测试怎么写
这套方案的测试重点是验证生成的 SQL 是否符合预期。我一般用 H2 内存数据库跑集成测试,或者用 MyBatis-Plus 的 SQL 日志断言。
@Test public void testBuildWrapper() { OrderQueryDTO query = new OrderQueryDTO(); query.setKeyword("张"); query.setStatus(1); QueryWrapper<OrderDetailVO> ew = OrderWrapperBuilder.build(query); String sql = ew.getCustomSqlSegment(); assertTrue(sql.contains("c.customer_name LIKE")); assertTrue(sql.contains("o.status =")); }getCustomSqlSegment()方法可以直接拿到生成的 SQL 片段,不用真的连数据库。这样测试跑得快,也能覆盖各种条件组合。
7. 我在实际项目中的几点体会
这套方案我从 2021 年开始用,前后在三个项目里落地。最大的感受是:它把多表查询的复杂度从"SQL 维护"转移到了"Wrapper 构建"。SQL 骨架稳定后基本不用动,所有变化都在 Java 代码里,配合 IDE 的重构和单元测试,改起来很踏实。
踩过的坑里,最值得说的是字段名问题。团队新人第一次用,十有八九会写 Java 字段名而不是数据库列名。我的解决办法是在 VO 类里加注释,并且在 Code Review 时重点看 Wrapper 构建部分。后来干脆写了个小工具,扫描 Wrapper 里的字符串,和数据库元数据比对,提前发现拼写错误。
另一个体会是分页的 count 查询。数据量小的时候无所谓,一旦上百万,count 就成了瓶颈。我现在的习惯是:新接口上线前,用生产数据量的十分之一跑一次压测,看 count 的耗时。如果超过 200ms,就考虑优化。
最后分享一个小技巧:${ew.customSqlSegment}生成的条件片段,可以用ew.getTargetSql()方法在日志里打印出来,方便调试。但注意这个方法返回的是带?的 SQL,参数值要另外从ew.getParamNameValuePairs()里取。我一般只在排查问题时用,平时靠 MyBatis 的 DEBUG 日志就够了。