MyBatis一对多分页:LIMIT 20,为什么凑不齐20个订单?

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");
            }
        }
    }
}

你们页面筛"包含某类商品的订单"时,会保留这个订单的其他明细吗?可以说说页面约定,这会直接决定筛选条件该放在哪一层。

相关推荐
代码方舟1 小时前
Java数据工程:利用天远全网运营商三要素优化线上实名认证合规体验
java·人工智能
BBmmo1 小时前
我的 Java 学习笔记 · 第 7 篇:泛型与通配符
java
独泪了无痕1 小时前
Hutool之RandomUtil:随机数生成的终极利器
java·后端
花开路口1 小时前
深入理解 Java/Kotlin 协变与逆变
java·kotlin
运行时异常1 小时前
【WMS 仓储系统集成 AI Agent 实战】第 7 讲:Vue3 前端工程化——Token 刷新锁、Markdown 渲染踩坑与权限体系
java
她的男孩1 小时前
开放接口限流从 20 改到 200 还是每分钟 20 次:拆完防重放+幂等+限流,我找到 5 个静默失效的坑
java·后端·架构
Y001 小时前
PostgreSQL 的锁:为什么你的 ALTER TABLE 会卡住,以及怎么查
数据库·postgresql
天天被压力1 小时前
【Python 量化取数指南 #13】Python 把行情落库:sqlite 一键存,回测随用随取
java·人工智能·python
天天被压力1 小时前
【Python 量化取数指南 #14】Python 清洗行情数据:复权停牌对齐,回测不翻车
java·人工智能·python