在家写代码这么多年Java后端的分页查询几乎每天都在写。早期用最原始的方式自己拼LIMIT offset, size每写一次就复制粘贴一次遇到连表查询还要小心翼翼数字段位置稍不注意就查出脏数据。后来项目里逐步统一技术栈开始用 PageHelper才发现分页这件事也能做得这么省心。这篇东西不是官方文档的复读机是我在实际项目里从引入到落地、从踩坑到优化的完整记录。如果你是刚接触 PageHelper 的开发者或者已经在用但碰到过一些莫名其妙的问题这篇内容应该能帮你少走不少弯路。1. 为什么大家都选 PageHelper先说清楚它解决了什么问题1.1 原始分页到底有多麻烦先回顾一下不用分页插件时我们是怎么写分页的。最典型的做法是先在 Service 层算好 offset再往 Mapper 里传两个参数public ListUserDO listUsers(int page, int size) { int offset (page - 1) * size; return userMapper.selectUserList(offset, size); }select idselectUserList resultTypecom.example.UserDO SELECT id, name, email, status, create_time FROM user ORDER BY id DESC LIMIT #{offset}, #{size} /select这看着不复杂但一旦业务变多就露出问题。比如要同时返回总数你还得再写一个 count 查询select idcountUserList resultTypelong SELECT COUNT(*) FROM user /select两个 SQL 放在一起如果后续加了筛选条件where 部分要同步改两处。漏改一处你列表页底部的“共 X 条”就永远对不上号。还有一种特容易踩的坑只要有一条业务 SQL 被公共方法封装过比如public ListUserDO queryUserList(UserQuery query) { // 先查总数 Long total userMapper.countUserList(query); // 再查列表 ListUserDO users userMapper.selectUserList(query, (query.getPage() - 1) * query.getSize()); return users; }当 query 里分页条件为空时offset 变成负数MySQL 直接抛异常。这种 bug 往往在生产环境才能暴露。1.2 PageHelper 的核心优势PageHelper 的思路完全不同它用 MyBatis 拦截器在 SQL 执行前动态改写 SQL。你业务代码里只写正常的查询方法不需要传分页参数分页参数通过一个静态方法单独设置PageHelper.startPage(pageNum, pageSize); ListUserDO users userMapper.selectUserList();就这么一个静态调用后续第一次查询就会被自动拼接上LIMIT同时还会自动生成并执行一条COUNT查询。拿到总数后通过PageInfo取用。这种设计带来的好处很明显现有 Mapper 方法不用改签名查询逻辑保持干净分页逻辑从业务代码里剥离代码可读性高COUNT 查询自动生成不用再手工维护一对 SQL返回结果兼容List原有遍历逻辑无需调整PageHelper 之所以在很多团队里成为首选除了功能本身还因为它的侵入性极低。你把分页注解和拦截器配置好后剩下就是业务层单行调用的事。2. 核心机制拆解PageHelper 的拦截器到底做了什么2.1 分页链路是怎么串起来的要真正用好 PageHelper不能只会调 API得搞清楚它拦截 SQL 的时机和改写逻辑。整个分页链路大概长这样Service 层调用PageHelper.startPage(pageNum, pageSize)该方法把分页参数存入当前线程的ThreadLocalService 层继续调用 Mapper 方法MyBatis 执行器执行前PageHelper 拦截器感知到当前线程存在分页参数拦截器根据方言生成分页 SQL如 MySQL 自动加LIMIT拦截器执行 COUNT 查询获取总数返回数据组装成Page对象清理 ThreadLocal关键在于第 6 步PageHelper 会自己判断原 SQL 是否包含ORDER BY、GROUP BY、DISTINCT等结构并生成尽可能准确的 COUNT SQL。拿上文的 user 表举例原 SQLSELECT id, name, email, status, create_time FROM user WHERE status 1 ORDER BY id DESCPageHelper 生成的 COUNT SQL 通常是SELECT COUNT(0) FROM user WHERE status 1它知道把 ORDER BY 去掉因为排序不影响行数。但遇到GROUP BY或DISTINCT这种“行数会变”的查询它会退化成SELECT COUNT(*) FROM (原SQL) tmp_count这种包一层子查询的方式保证总数准确。2.2 ThreadLocal 参数绑定和它的生命周期PageHelper 基于 ThreadLocal 传参听起来很优雅实际使用时却暗藏风险。ThreadLocal 的生命周期是跟着线程走的。在 Web 应用中线程池会复用线程如果一次分页查询发生异常没等分页执行就结束了ThreadLocal 又没有清理那么下一次请求用同一个线程时分页参数可能落到一条非预期的查询上。官方其实已经做了清理机制在拦截器执行完分页 SQL 后会把 ThreadLocal 里的参数移除。正常流程下没有问题。问题出在你手动调用了startPage但后面执行的查询根本不是 Mapper 查询或者 Mapper 方法被 AOP 代理、被其他拦截器劫持而没有走到 PaginationInterceptor。我自己的习惯是在finally代码块里主动调用PageHelper.clearPage()PageHelper.startPage(pageNum, pageSize); try { ListUserDO users userMapper.selectUserList(); return new PageInfo(users); } finally { PageHelper.clearPage(); }这种做法在用了异步任务、自定义线程池、消息消费的场景下特别重要能保证分页参数不在线程间“串味”。2.3 PageHelper 的方言自动识别PageHelper 支持多种数据库方言这在多数据库项目里特别有用。它通过helperDialect配置项自动识别当前数据源类型从而生成对应方言的 SQLMySQL / MariaDB生成LIMIT offset, sizePostgreSQL生成LIMIT size OFFSET offsetOracle生成基于ROWNUM的嵌套分页SQL Server生成ROW_NUMBER() OVER(...)分页所以项目里如果开发环境用 H2、生产环境用 MySQL或者同一个平台适配不同数据库PageHelper 能直接兼容不用为每种数据库单独写分页逻辑。3. 实操部署从 Maven 依赖到第一次分页查询3.1 引入依赖并配置拦截器PageHelper 有两个版本体系需要注意一个是独立的pagehelper另一个是专门适配 Spring Boot 的pagehelper-spring-boot-starter。如果你用的是 Spring Boot 2.x建议直接引入 starterdependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependencySpring Boot 3.x 用户要注意版本兼容性很多老版本 starter 基于旧的 Spring Boot 封装层写的直接替换依赖会导致启动时拦截器注册不进去页面查不到分页效果。我一般都等到 Spring Boot 发版一段时间后再升级 PageHelper而不是追最新版稳定压倒一切。如果是传统 SSM 项目则在mybatis-config.xml里配置拦截器plugins plugin interceptorcom.github.pagehelper.PageInterceptor property namehelperDialect valuemysql/ property namereasonable valuetrue/ /plugin /plugins3.2 基本用法演示单表分页查询配置弄好之后最简单的一次分页调用是这样public PageInfoUserVO getUserPage(int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); ListUserDO userList userMapper.selectUserList(); return new PageInfo(userList); }这行代码背后PageHelper 会执行一条 count 查询和一条 limit 查询然后通过PageInfo暴露给前端这些数据{ total: 102, pageNum: 1, pageSize: 10, pages: 11, list: [...] }前端拿到这些字段就能渲染分页按钮。这个PageInfo还带了很多额外的导航信息比如prePage、nextPage、isFirstPage、isLastPage、navigatepageNums等做复杂分页组件时很省事。3.3 复杂查询的分页写法JOIN 和动态条件实际项目里很少只有一个表更多是主表关联子表查询。比如查询用户列表同时带出用户最近一条订单信息Select( SELECT u.id, u.name, u.email, u.status, o.order_no, o.amount FROM user u LEFT JOIN user_order o ON u.id o.user_id WHERE u.status #{status} ORDER BY u.id DESC ) ListUserWithOrderDO selectUserWithOrder(Param(status) Integer status);配合 PageHelperPageHelper.startPage(1, 10); ListUserWithOrderDO list userMapper.selectUserWithOrder(1);这里的 COUNT 查询会自动生成成SELECT COUNT(0) FROM user u LEFT JOIN user_order o ON u.id o.user_id WHERE u.status 1因为分页只需要知道总行数所以 JOIN 条件会保留SELECT 字段全部忽略。这也是 PageHelper 比较聪明的地方。但要注意一个坑如果查询本身带了GROUP BYCOUNT 结果会失真因为分组后的行数不等于原表行数。官方处理方式是把原 SQL 包一层子查询SELECT COUNT(*) FROM ( SELECT u.id, COUNT(o.id) AS order_count FROM user u LEFT JOIN user_order o ON u.id o.user_id GROUP BY u.id ) tmp_count这种总数才是“分组后的真实总行数”。使用 PageHelper 时有时候你会看到日志中出现一条嵌套很深的 count 语句多半就是命中了这种场景。3.4 分页参数统一封装另一种常见的实践是把分页参数封装成一个Query对象方便前端统一传参也方便后续加校验public class PageQuery { private Integer pageNum 1; private Integer pageSize 10; public Integer getPageNum() { return pageNum; } // ... 省略 getter/setter }然后在 Service 层public PageInfoUserVO queryPage(PageQuery query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); ListUserDO list userMapper.selectUserList(); return new PageInfo(list); }我不建议在 Controller 层直接使用PageHelper.startPage因为 Controller 和 Service 之间可能还有参数校验、权限拦截、缓存处理等逻辑一旦startPage之后的第一次查询不是你想要分页的那个查询参数就会被提前消费。4. 配置项详解应当按需设置的 6 个关键参数4.1 常用配置速查表PageHelper 官方提供了几十个配置项我实际开发中最常用的是下面这些配置项默认值使用场景个人建议helperDialect自动指定数据库方言单数据源项目可以留空多数据源尽量手动指定reasonablefalse页码越界时是否自动纠正开启后 pageNum 小于1时按1查大于总页数时按最后一页查supportMethodsArgumentsfalse从 Mapper 方法参数中读取分页参数配合 Param(pageNum) 和 Param(pageSize) 使用params空自定义分页参数名很少用旧版本配置迁移时可能用到returnPageInfonone是否返回 PageInfo一般直接用 PageInfo 包装不需要单独配置rowBoundsWithCountfalse使用 RowBounds 时是否执行 COUNT兼容旧代码时用4.2 reasonable 配置开启与不开启的差异reasonabletrue是我在所有项目里都建议开启的一项。它解决的是“用户手动在 URL 上传了一个超出范围的页码”这种问题。比如你有 100 条数据、每页 10 条最后一页是第 10 页。用户手抖输入pageNum999开启 reasonable 后PageHelper 会自动把页码修正为 10返回最后一页数据不开启则直接返回空列表。这种处理方式更符合前端交互习惯用户翻到最后一页后想往后翻体验也应该是在原地停留而不是看到一个空页面。4.3 常见参数踩坑PageSize 传 0 或负数PageHelper 对pageSize0有特殊处理默认会视为“查询全部数据”。在导出数据这种场景这个特性蛮好用但对普通列表接口可能是个隐患。如果前端不小心传了 0就会把全表数据一次拉出来数据库压力瞬间拉满。我处理过的做法是在 Controller 入口统一校验ControllerAdvice public class PageParamValidator { ModelAttribute public void validatePage(RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize) { if (pageSize 0 || pageSize 100) { throw new IllegalArgumentException(pageSize must be between 1 and 100); } } }也可以在网关层做限流或参数校验反正不能默认信任前端入参。5. 进阶用法排序、嵌套结果映射和自定义 COUNT SQL5.1 排序字段动态传入分页列表经常需要按不同字段排序比如用户列表支持按创建时间、按订单金额排序。直接用ORDER BY #{sortField}是会报错的因为 MyBatis 预编译参数会把字段名当成值处理。正确做法是使用${}拼接Select( script SELECT id, name, email, create_time FROM user ORDER BY ${sortField} ${sortOrder} /script ) ListUserDO selectUserList(Param(sortField) String sortField, Param(sortOrder) String sortOrder);但这引出了一个安全风险${}拼接内容如果直接来自前端会存在 SQL 注入隐患。我的处理方式是在代码里做白名单校验String sortField create_time; if (sortFieldMap.containsKey(inputSortField)) { sortField sortFieldMap.get(inputSortField); } String sortOrder desc.equalsIgnoreCase(inputOrder) ? desc : asc;这比任何加密、过滤都可靠因为根本不把用户原始输入拼进 SQL。5.2 一对多查询的分页问题很多开发者一开始没意识到如果主查询和子查询是一对多关系直接用分页会产生“数据被截断”或“总数异常”的问题。例如查询订单列表每个订单带多个商品明细SELECT o.id, o.order_no, o.amount, i.item_name FROM orders o LEFT JOIN order_item i ON o.id i.order_id ORDER BY o.id DESC这种 SQL 会把一个订单的多条明细各占一行LIMIT 后可能出现“一个订单只返回部分明细”的结果。PageHelper 在这种情况下无法自动解决它只是在 SQL 层面加了 LIMIT并不会帮你去重折叠。常见的解法有两种先分页查订单主表再根据主键批量查明细在 Java 内存中做嵌套结果映射第一种更推荐因为数据库压力可控PageHelper.startPage(pageNum, pageSize); ListOrderDO orders orderMapper.selectOrderPage(); ListLong orderIds orders.stream().map(OrderDO::getId).toList(); ListOrderItemDO items orderItemMapper.selectByOrderIds(orderIds); MapLong, ListOrderItemDO itemMap items.stream() .collect(Collectors.groupingBy(OrderItemDO::getOrderId)); ListOrderVO result orders.stream().map(order - { OrderVO vo new OrderVO(); BeanUtils.copyProperties(order, vo); vo.setItems(itemMap.getOrDefault(order.getId(), Collections.emptyList())); return vo; }).toList();这样分页总数是订单数明细数据又是完整的逻辑清晰性能也可控。5.3 自定义 COUNT 查询PageHelper 提供了Select注解配合count属性来自定义总数 SQL 的方式但需要在 Mapper 接口里额外定义方法。大多数场景用不到但在统计结果集比较复杂的查询里很管用Select(SELECT COUNT(*) FROM user_registration WHERE register_date BETWEEN #{start} AND #{end}) long countUserRegistration(Param(start) String start, Param(end) String end); Select(SELECT * FROM user_registration WHERE register_date BETWEEN #{start} AND #{end}) ListUserRegistrationDO selectUserRegistration(...);PageHelper 在执行startPage后的第一次 List 查询时会优先检查有没有定义对应的count方法。如果命名规范能对得上就会调用你自定义的 count 方法。不过这块的命名匹配规则不同版本有细微差异升级后一定要回归测试。6. 实战中的高频问题排查与解决方案6.1 分页不生效的常见原因这是遇到最多的问题明明调了startPage返回结果却还是全表数据。根据我的排查经验可以把原因归纳成几张类型原因类型具体场景排查方向查询顺序问题startPage 后没有立即执行查询确认 startPage 后的第一个 Mapper 查询是否正是目标查询拦截器未生效Spring Boot 2.x 时依赖版本冲突确认实际加载的 PageHelper 版本检查是否有多套 MyBatis返回类型不匹配返回类型是List但被强转成自定义类型直接接收ListXxx然后用 PageInfo 包装特殊 Mapper 方法SelectProvider动态 SQL 中自定义拼接导致看一下执行时的日志确认 SQL 是否被改写多数据源时数据源判断错误PageHelper 拦截到的数据源不是目标库检查多数据源配置中 SQLSessionFactory 的绑定6.2 解决“COUNT 查询性能太慢”的问题COUNT 查询的性能在数据量上来后经常成为瓶颈。一条普通列表查询可能耗时 30ms但自动生成的 COUNT SQL 由于要扫描全表或者执行子查询一下变成 800ms。优化方向有几个给 where 过滤字段建立联合索引尽量让 COUNT SQL 走索引覆盖扫描如果列表实时性要求不高可以使用缓存把总数缓存 1~5 分钟极端大表情况下可以考虑禁用自动 COUNT改为异步统计或离线统计6.3 CPU 飙升或内存溢出的排查记录我之前处理过一个线上问题分页接口突然响应变慢CPU 飙升。排查后发现是某条查询语句的pageSize被前端传成了 10000PageHelper 老老实实地生成了LIMIT 999990, 10000的 SQLMySQL 为了拿到 offset 后面的 10000 条数据仍然需要扫描前 999990 条。这个问题的本质不是 PageHelper 的锅而是分页本身的特性。offset 越大扫描越深性能越差。优化方案是改用“游标分页”或“Keyset Pagination”即根据上一次查询最后一条记录的排序值来定位下一页SELECT id, name, email FROM user WHERE id #{lastId} ORDER BY id ASC LIMIT #{size}这种方式不受 offset 影响不管翻到第几页查询性能都恒定。PageHelper 本身不直接支持这种模式但它完全不影响你手写这种 SQL。很多实际项目里后台管理系统可以用 PageHelper而用户端的信息流、订单列表这种高频场景建议直接用游标分页。6.4 常见异常信息整理异常信息原因处理方式Cannot find table ...多数据源配置时默认数据源定位错误检查 Spring 上下文中的SqlSessionFactory归属PageHelper 不生效SQL 无 limitstartPage 后的查询不是首次执行确保查询前没有其他 Mapper 调用分页结果总数不准确SQL 中包含 GROUP BY、DISTINCT 等检查是否命中子查询 count 场景必要时自行定义 count分页参数被串线程异常场景下 ThreadLocal 未清理使用try/finally包裹并主动调用clearPage()Integer 参数类型不匹配JDK 版本升级或 BeanUtils 转换异常统一使用long接收 count 结果6.5 MyBatis 执行器与二级缓存冲突当启用 MyBatis 二级缓存时分页请求会把整个分页结果缓存下来下一个不同页码的请求可能命中同一条缓存。PageHelper 文档里明确指出分页结果不建议和二级缓存同时使用因为缓存 key 不包含分页参数。我对这个问题的建议是在 Mapper 的查询上禁用二级缓存或者使用useCachefalseselect idselectUserList resultTypeUserDO useCachefalse SELECT ... /select实际项目中二级缓存本身用得就很谨慎涉及分页的查询更是是非之地。7. 不同版本的选择和升级经验7.1 4.x 和 5.x 版本差异PageHelper 目前主流的版本有几条线。4.x 系列是老项目里见得最多的配置方式是基于PageInterceptor或者PageHelper这个类。5.x 版本重构了部分内部结构代码上最大的变化是推荐使用PageHelper.startPage的方式不变但内部参数处理方式更规范。如果你是从 4.x 升级到 5.x最需要关注的是 XML 配置中的类路径变化。老配置经常写property namedialect valuemysql/新版本里更推荐property namehelperDialect valuemysql/升级之后一定要跑一遍分页相关的核心用例特别是自定义 count 和排序这两个点这两个地方是老版本升级时最容易出现行为差异的位置。7.2 Spring Boot 3 的兼容性问题Spring Boot 3 全面拥抱 Jakarta EE 规范MyBatis 官方适配相对滞后PageHelper 早期版本跟上节奏也慢了一步。我踩过一次坑是项目从 Spring Boot 2.7 升级到 3.2PageHelper 拦截器正常注册但分页不生效排查半天发现是好几个依赖里同时引入了不同版本的 mybatis-spring互相干扰。解决方式其实就一个字“锁”。把 PageHelper、MyBatis、Spring Boot 的版本统一固定到经过验证的组合不要某个依赖单独升级。properties mybatis-spring-boot.version3.0.3/mybatis-spring-boot.version pagehelper.version1.4.7/pagehelper.version /properties8. 从 SQL 层面优化分页查询的额外建议8.1 深分页问题必须正视不管用什么分页插件深分页对数据库的压力都是客观存在的。以 MySQL 为例LIMIT 100000, 10意味着数据库要先读取前 100010 条记录然后丢弃前 100000 条。索引再快也架不住丢弃作业的耗时。项目里如果确实需要跳到很深的分页页面可以考虑从产品层面改动比如限制最大页码、改为“加载更多”按钮、采用滚动加载。从技术上也可以用“延迟关联”的方式。先用覆盖索引定位到主键范围再关联回表捞数据SELECT u.id, u.name, u.email, u.create_time FROM user u INNER JOIN ( SELECT id FROM user ORDER BY create_time DESC LIMIT 100000, 10 ) tmp ON u.id tmp.id ORDER BY u.create_time DESC这种 SQL 配合 PageHelper 写起来也能用你只需要让 PageHelper 分页外部主体自己控制内部查询结构。不过要留意 PageHelper 自动生成的 count 语句能否准确识别这种子查询。8.2 用 explain 检查分页 SQL 是否走索引分页接口慢第一条排查命令永远是EXPLAINEXPLAIN SELECT * FROM user WHERE status 1 ORDER BY id DESC LIMIT 10, 10;重点关注type字段和key字段。如果type是ALL说明是全表扫描分页注定好不到哪里去。如果key是主键索引那说明 where 条件里没有命中更合适的索引。这时候要么调整索引要么调整查询条件而不是死磕分页插件。8.3 需要保留 PageHelper 还是直接手写分页有时候我会被问到既然知道这些潜在问题是不是干脆不要用 PageHelper直接手写LIMIT更简单直接。我的观点是中小型项目、业务以简单列表为主、需要快速迭代PageHelper 绝对值得用它省下的样板代码和维护成本很明显。但如果你要处理几百上千万数据、对分页性能有极致要求那我建议手写或者使用专门针对深分页优化的方案。PageHelper 做的是通用分页不可能在所有极端场景下都最优。实用主义者会这样选前台用户端用自定义的游标分页或延迟关联后台管理列表这类数据量可控的场景用 PageHelper两者各得其所。9. 关于 PageHelper 使用习惯的真实体会最后说点我个人的实操总结。PageHelper 用了这么多年我踩过最大的坑永远不是它本身而是对它执行时机不够敏感。startPage不是“声明式配置”它是命令式编程它必须紧贴查询一旦中间隔了行代码行为就可能偏离预期。所以我的代码规范里专门加了一条PageHelper.startPage的行下方必须紧跟目标 Mapper 查询中间不允许出现任何分支、循环、日志、其他 Mapper 调用。另一个习惯是在封装一个通用分页返回结构时不要直接暴露PageInfo的原始实体。很多人图省事把ListOrderDO直接丢给前端订单表字段、金额结构全暴露后端改表结构前端立刻跟着崩。正确做法是封装一层 VO只输出前端需要的字段。还有一点是版本锁定。分页插件这个东西不是越新越好。新版解决了旧版 bug也可能引入新行为。在成熟的业务系统里除非有明确的功能需求或者安全修复我不会轻易升级 PageHelper 的版本。如果你正在一个老项目里碰分页查不到、总数不对、性能变慢的怪问题按照这篇文章里列的排查思路走一遍通常都能落地到具体原因。分页不难但细节不少能把细节处理好项目里这类问题就能少一大半。