一直在做后台管理系统的同学应该都有同感,列表页的需求翻来覆去就那几件事:主子表数据一起展示、关联用户名称、统计子表数量或金额,然后还必须分页。刚开始用 MyBatis 的时候,很多人会把多表查询和分页查询分开看,等真正写起来才发现,这两个需求凑到一起才是噩梦源头。
我最近在项目里重构了一批订单列表接口,表结构并不复杂,无非是订单主表、订单明细表、用户表,但接手的代码里用了一个巨大的 JOIN 查询,主表关联明细表之后行数膨胀,分页总数怎么都对不上,接口响应从几十毫秒劣化到两秒以上。这篇文章我想把 MyBatis 里多表查询、分页查询相关的设计思路、配置细节和优化手段完整捋一遍,结合我踩过的坑来讲,适合刚接触 MyBatis 的开发者,也适合已经在写业务但没系统梳理过这类问题的朋友。
1. 多表查询的返回结构定不下来,SQL 写得再顺也是白搭
1.1 先回答一个关键问题:你要的是“平铺行”还是“嵌套对象”
很多 MyBatis 新人拿到多表查询需求时,第一反应就是写一个 JOIN,然后 resultType 一个 DTO。比如订单列表要带用户昵称,最常见的写法是这样的:
xml复制<select id="selectOrderWithUser" resultType="com.example.dto.OrderWithUserDTO">
SELECT
o.id,
o.order_no,
o.amount,
u.nickname AS userName
FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
</select>
这种写法在数据量小、表关系简单的时候没什么问题。但如果页面要展示的不只是“用户昵称”这一个字段,而是还要展示“该订单下的商品明细列表”,问题就来了。JOIN 的结果会把订单主记录复制成多条,每条对应一个商品明细,前端拿到的是平铺数据,得自己按订单 ID 分组。
于是很多人开始在 Java 代码里做二次组装:
java复制Map<Long, OrderVO> map = new LinkedHashMap<>();
for (OrderWithItemDTO row : rows) {
OrderVO vo = map.computeIfAbsent(row.getId(), OrderVO::new);
vo.getItemList().add(row.getItemName());
}
这种手工分组逻辑一多,代码就变得很脆。而且它和 SQL 的字段顺序、返回结构强耦合,加一个关联表就要改一大片。
反过来,MyBatis 本来提供了 resultMap 来解决“数据库行到嵌套 Java 对象”的映射问题。真正合理的思路应该是:先想清楚你的接口到底需要一棵什么样的对象树,再决定 SQL 是 JOIN 还是分步查询,最后用 resultMap 把这棵树拼出来。
我个人的经验是,可以把多表查询拆成三类:
| 需求特征 | 推荐做法 | 原因 |
|---|---|---|
| 列表页只需要主表字段加另一张表的几个冗余字段 | JOIN 后 resultType 一个扁平 DTO | 结构简单,映射成本低 |
| 需要完整的父子级对象,比如订单带明细列表 | JOIN + resultMap 嵌套映射 | 一次查出完整对象树,避免循环查库 |
| 子集合量大,或获取某些关联数据不是列表主查询的刚需 | 先分页查主表,再用 IN 批量补查关联数据 | 避免 JOIN 导致行数膨胀,也避免 N+1 |
1.2 一对多 JOIN 是分页查询最大的“隐形杀手”
我之前排查过一个总数对不上的接口。场景是订单表关联明细表,业务上想统计每个订单的商品行数、金额合计,于是 SQL 写成了:
sql复制SELECT o.*, i.id AS item_id, i.quantity, i.price
FROM t_order o
LEFT JOIN t_order_item i ON i.order_id = o.id
表面看起来没毛病,但这条 SQL 查出来的记录数是“订单数 + 明细数”,而不是“订单数”。如果我用 PageHelper 自动分页,PageHelper 会把 count 查询自动生成为:
sql复制SELECT COUNT(0)
FROM t_order o
LEFT JOIN t_order_item i ON i.order_id = o.id
这个 count 结果是明细行总数,不是订单总数。前端看到的结果自然就是第二页开始全乱了。
这种问题不是 PageHelper 独有的,只要你对 JOIN 结果做 LIMIT,同样存在:LIMIT 截断的是“关联后的行”,不是“主表的行”。
所以一旦表关系变成了一对多,我强烈建议第一时间想清楚:分页的单位到底是哪张表的记录。如果分页对象是订单,那明细表的行就不能直接平铺在主查询结果里。解决办法不外乎两种,要么在查询里用 GROUP BY 把明细聚合到每行一个订单,要么主表分页后再单独补查明细。后面我会专门讲这部分。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. association/collection:嵌套映射不是可选项,是必须写透的关键点
2.1 多对一场景用 association,一对多场景用 collection
处理嵌套对象关系,MyBatis 的 resultMap 是核心。我们以订单和用户为例,一个订单属于一个用户,这是典型的多对一:
xml复制<resultMap id="OrderDetailMap" type="com.example.entity.Order">
<id property="id" column="order_id"/>
<result property="orderNo" column="order_no"/>
<result property="amount" column="amount"/>
<association property="user" javaType="com.example.entity.User">
<id property="id" column="user_id"/>
<result property="nickname" column="nickname"/>
</association>
</resultMap>
<select id="selectOrderDetail" resultMap="OrderDetailMap">
SELECT
o.id AS order_id,
o.order_no,
o.amount,
u.id AS user_id,
u.nickname
FROM t_order o
LEFT JOIN t_user u ON o.user_id = u.id
WHERE o.id = #{id}
</select>
association 标签的作用是告诉 MyBatis:把这一组列映射成一个独立对象,塞到 Order 的 user 属性上。这里面 id 标签非常重要,它决定了 MyBatis 判断“两行是不是同一个对象”的依据。
尤其是一对多映射时,没有正确配置 id 标签会引发非常隐蔽的问题。比如订单明细:
xml复制<collection property="itemList" ofType="com.example.entity.OrderItem">
<id property="id" column="item_id"/>
<result property="skuId" column="sku_id"/>
<result property="quantity" column="quantity"/>
</collection>
当同一个订单对应多条明细时,JOIN 查出来的结果里有同一订单的多行记录。MyBatis 是靠 resultMap 中的 id 来判断哪几行属于同一个 Order 对象的。如果你漏了 Order 的 id 映射,MyBatis 就无法把重复的订单行合并成一个 Order 实例,最后的 list 里会出现多个原本应该是同一个的 Order,集合里明细也被打散。这个坑最迷惑的地方在于,数据量小的时候不太容易发现,等明细一多,返回对象数量和预期完全对不上。
2.2 列别名冲突是在嵌套映射中几乎必踩的坑
多表 JOIN 后不同表很可能有同名字段,比如订单表有 id,用户表也有 id,明细表还有 id。如果 SQL 里直接写 SELECT o.id, u.id, i.id,MyBatis 拿到 ResultSet 后按列名取值,后面取到的 id 会覆盖前面的同名列。
解决办法很直接:SQL 里给每张表的列都起明确的别名。为了维护方便,我习惯用表名缩写做前缀:
sql复制SELECT
o.id AS order_id,
o.order_no AS order_no,
o.amount AS amount,
u.id AS user_id,
u.nickname AS user_nickname,
i.id AS item_id,
i.sku_name AS item_sku_name
FROM ...
然后在 resultMap 里一一对应。
这里有个实战技巧:当关联表很多、字段也很多时,给每个 result 标签都写 column 会很啰嗦。可以使用 columnPrefix 配合自动映射来简化。例如:
xml复制<resultMap id="OrderWithItemMap" type="com.example.entity.Order">
<id property="id" column="order_id"/>
<collection property="itemList"
ofType="com.example.entity.OrderItem"
columnPrefix="item_">
<id property="id" column="id"/>
<result property="skuName" column="sku_name"/>
<result property="quantity" column="quantity"/>
</collection>
</resultMap>
对应 SQL 里只要把明细表字段都起成 item_id、item_sku_name 这种带前缀的别名即可。columnPrefix 会根据前缀自动匹配,不需要在 collection 里一遍遍写全列名。用这个特性之前,先确认自己项目里的 MyBatis 版本,较老的版本不支持这个属性。
2.3 自动映射下划线转驼峰,别把 mapUnderscoreToCamelCase 当万能药
很多项目在 application.yml 里配置了:
yaml复制mybatis:
configuration:
map-underscore-to-camel-case: true
这个配置确实能省掉大量 result 标签,比如数据库字段 order_no 可以自动映射到 orderNo。但要注意,自动映射对嵌套结果并不总是可靠。尤其当你使用 columnPrefix 时,前缀的匹配逻辑和你是不是开了驼峰转换会有交互影响,处理不好就会出现关联属性全是 null 的情况。
我现在的做法是:简单查询用自动映射,凡是涉及 association/collection 的复杂 resultMap,一律显式列出关键列的映射关系。显式虽然啰嗦,但出错之后可以通过 SQL 和 resultMap 逐行比对,排查成本低很多。
3. N+1:LazyLoading 这类嵌套 select 会不会拖垮分页,取决于你放哪用
3.1 嵌套 select 的便捷与代价
除了 JOIN + resultMap 组装,MyBatis 的 association/collection 还支持通过嵌套 select 查询关联对象。比如:
xml复制<resultMap id="OrderUserMap" type="com.example.entity.Order">
<id property="id" column="id"/>
<association property="user"
column="user_id"
select="com.example.mapper.UserMapper.selectById"
fetchType="lazy"/>
</resultMap>
执行主查询查出订单后,如果访问了 order.getUser(),MyBatis 会再执行一次 selectById,把用户信息加载出来。这就是所谓的延迟加载,lazy loading。它的好处是拆分 SQL,代码结构清晰,缺点是很容易引发 N+1 查询问题。
假设我要查询一页订单,每页 10 条。如果查询订单列表后,在循环里逐单触发 order.getUser().getNickname(),那最终执行的 SQL 数量是 1 + 10 条。页大小越大,这条数越多。要是前端又把订单的明细列表也懒加载了,情况会更夸张,接口性能直接崩盘。
注意,这里说的 N+1 并不是 MyBatis 的问题,而是使用方式的问题。嵌套 select 本身非常适合“详情页”这种只查单条主记录的场景,主查询一次,关联数据按需加载,很优雅。但把它用在“列表分页”场景就要格外谨慎,因为列表天然对应多条主记录,每条主记录的延迟加载都会触发额外 SQL。
3.2 分页列表里的推荐做法:先查主表,再批量补数据
避免 N+1 最有效的办法,不是关闭延迟加载,而是让列表查询彻底不依赖嵌套 select。我最近做的订单列表优化就是典型例子。
原方案:主查询查订单,然后在 resultMap 里嵌套 select 用户和明细。
优化后方案:主查询只查订单表,分页;再根据当前页订单 ID 集合批量查用户和明细;最后在内存里按 ID 组装。
批量补查的 Mapper 大概长这样:
xml复制<select id="selectByOrderIds" resultType="com.example.entity.OrderItem">
SELECT *
FROM t_order_item
WHERE order_id IN
<foreach collection="orderIds" item="id" open="(" separator="," close=")">
#{id}
</foreach>
</select>
然后在 Service 层根据 orderId 分组塞回订单对象。这个方案的 SQL 数量固定为 3 条,不管页大小是 10 还是 20,都不会随数据量增长。
我知道有些同学会觉得内存组装比较“土”,不如一个 JOIN 优雅。但实际上,多表 JOIN 配合分页在数据库侧的成本往往更高,而内存组装只针对当前页的数据,数据量很小,压力可以忽略。为了代码“看起来像一把梭”而牺牲接口性能,不值得。
4. PageHelper 分页背后的 ThreadLocal + 拦截器机制,搞清楚才能不翻车
4.1 startPage 究竟做了什么
PageHelper 是 MyBatis 最常用的分页插件,但它不是一个黑盒。它的实现思路大致是:
调用 PageHelper.startPage(pageNum, pageSize) 时,把分页参数放进当前线程的 ThreadLocal 中。接下来执行 MyBatis 的 Mapper 方法时,PageHelper 的拦截器会拦截这次查询,取出 ThreadLocal 里的分页参数,改写 SQL,在末尾拼接 LIMIT 语句。
源码层面的细节不展开,但理解 ThreadLocal 这一点很重要,因为很多人分页出错都出在这里。
最常见的错误是:
java复制PageHelper.startPage(1, 10);
// 中间执行了别的查询
userMapper.selectUserLoginLog(userId);
// 本意是分页查询用户列表
userMapper.selectUserList();
startPage 只对紧随其后的第一个查询生效。如果中间插了另一个查询,分页参数会被那个查询吃掉,后面的用户列表反而没有分页。更糟糕的是,如果你在同一个方法里连续执行多次查询,PageHelper 内部做了清理,但实际业务数据已经被干扰。
4.2 PageHelper 自动 count 的代价
当我们使用默认的 PageHelper.startPage(pageNum, pageSize) 时,它会在查询列表之前先执行一条 count SQL,然后才执行带 limit 的列表 SQL。也就是说一次分页查询,底层是两条 SQL。
自动 count 对单表简单查询很有效,但多表 JOIN 场景下经常出问题。比如我前面提到的一对多 JOIN,page 自动生成的 count 是统计 JOIN 后的行数,这时总数必然偏大或偏小。
另外,带 GROUP BY 的聚合查询也容易出现 count 不对的情况。PageHelper 为了处理 count,会把原 SQL 做改写,但改写的规则是通用的,一旦 SQL 里有 GROUP BY、DISTINCT、HAVING,改写出来的 count 就未必等价于业务上想要的主表数量。
4.3 多数据源和分页插件冲突的问题
如果你的项目里配置了多个数据源,或者使用了动态数据源切换,要小心 PageHelper 通过 @Intercepts 注解作用在 Executor 上。它默认对所有的 SqlSessionFactory 生效。如果分库分表或者某些报表数据源的方言和业务库不一样,分页 SQL 可能被拼接成错误的方言。
排查这种问题,首先在配置里确认 PageHelper 的 dialect,其次看是否需要对特定数据源关闭分页插件拦截,不要把所有数据源都交给一套分页配置处理。
5. 多表 JOIN 分页的总数失控:自己维护一条 count,并不丢人
5.1 把“列表 SQL”和“count SQL”分开设计
我一直认为,复杂多表分页最稳的方式不是让插件去猜 count,而是自己显式写一条 count 查询。这样做有几个好处:
第一,列表 SQL 可以自由使用 JOIN、GROUP BY、聚合函数,不需要担心它被插件二次改写后产生语义偏差;第二,count 查询可以写得比列表 SQL 轻量得多,只统计主表数量;第三,SQL 逻辑完全在掌控之内,未来优化也好下手。
实际代码里,我一般这样设计 Mapper:
xml复制<sql id="orderListCondition">
<where>
<if test="q.status != null">
AND o.status = #{q.status}
</if>
<if test="q.userName != null and q.userName != ''">
AND EXISTS (
SELECT 1 FROM t_user u WHERE u.id = o.user_id AND u.nickname LIKE CONCAT('%', #{q.userName}, '%')
)
</if>
</where>
</sql>
<select id="countOrder" resultType="long">
SELECT COUNT(1)
FROM t_order o
<include refid="orderListCondition"/>
</select>
<select id="listOrderPage" resultMap="OrderWithItemCountMap">
SELECT
o.id,
o.order_no,
o.amount,
COUNT(i.id) AS item_count,
IFNULL(SUM(i.amount), 0) AS item_amount
FROM t_order o
LEFT JOIN t_order_item i ON i.order_id = o.id
<include refid="orderListCondition"/>
GROUP BY o.id, o.order_no, o.amount
ORDER BY o.create_time DESC
LIMIT #{offset}, #{limit}
</select>
注意,我把筛选条件抽成了 <sql id="orderListCondition">,count 查询和列表查询共用同一段条件。这样可以避免一个非常低级的错误:列表查询和 count 查询用了两套条件,导致总数对不上。以前我见过同事把 count 里的状态条件忘了加,列表只显示 20 条,total 却是几万条。
Service 层也不复杂:
java复制public PageResult<OrderVO> pageOrder(OrderQuery query) {
long total = orderMapper.countOrder(query);
if (total == 0) {
return PageResult.empty();
}
List<OrderVO> list = orderMapper.listOrderPage(query,
(query.getPageNum() - 1) * query.getPageSize(),
query.getPageSize());
return new PageResult<>(total, list);
}
这里我故意没有用 PageHelper 的 startPage,而是直接传 offset 和 limit。因为列表查询带 GROUP BY、聚合函数,插件自动包装反而碍事。手动控制以后,排序字段、分页单位都一目了然。
5.2 为什么 EXISTS 写法比 JOIN 更适合做过滤条件
上面的条件里,我用了 EXISTS 子查询来做用户昵称的模糊查询。如果改成 JOIN t_user 再过滤,count SQL 就不容易写轻量,分页也会被用户表行数干扰。EXISTS 的语义是“判断是否存在符合条件的数据”,它不会因为关联表有多条匹配记录而让主表行数翻倍,非常适合多表分页的过滤场景。
这个套路在处理“主表+从表过滤”时是通用的。比如我要按明细商品名称过滤订单,同样可以在 list 里用 EXISTS:
sql复制AND EXISTS (
SELECT 1 FROM t_order_item i
WHERE i.order_id = o.id
AND i.sku_name LIKE CONCAT('%', #{q.skuName}, '%')
)
主表还是那个主表,行数不会膨胀,分页自然就不会乱。
5.3 count 查询本身也可能很慢,别只盯着列表
很多时候我们只优化列表 SQL,忽略了 count SQL。实际上分页接口的响应时间往往是“count + 列表”的总和,如果 count 走了大表的全表扫描,照样慢。
针对 count 慢,常用的手段是让 count 查询走覆盖索引。比如上面的订单表场景,如果条件里只用到了 status 和 create_time,就可以建一个 (status, create_time) 的联合索引,让 count 直接扫索引而不是回表。对 MySQL 来说,COUNT(1) 在 InnoDB 表上扫描索引叶子节点,比扫聚簇索引快不少。
如果表实在太大,count 还要做模糊查询、多表过滤,那仅靠索引也可能扛不住。这种情况下,我遇到过不少团队选择用 Redis 缓存筛选后的总数,设置几十秒过期时间,用异步任务或者查询时回源更新。这种方案要特别注意缓存 key 的设计,条件一变 key 就变,命中率往往不高。我的建议是,只对最高频的几个筛选组合做缓存,不要试图缓存所有条件组合。
6. 多表深分页优化:优先砍 JOIN,而不是一上来就加 Redis
6.1 深分页慢的根源:MySQL 的 LIMIT 并不“跳过”
常见问题是,翻到第 100 页甚至更靠后时,列表接口明显变慢。拿 MySQL 来说,执行:
sql复制LIMIT 100000, 20
并不是跳过前 100000 行直接取 20 行,而是仍然会把前 100000 行取出来,然后丢弃。如果查询里带了 JOIN,MySQL 可能还要先把大量关联中间结果排序,再截取指定区间,代价可想而知。
所以深分页优化的第一刀,一定是让“排序 + 截断”发生在最小的数据集上。
6.2 延迟关联:先查主键,再回表取完整数据
多表分页查询里,我强烈推荐用推迟关联的思路:
sql复制SELECT
o.id,
o.order_no,
o.amount,
u.nickname,
COUNT(i.id) AS item_count
FROM t_order o
JOIN (
SELECT id
FROM t_order
WHERE status = 1
ORDER BY create_time DESC, id DESC
LIMIT 100000, 20
) tmp ON tmp.id = o.id
LEFT JOIN t_user u ON u.id = o.user_id
LEFT JOIN t_order_item i ON i.order_id = o.id
GROUP BY o.id,
