
做后端这几年分页查询是躲不开的日常操作。不管是后台管理的列表页还是移动端的下拉加载最终都要落到数据库查询上。最早手写LIMIT的时候每条SQL都要单独数偏移量还得额外拼一条COUNT遇到多表关联、条件动态变化代码里全是拼接逻辑又丑又容易错。后来项目里统一换成了Spring Boot MyBatis PageHelper的组合分页这件事才算真正省心。这篇文章就围绕Spring Boot项目里用PageHelper实现分页查询这件事从原理、配置、实操到性能调优和问题排查把核心细节都过一遍。适合刚接手Spring Boot项目、准备给接口加分页的同学也适合已经在用PageHelper但遇到过“分页不生效”“总数对不上”这类问题的人。读完你能搞清楚它背后是怎么工作的以及真正用的时候有哪些坑。1. 为什么分页这么烦人PageHelper背后的设计逻辑1.1 手写分页的痛点从图书馆找书说起先想一个问题为什么要分页本质是把“一次性取出所有数据”变成“按需取一小段”。拿数据库来说一张表几百万行一次性SELECT *出来网络传输、内存占用、应用层处理全是压力。分页让每次查询只拿一页比如20条压力就能接受。但分页的麻烦在于它不只是“取20条”这么简单。前端需要知道总共有多少条才能渲染出页码需要知道当前是第几页才能控制上一页下一页的可用状态还得处理用户点了第100页、数据源却只剩10页这种边界情况。所以分页接口通常要返回一整套信息当前页数据、总条数、总页数、当前页码等。如果手写大概是这样的流程前端传pageNum和pageSize后端算offset (pageNum - 1) * pageSize然后写一条SELECT ... LIMIT #{offset}, #{size}再写一条SELECT COUNT(*) FROM ...。单看一次还好一旦列表接口多起来——用户列表、订单列表、日志列表、商品列表——每个都这么来一遍重复代码就非常恶心。而且更麻烦的是动态条件筛选状态、搜索关键字、时间范围这些条件既要拼到主查询里又要拼到COUNT查询里漏一个就对不上。打个比方这就像你去图书馆找书知道书名却不知道它在哪一排。手写分页的做法是从第一本开始一本一本地翻翻到第1000本才停下来然后告诉你“你要的这本书在第1001本往后”。每次翻页都得从头开始数。PageHelper做的则是它根据你给的页码范围直接索引到目标位置同时还帮你数清了整排书架有多少本书。你只需要告诉它“我要第3排每排20本”它把书拿给你也把总数报给你。1.2 PageHelper的ThreadLocal机制一次startPage一次分页PageHelper的核心设计说穿了并不神秘。它的原理可以拆成三块ThreadLocal存储分页参数、MyBatis拦截器拦截SQL、以及根据方言改写SQL。先说ThreadLocal。当你调用PageHelper.startPage(pageNum, pageSize)时PageHelper并没有立刻去修改任何SQL它只是把分页参数存到了当前线程的一个ThreadLocal变量里。这个变量是线程私有的也就是说每个请求在自己的线程里设置分页参数互不干扰。这个设计有一个好处对业务代码的侵入性极低。你不需要把分页参数塞进Mapper方法的入参里不需要改动接口签名只要在查询前调用一下startPage查询自然就带上分页了。然后是拦截器。PageHelper作为一个MyBatis插件注册在Executor层。当MyBatis执行一个查询时拦截器会先检查ThreadLocal里有没有分页参数。如果有它就把当前这条SQL拦截下来根据数据库方言改写成分页SQL——MySQL就拼LIMITOracle就拼ROWNUMPostgreSQL就拼LIMIT OFFSET——然后再执行。同时在执行前它还会额外执行一条COUNT查询用改写的SQL算出总条数把结果塞进Page对象里。这里有个关键点很多人第一次用会踩坑startPage只对紧接着的第一次查询生效。PageHelper会在查询结束后自动清理ThreadLocal里的分页参数源码里叫clearPage。所以正确的姿势必须是先startPage然后马上执行Mapper查询。如果你在startPage之后、查询之前又执行了别的SQL那个SQL就会“抢走”分页参数导致你真正想分页的查询反而没有分页。后面第5节会详细讲这个坑。1.3 物理分页与逻辑分页PageHelper到底比手写好在哪分页有两种常见实现物理分页和逻辑分页。物理分页是数据库层面过滤只查询需要的行逻辑分页是先把所有数据查出来在内存里截取一段。逻辑分页虽然代码简单但数据量大时内存扛不住几乎没人会在生产环境这么干。PageHelper属于物理分页。它改写SQL让数据库只返回需要的行比如LIMIT 20, 20就是跳过前20行、取第21到40行。相比手写SQL它的优势主要体现在几个方面一是自动生成COUNT。手写的时候COUNT语句要自己维护PageHelper会根据查询SQL自动改写生成一条COUNT省掉一批重复劳动。二是多数据库方言支持。项目从MySQL迁到PostgreSQL或者国产数据库手写的LIMIT语法可能要全改PageHelper内置了多种方言只要配置helperDialect它能自动适配。三是合理参数兜底。比如reasonable配置设为true之后页码超出总页数时自动返回最后一页页码小于1时自动归1这些边界逻辑不用自己写。四是与MyBatis的融合。它和Mapper接口、XML映射天然配合对已有代码改动最小。这也是它成为Java生态里最流行的MyBatis分页插件之一的原因。2. 环境准备与版本选择先把地基踩实2.1 版本兼容矩阵Spring Boot 2.x和3.x完全不一样PageHelper的版本选择是很多人一开始就翻车的地方。核心原因是Spring Boot 3.x做了javax到jakarta命名空间的迁移而PageHelper的starter依赖包在适配这个变化时版本号体系也跟着调整了。如果你用的是Spring Boot 2.x比如2.7.x那么引入pagehelper-spring-boot-starter的1.4.x系列就行实测中1.4.7配Spring Boot 2.7.18、MyBatis 2.3.x的starter稳定运行没有问题。如果你用的是Spring Boot 3.x那就要用PageHelper 6.x的版本了。这个版本适配了jakarta包路径底层拦截逻辑也做了调整。很多同学在Spring Boot 3项目里直接引入1.4.x启动时报ClassNotFoundException就是因为PageHelper内部的类还依赖老包名。下面是一个我实际用过的组合供参考框架版本Spring Boot2.7.18mybatis-spring-boot-starter2.3.2pagehelper-spring-boot-starter1.4.7如果项目还在规划阶段直接上Spring Boot 3.2.x PageHelper 6.1.x也是可以的但要注意MyBatis相关依赖也要同步升级到支持jakarta的版本。2.2 引入依赖Maven的GAV坐标Spring Boot项目里用Maven引入极简单在pom.xml中加入starter即可dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency注意这个starter本身就包含了pagehelper核心包和MyBatis的集成逻辑不需要再单独引入mybatis-pagehelper之类的依赖否则版本冲突会让你头大。引入之后Spring Boot的自动配置会帮我们注册好PageHelper的拦截器不需要手动配置Interceptor类。这一点很重要后面第5节会讲“如果自己手动配置拦截器顺序不对会导致分页不生效”的问题。2.3 application.yml核心配置项逐行解读PageHelper在application.yml里的配置其实不多但每个都有讲究。我贴一份实际项目中用得比较完整的配置并逐行说明pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql auto-dialect: true auto-runtime-dialect: falsehelper-dialect指定数据库方言。这里我写死为mysql一是避免自动探测带来的性能开销二是避免多数据源时探测出错。如果项目只连一种数据库建议写死。reasonable是前面提过的边界兜底。设为true之后如果前端传pageNum0或者负数PageHelper会强制归1如果传的页码超过实际总页数自动返回最后一页数据。这个配置非常适合后台管理系统的列表接口不然每次前端页码越界后端还要专门处理。support-methods-arguments设为true后支持Mapper接口方法参数里直接写pageNum和pageSize字段。也就是说你可以不调用startPage而是把分页参数作为查询条件传入PageHelper自动识别。不过我个人习惯还是显式调用startPage理由在第5节会说。params: countcountSql这个配置含义是把Mapper方法里名为countSql的参数自动赋值为COUNT查询的SQL。它和support-methods-arguments配合用于自定义COUNT场景一般用不到但了解它没坏处。auto-dialect是让PageHelper自动探测数据库类型。如果你的项目只有一个数据源可以不开如果开了它会通过JDBC连接获取数据库类型并自动选择方言。多数据源时这个配置反而容易踩坑后面第4节展开说。还有几个配置虽然不常用但遇到问题时很有价值。offset-as-page-num设为false时传的pageNum按页码处理row-bounds-with-count设为true后使用RowBounds分页时也会自动执行COUNT查询。这些等真正遇到场景再调初学阶段不用管。3. 实操落地Controller到Mapper的分页链路全解析3.1 参数接收别让前端直接操控pageSize分页接口的第一步是接收参数。最直白的做法是在Controller方法上直接写RequestParam Integer pageNum, RequestParam Integer pageSize但一旦参数多了——比如还有搜索关键字、状态筛选——方法签名就会变得很长。更规范的做法是定义一个分页请求对象。我在项目里通常这样设计public class PageRequest { private Integer pageNum 1; private Integer pageSize 10; private String keyword; private Integer status; // getter / setter 省略 }前端不传时pageNum默认1pageSize默认10。这样即使前端漏传参数也不会导致查询结果异常。另外我习惯在Service层加一个兜底校验pageSize最大限制100。因为如果不限制前端传个pageSize10000一次查出一万条数据库和网络都受不了。这种参数校验不复杂却能避免很多性能问题。3.2 黄金三步startPage、查询、PageInfo分页的核心代码其实只有三行PageHelper.startPage(pageNum, pageSize); ListUserDO userList userMapper.selectUserList(query); PageInfoUserDO pageInfo new PageInfo(userList);第一步startPage设置分页参数。第二步执行Mapper查询这个查询会被PageHelper拦截并拼上LIMIT。第三步把返回的List包装成PageInfo对象。PageInfo是PageHelper提供的一个分页结果包装类里面除了数据列表之外还有一大堆分页元信息total总条数pages总页数pageNum当前页码pageSize每页条数hasNextPage是否有下一页hasPreviousPage是否有上一页isFirstPage是否第一页isLastPage是否最后一页navigatepageNums分页导航栏的页码数组这些字段已经足够覆盖前端的展示需求。比如分页组件要显示“共X条第Y/Z页”直接从PageInfo里取就行导航栏要显示“1 2 3 4 5”用navigatepageNums正好。有一点要注意startPage和查询之间不要有任何其他数据库操作。这两个动作必须是紧挨着的。如果中间插了一个别的Mapper查询分页参数就会被那次查询消费掉目标查询反而没有分页。这是使用PageHelper最核心的纪律。3.3 返回结果封装别把Page对象直接扔给前端很多初学者喜欢把PageInfo直接作为接口返回值省事。但用久了你会发现把DO数据库实体直接返回给前端是不太好的习惯。原因有两个一是DO里的字段可能包含不该暴露的信息比如数据库自增ID、内部状态码、审计字段二是DO的结构和前端要的数据结构往往不完全匹配比如前端需要一个createTime的格式化字符串DO里却是Date。所以我一般在Service层做一次DO到VO的转换然后再放入分页结果里返回。举个例子PageInfoUserVO pageInfoVO new PageInfo(); pageInfoVO.setPageNum(pageInfo.getPageNum()); pageInfoVO.setPageSize(pageInfo.getPageSize()); pageInfoVO.setTotal(pageInfo.getTotal()); pageInfoVO.setPages(pageInfo.getPages()); pageInfoVO.setList(userList.stream().map(UserConvert.INSTANCE::toVO).collect(Collectors.toList()));这样Controller层返回给前端的就是一个结构清晰、字段干净的PageInfoUserVO对象。另外有个序列化的小坑PageHelper的Page对象继承了ArrayList它的字段如total、pageNum是在运行时动态加进去的。如果你直接把Page对象序列化成JSON返回有些JSON库会把ArrayList的底层结构也序列化进去导致返回结果出现奇怪的字段。PageInfo则没有这个问题因为它是普通POJO。所以统一返回PageInfo不要直接返回Page。3.4 多条件动态SQL与分页共存实际项目里的列表查询基本不会是“查全表”而是带各种筛选条件。PageHelper和MyBatis动态SQL配合得很好。Mapper接口可以这样定义ListUserDO selectUserList(Param(query) UserQuery query);XML里用where和if动态拼条件select idselectUserList resultTypecom.example.domain.UserDO SELECT id, name, email, status, create_time FROM user where if testquery.keyword ! null and query.keyword ! AND (name LIKE CONCAT(%, #{query.keyword}, %) OR email LIKE CONCAT(%, #{query.keyword}, %)) /if if testquery.status ! null AND status #{query.status} /if /where ORDER BY id DESC /selectService层调用还是三步PageHelper.startPage(pageNum, pageSize); ListUserDO userList userMapper.selectUserList(query); PageInfoUserDO pageInfo new PageInfo(userList);动态条件完全写在XML里分页参数则通过ThreadLocal传给拦截器两者互不干扰。这也是PageHelper设计优雅的地方——业务查询逻辑和分页逻辑彻底分离你维护SQL时只需要关注查询条件不用管分页语法。需要注意的是如果有排序需求比如按创建时间倒序、按状态排序排序写在XML里即可。但如果排序字段是前端动态传的那就要小心了千万别把字段名直接拼到SQL里会有SQL注入风险。正确的做法是做字段白名单映射前端传createTime后端映射成create_time再拼进去。4. 进阶调优大偏移量、COUNT查询与嵌套分页4.1 深翻页为什么会越翻越慢用过PageHelper之后基础分页就没什么问题了。但列表页翻到很后面——比如第1000页——你会发现接口响应时间明显变长。这不是PageHelper的问题而是LIMIT本身的特性。MySQL执行LIMIT 10000, 20的时候并不是直接跳到第10001行开始读而是从头扫描数到第10000行再往后取20行。偏移量越大扫描的行越多查询就越慢。这在数据量小的时候感觉不到但表里有个几十万行翻到后面的页就会明显卡顿。针对深翻页业界常用的优化方案有几种。第一种是延迟关联SELECT u.id, u.name, u.email FROM user u INNER JOIN ( SELECT id FROM user ORDER BY id DESC LIMIT 10000, 20 ) tmp ON u.id tmp.id ORDER BY u.id DESC;思路是先只查主键用主键做LIMIT再回表关联查完整数据。因为主键索引扫描比全表扫描快得多所以整体性能会有明显改善。第二种是游标分页也叫键集分页、Keyset分页。核心是记住上一页最后一条记录的位置用WHERE id #{lastId}来取下一页。比如SELECT id, name, email FROM user WHERE id #{lastId} ORDER BY id DESC LIMIT 20;这样做的好处是不管翻到多深的页数据库都只扫描需要的那20行附近的数据性能恒定。坏处是前端不能用传统的页码导航而要用“加载更多”的交互方式。这在移动端的下拉加载里非常常见。第三种是禁止深翻页后台管理系统限制最大页码比如只能翻到100页超出就让用户用筛选条件缩小范围。这个方案看起来“土”但在实际业务里非常好用。用户真的会翻到几千页去看数据吗大部分时候不会。与其让数据库硬扛不如引导用户用搜索和筛选。PageHelper能解决的是分页的易用性问题它没法从根上解决深翻页的性能瓶颈。所以技术选型的时候要想清楚场景如果数据量不大PageHelper完全够用如果数据量很大、交互又是瀑布流可以自己用游标分页。4.2 COUNT查询的优化当总数计算变成瓶颈PageHelper执行分页查询的时候会自动先执行一条COUNT查询。大多数场景下这条COUNT很快。但遇到多表JOIN、带了复杂子查询的场景COUNT可能成为性能瓶颈。举个例子查询一个订单列表JOIN了订单明细表还要关联用户表取用户名。主查询要返回的是订单主表字段加上用户名但COUNT的时候PageHelper会根据查询SQL改写一条SELECT COUNT(*)如果改写得不好可能把JOIN的两张表的所有行都算进去速度自然慢。我在一个实际项目里遇到过这个问题一个报表查询主查询两秒能出结果COUNT却要跑八秒。接口总耗时几乎全耗在COUNT上。后来一分析发现主查询里有一个GROUP BYPageHelper改写的COUNT会在有GROUP BY的情况下处理得不够理想。解决办法有几个。第一个如果前端展示“总共X条”不是必须的可以在分页查询时使用PageHelper.startPage(pageNum, pageSize, false)。第三个参数是是否执行COUNT查询设为false就不查COUNT了接口快很多。代价是总页数、总条数这些字段会没有值前端只能显示当前页不能显示页码导航。第二个自定义COUNT SQL。PageHelper支持在Mapper里写一个专门用于COUNT的查询通过suffix来关联。配置countSuffix为CountPageHelper执行COUNT的时候会优先找selectUserListCount这个方法。这个方法的SQL可以自己手工优化比如去掉不必要的JOIN只留主表。select idselectUserListCount resultTypejava.lang.Long SELECT COUNT(*) FROM user where if testquery.keyword ! null and query.keyword ! AND (name LIKE CONCAT(%, #{query.keyword}, %) OR email LIKE CONCAT(%, #{query.keyword}, %)) /if /where /select第三个如果使用了GROUP BY或者查询里带了DISTINCTCOUNT的语义可能会变得很微妙。PageHelper对这种SQL的改写策略不总是完美的。我在项目里的经验是遇到复杂查询不要依赖PageHelper自动生成的COUNT优先自定义COUNT SQL。顺便说一个常见误用有些同学为了拿总数故意把pageSize设得很大想“一次性查完”。这个做法很危险它会让你退化成逻辑分页内存占用飙升数据库压力也不小。正确的思路是用PageInfo.getTotal()拿总数用LIMIT控制每次实际查询的数据量。4.3 多数据源下的拦截器为什么分页有时失灵Spring Boot MyBatis的多数据源场景PageHelper踩坑的概率比单数据源高不少。先说原理。PageHelper的starter在启动时会给MyBatis的SqlSessionFactory注册一个PageInterceptor。如果你用的是mybatis-spring-boot-starter并且只配置了一个数据源那一切正常。但如果项目里配了两个数据源每个数据源都有自己的SqlSessionFactory那么starter默认只往主数据源的SqlSessionFactory里注册拦截器。这就是为什么“一个数据源分页正常另一个数据源分页不生效”的原因。解决办法是给所有SqlSessionFactory都显式注册PageInterceptor。我的做法是在配置类里手动构造SqlSessionFactory时把PageInterceptor加进Plugins列表Bean public SqlSessionFactory secondarySqlSessionFactory(...) throws Exception { MybatisSqlSessionFactoryBean factory new MybatisSqlSessionFactoryBean(); factory.setDataSource(secondaryDataSource()); factory.setTypeAliasesPackage(com.example.domain); factory.setPlugins(new Interceptor[]{new PageInterceptor()}); return factory.getObject(); }还要注意多数据源时auto-dialect的自动探测可能解析到错误的方言。比如两个数据源一个是MySQL一个是PostgreSQL每次都自动探测的话切换到另一个数据源时可能拿到缓存里的旧方言。这种情况下最好按数据源分别指定helperDialect或者在配置里动态获取当前数据源类型。这一点比较麻烦但遇到问题先往“是否有多个SqlSessionFactory”这个方向排查通常能很快定位。4.4 嵌套结果查询为什么分页总数不对MyBatis里有个常见的关联查询方式叫嵌套结果Nested Results也就是在resultMap里用collection或association把关联表数据一起查出来。它和PageHelper组合时有一个隐蔽的坑总数可能对不上。原因在于PageHelper是在SQL层面做物理分页的它按主查询返回的行数来LIMIT。但使用了collection之后一条主记录可能因为关联了多条子记录在SQL查询结果里变成多行。mybatis在映射时再把这些行折叠回主记录。这就导致SQL层面LIMIT了20行折叠后可能只有15条主记录。如果外键关联正常、子记录也不多这个坑不容易触发。一旦某条主记录关联了多条子记录就会出现“总数和实际不符”“每页数据量不一致”的问题。我在项目里遇到类似场景时一般有两种应对方式。一种是改用嵌套查询Nested Select也就是分步查询先查主表再用association的select属性去查关联数据。这样每条主记录对应一次子查询分页的粒度自然就是主记录本身。坏处是会产生N1查询问题在数据量大时也要谨慎。另一种是干脆不在查询里关联而是先分页查主表数据拿到当前页的记录ID集合再单独查一次关联数据在应用层组装。这种做法虽然代码多一点但SQL最可控分页最准确性能也最好理解。在数据量不是特别大、字段映射又不复杂的项目里我推荐优先用这种方式。5. 问题排查实录分页不生效的几类典型场景5.1 最经典明明写了startPageSQL尾部就是没有LIMIT分页不生效是PageHelper使用中出现频率最高的问题。排查的时候我一般按下面这个流程走基本覆盖了90%的原因。先看日志里实际执行的SQL尾部有没有LIMIT。如果完全没有LIMIT说明PageInterceptor没有生效。这时先检查依赖Spring Boot 3.x项目是否误用了1.4.x版本的starter。再检查是否有自定义拦截器如果你自己写了一个MyBatisInterceptor并且在注册顺序上把PageInterceptor覆盖掉了分页就不会生效。再看你的调用顺序。startPage之后是不是紧接着执行了目标查询如果中间有别的Mapper查询ThreadLocal里的分页参数被“抢走”了目标查询自然没有分页。这种情况特别容易发生在Service层里先查了一个字典表、再做主查询的代码里。日志里也不会报错就是静悄悄的不分页很难发现。最后看Mapper方法签名。如果Mapper方法返回的不是List而是Page或者单个对象PageHelper的拦截逻辑可能不会生效。它设计上只拦截返回List的查询。所以Mapper方法一定不要自作主张把返回值类型改成PageUser应该返回ListUser。5.2 分页数据重复或总数不准数据重复的常见原因是JOIN查询。比如订单表和订单明细表LEFT JOIN如果一条订单有多条明细SQL查询结果里这条订单就出现了多次。PageHelper在SQL层面做LIMIT取到的20行可能是同一订单的几次重复出现折叠后实际返回的订单记录就少于20条或者出现了一条订单出现在两页里的情况。这种情况在SQL层面解决比较难因为LIMIT发生在JOIN之后。建议改成子查询分页先分页查出订单主表ID再JOIN明细。或者在上层自己组装前面第4.4节说过的方案。总数不准的常见原因是COUNT SQL被改写后语义变化。比如带GROUP BY的查询PageHelper改写的COUNT在语义上可能不等价。另一个常见问题是查询里如果带了去重COUNT(*)会包含重复行。遇到这种情况经验就是不要依赖自动生成的COUNT自己写一个COUNT查询用countSuffix关联进去。这里给一个排查的思路把PageHelper实际执行的COUNT SQL从日志里拷出来自己扔到数据库客户端跑一下看结果是否和预期一致。如果不一致就能确定是COUNT改写的问题。如果一致那可能就是数据本身有重复。5.3 序列化问题为什么返回给前端的JSON看起来不对劲这个问题出现在“直接返回Page对象”的写法里。Page对象继承自ArrayList它里面除了列表数据还带了一些分页字段。当你用Jackson序列化时因为它的父类是ArrayList序列化结果会包含两个部分一个是ArrayList的底层元素数组一个是Page自己的字段total、pageNum、pageSize等。两者拼在一起返回给前端的JSON结构就变得很奇怪常见的情况是出现一层额外的list字段或者字段顺序混乱。解决办法非常简单不要直接返回Page统一返回PageInfo。PageInfo是普通POJO序列化规则明确字段结构可控。如果你确实不想多包一层也可以自己定义一个统一响应体PageResultT把需要的字段手动拷贝进去。哪种都行关键是要对返回结构有掌控而不是把底层对象随便丢出去。5.4 一套避坑清单建议直接收藏根据这些年踩过的坑我整理了一份分页相关的避坑清单每次开发新接口或者排查分页问题时都会过一遍startPage后面紧跟目标查询中间不插任何其他数据库操作Mapper方法返回类型用List不要用Page统一返回PageInfo不要直接返回Page对象有GROUP BY、DISTINCT、UNION这类场景时优先自定义COUNT SQL多表JOIN且结果要折叠时不要依赖物理分页的准确性考虑应用层组装pageSize做上限限制防止前端传超大值ORDER BY字段使用白名单映射防止SQL注入多数据源时给每个SqlSessionFactory都注册PageInterceptor并明确方言深翻页场景用延迟关联、游标分页或限制最大页码来兜底reasonable配置建议打开能省掉很多边界处理的胶水代码第9条那个深翻页优化其实还值得多说一句。PageHelper解决的是“怎么写分页方便”的问题但数据库本身的性能瓶颈它帮不上忙。真正数据量上来之后我建议在架构层面想清楚是严格控制查询条件还是引入搜索中间件还是直接改游标分页。分页是工具业务场景才是决定方案的关键。就我个人体验来说PageHelper最让人舒服的一点是它对既有代码的侵入性几乎为零。你不需要改Mapper接口不需要给每个查询传分页参数不需要关心数据库方言只要在查询前加一行startPage剩下的交给插件。这种“少写代码还不容易出错”的体验在项目里反复使用之后确实能感受到设计的巧妙之处。当然它的坑也不少但只要理解了ThreadLocal和拦截器这两个核心机制大部分问题都能在几分钟内定位。希望这篇文章能让你少走一些弯路。