尧图精选

PageHelper 分页原理与实战:从 LIMIT 改写到底层优化

🕒 发布时间:2026/10/2 10:00:41 📁 来源:尧图网络
做Java后端这么多年每次接到列表接口的需求我心里都会先过一遍这个表数据量会不会涨分页要怎么分很多老项目早期图省事直接ListBook all bookMapper.selectAll();然后前端要第几页就subList一下等数据量到了几十万接口开始变慢、内存跟着飙才想起来该上物理分页。我这边项目后来统一用 PageHelper它就是 MyBatis 生态里最常用的物理分页插件。说得直白点它的核心工作就是在你执行查询 SQL 之前把语句悄悄改写成带LIMIT的 SQL让数据库只返回你需要的那几行。而LIMIT这条语句正是 SQL 查询语言里用于限制返回数据行数的关键字。这篇文章我会从分页方案选型背后的逻辑、PageHelper 的拦截器原理到 Spring Boot 里的完整配置与踩坑再到深分页优化和面试高频题一层一层剥开来讲。适合正在用 MyBatis 写接口的 Java 开发也适合准备面试时被问到“PageHelper 原理”和“逻辑分页物理分页区别”这类问题的人。看完你不仅能正常使用它还能在团队里讲清楚它为什么快、什么时候该换方案。1. 分页方案背后的取舍为什么物理分页必须依赖 LIMIT1.1 逻辑分页与物理分页从一次慢接口说起分页这件事早期很多代码都是这么写的先查出全部数据然后在内存里手动截取当前页需要的部分。比如list.subList((pageNum-1)*pageSize, pageNum*pageSize)。这种方案叫逻辑分页也叫内存分页。逻辑分页最大的优点是实现成本低业务代码不用感知数据库方言只要 SQL 能查出全量数据就行。但它的代价是“无论你要哪一页数据库都得把全表数据查出来再通过网络传给应用服务器”。数据量一旦上了十万级每次分页请求都是一次全表扫描加全量数据传输接口响应时间从几十毫秒变成几百毫秒甚至几秒应用内存里的垃圾对象也在急速堆积GC 压力随之暴涨。我见过一个老系统列表接口高峰期把堆内存直接顶满最后排查下来根因就是内存分页。物理分页则反过来它把“到底取哪几行”的判断下推到数据库层。MySQL 里对应的关键字就是LIMIT数据库从存储引擎层就开始控制返回行数只把当前页那几十条数据传回应用。数据量越大物理分页的优势越明显因为它绕开了最贵的全量传输环节。这也是 PageHelper 这类物理分页插件存在的根本原因它不是帮你“算”分页而是帮你把 SQL 改成分页 SQL让数据库自己去限行。1.2 LIMIT 关键字的语义边界偏移量、行数与执行顺序LIMIT 的语法看起来简单但真有不少人在细节上栽过跟头。最经典的两种写法-- 只返回前 10 行 SELECT * FROM t_book LIMIT 10; -- 跳过前 10 行返回接下来的 20 行也就是第 11 到第 30 行 SELECT * FROM t_book LIMIT 10, 20;第二种写法里的第一个数字是偏移量offset第二个数字是返回行数row_count。MySQL 8.0.1 之后也支持了标准写法LIMIT 20 OFFSET 10它和LIMIT 10, 20语义完全一致只是可读性更好。要注意的是这里的偏移量是从 0 开始算的第一行数据的偏移量是 0。所以“第 11 到第 30 行”对应的 offset 是 10因为前面已经跳过了 10 行。还有一个非常容易被忽略的点LIMIT的执行顺序在 SQL 里非常靠后通常是在WHERE筛选、GROUP BY分组、ORDER BY排序之后才执行。这意味着分页前必须先完成真正的筛选和排序数据库才能正确地“跳过 offset 行再取 row_count 行”。如果你的 SQL 里没有ORDER BY那么分页结果的顺序是不稳定的同一页数据在不同时间可能长得不一样。所以生产环境的分页 SQL我强烈建议永远带上一个确定性排序字段最好是主键或者唯一索引字段。1.3 不同数据库的分页方言为什么只有 MySQL 说 LIMITLIMIT 是 MySQL 系数据库的分页语法但 SQL 标准本身其实推荐的是OFFSET...FETCH。不同数据库的分页方言差别很大数据库分页写法备注MySQL / MariaDBLIMIT offset, row_count或LIMIT row_count OFFSET offset最简洁PostgreSQLLIMIT row_count OFFSET offset也是简洁路线OracleROWNUM ?或 12c 的OFFSET ? ROWS FETCH NEXT ? ROWS ONLY早期写法复杂SQL ServerOFFSET ? ROWS FETCH NEXT ? ROWS ONLY2012老版本用ROW_NUMBER() OVER()Db2FETCH FIRST ? ROWS ONLY配合OFFSET语法和标准接近这就解释了为什么 PageHelper 要搞一个“方言适配”能力。它内部维护了针对不同数据库的分页改写规则配置好helper-dialect之后同一个PageHelper.startPage(pageNum, pageSize)调用在 MySQL 里会变成LIMIT在 PostgreSQL 里会变成LIMIT/OFFSET在 SQL Server 里会变成OFFSET...FETCH。这一点在 3.3 节做多数据源配置时还会讲到。2. PageHelper 的工作原理从 startPage 到 LIMIT 改写2.1 拦截器机制PageHelper 是如何“混进”MyBatis 执行链的MyBatis 在启动时会通过XMLConfigBuilder解析配置文件构建出全局唯一的Configuration对象。这个对象里维护了一条拦截器链所有实现org.apache.ibatis.plugin.Interceptor接口的插件都会被注册进去在后续的 SQL 执行过程中被回调。PageHelper 的本质就是这样一个 MyBatis 拦截器它用Intercepts注解声明拦截Executor.query方法。Executor是 MyBatis 执行器的核心接口负责最终的 SQL 执行。PageHelper 选择拦截它是因为无论你是用SqlSession.selectList、Mapper 接口方法还是注解 SQL最终都会走Executor.query。拦截住这个方法就意味着能守住所有查询请求的必经之路。了解了这一点你再看“PageHelper 怎么知道我要分页”这个问题答案就清楚了一半。除了拦截点还有一个关键细节Executou.query有重载方法PageHelper 处理的是带RowBounds和ResultHandler参数的那个签名。它会先取出当前查询的MappedStatement、BoundSql和参数对象然后判断当前请求是否被标记了分页。如果是才会进入后面的 SQL 改写逻辑如果不是它就老老实实放行不掺和正常查询。这就是为什么 PageHelper 在未调用startPage时对性能影响可以忽略不计。2.2 ThreadLocal 与 Page 对象的生命周期startPage 为什么不能乱放PageHelper 使用ThreadLocal来传递分页参数这是它最核心的机制也是无数人踩坑的根源。当你调用PageHelper.startPage(pageNum, pageSize)时它会创建一个Page对象并存进当前线程的ThreadLocal里。紧接着执行的下一条查询 SQL会被拦截器检查到“当前线程里存在分页参数”于是触发分页改写。这里有一个铁律startPage后面必须紧跟你要分页的那条查询。中间哪怕多执行了一次别的 SQL 查询ThreadLocal 里的分页参数也可能被下一次查询消费掉或者覆盖导致分页作用在了错误的 SQL 上。更隐蔽的问题是线程池复用。ThreadLocal属于线程私有变量如果startPage之后抛了异常后面finally里没有清理干净线程归还到线程池后下一个任务复用这个线程时可能会莫名其妙地把上一次的分页参数带到自己的查询里造成“偶发分页失效”或者“诡异的分页结果”。PageHelper 在设计上已经在拦截器的 finally 里做了清理但它清理的前提是分页查询确实经过拦截器。如果你startPage之后程序直接 return 了没有执行任何 SQL那 ThreadLocal 里的Page就可能滞留。稳妥的做法是在业务方法里用try/finally包裹手动调用PageHelper.clearPage()。2.3 SQL 改写与 count 查询LIMIT 是怎么悄悄加进去的分页改写整个过程分两步。第一步自动生成 count 查询。PageHelper 会把原始 SQL 包一层生成类似SELECT COUNT(0) FROM (原始SQL) TOTAL的语句用于计算总条数。这个 count 值就是PageInfo.getTotal()的数据来源。第二步按方言改写原 SQL把分页参数追加进去。以 MySQL 为例原始 SQL 是SELECT * FROM t_book WHERE status 1 ORDER BY create_time DESC经过 PageHelper 改写后真正发送给数据库的是SELECT * FROM t_book WHERE status 1 ORDER BY create_time DESC LIMIT ?, ?这里的两个问号就是 PageHelper 自动绑定进去的 offset 和 row_count。它走的是PreparedStatement参数绑定也就是说你传给startPage的页码和每页大小不会直接被拼进 SQL 字符串而是作为参数传入。这个细节非常重要它意味着正常的页码分页不会因为pageNum传了特殊值而产生 SQL 注入。执行完分页查询后Executor.query返回的List其实已经被 PageHelper 悄悄替换成了Page对象。Page继承自ArrayList它除了包含当前页的数据还持有total、pageNum、pageSize等分页元数据。这也是为什么你拿到查询结果后可以强转成Page再交给PageInfo做进一步封装。3. Spring Boot 整合 PageHelper 完整实操配置、代码与参数3.1 依赖引入与关键配置项解析Spring Boot 项目接入 PageHelper 最简单的方式是引入官方 starterdependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency引入依赖后在application.yml里做基本配置pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql这几个配置项每一个都有实际的工程意义。helper-dialect指定数据库方言。填mysql就是强制走 MySQL 的LIMIT改写填auto则由 PageHelper 根据 JDBC 连接自动识别。生产环境我建议显式指定第一省去自动探测的开销第二避免多数据源和连接池代理导致探测错乱。reasonable是人性化开关。开启后如果前端传的pageNum 0会自动纠正为第 1 页如果pageNum大于总页数会自动纠正为最后一页。这个开关能省掉后端一堆防御性代码但要注意它也会“掩盖”前端传参错误如果你们希望接口对异常参数直接报错那就把它关掉。support-methods-arguments开启后允许直接在 Mapper 方法参数里声明pageNum和pageSizePageHelper 会自动识别并分页不需要显式调用startPage。我依然推荐用startPage因为显式调用更直观别人读代码时一眼就知道这里是分页点。3.2 Service/Controller 标准写法与 PageInfo 全字段说明真正写业务代码的时候我通常这样组织public PageInfoBookVO listBooks(String keyword, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); // 这后面只能跟目标查询不能夹杂其他 Mapper 调用 ListBook books bookMapper.selectByKeyword(keyword); PageInfoBookVO pageInfo new PageInfo(books); return pageInfo; }如果查询结果需要转 VO记得在PageHelper.startPage之后立即执行查询拿到List后再做转换千万不要在查询之前先查别的表否则分页参数会被第二次查询“劫走”。Controller 层接收分页参数也很简单GetMapping(/books) public ResultPageInfoBookVO list( RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize) { return Result.success(bookService.listBooks(pageNum, pageSize)); }PageInfo是 PageHelper 提供给前端的标准分页视图对象字段设计得比较完整。我平时最常用的字段是这些字段含义total总记录数来源于 count 查询pageNum当前页码pageSize每页大小pages总页数由 total 和 pageSize 计算list当前页数据列表isFirstPage/isLastPage是否首页 / 尾页hasNextPage/hasPreviousPage是否有下一页 / 上一页navigatepageNums分页导航页码数组适合前端渲染分页按钮有一点要注意查询结果直接new PageInfo(list)时如果中间的转换操作把Page对象变成了普通ArrayList一些分页扩展字段会丢失。所以我一般让查询方法直接返回原实体 List在 Controller 或 Service 里再各自组装PageInfo。如果你需要PageInfo里的导航栏数据就不要手动重新计算直接用它现成字段即可。3.3 多数据源与方言适配生产环境的真实选择多数据源场景下PageHelper 的方言配置需要格外小心。假设你的主库是 MySQL辅助库是 PostgreSQL只配置helper-dialect: mysql会导致 PostgreSQL 查出错误的分页 SQL。此时有几个选择一是对不同数据源使用不同的SqlSessionFactory并分别配置各自的 PageHelper 方言。这是最干净的方案但配置成本高。二是依赖helper-dialect: auto让 PageHelper 通过Connection.getMetaData()识别方言。这个方案在大多数常规连接池下没问题但如果你的数据源经过了代理、脱敏或读写分离中间件自动探测可能拿不到真实数据库类型。我踩过一坑项目接了读写分离中间件后自动探测误判了方言分页 SQL 在 MySQL 主库上执行时报语法错误排查到最后才发现是方言识别的问题。另外如果你们的项目里同时引入了 ShardingSphere 这类分库分表中间件要特别关注分页 SQL 的改写结果。分页中间件叠加分库分表后LIMIT的 offset 和 row_count 会被再次改写甚至可能做归并后的二次分页。网上那个“sharding groupby 改写了 limit”的报错本质就是分页与聚合改写顺序冲突。这种场景下我不建议 PageHelper 和分库分表中间件同时处理同一个查询最好让中间件自行处理分页或者严格规划好各自负责的 SQL 边界。4. 线上踩坑实录从失效分页到深分页性能优化4.1 分页失灵与 ThreadLocal 问题速查PageHelper 用起来不难但是坑是真不少。我把这几年在线上遇到的高频问题整理成了一张表排查的时候照着对就行。现象原因解决方案分页完全没生效返回全部数据没有调用startPage或startPage后执行了其他 SQL确保目标查询紧跟startPage检查执行顺序分页作用在了错的 SQL 上ThreadLocal 里的参数被中间的查询消费把startPage移到目标查询上一行偶发分页结果异常、时好时坏线程池复用时 ThreadLocal 残留业务方法用 try/finally 调PageHelper.clearPage()count 查询非常慢自动生成的 count SQL 包了复杂子查询配置手写 count 查询或简化 count 逻辑排序字段不生效ORDER BY写在子查询里外层被包了一层把排序条件放到最外层 SQL或者查询后在内存排序返回结果强转Page失败返回类型被 MyBatis 包装成了自定义结果用PageInfo构造或检查 resultMap 配置分页 SQL 语法错误方言配置与真实数据库不匹配显式设置helper-dialect其中startPage后执行其他 SQL 这个问题在代码 review 时经常出现。比如有人这么写PageHelper.startPage(pageNum, pageSize); ListBook books bookMapper.selectByKeyword(keyword); User user userMapper.selectById(userId);第二行查询已经把分页参数消费掉了第三行查 user 时其实会被“硬塞”一个奇怪的 LIMIT等你想分页的下一个查询真正执行时ThreadLocal 里的参数已经没了。这种代码在功能上未必立刻报错但分页结果一定不对而且很难察觉。4.2 深分页性能瓶颈LIMIT 1000000,10 为什么慢LIMIT 虽然好用但有一个致命问题偏移量越大查询越慢。举个例子LIMIT 1000000, 10并不是“从第 1000000 行开始取 10 行”这么简单。MySQL 的 InnoDB 引擎在执行这条 SQL 时会扫描完前 1000000 行再把它们全部丢弃最后才返回你要的 10 行。这个“扫描再丢弃”的过程完全浪费而且如果表数据量大每一行都需要回表读取没有覆盖索引的话IO 开销会被放大到令人绝望。我在线上优化过一个深分页接口表里 200 万条数据用户翻到第 5 万页接口耗时从 200ms 暴涨到 8 秒。当时用EXPLAIN一看type 是 ALLExtra 里有Using filesort典型的深分页问题。优化方案有两个方向。第一个是延迟关联延迟 joinSELECT b.* FROM t_book b JOIN ( SELECT id FROM t_book WHERE status 1 ORDER BY id DESC LIMIT 1000000, 10 ) tmp ON b.id tmp.id;子查询只查主键可以走索引避免回表拿到 10 个 id 后再回原表做 join 取完整数据整体效率提升非常明显。第二个是键集分页keyset pagination也叫游标分页。它彻底抛弃 offset 概念排序字段必须唯一且有索引SELECT * FROM t_book WHERE status 1 AND id #{lastId} ORDER BY id DESC LIMIT 10;前端翻下一页时把上一页最后一条记录的 id 传回来作为下一页的起始位置。这样无论翻到多深MySQL 都能从指定位置开始扫描 10 条性能恒定。缺点是无法跳页只能一页一页往下翻。对于“加载更多”这类交互键集分页是最优雅的方案。4.3 分页与 MyBatis 缓存、TypeHandler 等特性的协同边界PageHelper 和 MyBatis 一级缓存、二级缓存之间的关系也经常在面试里被追问。MyBatis 一级缓存是SqlSession级别的同一个 SqlSession 内执行相同 SQL 会命中缓存。分页查询在一级缓存里有个风险假如你连续两次用同一个 SqlSession 执行相同的startPage和查询第一次的结果是第 1 页第二次本来应该查第 2 页但 SQL 经过 LIMIT 改写后其实是两个不同的 SQL所以通常不会命中同一条缓存。真正要注意的是SQL 里如果有动态参数缓存键会包含参数值分页 SQL 的缓存键也包含了 offset 和 row_count一般不会串页。二级缓存是 namespace 级别的风险更大。如果某个查询开启了二级缓存并且缓存了整个 List 结果一旦这个缓存被其他会话命中分页参数可能被绕过返回的是整张表的数据而不是当前页。这就是为什么我不建议对分页查询开启二级缓存要么在 Mapper XML 里对分页方法单独设置useCachefalse。TypeHandler 和分页的关系比较隐蔽。PageHelper 在改写 SQL 时会把分页参数追加到BoundSql的参数映射列表中这些参数通常都是 Integer 或 Long走的是 MyBatis 内置的 TypeHandler一般不需要自定义处理。但如果你写的是自定义返回类型复杂查询要注意PageInfo的泛型推断必要的时候手动指定。还有一个容易被问到的点startPage和PageHelper.startPage返回的Page对象本质上是一次查询内的分页状态不要试图跨方法传递它。很多面试题喜欢问“PageHelper 是否线程安全”答案要拆开说对同一个线程内的查询分页参数是线程隔离的但是如果在同一个线程里连续开启分页上下游查询之间的“隔离”就靠自己代码把握了。5. 分页之外的 SQL 安全与慢 SQL 排查5.1 分页参数、排序字段与 SQL 注入必须守住的红线PageHelper 的分页参数是通过参数绑定方式传入 SQL 的所以pageNum、pageSize本身不会引起 SQL 注入。真正的高危点是排序字段。很多人喜欢把前端传来的排序字段直接拼进ORDER BY比如PageHelper.startPage(pageNum, pageSize); PageHelper.orderBy(create_time desc);这个orderBy方法接收的是字符串最终会原样拼接到 SQL 里。如果前端能控制这个字符串完全可以把create_time desc; drop table...之类的东西传进来。PageHelper 对此并不做任何安全检查它假定你已经控制好了入参。所以线上必须做排序白名单校验只允许固定的字段名和排序方向进入。private static final SetString ALLOWED_ORDER_COLUMNS Set.of(id, create_time, price, title); public String buildOrderBy(String column, String direction) { if (!ALLOWED_ORDER_COLUMNS.contains(column)) { throw new IllegalArgumentException(排序字段非法); } String dir desc.equalsIgnoreCase(direction) ? desc : asc; return column dir; }排序字段白名单这个习惯同样适用于其他手写 SQL 的场景。MyBatis 里能用#{}就不要用${}${}是直接字符串替换也是 SQL 注入的最常见入口。分词排序参数只是其中一个不起眼的点但很多注入案例恰恰是从“看起来不起眼”的排序字段撕开口子的。5.2 慢 SQL 排查思路与一个典型 LIMIT 优化案例大多数分页接口在数据量小的时候都很正常一旦量大慢 SQL 就成了家常便饭。排查分页慢 SQL我推荐按这个顺序来。第一步打开数据库慢查询日志。MySQL 里可以临时开启SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1;第二步拿到慢 SQL 之后用EXPLAIN看执行计划。重点看三个位置type是否为ALL或indexrows预估扫描行数是否过大Extra里有没有Using filesort或Using temporary。Using filesort意味着排序没有走索引这是分页 SQL 性能的典型杀手。第三步针对LIMIT本身做优化设计。我处理过一个比较典型的去重 分页慢 SQLSELECT DISTINCT product_id, title FROM t_order WHERE create_time 2024-01-01 ORDER BY create_time DESC LIMIT 100000, 20;这个查询慢在两点一是DISTINCT需要把大量候选行放入临时表去重二是深偏移量导致扫描大量数据。优化思路是把去重后的结果集尽可能缩小再分页SELECT product_id, title FROM ( SELECT product_id, title, create_time, ROW_NUMBER() OVER (PARTITION BY product_id ORDER BY create_time DESC) AS rn FROM t_order WHERE create_time 2024-01-01 ) t WHERE t.rn 1 ORDER BY create_time DESC LIMIT 100000, 20;配合(product_id, create_time)联合索引让子查询走覆盖索引整体执行时间能下降一个数量级。如果业务能接受键集分页甚至可以连LIMIT 100000这个大 offset 都彻底去掉性能会更加稳定。说实话PageHelper 能帮我们省掉手写分页 SQL 的重复劳动但它不会替我们解决数据模型和查询设计层面的问题。遇到深分页最快的方法往往不是继续调参数而是回到 SQL 本身用延迟关联、索引优化和业务交互层面的改造去解决。最后分享一个我自己的习惯项目里所有列表接口默认都用PageHelper.startPage加PageInfo但我要求 DBA 和核心开发定期扫一遍慢查询日志凡是单次LIMIT偏移量超过一万的查询都要单独评审。超过一定量级我会直接建议产品改成“加载更多”的键集分页方案。分页不是单纯技术问题它同时也在考验你对数据规模的理解和对用户体验的判断。这层考量放到位PageHelper 才算真正用明白了。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →