MyBatis 分页

4 阅读7分钟

本文由Gemini生成

在 Java 后端开发中,MyBatis PageHelper 几乎是分页功能的“默认标准”。它使用起来极其顺手,一行代码就能搞定分页。

但你是否真的读懂了它?

  • 为什么写了一句 startPage,查询结果就变了?
  • 为什么 Page 对象明明没被操作,里面却装满了数据?
  • 如果我想把数据库里的 1000 万条数据全部遍历出来,该怎么用 PageHelper 实现才不会内存溢出?
  • 前端为什么老是抱怨拿不到 total 总数?

本文将由浅入深,带你彻底搞懂 PageHelper 的“黑魔法”与最佳实践。


一、 核心谜题:PageHelper 是如何“魔法”介入的?

我们先看一段最经典的疑惑代码:

@Test
public void testMagic() {
    SqlSession sqlSession = sqlSessionFactory.openSession(true);
    EmpMapper mapper = sqlSession.getMapper(EmpMapper.class);

    // 1. 发号施令:开启第一页,每页4条
    Page<Object> page = PageHelper.startPage(1, 4);
    
    // 2. 执行查询 (看起来跟分页没关系)
    List<Emp> emps = mapper.selectByExample(null);
    
    // 3. 见证奇迹:page 对象里竟然有了 emps 的数据?
    System.out.println("Page size: " + page.size()); // 输出 4
    System.out.println("Are they same? " + (emps == page)); // 输出 true
}

疑问:为什么 page 变量明明没有和 emps 进行任何显式操作(比如 page.addAll(emps)),page 里却装满了查询结果?而且 empspage 竟然是同一个对象?

揭秘:ThreadLocal 与 拦截器

PageHelper 的工作流程其实是一场精密的“偷天换日”,利用了 Java 的 ThreadLocal 和 MyBatis 的 Interceptor(拦截器) 机制。

  1. 埋点 (startPage): 当你调用 startPage 时,PageHelper 创建了一个 Page 对象(它本质上是一个 ArrayList)。
    • 它将这个对象存入当前线程的 ThreadLocal 中(当前线程的私有仓库)。
    • 同时,它把这个对象的引用返回给了你的变量 page
  2. 拦截 (select): 当 MyBatis 准备执行 SQL 时,PageHelper 的拦截器介入。它去检查 ThreadLocal,发现里面有个 Page 对象,于是:
    • 改写 SQL:自动在 SQL 后面加上 LIMIT 0, 4
    • 统计总数:自动生成并执行一条 SELECT COUNT(0) 语句(为了计算总页数)。
  3. 填充 (Result): 当数据库返回数据后,拦截器再次从 ThreadLocal 取出那个 Page 对象,将查询结果 addAll 进去,并设置 Total 等属性。
  4. 返回: MyBatis 最终返回给 emps 的,其实就是 ThreadLocal 里那个已经被拦截器填满的 Page 对象。

结论pageemps 在内存中指向同一个对象。PageHelper 利用 ThreadLocal 实现了参数的隐式传递和结果回填。


二、 标准姿势:PageHelper 与 PageInfo 的分工

在日常开发中,我们经常混用这两个类。理清它们的分工非常重要,这决定了你的 API 是否规范。

1. PageHelper:发号施令者 (Action)

  • 作用:告诉插件“下一条 SQL 需要分页”。
  • 时机:必须在 Mapper 查询方法之前调用。
  • 注意startPage 必须紧跟查询语句,中间不要穿插其他逻辑,防止线程变量污染。

2. PageInfo:报表生成器 (Container)

  • 作用:对查询结果 List 进行包装,计算出“总页数”、“导航页码”、“是否有下一页”等前端需要的元数据。
  • 时机:在 Mapper 查询方法之后调用。

标准 Controller/Service 代码模板

public PageInfo<Emp> getEmpList(int pageNum, int pageSize) {
    // 1. 设置分页
    PageHelper.startPage(pageNum, pageSize);

    // 2. 获取数据 (返回的是被 Page 装饰过的 List)
    List<Emp> emps = mapper.selectByExample(null);

    // 3. 包装结果 (生成详细的分页信息给前端)
    // 这里的 5 表示底部导航条显示 5 个页码,如 [1, 2, 3, 4, 5]
    return new PageInfo<>(emps, 5); 
}

三、 数据结构可视化:为什么必须用 PageInfo?

很多开发者最大的困惑在于:“既然 Page 对象里已经有了 total,我直接返回 Page 不行吗?为什么非要包一层 PageInfo?”

我们看一眼两者的 JSON 序列化结果,就一目了然了。

1. Page 对象结构 (Page<E>)

Page 继承自 ArrayList。虽然它有 total 属性,但当它被转为 JSON 时,Jackson/Gson 默认只把它当做数组处理,忽略掉自定义属性。

前端收到的 JSON(惨剧)

[
  { "id": 101, "name": "Smith" },
  { "id": 102, "name": "Allen" }
]

结果:前端一脸懵逼,问你“总条数在哪?”

2. PageInfo 对象结构 (PageInfo<T>)

PageInfo 是一个标准的 POJO,专门为了 API 响应设计。

前端收到的 JSON(完美)

{
  "pageNum": 1,
  "pageSize": 2,
  "total": 10,           // 关键数据:总记录数
  "pages": 5,            // 关键数据:总页数
  "list": [              // 实际数据
    { "id": 101, "name": "Smith" },
    { "id": 102, "name": "Allen" }
  ],
  "isFirstPage": true,
  "hasNextPage": true,
  "navigatepageNums": [1, 2, 3, 4, 5] // 导航条
}

四、 深入解析:为什么会有 SELECT COUNT(0)?

当你执行分页查询时,控制台通常会打印出两条 SQL。

  1. 数据查询SELECT * FROM table LIMIT 0, 10
  2. 总数查询SELECT COUNT(0) FROM table

为什么要执行 Count? 它的唯一目的就是填充 PageInfo 中的 totalpages 字段。如果没有它,前端就无法渲染出“共 50 页”的分页条。

性能代价 对于小表,这无所谓。但如果你的表有 1000 万条数据

  • LIMIT 查询依然很快(配合索引)。
  • COUNT 查询可能会非常慢(需要扫描索引树),甚至导致数据库 CPU 飙升。

启示:如果是给用户看的页面,必须查 Count;但如果是后台批处理任务,请务必关掉它!


五、 进阶实战:如何优雅地循环遍历所有页?

假设你需要导出全量数据,或者对 100 万条数据进行清洗。绝对不能直接 selectAll,那会导致内存溢出 (OOM)。

你需要“分批次”循环读取。这里有两种策略:

策略一:While 循环 + Count 优化(推荐:最快、最省内存)

这种方式适合纯后台批处理,不需要知道总页数。

public void loopAllPages() {
    int pageNum = 1;
    int pageSize = 1000; // 批处理建议设大一点,比如 1000

    while (true) {
        // 关键点:第三个参数 false
        // 告诉插件:我只要数据,不要帮我查总数!(节省大量时间)
        PageHelper.startPage(pageNum, pageSize, false);
        
        List<Emp> emps = mapper.selectByExample(null);

        // 终止条件:查不到数据了
        if (emps == null || emps.isEmpty()) {
            break;
        }

        // 处理当前批次数据
        System.out.println("正在处理第 " + pageNum + " 批,共 " + emps.size() + " 条");
        process(emps);

        // 准备下一页
        pageNum++;
    }
}

为什么这不会 OOM(内存溢出)? 这就像用同一个水桶去打水。 每次循环执行 select 时,emps 变量都会指向一个新的 List 对象(装载新的 1000 条数据)。 上一次循环产生的那个 List 对象,因为已经没有变量引用它了,会迅速被 Java 垃圾回收器(GC)回收,释放内存。 结论:无论数据库有多少亿条数据,你的程序同一时刻占用的内存,永远只有 pageSize 那么多。

策略二:先查总数 + For 循环(适合需要进度条)

如果你需要在前端显示“正在处理 5/100 页”,则需要先获取总数。

// 1. 先查第一页,目的是拿到 Total 和 Pages
PageHelper.startPage(1, 1000); // 默认查 count
List<Emp> firstPage = mapper.selectByExample(null);
PageInfo<Emp> info = new PageInfo<>(firstPage);
int totalPages = info.getPages();

process(firstPage); // 处理第一页

// 2. 循环处理剩余页码
for (int i = 2; i <= totalPages; i++) {
    // 后续查询记得关闭 count,提升性能
    PageHelper.startPage(i, 1000, false); 
    List<Emp> nextBatch = mapper.selectByExample(null);
    process(nextBatch);
}

六、 避坑指南

在使用 PageHelper 时,有两个隐蔽的坑最容易踩:

1. reasonable 参数导致的死循环

如果在 mybatis-config.xmlapplication.yml 中开启了“参数合理化” (reasonable=true):

  • 现象:当你请求的页码 pageNum > totalPages 时(比如总共5页,你查第10页),PageHelper 会自动返回最后一页(第5页)的数据,而不是空集合。
  • 后果:在 策略一while 循环中,因为永远查不到空集合,程序会无限重复处理最后一页数据,造成死循环。
  • 解法:批处理时,务必使用 startPage(p, s, false),或手动检查 if (emps.size() < pageSize) break;

2. DTO 转换丢失 Total

  • 错误写法
    PageHelper.startPage(1, 10);
    List<User> users = mapper.select(); // users 是 Page 对象,有 total
    List<UserDTO> dtos = convert(users); // dtos 是普通 ArrayList,total 丢了!
    return new PageInfo<>(dtos); // 这里的 total 变成了当前页的数量 (10)
    
  • 正确写法
    PageHelper.startPage(1, 10);
    List<User> users = mapper.select();
    
    // 1. 先用查询结果创建 PageInfo,保留分页元数据
    PageInfo<User> info = new PageInfo<>(users);
    
    // 2. 创建新的 PageInfo 并复制属性
    PageInfo<UserDTO> dtoInfo = new PageInfo<>();
    BeanUtils.copyProperties(info, dtoInfo); 
    
    // 3. 填入转换后的数据
    dtoInfo.setList(convert(users)); 
    return dtoInfo;
    

七、 总结

PageHelper 虽小,五脏俱全。

  1. 原理:利用 ThreadLocal 传参,拦截器改写 SQL。
  2. 结构Page 是隐形容器,PageInfo 是展示报表。
  3. Count:是分页的性能杀手,批处理时记得关掉它。
  4. 遍历:使用 while 循环配合 count(false) 是处理海量数据的最佳方案。

希望这篇文章能帮你彻底驯服 MyBatis 分页!