首页 / 资讯中心 / 文章详情

Java分页查询从原理到实战:手写分页与插件避坑指南

Java分页查询从原理到实战:手写分页与插件避坑指南 ★ FEATURED ARTICLE
接手了不少项目之后特别能体会“分页查询”这件事在Java后端开发里的位置——说难不难但真的要做对、做稳、做快牵扯到的细节一点都不少。最近排查一个线上告警某后台管理系统的订单查询接口在数据量过了几万条后响应时间到了四五秒一看代码查询居然把全表数据都查出来再丢给前端去“自己翻页”。这个场景太典型了很多项目初期数据量小的时候感受不到问题等量起来之后分页就成了一个绕不开的关键能力。这篇博文就把Java分页查询这件事从头到尾捋一遍包括为什么分页是后端必选项、pageNum和pageSize是怎么翻译成SQL的、JDBC和MyBatis里手写分页的完整步骤、市面上常用分页插件的集成方式和原理、以及实战里踩过的各种坑。没有太多高深的理论都是可以直接照着改、照着抄的实操经验。1. 分页查询为什么是后端必选项从一次接口超时事故说起1.1 那次线上告警的完整排查过程当时告警信息显示订单查询接口的P99延迟达到了4.8秒调用量并不算大但每次都奇慢。上去看了一段代码逻辑是这样的Service层调用Mapper查询出该用户全部订单返回ListOrder然后在内存里用subList截取一段返回给前端。下单用户只要稍微多买几单这个接口的响应体就很大有些用户累计订单上百条再叠加订单明细的字段一个接口返回光是JSON序列化就要耗费大量时间。更麻烦的是这个接口在网关层又做了响应体大小的限制数据一大就触发告警前端拿到的也是一坨超过预期结构的数据分页组件根本不知道总分页数是多少。这个案例想说明一个很基础的道理分页查询不是“可以加分”的功能而是后端查询接口的基本能力。一个查询接口如果默认把全量数据查出来再交给应用层或前端去“切片”那么随着用户量和数据量的增长迟早会撞上内存、带宽、序列化性能这三堵墙。1.2 没有分页的查询会带来哪几类问题从实际系统设计角度看一个查询接口没有分页问题通常集中在四个方面数据库压力select * from t where xxx不带limit数据库要把所有符合条件的行都扫描出来传输给应用层的成本很高。即使有索引返回大量行本身也会占用大量的网络IO和数据库服务端资源高并发的时候很容易拖垮连接池。应用内存占用数据一旦到了应用层会以对象形式存进JVM堆内存。假设每行数据序列化到Java对象占用200字节一万行就是2MB左右这个尺寸在单个请求里可能不致命但并发100个请求就是200MBGC压力会直接显现。响应时间与前端体验JSON序列化这个环节经常被忽略数据量一多序列化耗时会非线性增长前端解析同样变慢。用户翻页本来只需要20条数据结果却下载了全部数据体验极差。接口契约不稳定没有分页参数的接口返回体结构是“全量list”前端无法知道total、pages这些元信息也就无法正确渲染分页器。很多前端只好自己数长度造成显示不准的问题。1.3 分页查询要解决的核心问题是什么分页查询本质上解决的是“在大数据量集合中如何按需获取某一部分数据”的问题。一个好的分页实现需要同时回答四个问题当前请求要哪一页每页要多少条——对应pageNum和pageSize两个入参。数据库里到底有多少条记录符合条件——对应total用于计算总页数。这一页的数据应该按什么顺序取——对应orderBy排序条件顺序不稳定会导致翻页错乱。如何避免重复和遗漏——对应分页查询的边界条件比如深分页、并发修改、缓存等场景。这四个问题前两个是参数设计后两个是SQL和业务逻辑。理解了这四点后面的代码写起来就不会只停留在“调一下插件”的层面。2. 分页查询的第一性原理pageNum与pageSize如何翻译成SQL很多同学用了很久的分页插件却不知道插件帮你做了什么。其实分页查询落到数据库层面核心就一件事把“第几页、每页多少条”换算成数据库能识别的行数范围。2.1 偏移量与每页条数的换算关系假设前端传参是current当前页码和pageSize每页条数后端在拼接SQL时需要计算偏移量offsetoffset (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写法说明MySQLLIMIT offset, pageSize或LIMIT pageSize OFFSET offset最常用offset从0开始PostgreSQLLIMIT pageSize OFFSET offset语法与MySQL相近但语义更严格Oracle 12cOFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY语义清晰较新版本支持SQL Server 2012ORDER BY ... OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY必须以order by开头H2LIMIT 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_ROWSMySQL一次性拿到总数但该方案在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 PageResultOrder 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. 数据查询SQLuser_id用占位符防注入 String dataSql SELECT id, order_no, amount FROM t_order WHERE user_id ? ORDER BY id DESC LIMIT ?, ?; ListOrder 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 idselectOrderPage resultTypecom.example.Order SELECT id, order_no, amount FROM t_order WHERE user_id #{userId} ORDER BY id DESC LIMIT #{offset}, #{pageSize} /select select idcountOrder resultTypelong SELECT COUNT(*) FROM t_order WHERE user_id #{userId} /select对应的Mapper接口ListOrder 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}) ListOrder 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中体现任何分页参数查询方法就是一个普通的列表查询ListOrder selectOrderListByUserId(Param(userId) Long userId);第四步Service层调用时开启分页。在调用Mapper方法之前通用分页插件会通过一个Page对象把分页参数放到当前线程上下文里并约定最近的那个select方法会被改写成带分页的SQL。调用后拿到的是Page对象它本身是List的子类因此可以无缝地传给前端也可以通过它来获取totalpublic PageResultOrder queryOrderPage(Long userId, int pageNum, int pageSize) { PageOrder page PageHelper.startPage(pageNum, pageSize, true); ListOrder orders orderMapper.selectOrderListByUserId(userId); PageInfoOrder 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结构包含五个核心字段字段类型说明recordsList当前页数据列表totallong总记录数currentlong当前页页码sizelong每页条数pageslong总页数通常根据total和size计算有些场景还需要加is_has_next、is_has_prev这样的便捷字段方便前端无限滚动场景使用。但不管加什么核心五件套一定要稳定。下面是一个完整实现参考public class PageResultT { private ListT records; private long total; private long current; private long size; private long pages; public PageResult() {} public PageResult(ListT 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 PageResultT of(ListT 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 PageResultOrderVO 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层才发现参数异常。很多无意的超大页请求其实都在网关层就能拦截掉这样既保护了数据库又让接口的响应更稳定。分页做得好的项目往往不是因为它用了多么高深的框架而是每一步的细节兜底做得到位。
阅读完成 · 觉得有帮助?
咨询建站