有个订单列表接口,平时 300ms,高峰突然冲到 8 秒。Java 代码看起来很干净:Mapper 查订单,resultMap 里用 collection 把明细带出来。真正的问题藏在 SQL 日志里:一页 50 条订单,先查 1 次主表,再为每条订单查 1 次明细,妥妥跑成了 1+N。
MyBatis 官方 XML 映射文档在 nested select 那一段就提醒过 N+1 Selects Problem,也提供了 nested results、collection 等复杂对象图映射方式。生产里不要把这些机制当银弹,列表页、详情页、分页页的查询策略应该分开设计。

这个坑为什么容易漏
MyBatis 的 nested select 写起来非常自然,Mapper XML 也很清爽。问题是它把“每行再查一次”的成本藏在映射阶段,Java 代码里看不到循环,接口数据少时也不明显。一旦分页条数变大,或者子表查询本身需要走复杂索引,数据库 QPS 和接口 p99 会一起抬头。
我排查这类问题,第一步不是看 explain,而是数 SQL。打开 SQL 日志或者看 APM trace,如果一次 HTTP 请求里同一个 Mapper 方法重复几十次,就先别谈索引优化,先把查询形态改对。
什么时候用 JOIN nested results
如果是详情页,主对象只有一条,子集合规模可控,用 JOIN 配合 resultMap collection 是可以的。它把多次查询合成一次结果集,再由 MyBatis 去重和组装对象图。
但列表页要谨慎。一对多 JOIN 会放大行数,分页和排序语义很容易变复杂。你以为 limit 50,JOIN 后可能变成明细行的 50,不是订单的 50。这个时候硬 JOIN 反而会制造新问题。

列表页我更常用两段查询
订单列表这类接口,我更愿意先分页查主表,拿到当前页订单 id,再用 IN 批量查子表,最后在 Java 里 groupBy 组装 DTO。这样 SQL 次数固定为 2 次,主表分页语义也更稳定。
Listorders = orderMapper.selectPage(query); List ids = orders.stream().map(Order::getId).toList(); Map > itemMap = itemMapper.selectByOrderIds(ids) .stream() .collect(Collectors.groupingBy(OrderItem::getOrderId));
这段代码没有 nested select 看起来优雅,但它的成本是显性的:一页就是两次 SQL。上线后监控、压测、review 都更容易解释。

别忘了 count 和索引
很多分页慢查询真正慢在 count。主查询被优化了,count 还在扫大表,接口一样慢。我的习惯是把列表查询和 count 查询分开看执行计划,必要时给 count 做更轻的条件、缓存短时间总数,或者在业务允许时使用“是否有下一页”替代精确总数。
两段查询里的 IN 也不是无限制的。页大小如果很大,要考虑分批和数据库参数上限;子表必须有 order_id 这类外键索引,否则批量查也只是换一种慢法。
上线检查清单
- 一次列表请求实际执行了几条 SQL?是否随页大小线性增长?
- Mapper XML 里是否有 collection/association nested select 用在列表页?
- JOIN 后分页语义是否仍然按主表分页,而不是按明细行分页?
- 两段查询的 IN 字段是否有索引,页大小是否可控?
- count SQL 是否单独看过执行计划和耗时?
- 优化前后是否对比 p95/p99、DB QPS、慢 SQL 数量和返回数据一致性?
最后聊两句
MyBatis 的优点是 SQL 可控,缺点也是 SQL 真的要你自己负责。N+1 不是框架背锅,它通常是接口形态和映射方式没有对齐。
我的建议是:详情页可以追求对象图映射的便利,列表页优先追求 SQL 次数稳定和分页语义清楚。把 SQL 数量从代码里揪出来,很多所谓“数据库突然慢了”的事故,其实就已经解决一半。