ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot分页查询实战:从LIMIT/OFFSET到PageHelper与深翻页优化

Spring Boot分页查询实战:从LIMIT/OFFSET到PageHelper与深翻页优化 分页查询大概是后端开发里最稀松平常、却又最容易翻车的一个需求了。只要是 Spring Boot 项目几乎绕不开列表页、翻页、搜索尤其是后台管理系统或者面向 C 端的业务列表。每次面试问分页总能听到一堆“哦我用过 PageHelper”、“直接传 page 和 size 不就行了”但真问到底层原理、Count 查询是不是有损耗、深翻页为什么慢、返回结构该不该包一层很多人就支支吾吾了。这篇文章不打算做面面俱到的百科式讲解而是把 Spring Boot 框架下分页查询这条线路串一遍从最基础的 offset 和 limit 原理到基于 MyBatis 生态的 PageHelper 和 MyBatis-Plus 插件玩法再到接口参数设计、Count 性能优化、以及那些文档里不会写清楚的坑。适合刚接触 Spring Boot 想系统搞懂分页方案的初学者也适合写了两三年 CRUD 但没仔细抠过分页细节的同行照着文中的思路可以直接落地到自己的项目里。1. 分页查询的核心思路与方案选型1.1 分页的本质就是一段带着偏移量的 SQL先别急着上框架分页这件事拆到底层无非就是数据库帮你“只取一段数据”。以 MySQL 为例最朴素的分页语句是SELECT * FROM user ORDER BY id DESC LIMIT 10 OFFSET 20;这里LIMIT 10表示取 10 条OFFSET 20表示跳过前面 20 条。换算成页面参数就是第一页取OFFSET 0第二页取OFFSET 10第 N 页取OFFSET (pageNum - 1) * pageSize。用生活化一点的话说这就好比你去食堂排队打饭窗口一次只出 10 份。第一锅是队伍最前面的 10 个人第二锅就要等前 10 个人打完、再往后数 10 个如此类推。OFFSET就是“前面已经打走的人数”LIMIT就是“这一锅出几人”。理解了这一层再去接触各种分页插件你就不会觉得它们神秘了。不管 PageHelper 还是 MyBatis-Plus 的分页插件本质上都在帮你做同一件事在 Mapper 方法执行前把LIMIT ? OFFSET ?动态拼接到 SQL 后面再把数据库返回的结果封装成带“总数、第几页、总页数”这些元信息的结构。不过offset 大偏移量是分页的原罪。当OFFSET 100000的时候数据库依然要把前十万行都扫一遍然后丢弃再往下取 10 条这就解释了为什么“翻到第 100 页就明显变慢”。后面我会专门聊深翻页的优化方案这里先留个印象。1.2 三种主流分页方案怎么选Spring Boot 生态里常见分页方案大致有三条路自己手写 limit、用 PageHelper、用 MyBatis-Plus 的分页插件。我分别说一下它们的适用场景和坑点。方案优点缺点适合场景手写 LIMIT 封装完全可控、不依赖插件、SQL 一眼看清需要自己写 Count、自己封装返回体、容易漏统计SQL 特别简单或公司禁止引入额外依赖的项目PageHelper接入轻、PageHelper.startPage()一调就生效、PageInfo现成ThreadLocal 机制有使用约束、对复杂嵌套 SQL 偶尔判断不准老项目、以传统 MyBatis XML 为主的项目MyBatis-Plus 分页插件与 MP 的BaseMapper.selectPage配套代码极简必须注册MybatisPlusInterceptor部分自定义 SQL 要对IPage参数做特殊处理新项目、底层想省事、大量使用 MyBatis-Plus 的项目我个人的倾向是如果你的项目技术栈是 Spring Boot MyBatis XML那 PageHelper 是最省心的选择如果项目一上来就用了 MyBatis-Plus那没必要额外引 PageHelper用自带的PaginationInnerInterceptor就够了如果项目本身极其简单比如就三五个列表接口未来也没有复杂分页诉求那手写一次也无妨反而能少两个依赖。下面我会以最主流的“Spring Boot MyBatis PageHelper”为主线展开因为这套组合在 Java 后端招聘市场和存量项目中的覆盖面最广。讨论完它之后再补充 MyBatis-Plus 的写法方便你在不同项目之间横跳。2. 手把手搭建分页查询依赖、配置与核心代码2.1 引入依赖和声明拦截器假设你已经有了一个 Spring Boot 项目数据库用 MySQL持久层是 MyBatis。第一步是引入 PageHelper 的 starter 依赖。以 Maven 为例dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency版本别乱选我推荐用 1.4.7。这个版本对 Spring Boot 2.x 支持得很稳网上有大量生产环境验证。如果你用的是 Spring Boot 3.x注意选 2.x 系列的新版本因为底层涉及到 javax 和 jakarta 包名切换的问题。引入依赖之后如果是用 starter 的方式通常不需要额外写Bean配置starter 会自动装配。但如果你是在非 starter 项目里单独引入 PageHelper 核心包那需要在配置类里显式声明Configuration public class MybatisConfig { Bean public PageInterceptor pageInterceptor() { PageInterceptor interceptor new PageInterceptor(); Properties properties new Properties(); properties.setProperty(helperDialect, mysql); properties.setProperty(reasonable, true); interceptor.setProperties(properties); return interceptor; } }其中reasonable这个参数值得单独说一下。把它设为true之后如果请求传的pageNum超过了最大页数PageHelper 不会直接报错而是把页码自动纠正为最后一页页数传 0 或负值时会自动纠正为第一页。这个行为在面向用户的前台页面里很友好但如果你希望“查出空列表就返回空”那反而要谨慎使用。2.2 Service 层最标准的写法startPage 紧贴查询这是整个分页实操里最重要、也最容易被忽略的一条铁律PageHelper.startPage()调用之后必须紧跟第一条 Mapper 查询语句。Override public PageInfoUserVO pageList(UserQuery query) { // 1. 分页参数放在查询之前 PageHelper.startPage(query.getPageNum(), query.getPageSize()); // 2. 紧随其后的这条 Mapper 查询才会被拦截并拼接 limit ListUser userList userMapper.selectByCondition(query); // 3. 用 PageInfo 包装得到分页元数据 return new PageInfo(userList); }为什么强调“必须紧贴”因为 PageHelper 的实现机制是把分页参数保存到了ThreadLocal里然后通过 MyBatis 的拦截器在真正执行 SQL 之前读取这些参数、改写 SQL。如果你在startPage和 Mapper 查询之间又执行了别的数据库操作比如先查了一次角色表、或者循环查了一堆字典拦截器就会判断当前执行的这条 SQL 是否应该被分页。基于“首条 SQL 优先”的原则很容易把分页条件拼接到错误的对象上。我遇到过一个经典的反面例子有同事在startPage()和真正查询之间调了一个异步日志写入方法那个方法内部查了系统配置表结果分页 SQL 跑到了配置表上。前端拿到的不只是数据错乱连总量统计都是错的而且这种 Bug 不仔细排查还非常隐蔽。所以写代码的时候养成习惯startPage的前一行和后一行之间只放与本次查询直接相关的代码中间不要夹杂任何可能触发 SQL 的逻辑。如果查询之前要做复杂的数据加工请把这些加工放在startPage之前。2.3 Mapper 与 XML 层保持简单Mapper 接口不需要做任何特殊处理和平常写查询一样ListUser selectByCondition(Param(name) String name, Param(status) Integer status);对应的 XML 也只需要写正常的业务查询select idselectByCondition resultTypecom.example.entity.User SELECT id, username, status, create_time FROM user where if testname ! null and name ! AND username LIKE CONCAT(%, #{name}, %) /if if teststatus ! null AND status #{status} /if /where ORDER BY create_time DESC /select注意XML 里绝对不要自己写LIMIT也尽量不要写select count(*)语句。这些 PageHelper 都会自动处理它会把你的查询语句包一层变成 count 语句然后执行第二次查询来获取总数。手动加 limit 会引发语法冲突手动写 count 则纯属多此一举。可能有朋友问PageHelper 自动生成的 count 会不会有性能问题会尤其当你的表数据上百万、查询条件又特别复杂的时候。但这时候的正确做法不是自己去写 count而是用后面会提到的countSuffix自定义优化副本或者干脆换成分页结果缓存方案。直接往 XML 里塞 count 属于开倒车它既打破了下划线驼峰映射的统一封装也可能因为和插件拼接的 SQL 不一致导致脏数据。2.4 分页返回体PageInfo 到底给我们带来了什么new PageInfo(list)返回的对象里包含了非常丰富的分页信息在 Controller 层直接返回即可完成联调。常用的字段包括pageNum当前页码pageSize每页条数size当前页实际取到的条数total总记录数pages总页数list当前页的数据列表hasNextPage是否有下一页hasPreviousPage是否有上一页isFirstPage是否为第一页isLastPage是否为最后一页我建议 Controller 层直接返回ResultPageInfoUserVO这类统一结构而不是把list单独拆出来和 total 重新组装。原因很简单如果上游以后要做“回到列表后保持页码”之类的功能需要的就是一整套分页状态信息而且前端需要什么你就给什么减少猜来猜去的沟通成本。不过有一点要注意PageInfo里的list类型是可变的如果你的项目里统一用了Result包装记得检查响应里是否有多余的字段序列化。比如有些老项目会让PageInfo直接暴露给 Vue 或 React 页面前端突然发现多了个navigatepageNums数组往往就是PageInfo没有做裁剪。面对这个情况要么用PageInfo.setNavigatePages()控制导航页码数量要么在 VO 层自己建一个PageResultT类保留需要的字段。我个人更推荐后者因为把分页数据结构稳定住对前后端接口文档约束更友好。3. 分页接口的参数设计与前端配合3.1 参数约定pageNum 从 1 开始还是从 0 开始这是一个看起来很小、实则涉及前后端默契的争议点。我给的建议是项目里统一约定pageNum从 1 开始pageSize默认 10最大不超过 100。为什么从 1 开始因为大多数非技术背景的运营同学看第一页就是“第 1 页”而且 PageHelper 内部就是以 1 为首页来设计的。一个团队如果前后端语言不同、或者在新老接口之间切换最容易出 Bug 的地方就是“前端加了 1后端不认”或者“后端减了 1前端缓存错位”。后端在接收参数时不要盲目信任客户端传来的值。建议在 Service 层统一做参数兜底public class PageParam { private Integer pageNum 1; private Integer pageSize 10; public int getPageNum() { return pageNum null || pageNum 1 ? 1 : pageNum; } public int getPageSize() { if (pageSize null || pageSize 1) { return 10; } return Math.min(pageSize, 100); } }这里把 pageSize 上限兜到 100 的含义是防止有人一下拉 10000 条直接打穿数据库或把接口响应体撑爆。有些团队还会加上排序字段的白名单校验比如只允许传create_time、id杜绝客户端把任意 SQL 片段传给 orderBy。这个我强烈支持后面在讲注入风险时会展开。3.2 Controller 层怎么写得干净又清晰控制层的主要职责是“接参数 转 VO 返回统一结构”。分页参数建议用一个对象接收而不是散落成RequestParam Integer pageNum和RequestParam Integer pageSize。面向未来的扩展你以后还会加orderBy、orderType、keyword都往这个对象里塞接口签名就不会变成一长串。GetMapping(/user/list) public ResultPageInfoUserVO pageList(PageParam pageParam) { PageInfoUserVO page userService.pageList(pageParam); return Result.success(page); }这个写法的好处是URL 传参与PageParam字段一一映射Spring MVC 自动完成绑定省掉大量手工取值赋值。同样的套路也可以用在搜索条件对象上比如UserQuery继承PageParam让查询条件和分页条件天然合体。3.3 动态排序怎么实现搜索列表类接口十个里有八个要排序。最简单的做法是前端传orderBy字段名和orderType后端拼到 SQL 里。但这里有两个隐患第一是 SQL 注入。如果直接把orderBy拼进 SQL等于把 SQL 片段交给了客户端orderByid;DROP TABLE user--这种操作不是危言耸听。安全做法是维护一个白名单只允许映射表结构里真实存在的列名private static final MapString, String ORDER_MAP Map.of( id, id, createTime, create_time, updateTime, update_time );第二是排序是否参与缓存。如果分页接口做了缓存排序字段要作为缓存 key 的一部分否则不同排序结果互相串数据。这个问题比较容易忽略但一旦出现特征就是“点完价格排序显示的却是时间排序的数据”排查起来挺绕。3.4 返回给前端的分页结构一个标准的 JSON 样子假设后端的用户列表接口返回成功前端看到的 JSON 大致长这样{ code: 200, message: success, data: { pageNum: 1, pageSize: 10, total: 203, pages: 21, list: [ { id: 1001, username: zhangsan, status: 1 } ] } }这个结构里最关键的两个字段是total和list。前端不管是做分页器、做“加载更多”、还是做下拉刷新几乎都离不开这两个值。pages可以用来预判滚动条接近底部时是否还有更多数据。至于hasNextPage这类派生字段在移动端场景下能省一次请求但在管理后台这种 PC 分页器里意义不大属于“有则更好、没有也不影响”。4. 扩展场景与进阶优化4.1 多表联查时分页如何保持正确分页查询遇到多表联查是实际项目中绕不过去的坎。典型场景查询用户列表但每个用户要带出最近一笔订单的信息。如果直接在 SQL 里LEFT JOIN订单表就会出现“一个用户有多张订单导致用户被查出多行”的问题。用生活类比解释你打算分页打印一份人员名单名单里每个员工只保留“最近一条培训记录”。如果培训记录有多条直接 join 就会让同一个人在名单里出现多次总数统计也随之失真。解法和思路并不复杂核心是“先分页用户再补全订单信息”第一步用子查询或GROUP BY保证每个用户只出现一行再在这之上分页。第二步把分页得到的用户主键集合传给第二个查询批量查出这些用户的最近订单。第三步在内存中或 Java 层面做一次 Map 归并把订单信息挂到对应用户。这套“分页主表 拼接明细”的套路是我最推荐的做法。它不但能保证 total 和 list 一一对应还能减少大范围 join 时 MySQL 对临时表的排序压力。由于 PageHelper 的核心是拦截并改写主查询只要你第一条 SQL 写的是主表查询、没有乱七八糟的重复行风险那么分页插件就能正常跑。注意千万别让人为DISTINCT与 PageHelper 的 count 生成逻辑打架一旦出现 count 结果和 list 行数明显对不上优先怀疑就是 join 产生的重复行被DISTINCT抵消了但 PageHelper 的 count 可能没识别出这一点。4.2 深翻页优化从 OFFSET 到游标当表数据量巨大、用户又真的翻到很深的页码传统的LIMIT 100000, 10会越来越吃力。数据库需要先读前 100000 行再把它们全部丢弃才能拿到目标 10 行这个成本完全浪费。业界通用的优化手段是“游标分页”也叫 keyset pagination。它不靠 offset 偏移而是基于一个有序的唯一字段比如SELECT * FROM user WHERE id #{lastId} ORDER BY id DESC LIMIT 10这里的lastId是上一页最后一条数据的主键。第二页就是“从上次读到的最小的那个 id 往前再数 10 条”。这种写法即便翻到第 10000 页也只是从某个确定位置往后扫性能波动很小。但游标分页也有它的代价前端不能自由跳页只能上一页下一页。所以它更适合移动端信息流、订单流水这类“用户可以点下一页点到底”的场景而对于后台管理这种“运营想直接输入页码跳到第 100 页”的需求游标分页就不适用了。还有些思路比如“先查出符合条件的 id 子集再 join 原表取详情”虽然能让你精确地对主键集做分页但配合 PageHelper 时要注意插件生成的 count 是否依然高效。实战里我见过把复杂关联查询的分页改成“id 分页”后响应时间从 2 秒降到 200 毫秒的案例。构造方式不复杂先做一个轻量的子查询只取主键再使用 PageHelper 分页主键列表最后用WHERE id IN (...)回表取完整数据。这个方案的优点是深度翻页依然能用缺点是实现要分层没那么无脑。4.3 Count 查询开销怎么降下来PageHelper 每次查询都会自动执行一次 count也就是同一个查询条件跑了两遍 SQL。多数场景下这没什么问题但遇到 select 的子查询特别重或者 join 特别多count 的开销就会拖慢整个接口。PageHelper 提供了countSuffix配置默认是_COUNT。你可以在 mapper XML 里定义同名的 count 查询select idselectUserList_COUNT resultTypelong SELECT COUNT(*) FROM user WHERE create_time NOW() /select这样 PageHelper 就会优先使用你提供的这个 count而不是自己生成。要注意Count 查询是为分页服务的必须和主查询的行数口径一致否则 total 一旦不对分页器会直接“跳页跳飞”。实际开发中我会优先保证把业务查询本身优化好等到 profile 工具确实显示 count 占了明显比例再考虑这个优化不然容易过度设计。4.4 分页结果缓存怎么设计才不翻车对于首页列表、热门榜单这类访问量大、数据变化不频繁的接口给分页结果加缓存是常见优化手段。但分页缓存的坑不在“存不快”而在“一致性问题”新增了一条数据后第 2 页的内容应该发生什么变化最稳妥的做法是给“分页结果”设定一个很短的过期时间比如 60 到 120 秒。短缓存能挡住瞬时高峰又不会让数据长期失真。另一种做法是只缓存“第一页”因为用户第一次进入列表大概率只看第一页后面翻页默认走真实 SQL命中率也很可观。我自己做商城系统时就是先缓存首页列表 搜素关键词的前三页实测下来数据库压力降低得很明显又不会出现运营修改商品后用户还看到旧数据的问题。如果你的业务对实时性极度敏感比如库存、价格类数据那我就不建议对分页结果做缓存了费了老大劲儿最后还是要缓存失效清全场还不如不做。5. 常见问题与排查技巧实录5.1 问题速查表照着对照就能定位现象可能原因排查思路查询结果没分页返回全量数据忘记调用startPage或startPage与 Mapper 查询之间插入了其他 SQL 操作检查 Service 层调用顺序确认 startPage 后面紧跟主查询total与list行数对不上多表 join 产生重复行或 count 被DISTINCT干扰单独执行 count 和主查询对比行数差异报错runtime: 分页查询没有找到要分页的数据startPage 后第一条 SQL 不是分页目标 SQL被拦截到了别的 Mapper 上检查 startPage 与目标查询之间是否有额外查询分页结果排序失效顺序乱PageHelper 拦截后及 ORDER BY 与 LIMIT 的拼接顺序问题或传入的排序字段未做白名单查看打印的 SQL 是否包含完整 ORDER BY检查 XML 书写位置翻到深页码时接口极慢OFFSET 过大、数据库全表扫描考虑游标分页或 id 分页方案传到前端的 PageInfo 字段太多PageInfo 默认带了很多导航字段VO 层重新封装或使用PageInfo.setNavigatePages()Count 查询占用大量时间自动生成的 count 较复杂使用countSuffix自定义 count或优化主查询并发场景下偶尔分页参数互相串ThreadLocal 在异步线程池中没有传递确保分页逻辑在独立线程内完成或显式清理 ThreadLocal5.2 踩过的坑startPage 放在循环里我曾经排查过一个诡异问题一个批量接口里循环处理多个用户的订单每次循环都调用一次分页查询结果前几次查询的数据居然串了。问题根源就是循环内每次调用新的startPage但上一次查询可能因为某个异常中断没有执行对应的 mapper 查询ThreadLocal 里的旧参数被下一次查询消费了。PageHelper 的防御策略是在每次startPage时清理旧的 ThreadLocal 参数但如果代码路径复杂异常分支里没走查询就仍然有可能带着上一个循环留下的参数。我的建议是不要把分页逻辑放在循环里尽量在循环外先拿出整页数据再加工。如果实在避免不了可以在每个分页查询之后调用一次PageHelper.clearPage()确保底层线程局部变量被清空。这个坑在单元测试里很难暴露因为测试环境通常是单线程串行执行而且数据量小参数错了也不一定看出来。真正生产环境多线程并发一上来偶发的错乱会让你很难复现。宁可代码里多写一行显式清理也别赌运行环境。5.3 一个典型的排查流程列表数据“多了一页”事件背景运营反馈后台商品列表分页不准一共 100 条数据每页 10 条应该只有 10 页但点第 11 页还能看到数据。我的排查步骤大致是这样第一步查看后端打印的 SQL。因为 PageHelper 会打印被改写后的语句如果里面有LIMIT 100, 10说明 offset 计算本身没问题那问题就出在总数统计上说明total被算小了。第二步手动执行 count 和主查询。我发现主查询有 join 商品分类表同一个商品因为分到多个分类被查出重复行直接导致 list 里出现重复商品而 PageHelper 生成的 count 是基于 join 后的结果去计数的所以 total 天然大于真实商品数。奇怪的是 total 应该偏大才对怎么会偏小再一查原来主查询外面又包了一层DISTINCT这会把重复行去掉但 count 的生成可能没包含同样的 DISTINCT 逻辑口径对不上了。第三步改法是重写 mapper先在一个子查询里对商品去重再对子查询结果分页。这样 PageHelper 生成的 count 和 list 都是基于去重后的结果total 立刻恢复正常。这次排查看似简单但暴露了一个共性问题分页接口里的 SQL 一定要保证“主查询的行数”和“count 统计口径”天然一致任何使用了 DISTINCT、GROUP BY、多表连接的通病都需要在设计阶段就考虑清楚否则迟早要返工。5.4 关于 sql 日志与自我检查开发阶段一定要把 MyBatis 的 SQL 日志打印开关打开。Spring Boot 里配置mybatis: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl然后每次请求分页接口你会在控制台看到类似下面的日志片段 Preparing: SELECT count(0) FROM (SELECT id, username FROM user WHERE status ?) table_count Preparing: SELECT id, username FROM user WHERE status ? ORDER BY create_time DESC LIMIT 10看到这两条 SQL分页基本是成功的。如果第一条 count 的 SQL 和你预期的业务查询明显对不上趁早分析和纠正比等到联调阶段去抓包来查要快得多。这也是我非常推荐的“日志先行”思路调接口之前先确认底层 SQL 长什么样再谈其他。5.5 分页超时该如何设置如果列表接口本身查询就慢不要在分页上硬调那是治标不治本。给数据库加查询超时是必要的兜底手段比如在连接池或框架层面配置statement查询超时时间。Spring Boot 的spring.datasource.hikari.connection-timeout控制的是获取连接的超时不是执行超时执行超时得在 JDBC 驱动 URL 上加socketTimeout60000或者给 Mapper 接口方法加Options(timeout 10)。有人会问这跟分页有什么关系关系大了。深翻页慢查询一旦拖死连接池所有接口都会雪崩。给分页查询带上超时相当于给系统装了一根保险丝宁可这一页报错也不能拖垮全局。6. 分页查询这事我还有几句经验要交代老实说分页查询本身不难难的是分页背后那些没写进文档的判断和取舍。从最原始的LIMIT/OFFSET到 PageHelper 的自动拼接再到多表联查时的数据口径校准、深翻页场景的游标改造每一步都是在“简单可用”和“高性能”之间做权衡。从实用角度出发我给新人三条建议第一先掌握最朴实的分页原理用 SQL 手写一次搞清楚 offset 和 limit 到底在干什么第二用 PageHelper 这类插件提升开发效率时一定要理解它的 ThreadLocal 机制和“第一 SQL 被拦截”的规则不然你在异步多线程场景下迟早会踩坑第三遇到分页性能问题时别急着怪插件先去查 SQL 执行计划看看是不是 join 条件缺索引、深翻页 offset 过大、或者 count 查询太重。这些东西说起来都是老生常谈可每次在生产环境里真刀真枪排一次印象都会更深一层。我自己经历过半夜看日志追分页数据错乱也经历过用游标分页把一个秒级接口优化到几十毫秒。分页这一关过了你对 Spring Boot 后端项目的整体数据流把控都会上一个台阶。
返回列表