LIMIT 20 查出了20行,MyBatis 映射后却只有7个订单,第7个订单还少了两条明细。这是下面这组本地实验的实际结果。
问题出在分页的位置:一条订单关联多条明细,LIMIT 截取的是关联后的行;页面需要的则是20个完整订单。要修的不只是订单数量,还有每个订单的明细是否齐全。
下面用 MyBatis 3.5.19、H2 2.3.232、JDK 21 对比两种改法:先分页订单 ID,再取明细;或者把订单分页放进子查询。实验没有使用分页插件。
这20行,到底是什么
准备 25 个订单。为了看清边界,前几条数据这样放:
| 订单 | 明细数 | JOIN 后占用的行 |
|---|---|---|
| 1---6 | 每单3条 | 前18行 |
| 7 | 4条 | 第19---22行 |
| 8 | 0条 | LEFT JOIN 仍保留1行 |
| 9---25 | 每单1条 | 后续17行 |
所有订单的 created_at 相同,再按唯一的订单 ID 排序。这样不会靠碰巧不同的时间值得到稳定顺序。
错误查询如下:
sql
SELECT o.id AS order_id, d.id AS line_id, d.sku
FROM orders o
LEFT JOIN details d ON d.order_id = o.id
ORDER BY o.created_at, o.id, d.id
LIMIT 20;
实际结果是:
text
SQL结果:20行
映射后的订单:7个
每单拿到的明细数:[3, 3, 3, 3, 3, 3, 2]

第七个订单占第19---22行,LIMIT 20 只留下前两条明细。订单对象虽然还在,内容已经不完整了。
MyBatis 的嵌套 collection 会把相同订单对应的多行组成一个对象,因此二十行不会自动变成二十个订单。父、子映射都明确写 <id>,正是为了识别对应对象;可对照官方 collection 嵌套结果示例。
这里同时选了明细 ID 和 SKU。给整条查询加 DISTINCT,这些明细行仍然不同,不能把问题变成"只返回二十个完整订单"。
先选订单,再取明细
要让分页单位回到订单,先确定本页有哪些订单,再展开它们的明细。第一种办法分两次查询,先取本页订单 ID:
sql
SELECT o.id
FROM orders o
ORDER BY o.created_at, o.id
LIMIT 20 OFFSET 0;
然后只展开这二十个订单:
sql
SELECT o.id AS order_id, d.id AS line_id, d.sku
FROM orders o
LEFT JOIN details d ON d.order_id = o.id
WHERE o.id IN (/* 第一条查询得到的ID */)
ORDER BY o.created_at, o.id, d.id;
第二条 SQL 不再对展开后的行做 LIMIT。完整程序用 foreach 绑定 ID 参数,返回后再按第一次得到的 ID 顺序组装,避免把 IN 当成排序规则。
空页直接返回,不执行第二条查询。没有明细的订单通过 LEFT JOIN 保留下来,collection 设置 notNullColumn="line_id",不创建一条全空的明细对象。
实测第一页有 20 个订单,订单 7 得到完整的 4 条明细,订单 8 的明细列表为空;第二页是订单 21---25。
如果希望一条 SQL 完成,也可以把刚才的订单分页放进子查询,再在外层关联明细:
sql
SELECT o.id AS order_id, d.id AS line_id, d.sku
FROM (
SELECT id, created_at
FROM orders
ORDER BY created_at, id
LIMIT 20 OFFSET 0
) o
LEFT JOIN details d ON d.order_id = o.id
ORDER BY o.created_at, o.id, d.id;
这里仍然先分页订单,再展开明细。外层的 ORDER BY 也要保留,不能依赖子查询替最终结果排序。两种写法在这组数据上返回相同的订单、明细和顺序。
选哪种还要看实际查询和数据库执行计划。本例只核对分页结果,没有做 MySQL 性能测试,也不能因为一条 SQL 就宣布它一定更快。
筛订单,还是筛要展示的明细
分页确定了每页取哪些订单,筛选还要确定每单展示哪些明细。比如页面要求"包含 wanted 商品的订单":找到订单以后,是展示它的全部明细,还是只显示 wanted 那几行?
本例选择前者。订单 1 的三条明细里有一条命中,订单 7 的四条明细里也有一条命中。结果应该返回两个订单,明细数分别是 3、4。
可以在父表分页条件里用 EXISTS:
sql
SELECT o.id
FROM orders o
WHERE EXISTS (
SELECT 1 FROM details f
WHERE f.order_id = o.id AND f.sku = 'wanted'
)
ORDER BY o.created_at, o.id
LIMIT 20 OFFSET 0;
后续获取明细时不再按 SKU 裁掉其他行。订单总数也用同一个 EXISTS 条件,从订单表计数。
如果页面只展示命中的明细,就要在明细查询上加对应条件。这两种需求都合理,但筛选条件放在哪里、最终返回什么,需要先约定清楚。
这份数据里,订单总数是 25,LEFT JOIN 展开的总行数却是 40。总页数要跟页面里的实体一致;拿 40 当订单总数,页码也会出错。
别漏掉第二页和空明细
完整程序检查了这些结果:
| 检查内容 | 本次实际结果 |
|---|---|
| 直接 JOIN 后 LIMIT | 20行 → 7个订单,第7单只剩2条明细 |
| 先分页 ID,第一页 | 20个订单;第7单4条明细,第8单空列表;2次查询 |
| 先分页 ID,第二页 | 21---25,与第一页无重复;2次查询 |
| 超出最后一页 | 空列表;只查1次 |
| 分页子查询 | 前两页与两次查询版的内容、顺序一致 |
| 总数 | 25个订单;不是40条关联结果 |
| 筛包含 wanted 的订单 | 返回1、7,明细仍是3、4条;总数2 |
这里的数据在读取期间没有变化。唯一排序能消除同时间值的排序歧义,不能阻止并发新增、删除让 OFFSET 页码移动 。两次查询之间的数据一致性,也取决于数据库、事务和隔离级别,不能因共用 SqlSession 就认为天然得到同一快照。
演示程序发现分页选中的订单随后缺失,会直接报错,方便暴露这个条件;生产接口应按实际业务决定快照、重试或缺失项处理方式。高频变化的大列表还可以另行评估游标分页,本文没有实现它。
复制运行
下面两个文件就是完整程序。项目目录执行:
bash
mvn -q compile exec:java
最终打印 PASS: 10 assertions。这些断言核对结果集合、明细完整性、顺序和查询数,运行不需要业务数据库。
pom.xml:
xml
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId>
<artifactId>order-page-lab</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.19</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.3.232</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.5.0</version>
<configuration>
<mainClass>demo.PageLab</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
src/main/java/demo/PageLab.java:
java
package demo;
import java.io.StringReader;
import java.sql.Statement;
import java.util.*;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.*;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
public class PageLab {
public static class Line { public int id; public String sku; }
public static class Order {
public int id;
public List<Line> lines = new ArrayList<>();
}
public interface Mapper {
List<Map<String,Object>> raw();
List<Order> bad();
List<Integer> ids(@Param("offset") int offset, @Param("size") int size,
@Param("sku") String sku);
List<Order> details(@Param("ids") List<Integer> ids);
List<Order> subquery(@Param("offset") int offset, @Param("size") int size,
@Param("sku") String sku);
int count(@Param("sku") String sku);
int joinCount();
}
// XML 放在字符串里,方便复制复现;实际项目可放到 Mapper.xml。
static final String XML = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="demo.PageLab$Mapper">
<resultMap id="order" type="demo.PageLab$Order">
<id property="id" column="order_id"/>
<collection property="lines" ofType="demo.PageLab$Line" notNullColumn="line_id">
<id property="id" column="line_id"/>
<result property="sku" column="sku"/>
</collection>
</resultMap>
<sql id="columns">o.id AS order_id,d.id AS line_id,d.sku</sql>
<sql id="eligible">
<if test="sku != null">
WHERE EXISTS (SELECT 1 FROM details f WHERE f.order_id=o.id AND f.sku=#{sku})
</if>
</sql>
<sql id="wrong">
SELECT <include refid="columns"/> FROM orders o
LEFT JOIN details d ON d.order_id=o.id
ORDER BY o.created_at,o.id,d.id LIMIT 20
</sql>
<select id="raw" resultType="map"><include refid="wrong"/></select>
<select id="bad" resultMap="order"><include refid="wrong"/></select>
<select id="ids" resultType="int">
SELECT o.id FROM orders o <include refid="eligible"/>
ORDER BY o.created_at,o.id LIMIT #{size} OFFSET #{offset}
</select>
<select id="details" resultMap="order">
SELECT <include refid="columns"/> FROM orders o
LEFT JOIN details d ON d.order_id=o.id WHERE o.id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY o.created_at,o.id,d.id
</select>
<select id="subquery" resultMap="order">
SELECT <include refid="columns"/> FROM
(SELECT o.id,o.created_at FROM orders o <include refid="eligible"/>
ORDER BY o.created_at,o.id LIMIT #{size} OFFSET #{offset}) o
LEFT JOIN details d ON d.order_id=o.id
ORDER BY o.created_at,o.id,d.id
</select>
<select id="count" resultType="int">
SELECT COUNT(*) FROM orders o <include refid="eligible"/>
</select>
<select id="joinCount" resultType="int">
SELECT COUNT(*) FROM orders o LEFT JOIN details d ON d.order_id=o.id
</select>
</mapper>
""";
@Intercepts(@Signature(type=StatementHandler.class, method="query",
args={Statement.class, ResultHandler.class}))
public static class Counter implements Interceptor {
int count;
public Object intercept(Invocation invocation) throws Throwable {
count++;
return invocation.proceed();
}
}
static List<Order> twoQueries(Mapper m, int offset, String sku) {
var ids = m.ids(offset, 20, sku);
if (ids.isEmpty()) return List.of();
var index = new HashMap<Integer,Order>();
m.details(ids).forEach(o -> index.put(o.id, o));
// 以第一次分页的 ID 顺序为准,不依赖 IN 集合的返回顺序。
return ids.stream().map(id -> Objects.requireNonNull(index.get(id))).toList();
}
static List<Integer> ids(List<Order> orders) {
return orders.stream().map(o -> o.id).toList();
}
static List<String> snapshot(List<Order> orders) {
return orders.stream().map(o -> o.id + ":" +
o.lines.stream().map(d -> d.id + "/" + d.sku).toList()).toList();
}
static void require(boolean ok, String message) {
if (!ok) throw new AssertionError(message);
}
public static void main(String[] args) throws Exception {
var ds = new UnpooledDataSource("org.h2.Driver", "jdbc:h2:mem:page_lab", "sa", "");
try (var keep = ds.getConnection(); var sql = keep.createStatement()) {
sql.execute("CREATE TABLE orders(id INT PRIMARY KEY,created_at INT NOT NULL)");
sql.execute("CREATE TABLE details(id INT PRIMARY KEY,order_id INT NOT NULL,sku VARCHAR(20))");
try (var order = keep.prepareStatement("INSERT INTO orders VALUES(?,1000)");
var detail = keep.prepareStatement("INSERT INTO details VALUES(?,?,?)")) {
for (int id = 1; id <= 25; id++) {
order.setInt(1,id); order.executeUpdate();
int n = id <= 6 ? 3 : id == 7 ? 4 : id == 8 ? 0 : 1;
for (int j = 1; j <= n; j++) {
detail.setInt(1,id*10+j); detail.setInt(2,id);
detail.setString(3,(id==1 && j==1 || id==7 && j==3) ? "wanted" : "other");
detail.executeUpdate();
}
}
}
var config = new Configuration(new Environment("lab",new JdbcTransactionFactory(),ds));
var counter = new Counter(); config.addInterceptor(counter);
new XMLMapperBuilder(new StringReader(XML),config,"inline.xml",config.getSqlFragments()).parse();
var factory = new SqlSessionFactoryBuilder().build(config);
try (var s = factory.openSession()) {
var m = s.getMapper(Mapper.class);
var raw = m.raw(); var bad = m.bad();
require(raw.size()==20 && bad.size()==7 && bad.get(6).lines.size()==2,"反例");
System.out.println("BAD raw=20 orders=7 order7.lines=2");
System.out.println("BAD groups="+bad.stream().map(o -> o.id+":"+o.lines.size()).toList());
counter.count=0;
var first = twoQueries(m,0,null);
require(first.size()==20 && first.get(6).lines.size()==4
&& first.get(7).id==8 && first.get(7).lines.isEmpty() && counter.count==2,"第一页");
System.out.println("TWO page1 orders=20 order7.lines=4 order8.lines=0 queries=2");
counter.count=0;
var second = twoQueries(m,20,null);
require(ids(second).equals(List.of(21,22,23,24,25)) && counter.count==2,"第二页");
require(Collections.disjoint(ids(first),ids(second)),"页间重复");
System.out.println("TWO page2 ids="+ids(second)+" queries=2");
counter.count=0;
require(twoQueries(m,40,null).isEmpty() && counter.count==1,"空页");
System.out.println("TWO empty queries=1");
counter.count=0;
require(snapshot(m.subquery(0,20,null)).equals(snapshot(first)) && counter.count==1,"子查询第一页");
require(snapshot(m.subquery(20,20,null)).equals(snapshot(second)),"子查询第二页");
System.out.println("SUBQUERY both pages match; page1 queries=1");
require(m.count(null)==25 && m.joinCount()==40,"计数口径");
System.out.println("COUNT orders=25 leftJoinRows=40");
var filtered = twoQueries(m,0,"wanted");
require(ids(filtered).equals(List.of(1,7)) && filtered.get(0).lines.size()==3
&& filtered.get(1).lines.size()==4 && m.count("wanted")==2,"筛订单保留全部明细");
require(snapshot(filtered).equals(snapshot(m.subquery(0,20,"wanted"))),"筛选子查询");
System.out.println("FILTER ids=[1, 7] lines=[3, 4] count=2; both methods match");
System.out.println("PASS: 10 assertions");
}
}
}
}
你们页面筛"包含某类商品的订单"时,会保留这个订单的其他明细吗?可以说说页面约定,这会直接决定筛选条件该放在哪一层。