接手了不少项目之后,特别能体会“分页查询”这件事在Java后端开发里的位置——说难不难,但真的要做对、做稳、做快,牵扯到的细节一点都不少。最近排查一个线上告警,某后台管理系统的订单查询接口在数据量过了几万条后响应时间到了四五秒,一看代码,查询居然把全表数据都查出来再丢给前端去“自己翻页”。这个场景太典型了,很多项目初期数据量小的时候感受不到问题,等量起来之后,分页就成了一个绕不开的关键能力。
这篇博文就把Java分页查询这件事从头到尾捋一遍,包括为什么分页是后端必选项、pageNum和pageSize是怎么翻译成SQL的、JDBC和MyBatis里手写分页的完整步骤、市面上常用分页插件的集成方式和原理、以及实战里踩过的各种坑。没有太多高深的理论,都是可以直接照着改、照着抄的实操经验。
1. 分页查询为什么是后端必选项:从一次接口超时事故说起
1.1 那次线上告警的完整排查过程
当时告警信息显示订单查询接口的P99延迟达到了4.8秒,调用量并不算大,但每次都奇慢。上去看了一段代码,逻辑是这样的:Service层调用Mapper查询出该用户全部订单,返回List<Order>,然后在内存里用subList截取一段返回给前端。下单用户只要稍微多买几单,这个接口的响应体就很大;有些用户累计订单上百条,再叠加订单明细的字段,一个接口返回光是JSON序列化就要耗费大量时间。
更麻烦的是,这个接口在网关层又做了响应体大小的限制,数据一大就触发告警,前端拿到的也是一坨超过预期结构的数据,分页组件根本不知道总分页数是多少。
这个案例想说明一个很基础的道理:分页查询不是“可以加分”的功能,而是后端查询接口的基本能力。一个查询接口如果默认把全量数据查出来再交给应用层或前端去“切片”,那么随着用户量和数据量的增长,迟早会撞上内存、带宽、序列化性能这三堵墙。
1.2 没有分页的查询会带来哪几类问题
从实际系统设计角度看,一个查询接口没有分页,问题通常集中在四个方面:
- 数据库压力:
select * from t where xxx不带limit,数据库要把所有符合条件的行都扫描出来,传输给应用层的成本很高。即使有索引,返回大量行本身也会占用大量的网络IO和数据库服务端资源,高并发的时候很容易拖垮连接池。 - 应用内存占用:数据一旦到了应用层,会以对象形式存进JVM堆内存。假设每行数据序列化到Java对象占用200字节,一万行就是2MB左右,这个尺寸在单个请求里可能不致命,但并发100个请求就是200MB,GC压力会直接显现。
- 响应时间与前端体验:JSON序列化这个环节经常被忽略,数据量一多,序列化耗时会非线性增长,前端解析同样变慢。用户翻页本来只需要20条数据,结果却下载了全部数据,体验极差。
- 接口契约不稳定:没有分页参数的接口,返回体结构是“全量list”,前端无法知道
total、pages这些元信息,也就无法正确渲染分页器。很多前端只好自己数长度,造成显示不准的问题。
1.3 分页查询要解决的核心问题是什么
分页查询本质上解决的是“在大数据量集合中,如何按需获取某一部分数据”的问题。一个好的分页实现,需要同时回答四个问题:
- 当前请求要哪一页?每页要多少条?——对应
pageNum和pageSize两个入参。 - 数据库里到底有多少条记录符合条件?——对应
total,用于计算总页数。 - 这一页的数据应该按什么顺序取?——对应
orderBy排序条件,顺序不稳定会导致翻页错乱。 - 如何避免重复和遗漏?——对应分页查询的边界条件,比如深分页、并发修改、缓存等场景。
这四个问题,前两个是参数设计,后两个是SQL和业务逻辑。理解了这四点,后面的代码写起来就不会只停留在“调一下插件”的层面。
2. 分页查询的第一性原理:pageNum与pageSize如何翻译成SQL
很多同学用了很久的分页插件,却不知道插件帮你做了什么。其实分页查询落到数据库层面,核心就一件事:把“第几页、每页多少条”换算成数据库能识别的行数范围。
2.1 偏移量与每页条数的换算关系
假设前端传参是current(当前页码)和pageSize(每页条数),后端在拼接SQL时需要计算偏移量offset:
offset = (current - 1) * pageSize为什么是减1?因为前端说的“第1页”在数据库行里对应的是从第0行开始的连续区间。比如pageSize = 20:
- 第1页:从第0行开始取20条,即
LIMIT 0, 20 - 第2页:从第20行开始取20条,即
LIMIT 20, 20 - 第3页:从第40行开始取20条,即
LIMIT 40, 20
这个公式看起来简单,但它有两个极端情况需要注意:第一,current如果小于1,要强制设为1,否则会出现负偏移量的SQL错误;第二,pageSize需要一个上限保护,常见的做法是如果超过100就截断为100,防止有人恶意传一个100万的值把数据库打崩。
2.2 不同数据库方言的分页SQL写法
Java后端要面对的数据库往往不止一种,老项目可能是MySQL,新项目可能上了PostgreSQL,银行类项目可能还在用Oracle。分页SQL在各大数据库里的写法差异不小,我把常用的列一下:
| 数据库 | 分页SQL写法 | 说明 |
|---|---|---|
| MySQL | LIMIT offset, pageSize或LIMIT pageSize OFFSET offset | 最常用,offset从0开始 |
| PostgreSQL | LIMIT pageSize OFFSET offset | 语法与MySQL相近,但语义更严格 |
| Oracle 12c+ | OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY | 语义清晰,较新版本支持 |
| SQL Server 2012+ | ORDER BY ... OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY | 必须以order by开头 |
| H2 | LIMIT pageSize OFFSET offset | 测试库常用,兼容MySQL |
这里有一个比较隐蔽的问题:Oracle等数据库如果采用老式ROWNUM分页写法,外层查询排序时机稍有不对,就可能导致分页数据错乱,所以我在项目里一般建议能上OFFSET/FETCH语义的就用新语法,别手动拼ROWNUM来折腾。
2.3 为什么分页通常需要两条SQL而不只是一条
一个完整的分页返回结构,除了当前页的records,还需要total总记录数。前端要知道总共有几页,从而渲染页码。total的获取常见有几种方案:
- 方案A:单独执行
SELECT COUNT(*) FROM t WHERE 条件,再执行数据分页查询。 - 方案B:在分页数据SQL中通过
SQL_CALC_FOUND_ROWS(MySQL)一次性拿到总数,但该方案在MySQL 8.0之后已不建议继续使用。 - 方案C:不查总数,前端通过“是否还有下一页”的方式近似分页,这种方案适合“无限滚动”场景,不适合传统的页签式分页。
项目实践里绝大多数场景选择方案A,两条SQL虽然多一次数据库往返,但逻辑清晰、可读性好,性能影响可以通过count查询的优化来抵消。真正的重点反而在于:count查询要做到即使不加载具体行,也能通过索引覆盖或子查询改写来获得高性能。这一点后面“优化章节”还会专门讲。
3. 从零手写分页:JDBC与MyBatis两种方式的完整步骤
在引入封装好的分页插件之前,先掌握手写分页的思路很重要,这样才能在框架失效、SQL复杂、团队特殊需求时自己写一个正确可靠的分页。
3.1 JDBC 手写分页的完整链路
最原始的场景就是使用JDBC直接查询数据库。分页查询的关键在于手动拼接SQL,但拼接绝不等于字符串拼出完整SQL——为了防止SQL注入,参数得用占位符?来传递。
一个标准的JDBC分页查询步骤大致如下:
- 先从请求参数中拿到
pageNum和pageSize,计算offset。 - 根据数据库方言,编写分页SQL模板,例如MySQL下使用
LIMIT ?, ?。 - 通过
PreparedStatement设置offset和pageSize参数。 - 执行查询,遍历
ResultSet并封装为业务对象。 - 单独执行一条
COUNT语句获取总数,封装返回结果。
我举个MySQL场景的最小代码片段,方便你理解这个完整链路:
public PageResult<Order> queryByPage(int pageNum, int pageSize, long userId) { // 1. 计算偏移量,并对非法参数做保护 int safePageNum = Math.max(pageNum, 1); int safePageSize = Math.min(Math.max(pageSize, 1), 100); int offset = (safePageNum - 1) * safePageSize; // 2. 数据查询SQL,user_id用占位符防注入 String dataSql = "SELECT id, order_no, amount " + "FROM t_order WHERE user_id = ? ORDER BY id DESC LIMIT ?, ?"; List<Order> records = new ArrayList<>(); try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(dataSql)) { stmt.setLong(1, userId); stmt.setInt(2, offset); stmt.setInt(3, safePageSize); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { Order order = new Order(); order.setId(rs.getLong("id")); order.setOrderNo(rs.getString("order_no")); order.setAmount(rs.getBigDecimal("amount")); records.add(order); } } } catch (SQLException e) { throw new RuntimeException("分页查询失败", e); } // 3. 总数查询SQL long total = 0; String countSql = "SELECT COUNT(*) FROM t_order WHERE user_id = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(countSql)) { stmt.setLong(1, userId); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { total = rs.getLong(1); } } } catch (SQLException e) { throw new RuntimeException("总数查询失败", e); } return PageResult.of(records, total, safePageNum, safePageSize); }这段代码虽然简单,但已经是一个比较完整的分页链路。实际的代码中还会把COUNT和数据查询放进同一个事务边界,并考虑连接复用,避免每次查询都新建连接。手写JDBC最大问题不是写不出来,而是每张表都要复制一遍模板代码,维护成本比较高。
3.2 MyBatis 手写分页的三种常见姿势
MyBatis是Java后端目前最常用的持久层框架,在不用额外插件的情况下,手写分页一般有三种姿势:
姿势一:Mapper XML中动态拼接。
这种方式最直观,在Mapper XML文件里写LIMIT #{offset}, #{pageSize},同时加一个count查询方法。
<select id="selectOrderPage" resultType="com.example.Order"> SELECT id, order_no, amount FROM t_order WHERE user_id = #{userId} ORDER BY id DESC LIMIT #{offset}, #{pageSize} </select> <select id="countOrder" resultType="long"> SELECT COUNT(*) FROM t_order WHERE user_id = #{userId} </select>对应的Mapper接口:
List<Order> selectOrderPage(@Param("userId") Long userId, @Param("offset") int offset, @Param("pageSize") int pageSize); long countOrder(@Param("userId") Long userId);Service层调用的时候,先count,再查询数据,最后封装。这种方式的好处是SQL完全掌握在自己手里,复杂关联、多表聚合都可以随意表达;缺点也很明显,每个需要分页的查询都要写两个方法,表一多,XML文件里到处是这种重复结构。
姿势二:@Select注解写SQL。
适合简单场景,不是特别推荐,因为动态条件一多,注解里的<script>看起来会很乱:
@Select("SELECT id, order_no, amount FROM t_order " + "WHERE user_id = #{userId} ORDER BY id DESC " + "LIMIT #{offset}, #{pageSize}") List<Order> selectOrderPage(@Param("userId") Long userId, @Param("offset") int offset, @Param("pageSize") int pageSize);姿势三:使用RowBounds/物理分页参数。
MyBatis内置了一个RowBounds类,可以在Mapper方法里作参数传入,但要注意:RowBounds默认属于内存分页,它会把所有满足条件的结果加载到内存再截取,数据量大时非常危险。
在配置了pageSize相关的物理分页方言之后,它才能触发对SQL的改写,不同版本和不同配置下行为不一致,所以我建议:只要数据量可能过万,就不要依赖RowBounds做“默认分页”,宁可自己写LIMIT。
3.3 手写分页和通用分页插件的取舍
用了一段时间手写分页后,我觉得它最大的价值在于“被迫理解了分页的本质”。但到了项目中期,表数量增多、查询条件复杂,再手写就显得笨重了。
手写分页的优缺点:
| 维度 | 手写分页 | 通用分页插件 |
|---|---|---|
| SQL可见性 | 完全可见,易审查 | 运行时动态改写,需要理解插件原理 |
| 复杂SQL适配 | 灵活,随时调整 | 部分子查询/关联查询需要特殊处理 |
| 代码量 | 每个查询需要额外实现count逻辑 | 自动生成count,代码少 |
| 性能可控性 | 每条SQL可单独优化 | 统一策略,复杂场景需手动补充 |
| 学习成本 | 门槛低 | 需要了解拦截器机制 |
4. 项目里常用的MyBatis分页插件集成步骤与原理浅析
大部分Java项目最终都会选择通用分页插件来减少重复劳动,集成起来非常快,但要想用得稳,还是得知道它底层做了什么。
4.1 集成步骤:依赖接入与拦截器注册
以MyBatis生态最常用的分页插件为例,集成分四步:
第一步,引入依赖。在pom.xml中加入分页插件的依赖包。注意版本要和当前使用的MyBatis版本兼容,否则可能出现拦截器不生效的情况。
第二步,在配置类或MyBatis配置中注册分页拦截器。
用Spring Boot的配置类写法大概是这个风格:
@Configuration public class MyBatisConfig { @Bean public SqlSessionFactoryBean sqlSessionFactory(DataSource dataSource) { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); // 直接构建分页拦截器并设置一些参数 Interceptor pageInterceptor = new PageInterceptor(); Properties props = new Properties(); props.setProperty("supportMethodsArguments", "true"); props.setProperty("reasonable", "true"); props.setProperty("helperDialect", "mysql"); pageInterceptor.setProperties(props); factoryBean.setPlugins(new Interceptor[]{pageInterceptor}); return factoryBean; } }如果项目是对老SSM工程进行改造,也可以直接在mybatis-config.xml里加<plugins>节点。注册之后插件会拦截Executor层的查询方法,在不改变现有SQL的前提下做二次处理。
第三步,Mapper接口里写普通查询方法。不需要在SQL中体现任何分页参数,查询方法就是一个普通的列表查询:
List<Order> selectOrderListByUserId(@Param("userId") Long userId);第四步,Service层调用时开启分页。在调用Mapper方法之前,通用分页插件会通过一个Page对象把分页参数放到当前线程上下文里,并约定最近的那个select方法会被改写成带分页的SQL。调用后拿到的是Page对象,它本身是List的子类,因此可以无缝地传给前端,也可以通过它来获取total:
public PageResult<Order> queryOrderPage(Long userId, int pageNum, int pageSize) { Page<Order> page = PageHelper.startPage(pageNum, pageSize, true); List<Order> orders = orderMapper.selectOrderListByUserId(userId); PageInfo<Order> pageInfo = new PageInfo<>(orders); return PageResult.of(pageInfo.getList(), pageInfo.getTotal(), pageInfo.getPageNum(), pageInfo.getPageSize()); }4.2 插件工作原理:SQL自动改写的两个关键动作
通用分页插件能够“拦截”原本没有分页语义的SQL,核心依赖的是MyBatis的插件机制。MyBatis允许开发者自定义Interceptor,在四大核心对象(Executor、StatementHandler、ParameterHandler、ResultSetHandler)创建和调用时插入自己的逻辑。
分页插件的拦截点主要在Executor.query方法附近。它拿到原始查询SQL之后,会做两个动作:
- 改写count SQL:如果你允许插件自动查总数,它会基于原始SQL生成一条
SELECT COUNT(*)语句,尽量去掉原SQL中的ORDER BY,避免不必要的排序开销。 - 改写page SQL:它会根据配置的数据库方言,在原始SQL末尾拼接对应的分页语法,比如MySQL下追加
LIMIT ?, ?,PostgreSQL下追加LIMIT ? OFFSET ?。
正是因为这种“运行时改写”,在使用插件时有一个逻辑必须明白:分页插件只对紧随其后的第一条查询语句生效。业务代码里如果先执行了别的查询,再执行目标查询,分页参数就会被错误消耗,页面数据完全对不上。有些框架做了参数的线程隔离和自动清理,但最佳实践仍然是让startPage和Mapper调用尽量紧邻。
4.3 插件集成后的边界条件与注意事项
用了插件并不代表万事大吉。我见过好几次“分页插件分页无效”的报障,最后定位发现是以下三种情况:
- SQL中已存在
LIMIT手写分页,插件不会正确二次拼装,通常表现为分页后数据为空或SQL报错。 - 查询返回的是
Map而不是List,插件拦截逻辑有时候拿不到预期的结果类型,导致total为0。 - 使用了包含
WITH或GROUP BY的复杂查询,插件自动生成的count语句可能不符合业务语义,比如统计的是分组后的组数而不是记录数。这种情况就需要手动提供countQuery,让插件使用指定的count语句。
所以,集成插件的时候最好先跑一个最简单的分页Demo,确认total和records都对,再处理复杂查询。这里尤其推荐在集成早期就把“复杂查询的分页count正确性”纳入测试用例,不然后面回归起来非常痛。
5. 分页查询实战中踩过的坑:count不准、深分页、排序错乱与缓存干扰
分页的坑,通常在数据量小的时候完全不会显现,等到数据量大了,一个比一个扎心。我把在实践中遇到过的几个高频坑整理一下,每个都给排查思路和解决方案。
5.1 count查询非常慢:问题的根源不在分页,而在统计
某次性能压测,SQL单查数据页只要几十毫秒,但整个分页接口却要两秒多。用数据库工具一分析,慢在自动生成的select count(*)上。
排查链路是这样的:表的WHERE条件字段虽然有索引,但count查询里有一个OR条件,索引优化器对OR的支持通常不如AND理想,导致它做了全表扫描。我们在压测环境给一张千万级表做count,没有命中任何索引的情况下,扫描非常致命。
这类问题的解决思路有三个层次:
- 第一层:给where条件字段设计合适的组合索引。比如经常按
user_id和status两个条件过滤,那么建(user_id, status)的联合索引,而不是分开建两个单列索引,这样count就能走索引扫描而非全表扫描。 - 第二层:简化count查询的join。如果分页主查询涉及多张关联表,但统计总数只需要主表的行数,手动指定count语句只查主表,能显著降低复杂度。
- 第三层:业务容忍非精确总数。有些列表场景并不需要精确到每条记录数,比如后台操作日志,可以给总数加缓存,定时刷新,避免每次请求都执行一次大
count。
另外提一个容易忽略的参数:COUNT语句中不要保留大字段或不需要的列,它只关心行数,和具体列无关。
5.2 深分页性能崩塌:limit的偏移量越大越慢
LIMIT 100000, 20在MySQL里看着只是取20条,实际上数据库要把前100020条都扫描并丢弃后才能得到结果。翻到几十页之后,响应时间会成倍增加。
排查时用EXPLAIN看执行计划会看到一个典型现象:即使ORDER BY id走了主键索引,也要扫描前十万行才能定位到目标页。
解决深分页的方法主要有三种:
- 延迟关联:先只查主键
id,拿到这页的id集合,再用id IN (...)去关联查询完整记录。 - 基于游标或键集分页:前端不再传
pageNum,而是传“上一页最后一条记录的id”,SQL写成WHERE id < ? ORDER BY id DESC LIMIT 20。优点是翻页越深性能越稳定,缺点是无法直接跳转到任意页。 - 限制最大翻页深度:限制用户最多翻100页,超过后提示使用查询条件缩小范围。这在业务上完全站得住脚,对后台列表类功能尤其实用。
我测试过一个1000万行的场景,LIMIT 900000, 20耗时在1秒以上,改成键集分页后耗时降到10毫秒级别,差距是两个数量级。所以我现在的建议是:普通后台管理分页用经典pageNum/pageSize,数据量大且有“只能顺序翻页”场景时,优先考虑键集分页。
5.3 排序错乱导致同一行数据重复出现
分页查询如果排序字段不唯一,会出现“上一页出现过的记录,下一页又出现了”,或者某条记录被漏掉。这个问题的根子不在SQL语法,而在于数据库对重复排序值的返回顺序是不确定的。
举个例子,ORDER BY create_time,这一列有很多条记录是同一秒创建的。数据库返回这20条时,它的内部顺序可能依赖于物理存储位置、索引扫描顺序等不稳定的因素。当翻页偏移量变化时,同一时刻的记录是否落在同一页内,就可能乱掉。
我的标准修复方案是“唯一排序兜底”:主排序字段保持业务语义,末尾一定追加一个绝对唯一的字段。比如:
SELECT id, order_no, create_time FROM t_order WHERE user_id = #{userId} ORDER BY create_time DESC, id DESC LIMIT #{offset}, #{pageSize}这样即使create_time完全重复,id DESC也能稳定地全序排列,每一行的位置就是确定的。实践下来,这个改动对分页正确性的提升非常明显,是分页查询最值得养成的一个习惯。
5.4 缓存与分页数据不一致
列表查询加缓存是常见优化,但分页数据加缓存非常容易出错。我第一次踩这个坑是在一个配置管理列表上:接口被用户刷得很频繁,团队给列表加了一层本地缓存,缓存key用pageNum和pageSize拼接。结果某条记录被修改后,缓存没有及时失效,用户翻了几页看到的还是旧数据。
这里要理解分页查询结果和单条对象数据的本质区别:单条对象可以被ID定位,缓存失效策略很容易设计;分页列表是多个条件的组合结果,任何一个条件变化,缓存都可能失效。
如果业务对实时性要求很高,我的建议是不要轻易缓存分页结果。如果确实要缓存,可以采取“短TTL+业务主动删除”的双重策略,并且在设计缓存key时把查询条件、排序条件、分页参数全部包含进去。另外,很多团队采用“缓存总记录数,不缓存具体页数据”的方案,也能缓解count查询压力,数据的实时性还能保持。
5.5 对象属性映射与序列化导致的翻页异常
分页查询本身没错,但返回给前端时,如果DTO/VO里字段类型不对,也会出现各种诡异现象。最常见的有两种:一是total返回的是Long,前端JS精度不够导致大数失真;二是嵌套对象循环引用,Jackson序列化直接抛异常。
我的做法是:分页返回结构里的total、pages这类数值,统一用long在服务端算好,前端JS里用字符串处理或直接不展示超过安全范围的值;嵌套对象则通过@JsonIgnore或专门的DTO来打断循环引用。分页查询的终点是“稳定的接口契约”,数据结构设计得不干净,SQL再快也白搭。
6. 分页结果统一封装与前端交互的推荐做法
分页实现的最后一块拼图,是对外返回结构的设计。很多项目初期是直接把Page对象塞给前端,导致前端必须依赖某个框架的返回格式,换一套前端就崩。我推荐的方案是自定义一套统一的分页结果结构,让后端和前端都只依赖这套契约。
6.1 统一分页返回结果的字段设计
一个经典的PageResult结构包含五个核心字段:
| 字段 | 类型 | 说明 |
|---|---|---|
| records | List | 当前页数据列表 |
| total | long | 总记录数 |
| current | long | 当前页页码 |
| size | long | 每页条数 |
| pages | long | 总页数,通常根据total和size计算 |
有些场景还需要加is_has_next、is_has_prev这样的便捷字段,方便前端无限滚动场景使用。但不管加什么,核心五件套一定要稳定。下面是一个完整实现参考:
public class PageResult<T> { private List<T> records; private long total; private long current; private long size; private long pages; public PageResult() {} public PageResult(List<T> records, long total, long current, long size) { this.records = records; this.total = total; this.current = current; this.size = size; this.pages = (total + size - 1) / size; } public static <T> PageResult<T> of(List<T> records, long total, long current, long size) { return new PageResult<>(records, total, current, size); } }pages的计算公式(total + size - 1) / size是一个向上取整的写法,避免total为0时出现除零或负数问题。要注意的一点是:current和size必须使用业务层处理后的安全值,而不是用户传入的原始值,否则前端拿到的“当前页”和实际返回的数据对不上。
6.2 请求参数的统一约定与消息转换方式
前端请求分页接口时,通常有两种传参习惯:一种是通过请求参数直接传pageNum和pageSize,另一种是传一个PageRequest对象。我更推荐后者,因为它能统一处理类型转换、默认值、效验逻辑。
public class PageRequest { private long current = 1; private long size = 10; public long getCurrent() { return Math.max(current, 1); } public long getSize() { return Math.min(Math.max(size, 1), 100); } }后端控制器接收时这样写,参数结构就非常清楚了:
@GetMapping("/orders") public PageResult<OrderVO> queryOrders(PageRequest pageRequest, @RequestParam Long userId) { return orderService.queryPage(userId, pageRequest); }6.3 多表关联与聚合查询的分页处理
分页查询有时候会碰上多表关联聚合,直接把JOIN结果分页是有风险的。比如订单主表要和订单明细表关联得到商品数量,如果明细表一条订单有多行,JOIN之后的总记录数就会多于订单数,直接分页会出现“订单重复出现”或“总数虚高”。
正确做法通常是先分页主表,再查询明细聚合。具体来说就是分两步:
- 查询当前页的主表ID集合:
SELECT id FROM t_order WHERE ... ORDER BY ... LIMIT ... - 再根据ID集合去关联查询明细,并把明细按ID分组后合并到结果中。
这种写法有一个额外的好处:关联查询发生在很小的ID集合上,性能反而比一次性大JOIN更可控。很多ORM框架可以通过@BatchSize或IN查询来优雅地实现这一点。
6.4 分页接口自测清单
项目上线前,我会给分页接口列一份自测清单,照着检查一遍,大部分翻页问题都能及时暴露:
- 第一页返回的总数和实际数据条数是否一致?
- 最后一页的数据条数是否小于等于
pageSize? - 每页之间有没有重复记录?有没有遗漏记录?(对同一个排序集合做连续翻页,然后合并对比)
pageNum传0、负数、超大值时,接口是否还能返回稳定且合理的响应?pageSize传0、负数、超上限时,是否被正确截断?- 排序字段有重复值时,是否追加了唯一排序兜底字段?
- 去掉分页参数时,是否走默认第一页、默认每页条数?
我通常会在测试环境用固定数据集跑一遍这份清单,尤其是“重复/遗漏”的检测,在深分页场景下最容易暴露问题。等这份清单过了,分页接口基本就能安心出门了。
最后再分享一个小技巧:在微服务里写分页的时候,一定要在网关或统一过滤器里对pageNum和pageSize做一次拦截校验,不要等到Service层才发现参数异常。很多无意的超大页请求,其实都在网关层就能拦截掉,这样既保护了数据库,又让接口的响应更稳定。分页做得好的项目,往往不是因为它用了多么高深的框架,而是每一步的细节兜底做得到位。