☰
PageHelper分页查询原理与高频坑位优化实战
2026/10/14 17:53:43 网站建设 项目流程

做了这么多年Java后端,分页是我最常写、也最容易想当然的一个功能。新项目一搭起来,列表查询几乎清一色是PageHelper.startPage(pageNum, pageSize),后面紧跟一个Mapper查询,剩下的总数、总页数、当前页数据全部由PageHelper分页查询这套机制补齐。它也因此成了很多MyBatis项目里的默认标配。

但标配归标配,我这些年接手过的系统里,因为分页出的问题并不少:总数对不上、分页跑到别的查询上、异步线程里分页参数串了、深分页慢到接口超时……很多问题看起来像是"插件坏了",实际是使用姿势出了问题。这篇文章我想把PageHelper分页查询的底层原理、接入步骤、高频坑位和优化思路一次性说清楚,既适合刚入门想搞懂分页原理的新人,也适合正在被各种奇异分页问题折磨的老手。

1. 为什么分页这么件"小事",最后都交给了PageHelper

1.1 手写分页的痛点

早些年我还在写原生JDBC和早期MyBatis的时候,分页是每个列表接口最烦人的重复劳动。一个用户列表,你得先写一条总数查询,再写一条数据查询:

SELECT COUNT(*) FROM user WHERE status = 1; SELECT * FROM user WHERE status = 1 ORDER BY id DESC LIMIT #{offset}, #{pageSize};

这两条SQL虽然简单,但带来的麻烦一点都不少:

  • 查询条件要维护两遍,改一个过滤条件,count和数据两条SQL都得同步改,漏改一处就是线上事故。
  • 前端传过来的是pageNum和pageSize,底层SQL要的是offset和limit,每个Service层都得写一遍(pageNum - 1) * pageSize。
  • 换一个数据库方言,LIMIT写法完全不同:MySQL是LIMIT offset, size,Oracle是老式ROWNUM,PostgreSQL又是OFFSET ... LIMIT。数据库一换,所有手写分页全要重来。
  • 如果分页总数和当前页数据不是同一条SQL查出来的,在数据不断写入的场景下,总数和数据很容易出现轻微的不一致。

更粗暴的做法是把全表查出来丢到内存里subList一下。数据量在几百条时这个方案最省事,但一旦到了几十万条,内存直接撑不住,接口耗时也直线上升。我见过不止一个内部系统这么干过,最后无一例外都在数据量上来之后返工。

1.2 PageHelper到底帮你做了什么

PageHelper解决的核心问题,是把"分页"这个横切关注点从业务代码里抽出去。你在业务层只需要做一件事:在查询前声明"我要第几页、每页几条"。剩下三件事插件自动完成:

  • 自动生成并执行一条count查询,算出满足条件的总记录数。
  • 自动把原始查询SQL改写成当前数据库方言下的分页SQL,比如MySQL的LIMIT。
  • 自动把返回结果包装成分页对象,让你能拿到total、pages、pageNum这些元信息。

关键是它完全不要求你改Mapper接口和XML。原来返回List<User>的方法不用换成什么特殊类型,因为分页后的结果对象Page本身就是ArrayList的子类,把接口声明从List<User>改成Page<User>也行,保持List<User>也不影响使用——这就是侵入性小的含义。

用一个表格对比手写分页和PageHelper分页,差异就很清楚了:

对比维度手写分页PageHelper分页
SQL数量count和数据两条,需各自维护一条业务SQL,插件生成count
方言适配每个数据库手写一套配置helper-dialect自动适配
业务代码每个Service都要算offset一行startPage搞定
返回信息需自己封装分页结构Page/PageInfo直接给全套元信息
对已有代码的侵入涉及所有查询方法改造Mapper方法签名基本不动

1.3 什么项目适合用PageHelper

这不是废话,很多人把PageHelper当成万能分页器,什么查询都往上套,后面必然出问题。以我的经验,适合用PageHelper的场景有三个特征:项目基于MyBatis体系、查询是标准的业务列表查询、数据量没有大到需要专门做深分页优化。比如用户管理列表、订单列表、日志查询这类——一张主表带少量关联,过滤条件相对固定,分页返回给前端表格组件,这是PageHelper的主场。

反过来,复杂的聚合报表SQL、需要精确控制统计口径的多表汇总、全文检索、超大数据量的导出,这些场景我会直接在第六章里说明为什么别硬套PageHelper。

2. PageHelper分页查询的底层逻辑:拦截器、ThreadLocal与SQL改写

2.1 MyBatis插件拦截的其实是Executor

想搞懂PageHelper为什么是"紧跟查询才生效",就得知道它在MyBatis里到底拦的是什么。MyBatis允许用插件拦截四大核心对象:Executor、StatementHandler、ParameterHandler、ResultSetHandler。SQL真正发到数据库之前,要先经过Executor,它是SQL执行的入口调度器。

PageHelper的PageInterceptor拦截的就是Executor.query()方法。你可以把它理解成一个海关检查站:每次Mapper查询这个"货物"经过Executor时,拦截器都会先摸一下自己兜里有没有分页指令。如果有,就把这条查询拦下来做改造;如果没有,就正常放行。所以它不是在SQL解析层之外另起炉灶,而是完全寄生在MyBatis的执行链路上,这也是为什么它只能作用于MyBatis查询,对JdbcTemplate、MyBatis-Plus自带的QueryWrapper分页方法是另一套机制。

2.2 ThreadLocal:为什么是"取一次就消费"

PageHelper.startPage(pageNum, pageSize)做的事情非常朴素:它把页码和每页条数封装成一个Page对象,放进一个静态的ThreadLocal里。这个ThreadLocal是"一次性的",也就是说它是个单次接力棒,取了就消费,取完不留。

举例来说:

PageHelper.startPage(1, 10); List<User> users = userMapper.selectByStatus(1);

startPage把分页参数放到线程的ThreadLocal里,紧接着userMapper.selectByStatus(1)执行时被拦截器发现"这个线程有分页参数",于是拦截器把这个参数取走,执行count查询、改写LIMIT,最后把ThreadLocal清掉。整个过程就像你把一张纸条贴在自己工位上,告诉下一位路过的人帮他处理一件事——如果同时有两个人路过,只有第一个人会拿到纸条。

这也是官方为什么反复强调"startPage后面必须紧跟第一个MyBatis查询":因为分页参数只对紧跟的第一条查询生效,后面的查询就拿不到接力棒了。这种设计换来的好处是业务方法签名完全不用改,代价就是调用时机必须严格。

2.3 count查询与LIMIT改写是怎么发生的

一次完整的PageHelper分页查询,内部大致经历这么几步:

  1. 拦截器从ThreadLocal拿到Page对象,先判断是否需要执行count查询。startPage默认是要count的,后面会讲怎么关掉。
  2. 插件解析原始SQL,尝试生成一条count SQL。生成逻辑会尽量去掉ORDER BY,因为统计总数时排序没意义,去掉能省排序开销。对于带GROUP BY或者DISTINCT的SQL,count的生成策略会更保守,这也是后面坑三里很多问题的源头。
  3. 执行count查询,拿到总数。
  4. 根据数据库方言改写原SQL,MySQL追加LIMIT offset, pageSize,Oracle改成ROWNUM或OFFSET ... FETCH写法。
  5. 执行改写后的数据查询,把结果和总数一起封装成Page对象返回。
  6. 清理ThreadLocal里的分页参数。

这套流程里的每一步都可能出问题,但只要理解了流程,排查起来就有的放矢。比如count SQL生成了奇怪结果,你第一反应就该去检查原始SQL里有没有GROUP BY、DISTINCT、一对多JOIN;比如分页数据正常但总数异常,你该怀疑是不是count口径和数据查询口径不一致。

3. 项目接入PageHelper分页查询:依赖、配置与三种核心写法

3.1 依赖引入与版本选择

用Spring Boot项目时,最省事的方式是引入官方starter:

<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>

注意版本不是随便选一个就完事。Spring Boot 2.x搭配1.4.x这个版本线比较稳;如果你用的是Spring Boot 3,要选2.x版本的starter,不然启动时会因为包兼容问题报一堆莫名其妙错误。传统Spring项目或者不想用starter的话,也可以只引入核心包pagehelper,然后在MyBatis配置里手动注册插件:

<plugins> <plugin interceptor="com.github.pagehelper.PageInterceptor"> <property name="helperDialect" value="mysql"/> <property name="reasonable" value="true"/> </plugin> </plugins>

我个人建议新项目直接走starter,省去手动注册的步骤,配置也统一放application.yml,团队接手时更清晰。

3.2 application.yml关键配置逐项说明

starter方式的配置集中在配置文件中,最好把每一项含义交代清楚:

pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: false params: count=countSql
配置项作用我的一般建议
helper-dialect指定数据库方言,可选mysql/oracle/postgresql/sqlserver等单数据源建议显式写死,避免自动识别误差
reasonable页码越界自动修正:pageNum小于1改成1,超过总页数改成最后一页前端分页组件传参不可控时建议true
support-methods-arguments是否支持从Mapper方法参数里自动识别分页参数团队规范统一时再开,默认false更可控
params设置分页参数别名,常用count=countSql处理复杂count遇到count生成异常时配置
count-suffix自定义Count查询方法后缀,默认_COUNT多表复杂查询建议配合使用

helper-dialect在单数据源项目里显式指定最稳妥,因为自动识别靠的是Connection的元数据,一旦用了代理数据源或者连接池做了特殊包装,个别情况下识别会走偏。多数据源项目则需要额外关注auto-runtime-dialect,这类场景本身就不适合用PageHelper硬撑,后面会提到。

3.3 最常用的三种写法

第一种,也是最常见的,startPage加PageInfo:

PageHelper.startPage(pageNum, pageSize); List<User> users = userMapper.selectByStatus(status); PageInfo<User> pageInfo = new PageInfo<>(users); Long total = pageInfo.getTotal(); List<User> list = pageInfo.getList();

PageInfo会进一步包装Page,把hasNextPage、hasPreviousPage、navigatepageNums这些前端分页组件需要的导航信息也算好。

第二种,调用方持有的是偏移量而不是页码,用offsetPage:

PageHelper.offsetPage(offset, limit); List<Order> orders = orderMapper.selectAllOrders();

它和startPage的区别是参数语义不同,底层效果一样。适合游标已经算好、或者外部系统直接传offset的场景。

第三种,用startPage返回的Page对象配合lambda:

PageInfo<User> pageInfo = PageHelper.startPage(1, 10) .doSelectPageInfo(() -> userMapper.selectByStatus(1));

doSelectPage返回Page,doSelectPageInfo直接返回PageInfo。这种写法比裸写startPage多一层保护:Page对象内部会自动处理分页参数的清理和管埋,减少参数残留的隐患。我现在的项目里新代码基本都是这种写法。

3.4 Page与PageInfo:什么时候用哪个

很多新手分不清Page和PageInfo,直接把Mapper返回的Page类型暴露给前端,结果JSON里多出一堆startRow、endRow、navigatepageNums内部字段,又丑又容易让前端困惑。

对比项PagePageInfo
本质ArrayList的子类,查询结果本身对Page的再封装,增加导航信息
核心字段pageNum、pageSize、total、pages在Page基础上增加hasNextPage、hasPreviousPage、nextPage等
适用场景Service内部快速取total和list接口返回给前端做完整分页组件
序列化体积相对精简字段较多,建议自定义VO裁剪

我的实践是:Controller返回给前端时不要直接吐PageInfo,而是定义一个只有total、pageNum、pageSize、list四个字段的PageResult<T>,把PageInfo的值拷进去。多写几个字,但接口语义干净,前端对接成本也低。

3.5 一个完整的查询链路示例

一个标准的分页查询从Controller到Mapper大概长这样:

public PageResult<UserVO> queryUserPage(int pageNum, int pageSize, Integer status) { int safePageNum = Math.max(pageNum, 1); int safePageSize = Math.min(Math.max(pageSize, 1), 100); PageInfo<User> pageInfo = PageHelper.startPage(safePageNum, safePageSize) .doSelectPageInfo(() -> userMapper.selectByStatus(status)); List<UserVO> voList = pageInfo.getList().stream() .map(UserVO::from) .collect(Collectors.toList()); return PageResult.of(pageInfo.getTotal(), safePageNum, safePageSize, voList); }

Mapper这边保持最简:

@Select("SELECT * FROM user WHERE status = #{status} ORDER BY id DESC") List<User> selectByStatus(@Param("status") Integer status);

这里我做了两件手写时代必须自己做的事:把pageSize限制在100以内,防止有人传5000把数据库查穿;把pageNum至少修正为1,配合reasonable配置双保险。

4. PageHelper分页查询最常见的五个坑:失效、串数、错count

4.1 坑一:startPage后面跟的不是目标查询

这个坑几乎是PageHelper新手翻车排行榜第一。看下面的代码:

PageHelper.startPage(1, 10); List<Role> roles = roleMapper.selectAllRoles(); // 被分页的是它 List<User> users = userMapper.selectUsers(); // 这个反而没分页

开发者本意是分页查用户,结果分页参数先碰上了角色查询,用户列表返回了全部数据,角色列表反而被分页了——还是那种只有10条、页面怎么翻都不对的数据。排查时如果只盯着用户查询看,怎么都想不通。

修复方案有两层。第一层是代码层面保证"startPage后面紧跟目标查询",中间不能插入任何其他MyBatis查询。第二层是逻辑层面:如果前面确实需要先执行别的查询(我见过先查配置、再查主体数据的业务),那就把顺序调整成"先做辅助查询,最后再startPage主查询"。

4.2 坑二:ThreadLocal残留导致分页参数串到别的查询

正常路径下,拦截器执行完查询会把ThreadLocal清理掉。但有两种场景清不干净:

  • 调用startPage后,还没轮到任何Mapper查询,业务代码就抛了异常。
  • 使用了线程池,某次请求的分页参数没被消费,线程归还给池子后,下一个请求恰好复用了这个线程。

第二种的现场表现很迷惑:明明是A接口查列表,B接口的分页参数却作用到了A的查询上。我排查过一个莫名的"某个列表偶尔只返回10条"的问题,最后发现是并行任务里有人用了parallelStream,子线程里执行了startPage,finally里又没有清理,线程池复用后残留参数串到了别的查询。

标准解法是在finally里兜底清理:

PageHelper.startPage(1, 10); try { List<User> users = userMapper.selectByStatus(status); } finally { PageHelper.clearPage(); }

还有一个重要原则:不要在父线程startPage,然后丢到子线程/异步任务里做查询。ThreadLocal是线程隔离的,父线程的接力棒子线程根本接不到。异步场景的正确做法是把startPage和查询都放进同一个子任务内部,并且各加各的清理。

4.3 坑三:多表JOIN时总数是关联行数

这是业务系统里最容易被误判为"PageHelper有Bug"的坑。比如一个订单列表,要展示订单信息加用户昵称加商品名称,SQL大概长这样:

SELECT o.id, o.order_no, u.name, p.product_name FROM orders o LEFT JOIN users u ON o.user_id = u.id LEFT JOIN order_products p ON p.order_id = o.id WHERE o.create_time >= #{beginTime} ORDER BY o.id DESC

如果某个订单有3个商品,这个订单在结果集里会展开成3行。PageHelper自动生成的count统计的是JOIN后的行数,一个订单就被算成了3个,总数自然虚高。更麻烦的是,如果JOIN多对多还有笛卡尔积,那count误差会被放大得离谱。

这个问题我一般用countSuffix手动接管count查询来解决。PageHelper在生成count之前,会先找Mapper接口里有没有同名加_COUNT后缀的方法,有就直接用,不再自动生成:

public interface OrderMapper { List<OrderVO> selectOrderPage(@Param("beginTime") Date beginTime); Long selectOrderPage_COUNT(@Param("beginTime") Date beginTime); }
<select id="selectOrderPage_COUNT" resultType="long"> SELECT COUNT(DISTINCT o.id) FROM orders o LEFT JOIN users u ON o.user_id = u.id LEFT JOIN order_products p ON p.order_id = o.id WHERE o.create_time >= #{beginTime} </select>

注意_COUNT方法的参数必须和原查询方法完全一致,否则插件匹配不上会报错。如果实在不想维护handwritten count,也可以退而求其次配置params: count=countSql,让插件用子查询方式兜底,但子查询会把整个JOIN结果集临时装一遍,性能一般。

4.4 坑四:pageSize=0并不是你想象的分页

有段时间团队里流行"pageSize传0表示不分页,返回全部",理由是PageHelper文档提到过pageSize=0的特殊处理。实践下来这个约定很危险:不同版本、不同pageSizeZero配置下,pageSize=0的行为并不统一,有的版本直接返回空,有的版本返回全部但count可能不执行,有的版本行为还依赖reasonable配置。

我的态度很明确:接口设计里不要把pageSize=0当成"导出全部"或"不分页"的暗号。需要全量数据就单独写一个不分页的查询方法,语义清晰,SQL也可以针对全量做优化。分页接口只负责分页,越界和边界值尽量由服务端统一规整,不要让业务含义隐藏在边界行为里。

4.5 坑五:分页+排序+DTO映射的组合问题

这个坑比较综合,我拆成三件事说。

第一,排序不稳定导致翻页错乱。列表按create_time排序,但同一秒内创建的数据有几十条,没有次级排序键,第一页末尾和第二页开头就可能出现同一条记录,或者某条记录在两页里都查不到。解决方法是给排序条件加唯一字段兜底,比如ORDER BY create_time DESC, id DESC。

第二,count查询对ORDER BY的改写不总是那么聪明。绝大部分场景插件会直接去掉ORDER BY,但遇到排序字段带子查询、带复杂函数时,解析失败的情况我也遇过。表现形式是分页查询直接报错或count结果异常。遇到这种SQL,老实走4.3的手动count方案,别跟插件较劲。

第三,获取分页信息后做二次转换,把分页信息弄丢了。常见错误是拿到List<User>后转成新的ArrayList,再new PageInfo<>(新list),这时候total就变成了当前页大小,翻页功能直接废掉。正确的顺序是先用PageInfo包好原始结果,再从pageInfo.getList()里取数据做VO映射,总数从pageInfo.getTotal()拿。

另外提醒一句带collection嵌套结果映射的查询:MyBatis会把一对多结果在内存里组装,PageHelper只管SQL层的LIMIT,管不了内存拼装。有时候total是对的,但list.size()比pageSize小,这是因为一个主记录带出了多个子记录。这种查询建议改成先分页查主表,再用IN查子表分别组装。

4.6 分页问题快速排查清单

遇到分页行为诡异,我一般按这个顺序捋,多数问题十分钟内能定位:

  1. 确认分页参数作用的是不是目标查询——查startPage后面跟的第一个Mapper方法。
  2. 确认是不是线程问题——查异常日志里的线程名、有没有parallelStream或异步任务。
  3. 确认count口径——看SQL有没有JOIN一对多、GROUP BY、DISTINCT。
  4. 确认边界值——pageNum和pageSize有没有可能是0、负数、超大值。
  5. 确认返回结构——Page/PageInfo有没有被二次拷贝,VO映射是否丢元信息。

5. 大数据量场景下的PageHelper分页查询优化:count与深分页

5.1 count查询的两种加速思路

分页慢,很多时候慢在count查询上。PageHelper每次分页都会先执行count,表数据量一大,count本身可能比数据查询还慢。

优化思路之一是跳过不需要的count。如果你的交互是"加载更多"而不是传统翻页,完全可以不返回总数,用PageHelper.startPage(pageNum, pageSize, false)关掉count,只返回当前页数据,用户往下滚动时继续传下一页页码。少了count这条SQL,接口响应会明显变快。

优化思路之二是把count查询的索引喂饱。count语句通常能去掉ORDER BY,但WHERE条件该走的索引还是得走。比如按create_time >= ?过滤,那就给create_time建一个合适的索引,让count走索引统计而不是全表扫。真要较真的话,多条件过滤的覆盖索引也是一个方向。

5.2 深分页本质:LIMIT 100000,20 为什么慢

分页越往后翻越慢,这个锅不是PageHelper的,而是MySQLLIMIT本身的机制问题。LIMIT 100000, 20的意思是"数到第100020行,然后取最后20行",前面10万行虽然不返回,但每一行都要被扫描和过滤一遍。翻到第1000页时,哪怕每页20条,数据库也得实打实扫两万行。

PageHelper只是帮你拼出这个LIMIT,它不改变深分页的性能本质。要治本,常见的做法是键集分页,也就是游标分页:

SELECT * FROM orders WHERE id > #{lastId} ORDER BY id ASC LIMIT #{pageSize};

这种写法完全不用PageHelper也行,因为它的分页语义变了——不是"跳到第N页",而是"从上次结束的地方继续取"。优势是每一页的SQL都只扫描必要的数据行,翻到第1万页和第2页耗时几乎一样。劣势是无法直接展示总页数和跳页,所以应用场景是无限滚动、动态加载这类交互。

如果你既想要这种性能,又不想彻底抛弃PageHelper的写法,可以在业务层自己维护一个"上一页最后一个ID"作为参数,SQL里手动加id > #{lastId},然后仍然用分页框住每页条数,只是不再依赖offset了。

5.3 多表JOIN分页的正反两种姿势

大数据量的多表JOIN分页,正面姿势是先分页主表ID,再查明细。反面姿势是直接让数据库对一个大JOIN结果集做LIMIT,还要对JOIN结果做count。

反面姿势的问题前面已经说透:count口径不准、全表关联扫描消耗大、内存临时表膨胀。正面姿势的SQL拆成两步:

-- 第一步:只分页主表ID SELECT id FROM orders WHERE create_time >= #{beginTime} ORDER BY id DESC LIMIT #{offset}, #{pageSize}; -- 第二步:用当前页的ID集合查完整数据 SELECT o.*, u.name FROM orders o LEFT JOIN users u ON o.user_id = u.id WHERE o.id IN (...) ORDER BY o.id DESC;

总数统计直接用主表维度:

SELECT COUNT(id) FROM orders WHERE create_time >= #{beginTime};

这种"先拿主键再查明细"的模式,明细查询可以走id主键索引,JOIN的数据量也从"全表JOIN"缩小到"当前页的ID范围",性能和count准确性都更有保障。代价是代码里要写两步查询,在Service层自己组装,不能把PageHelper当成一键分页神器。

5.4 什么时候别用PageHelper硬扛

再补一个边界清单,这些都是我实际踩过或看过别人踩的:

  • 超大规模报表和OLAP聚合:复杂的GROUP BY、汇总、透视,让专业的查询引擎去做,PageHelper的count和LIMIT在这种SQL上帮倒忙。
  • 全文检索:请用搜索引擎自己的分页逻辑,MySQL里LIKE '%xx%'分页纯属自虐。
  • 全量导出:用MyBatis的Cursor游标查询流式读取,比PageHelper逐页查询高出一个量级。逐页查导出会遇到数据写入导致的页间偏移,导出的数据会重复或漏掉。
  • 多数据源且方言不同:虽然PageHelper有auto-runtime-dialect,但复杂度和收益不成正比,不如在数据访问层做隔离。

6. 我对PageHelper分页查询使用边界的一些个人经验

6.1 不推荐用PageHelper的场景清单

综合看下来,我给自己定了一条使用边界:PageHelper只用于"在线业务系统的标准列表查询",凡是列表之外的分页需求,先停下来想想是不是真需要它。写操作、批量任务、导出、报表聚合、全文检索、深翻页,这些场景我都不会再套PageHelper。

举个具体例子,之前有个导出接口,最初实现是循环调用分页查询,每页500条直到取完。数据量到几十万条时,导出接口跑了十几分钟,数据库压力巨大。后来改成MyBatis的Cursor游标查询,一条SQL流式读取,内存恒定,时间缩短到原来的零头。这本质上不是PageHelper的问题,而是"查询模式选错了",但很多团队默认所有列表都用PageHelper,导出也就顺手写了分页循环。

6.2 几个让分页更稳的小习惯

最后分享几个我现在写分页代码时的固定动作,都是小细节,但组合起来能省很多排查时间。

第一,所有分页入参在进入Service前统一校验和规整。pageNum小于1按1算,pageSize上限卡在100或200,超过就截断。这不是业务限制,是保护数据库的手段。

第二,分页查询的排序字段做成白名单。PageHelper提供了orderBy相关方法,但我几乎不用它拼用户传入的排序字段,因为那等于把排序SQL拼接暴露给了入参。排序逻辑老老实实写在Mapper XML里,前端传入的排序字段先过白名单校验,再映射成XML里固定的ORDER BY分支。

第三,返回对象统一用自定义的PageResult<T>,内部字段就total、pageNum、pageSize、list四个。前端看到一个稳定的结构,后端改PageInfo还是改Page都不会影响接口契约。

第四,代码审查时看到裸写的PageHelper.startPage,我一定会多问一句:后面紧跟的是不是目标查询?finally里有没有清理?能不能改成doSelectPageInfo的写法?这三个问题问完,大部分分页隐患当场就暴露了。

回到开头那句话,分页看着是小事,但它是每个列表接口的地基。用了PageHelper不等于分页就万无一失,搞清楚它取了什么、改了什么、什么时候失效,才是真正不上头的方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询