ARTICLE DETAIL

资讯详情

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

PageHelper分页插件深度解析:原理、实战与性能优化

PageHelper分页插件深度解析:原理、实战与性能优化 1. 为什么分页这件事值得单独拎出来讲做业务系统的人都有一个共识列表查询是最容易出问题的环节。数据量小的时候select * from table一把梭前端拿到全部数据自己切片谁都不会觉得有问题。可一旦表里数据过了几十万行这种写法就是灾难——数据库连接被长时间占用、网络传输大量无用数据、前端渲染卡死整个链路都在为“只看第2页的10条数据”这件事付出巨大代价。分页要解决的核心问题就一个只取当前页需要的那一小段数据。听起来简单但落到 MyBatis 这种半自动 ORM 框架上麻烦就来了。MyBatis 本身不提供分页能力你得自己写limit、自己写count语句、自己处理不同数据库的方言差异。一个列表接口业务逻辑可能就三行分页相关的代码却要写三十行而且每个 Mapper 都要重复一遍。PageHelper 就是冲着这个痛点来的。它是 MyBatis 的一个扩展插件通过拦截器机制在你执行查询之前动态改写 SQL自动帮你拼上分页语句、自动执行 count 查询、自动封装分页结果。你只需要在查询前调用一行PageHelper.startPage(pageNum, pageSize)剩下的它全包了。这篇文章适合谁看如果你正在用 SpringBoot MyBatis 做后端开发列表接口写到吐或者你已经在用 PageHelper 但只会startPage这一招对它的内部机制、性能陷阱、复杂场景下的用法一知半解那这篇内容应该能帮你把这块知识补完整。我会从设计思路讲到源码级别的执行流程再给出一套可以直接抄的完整代码最后把那些年我踩过的坑一个个摊开说。2. PageHelper 的整体设计与拦截器思路拆解2.1 它到底是怎么“偷偷”把分页做了的理解 PageHelper 的关键在于理解 MyBatis 的插件机制。MyBatis 允许你通过Interceptor接口对四大核心对象进行拦截Executor、StatementHandler、ParameterHandler、ResultSetHandler。PageHelper 选择拦截的是Executor的query方法。为什么是 Executor因为 Executor 是 MyBatis 执行查询的入口所有select语句最终都会走到这里。在这个位置拦截你能拿到完整的MappedStatement包含 SQL 信息、参数对象、以及分页相关的上下文。拦截时机足够早改写 SQL 的空间也足够大。整个流程用一句话概括你在 ThreadLocal 里放一个分页参数PageHelper 在拦截到 query 方法时把它取出来改写 SQL 加上 limit执行完 count 查询后把结果包装成 Page 对象返回最后清理 ThreadLocal。这里有个设计细节值得注意分页参数是通过ThreadLocal传递的而不是通过方法参数。这意味着PageHelper.startPage()必须和紧接着的那一次查询在同一个线程里且中间不能插入其他查询。这是很多人第一次用 PageHelper 时最容易犯的错误后面会详细说。2.2 为什么用拦截器而不是 AOP 或者手写工具类有人可能会问我写个工具类手动拼 limit 不行吗当然行但有几个问题绕不开。第一是方言适配。MySQL 用limitOracle 用rownum嵌套SQL Server 用top或者offset fetchPostgreSQL 和 MySQL 类似但细节有差异。你手写工具类就得为每种数据库维护一套拼接逻辑。PageHelper 内置了多种方言实现通过Dialect接口抽象切换数据库时基本不用改代码。第二是count 查询的自动处理。分页不只是取当前页数据还要知道总数。手写的话你得再写一条select count(*)语句而且这条语句的 where 条件必须和主查询完全一致维护起来很容易出错。PageHelper 会自动根据你的原 SQL 生成 count 语句把select字段替换成count(0)去掉 order by 等不影响计数的部分。第三是结果封装的统一。分页查询返回的不只是数据列表还有总页数、总记录数、当前页码、是否有下一页等信息。PageHelper 返回的Page对象继承自ArrayList同时携带了这些分页元数据前端拿到的结构是统一的。用 AOP 行不行理论上可以但 AOP 的切点很难精确匹配到“某一次具体的查询”而且 AOP 拿不到 MyBatis 内部的 SQL 信息改写 SQL 这件事就做不了。拦截器是唯一能在 SQL 执行前拿到并修改 SQL 的位置。2.3 核心组件一览PageHelper 内部有几个关键类理解它们的分工后面看源码和排查问题会轻松很多。组件职责PageInterceptor拦截器入口实现Interceptor接口拦截 Executor.queryPageHelper对外工具类提供 startPage 等静态方法负责往 ThreadLocal 放参数Page分页结果对象继承 ArrayList携带 total、pageNum、pageSize 等元数据PageInfo对 Page 的进一步封装提供更友好的分页信息如 isFirstPage、isLastPageDialect方言接口不同数据库实现不同的分页 SQL 拼接逻辑SqlUtilSQL 处理工具负责生成 count SQL、处理 order by 等PageAutoDialect自动识别数据库方言根据 JDBC URL 或配置选择对应实现这几个类的关系是PageHelper.startPage()把参数放进 ThreadLocalPageInterceptor拦截到查询后从 ThreadLocal 取出参数通过PageAutoDialect找到合适的Dialect用SqlUtil改写 SQL执行 count 和分页查询最后把结果包装成Page返回。3. 核心细节解析与实操要点3.1 startPage 的正确打开方式最基础的用法长这样PageHelper.startPage(1, 10); ListUser users userMapper.selectAll();这两行代码必须紧挨着中间不能有任何其他 MyBatis 查询。原因前面提过分页参数存在 ThreadLocal 里拦截器只会在下一次查询时消费这个参数。如果你中间插了别的查询那个查询会被分页而你以为要分页的查询反而没分页。我见过最典型的翻车场景是这样的PageHelper.startPage(pageNum, pageSize); // 中间调了一个校验方法里面查了一次数据库 User currentUser userMapper.selectById(userId); // 这次查询被分页了 ListOrder orders orderMapper.selectByUser(userId); // 这次没分页这种 bug 很隐蔽因为selectById返回的是单个对象被分页后如果当前页没有数据返回的就是 null然后你的校验逻辑就莫名其妙失败了。排查半天才发现是 startPage 的位置不对。注意PageHelper.startPage()之后只有第一个 MyBatis 查询会消费分页参数。消费之后 ThreadLocal 会被清理后续查询不受影响。3.2 Page 和 PageInfo 的区别与选择查询返回的List实际上是一个Page对象你可以直接强转PageHelper.startPage(1, 10); ListUser users userMapper.selectAll(); PageUser page (PageUser) users; long total page.getTotal(); int pages page.getPages();但更推荐用PageInfo包装一层PageInfoUser pageInfo new PageInfo(users);PageInfo提供了更丰富的属性比如isFirstPage、isLastPage、hasPreviousPage、hasNextPage、navigatePages等前端做分页组件时这些信息很有用。而且PageInfo的构造函数会自动判断传入的 List 是不是 Page 类型如果是就提取分页信息如果不是就当作普通列表处理total 等于列表大小。两者的选择很简单如果只是后端内部用拿 total 和当前页数据就够了用Page强转即可如果要返回给前端做分页 UI用PageInfo更省事。3.3 合理分页参数的几个考量startPage(pageNum, pageSize)这两个参数看似简单但有几个细节值得说。pageNum 从 1 开始不是 0。传 0 或者负数PageHelper 会怎么处理实测下来传 0 时它会当作第一页处理传负数时也会做兼容。但依赖这种兼容不是好习惯前端传参时最好做一次校验。pageSize 的设置要结合业务场景。后台管理系统的表格一页 10 到 20 条比较合适移动端下拉加载一页 10 条左右数据导出场景可能一页几千条。但 pageSize 不能无限大否则分页就失去意义了。我一般会在 Controller 层做一个上限校验比如最大不超过 500防止有人恶意传pageSize999999把数据库拖垮。startPage 还有一个重载方法startPage(pageNum, pageSize, count)第三个参数控制是否执行 count 查询。如果你不需要总数传false可以省掉一次 count 查询性能会好一些。这个在后面性能优化部分会展开。3.4 分页插件在 SpringBoot 中的配置在 SpringBoot 项目里集成 PageHelper 非常简单加依赖、写配置两步搞定。依赖方面用 starter 最省事dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency这个 starter 会自动把PageInterceptor注册到 MyBatis 的插件链里不需要你手动配置SqlSessionFactory。配置方面在application.yml里加几行pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql这几个配置项的含义helper-dialect指定数据库方言。虽然 PageHelper 能自动识别但显式指定更稳妥避免识别错误。reasonable合理化分页参数。当 pageNum 小于 1 时查第一页大于总页数时查最后一页。这个配置建议开启能避免很多边界问题。support-methods-arguments支持通过 Mapper 方法参数传递分页参数后面会讲。params从方法参数中识别 count 查询相关的参数。提示如果你的项目里同时有多个数据源helper-dialect不要写死让它自动识别或者针对不同数据源配置不同的SqlSessionFactory。4. 完整代码示例与实操过程4.1 项目结构与依赖假设我们做一个用户管理模块包含用户列表分页查询。项目结构大致如下src/main/java/com/example/demo/ ├── controller/UserController.java ├── service/UserService.java ├── service/impl/UserServiceImpl.java ├── mapper/UserMapper.java ├── entity/User.java └── common/PageResult.java src/main/resources/ ├── mapper/UserMapper.xml └── application.yml依赖除了 pagehelper-spring-boot-starter还需要 mybatis-spring-boot-starter 和数据库驱动dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency4.2 实体类与 Mapper 定义实体类就是普通的 POJOpublic class User { private Long id; private String username; private String email; private Integer age; private Date createTime; // getter setter 省略 }Mapper 接口Mapper public interface UserMapper { ListUser selectByCondition(Param(username) String username, Param(minAge) Integer minAge); }对应的 XMLselect idselectByCondition resultTypecom.example.demo.entity.User select id, username, email, age, create_time from user where 1 1 if testusername ! null and username ! and username like concat(%, #{username}, %) /if if testminAge ! null and age gt; #{minAge} /if order by create_time desc /select注意这里我没有写任何 limit 语句分页完全交给 PageHelper 处理。4.3 Service 层的分页调用Service 实现类Service public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Override public PageInfoUser queryByPage(String username, Integer minAge, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); ListUser users userMapper.selectByCondition(username, minAge); return new PageInfo(users); } }这三行代码就是分页的全部逻辑。startPage设置参数查询自动分页PageInfo包装结果。4.4 Controller 层与统一返回结构ControllerRestController RequestMapping(/api/user) public class UserController { Autowired private UserService userService; GetMapping(/list) public PageResultUser list(RequestParam(required false) String username, RequestParam(required false) Integer minAge, RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize) { // 参数上限保护 if (pageSize 500) { pageSize 500; } PageInfoUser pageInfo userService.queryByPage(username, minAge, pageNum, pageSize); return PageResult.success(pageInfo); } }统一返回结构PageResultpublic class PageResultT { private int code; private String message; private ListT data; private long total; private int pageNum; private int pageSize; private int pages; public static T PageResultT success(PageInfoT pageInfo) { PageResultT result new PageResult(); result.code 200; result.message success; result.data pageInfo.getList(); result.total pageInfo.getTotal(); result.pageNum pageInfo.getPageNum(); result.pageSize pageInfo.getPageSize(); result.pages pageInfo.getPages(); return result; } // getter setter 省略 }4.5 实际执行时 SQL 发生了什么当你调用queryByPage(zhang, 18, 2, 10)时PageHelper 内部实际执行了两条 SQL。第一条是 count 查询PageHelper 自动生成select count(0) from user where 1 1 and username like concat(%, zhang, %) and age 18注意它把select id, username, ...替换成了select count(0)并且去掉了order by create_time desc因为排序对计数没有意义去掉能提升性能。第二条是分页查询select id, username, email, age, create_time from user where 1 1 and username like concat(%, zhang, %) and age 18 order by create_time desc limit 10, 10limit 10, 10表示从第 11 条开始取 10 条对应第 2 页。这个 offset 的计算方式是(pageNum - 1) * pageSize。4.6 通过方法参数直接传分页参数除了startPagePageHelper 还支持从 Mapper 方法参数中自动识别分页参数。前提是配置了support-methods-arguments: true。ListUser selectByCondition(Param(username) String username, Param(minAge) Integer minAge, Param(pageNum) int pageNum, Param(pageSize) int pageSize);调用时直接传参不需要startPageListUser users userMapper.selectByCondition(zhang, 18, 2, 10);这种方式的好处是分页参数和业务参数在一起不容易出现 startPage 位置错误的问题。但缺点是 Mapper 接口被分页参数污染了不够干净。我个人更倾向于用startPage把分页逻辑集中在 Service 层。5. 性能陷阱与常见问题排查5.1 count 查询慢的根因与优化这是 PageHelper 被吐槽最多的问题数据量大了之后select count比主查询还慢。原因在于 count 查询需要扫描满足条件的所有行。如果你的 where 条件没有走索引或者表本身有几百万行count 就是全表扫描。而主查询因为有 limit只需要扫描到满足条数的数据就能返回。优化思路有几个方向。第一给 where 条件加索引。这是最根本的。比如上面例子中username和age是查询条件如果经常按这两个字段过滤就建联合索引。count 查询和主查询都能受益。第二不需要总数时关掉 count。PageHelper.startPage(pageNum, pageSize, false)第三个参数传 false就不执行 count 查询。适合移动端下拉加载这种不需要知道总页数的场景。第三用估算值代替精确 count。如果业务上能接受总数不精确可以用explain或者查系统表获取估算行数。比如 MySQL 的explain select ...返回的 rows 字段就是一个估算值速度极快。但这种方式精度有限适合对总数不敏感的场景。第四缓存总数。如果数据变化不频繁可以把 count 结果缓存起来设置一个合理的过期时间。数据量大的列表页用户通常不会翻到很后面总数的精确性要求没那么高。实操心得我一般会在 count 查询超过 500ms 的接口上做标记然后针对性优化。大部分情况下加个索引就解决了少数情况才需要上缓存或者估算。5.2 分页参数错乱的几种典型场景场景一startPage 后插入了其他查询。前面已经讲过这是最常见的。解决办法是让 startPage 和查询紧挨着或者用方法参数传分页参数。场景二异步线程中分页失效。ThreadLocal 是线程隔离的如果你在主线程 startPage然后在子线程里执行查询分页参数取不到。这种情况需要把分页参数显式传到子线程在子线程里重新 startPage。场景三多次 startPage 只生效一次。连续调用两次 startPage只有最后一次生效因为 ThreadLocal 里的值被覆盖了。而且第一次的分页参数如果没有被消费会一直留在 ThreadLocal 里影响后续查询。所以 startPage 之后一定要确保有查询消费它。场景四分页参数被其他插件消费。如果项目里还有其他 MyBatis 插件也拦截了 Executor.query执行顺序可能影响分页。PageHelper 的拦截器顺序可以通过Intercepts注解和配置调整一般让它靠前执行。5.3 常见问题速查表问题现象可能原因排查方向查询结果没有被分页startPage 后没有紧接查询或查询走了其他数据源检查 startPage 位置确认数据源配置返回的 total 为 0count 查询被拦截或 SQL 改写异常开启 MyBatis 日志查看实际执行的 count SQL分页后数据重复或丢失order by 字段不唯一limit 分页不稳定在 order by 中加上唯一字段如 idcount 查询报语法错误原 SQL 太复杂PageHelper 改写失败手动指定 count SQL或简化原 SQL分页参数不生效ThreadLocal 被清理或跨线程检查是否跨线程是否被其他查询消费pageSize 过大导致慢查询没有做上限校验在 Controller 层限制 pageSize 最大值5.4 复杂 SQL 下 count 改写的坑PageHelper 自动生成 count SQL 的逻辑是把 select 到 from 之间的字段替换成count(0)去掉 order by。这个逻辑对简单 SQL 没问题但遇到复杂 SQL 就可能出问题。比如带有group by的 SQLselect dept_id, count(*) as num from user group by dept_idPageHelper 改写后会变成select count(0) from user group by dept_id这条 SQL 返回的是每个部门的数量而不是总组数。正确的 count 应该是select count(*) from (select dept_id from user group by dept_id) tmp遇到这种情况你需要手动指定 count SQL。PageHelper 提供了countSql参数PageHelper.startPage(pageNum, pageSize) .setCountSql(select count(*) from (select dept_id from user group by dept_id) tmp);或者在 Mapper XML 里用countSuffix等方式配置。总之复杂 SQL 下不要盲目信任自动 count一定要验证生成的 SQL 是否正确。5.5 和 MyBatis-Plus 分页的对比很多人会拿 PageHelper 和 MyBatis-Plus 的分页插件做对比。两者思路类似都是拦截器改写 SQL但使用方式有差异。MyBatis-Plus 的分页需要传入IPage对象作为方法参数分页信息跟着方法走不依赖 ThreadLocal所以不存在 startPage 位置错误的问题。但它的 Mapper 接口需要继承BaseMapper对纯 MyBatis 项目有侵入性。PageHelper 的优势是轻量、无侵入纯 MyBatis 项目可以直接用。劣势是 ThreadLocal 机制带来的心智负担。选哪个看项目情况新项目用 MyBatis-Plus 全家桶分页用它的老项目纯 MyBatis加 PageHelper 最省事。6. 几个容易被忽略的进阶用法6.1 分页查询返回自定义结构有时候你不想返回实体类列表而是返回 DTO。PageHelper 同样支持只要查询返回的是 List分页信息就能提取。PageHelper.startPage(pageNum, pageSize); ListUserDTO dtos userMapper.selectDtoByCondition(username); PageInfoUserDTO pageInfo new PageInfo(dtos);PageInfo是泛型的对元素类型没有要求。count 查询和分页查询的改写逻辑只关心 SQL 结构不关心返回类型。6.2 配合 MyBatis 二级缓存使用PageHelper 和 MyBatis 的二级缓存一起用时要注意分页查询的 SQL 是动态改写的每次 limit 参数不同生成的 SQL 也不同缓存命中率会很低。而且 count 查询和分页查询是两条 SQL缓存策略要分别考虑。如果列表数据变化不频繁可以考虑在 Service 层做缓存把整个 PageInfo 对象缓存起来而不是依赖 MyBatis 的二级缓存。这样缓存粒度更可控也避免了分页 SQL 动态改写带来的缓存碎片问题。6.3 多数据源下的方言配置项目里如果有多个数据源比如主库 MySQL、从库 OraclePageHelper 的方言配置就不能写死。可以让它自动识别PageHelper 会根据 JDBC URL 判断数据库类型。但自动识别偶尔会出错特别是用了连接池或者代理的情况下。更稳妥的做法是为每个数据源单独配置SqlSessionFactory在各自的配置里指定helperDialect。这样虽然配置麻烦一点但不会出现方言错乱的问题。6.4 分页参数的合理化配置reasonable: true这个配置我建议开启。它的作用是当 pageNum 小于 1 时查第一页当 pageNum 大于总页数时查最后一页。没有这个配置的话pageNum 超出范围会返回空列表前端可能显示空白页用户体验不好。但要注意reasonable只在执行了 count 查询时才生效因为需要知道总页数才能判断是否超出。如果你关掉了 count 查询这个配置就不起作用了。还有一个相关配置pageSizeZero当 pageSize 为 0 时返回全部结果。这个配置要慎用等于绕过了分页保护数据量大时可能直接把内存撑爆。我一般不开这个配置。7. 我在实际项目中的几点体会PageHelper 这个组件用起来简单但要用好需要理解它的边界。我自己的经验是把它当作一个便利工具而不是万能方案。简单列表查询startPage 一行搞定开发效率极高。但遇到复杂 SQL、大数据量、多数据源这些场景就要多留个心眼。count 查询慢的时候不要只想着换分页组件先看看索引和 SQL 本身有没有优化空间。分页参数错乱的时候先检查 startPage 的位置和线程上下文。还有一个习惯我坚持了很多年任何分页接口上线前都要用真实数据量压测一遍。开发环境数据少count 查询几十毫秒看不出问题。生产环境几百万行数据同样的 SQL 可能跑几秒。提前发现比上线后被用户投诉要好得多。最后分享一个小技巧如果你不确定 PageHelper 生成的 SQL 长什么样把 MyBatis 的日志级别调到 DEBUG它会打印出实际执行的 SQL 和参数。这是排查分页问题最直接的手段比看源码快得多。
返回列表