ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Pagehelper分页查询原理与实战:从配置到避坑全解析

Pagehelper分页查询原理与实战:从配置到避坑全解析

1. 项目概述:Pagehelper分页查询是什么,能解决什么问题

分页是所有业务系统里几乎绕不开的一个需求。列表页、报表页、后台管理页,只要是数据展示,基本都得分页。早年用MyBatis写分页,最原始的办法就是在SQL末尾手动拼LIMIT #{offset}, #{pageSize},每写一个查询就得复制粘贴一遍,如果哪天表结构变了或者统计口径变了,还得逐个排查SQL,那滋味真的不好受。后来有了各种分页插件,其中用起来最顺手、受众也最广的,就是大伙儿熟知的Pagehelper。

Pagehelper是MyBatis生态里的一个分页插件,核心作用很简单:在不改动原有SQL逻辑的前提下,自动生成带分页参数的SQL,并额外发出一次count查询拿到总记录数。你只需要在业务代码里设置页码和每页条数,后面的事插件全包了。也就是说,原来你要手写的东西:

SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC LIMIT 0, 20 SELECT COUNT(*) FROM orders WHERE status = 1

现在可以完全不用碰SQL,只写业务查询本身:

PageHelper.startPage(1, 20); List<Order> list = orderMapper.selectByStatus(1)

两句代码,分页生效,同时还能通过返回的PageInfo拿到当前页数据、总记录数、总页数、是否第一页/最后一页这些元数据。这个体验,谁用谁知道。

这篇文章适合哪些人看?如果你是刚接触MyBatis分页的初学者,可以把它当作一篇上手教程;如果你已经在用Pagehelper但遇到一些诡异问题,比如分页不生效、count结果不对、嵌套查询数据重复、多数据源下失效,那接下来的排查经验应该能帮你少走不少弯路。我尽量把原理和实操串在一起讲,不光告诉你怎么配,也尽量讲清楚为什么这么配。

2. 环境准备与基础配置

2.1 引入依赖和插件配置的几种姿势

不同项目用的构建工具不一样,但引入Pagehelper的路径基本是一致的。Maven项目直接在pom.xml里加依赖:

<dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper</artifactId> <version>5.3.3</version> </dependency>

如果你的项目用的是Spring Boot 3.x,需要引入的是适配MyBatis 3.x的新包:

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

Gradle项目对应改一下写法就行,原理一样。

依赖引入之后,还需要把插件挂到MyBatis上。这里有三条路可选,我分别说下适用场景:

方式一:MyBatis全局XML配置(原生MyBatis项目)

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

这种方式适合没有使用Spring Boot、直接裸配MyBatis的项目,把所有配置集中在mybatis-config.xml里,规则清晰。

方式二:Spring Boot自动配置(推荐)

在application.yml或application.properties里加:

pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: count=countSql

这是目前最常见的用法,尤其是配合pagehelper-spring-boot-starter使用,几乎零配置就能跑起来。默认情况下,starter会自动注入一个PageInterceptor实例,不需要你再手动定义。

方式三:Java配置类手动注册

如果项目里对MyBatis配置做了定制,想手动控制插件加载顺序,可以写一个配置类:

@Configuration public class MyBatisConfig { @Bean public PageInterceptor pageInterceptor() { PageInterceptor interceptor = new PageInterceptor(); Properties properties = new Properties(); properties.setProperty("helperDialect", "mysql"); properties.setProperty("reasonable", "true"); properties.setProperty("supportMethodsArguments", "true"); interceptor.setProperties(properties); return interceptor; } }

不过有个坑需要注意:如果项目里同时存在Spring Boot自动配置又手动定义了PageInterceptor,很可能出现插件重复注册的问题,导致每次查询被拦截两次,分页参数行为变得混乱。所以用了starter就别再加手动配置,反之亦然。

2.2 关键参数详解:helperDialect、reasonable与supportMethodsArguments

很多新人配置Pagehelper时直接照抄网上的配置文件,几个参数到底什么意思其实没搞明白。这里我把最关键的三个参数展开说下,因为这直接决定了你在生产环境会不会踩坑。

helperDialect:指定数据库方言。这个参数的作用是让插件知道目标数据库的类型,从而生成不同风格的分页SQL,比如MySQL是LIMIT,Oracle是ROWNUM,PostgreSQL是LIMIT ... OFFSET。好的一面是插件会自动识别JDBC URL来判断方言,但多个数据源混用或者连接池做了特殊路由时,自动识别可能不准确,最稳妥的做法还是手动指定。

reasonable:分页参数合理化。当这个值为true时,如果请求页码小于1,比如传了pageNum=0或者-1,插件会自动把页码校正为1;如果请求页码大于总页数,会自动把页码修正为最后一页。这个参数在面向用户的前端接口里非常有用,因为你无法保证前端从来不会传一个越界的页码。但如果你的场景是需要严格校验边界、不允许用户随便翻到最后一页之外的,那就保持false,让越界请求直接返回空列表。

supportMethodsArguments:是否支持从Mapper接口方法参数中读取分页信息。默认值是false,如果把它设为true,允许你这样写:

List<Order> selectByStatus(@Param("status") Integer status, @Param("pageNum") Integer pageNum, @Param("pageSize") Integer pageSize);

插件会自动去匹配参数名中带有pageNum和pageSize的入参,无需显式调用PageHelper.startPage()。这个模式适合团队统一封装分页参数对象的场景,但我不建议在同一个项目里混用两种方式,一会用startPage()一会依赖参数自动识别,出了问题排查起来很麻烦,因为你根本不知道这条查询的分页参数到底是从哪来的。

注意:supportMethodsArguments和PageHelper.startPage()同时存在时,后者的优先级高于参数自动识别。但千万别在同一代码路径里两种方式都用,实测下来容易出现分页参数被重复消费或者被覆盖的诡异行为。

2.3 为什么推荐Spring Boot集成方式

从我个人的项目经验来看,现在新起的服务十有八九都是Spring Boot项目,直接用pagehelper-spring-boot-starter是最省心的。它的自动配置做了几件事:自动注册拦截器、自动加载配置文件中的pagehelper.*属性、自动适配当前MyBatis版本。

最关键的是解决了版本匹配的问题。Pagehelper插件本身需要跟MyBatis版本兼容,手动引入pagehelper包再去配MyBatis时,经常遇到java.lang.NoSuchMethodError或者ClassNotFoundException这类版本冲突问题。starter把兼容性处理封装好了,你基本不用关心MyBatis是3.5.6还是3.5.9,它都能正常跑。

不过有一点要提醒,starter版本和插件版本不要混为一谈,如果你需要通过Maven排除传递依赖来升级MyBatis版本,要留意不要破坏了pagehelper对MyBatis的内部依赖关系。建议先看一下你引入的starter的pom.xml里到底传递了哪些依赖,再动手排除。

3. Pagehelper分页的核心机制与原理

3.1 拦截器如何拦截SQL并改写

Pagehelper本质上是一个MyBatis插件(Interceptor)。MyBatis允许你在四个核心组件上挂拦截器:Executor、StatementHandler、ParameterHandler、ResultSetHandler。Pagehelper拦截的是Executor的query方法。

当你调用PageHelper.startPage(1, 20)时,Pagehelper做了什么?说简单点,就是在当前线程的ThreadLocal里存了一个分页参数对象(包含页码、每页条数、是否count等)。接下来你调用的Mapper方法在进入Executor查询前,拦截器会检查ThreadLocal里有没有分页参数。如果有,它先取出参数并清空ThreadLocal,然后开始两个动作:

第一个动作:执行一次count查询。它会根据当前要执行的原SQL,生成对应的SELECT COUNT(*)语句,比如把原SQL里的SELECT * FROM orders WHERE status = 1 ORDER BY create_time DESC改写为SELECT COUNT(*) FROM orders WHERE status = 1,ORDER BY会被自动去除,JOIN查询会尽量简化,但具体简化的程度取决于SQL的复杂程度。

第二个动作:改写原SQL并执行分页查询。比如MySQL方言下,会拼上LIMIT 0, 20。这里的0是根据你传入的pageNum=1和pageSize=20计算出来的offset:(pageNum - 1) * pageSize = 0。

这两个动作发生在同一次查询的上下文中,所以从调用者的视角看,仿佛是一个Mapper方法同时返回了分页数据和总条数,实际上内部执行了两次查询。

3.2 PageInfo中的元数据到底是怎么算的

执行完查询之后,拿到的是当前页的数据列表。如果你想获取总记录数、总页数这些信息,需要把结果封装为PageInfo:

PageInfo<Order> pageInfo = new PageInfo<>(list);

PageInfo里面包含的字段大概有这么些:pageNum、pageSize、size(当前页实际返回条数)、startRow、endRow、total(总记录数)、pages(总页数)、list(当前页数据),以及isFirstPage、isLastPage、hasPreviousPage、hasNextPage等辅助状态位。

关键一点:这里的total是从count查询里拿到的,pages是用total和pageSize算出来的。计算方式:

pages = total % pageSize == 0 ? total / pageSize : total / pageSize + 1

这行逻辑本身没问题,但有一个容易忽略的点:PageInfo的构造函数会重新计算startRow和endRow,而startRow是(pageNum - 1) * pageSize + 1的语义,跟SQL里的offset(从0开始)差了1。如果你把startRow直接当成SQL里的offset用,会始终错一位,这个细节做报表导出或手动拼接分页SQL时很容易踩。

3.3 ThreadLocal机制与安全隐患

上面提到Pagehelper用ThreadLocal来传递分页参数,这个设计极大简化了API调用方式,但也带来两个隐患。

第一个隐患是线程池复用时参数错乱。如果某个请求设置了分页参数后,Mapper查询还没执行,线程就被归还到线程池,下一次从线程池取出这条线程执行另一个查询时,ThreadLocal里还留着上一个请求的分页参数,就会把不该分页的查询强制分页了。

第二个隐患是忘记消费导致的内存残留。Pagehelper在拦截器里取到分页参数后,会主动把ThreadLocal清掉,正常流程下不会残留。但如果你的代码出现了异常,比如PageHelper.startPage()之后第一个就抛异常了,那ThreadLocal里就不会被清除。后续这个线程再被复用,就可能出现问题。所以严格来说,官方文档也建议:PageHelper.startPage()之后紧接着调用你的Mapper方法,中间不要穿插其他可能有异常的代码。

我自己在项目里做过一次事故复盘,就是这类问题。某天晚上一个列表接口突然返回了全表数据,排查下来发现是前一个请求在设置分页后、查询前抛了异常,ThreadLocal残留了分页参数,下一个请求复用了同一个线程,但那条查询本来就不应该分页,结果插件把PageInfo对象强塞了进来。好在那次是内部接口,影响范围有限,但也足够让人长记性。

提示:凡是使用PageHelper.startPage()的代码块,建议包在try-finally里,finally中显式调用PageHelper.clearPage(),确保线程安全,这是最稳的写法,没有之一。

4. 实操过程:从零实现一个带分页的用户列表接口

4.1 项目准备与表结构设计

为了把整个过程串起来,我设计了一个非常常见的用户查询场景:后台管理系统的用户列表页,支持按状态筛选、按创建时间倒序、分页展示。表结构简化如下:

CREATE TABLE `sys_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(50) NOT NULL COMMENT '用户名', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '状态 1-正常 2-禁用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), KEY `idx_status_create_time` (`status`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这里有一个小设计点:idx_status_create_time这个联合索引,是为了让WHERE status = ? ORDER BY create_time DESC这条查询能够走索引,避免到大数据量时候产生文件排序。分页接口在大数据量下的性能,一大半取决于SQL本身能不能走好索引,分页插件只是帮你改了SQL尾巴,索引设计还得自己上心。

4.2 实体类、Mapper接口与XML编写

实体类:

public class SysUser { private Long id; private String username; private String nickname; private Integer status; private Date createTime; // getter/setter 略 }

Mapper接口:

public interface SysUserMapper { List<SysUser> selectUserPage(@Param("status") Integer status); }

Mapper XML:

<select id="selectUserPage" resultType="com.example.entity.SysUser"> SELECT id, username, nickname, status, create_time FROM sys_user WHERE 1 = 1 <if test="status != null"> AND status = #{status} </if> ORDER BY create_time DESC </select>

大家可能注意到,这个Mapper接口里根本没有分页参数,也没有LIMIT,分页完全靠调用方来控制,这是Pagehelper最核心的使用方式——查询逻辑保持纯净,分页是外挂的横切行为。

4.3 Service层调用与PageInfo封装

Service层的代码如下:

public PageInfo<SysUser> getUserPage(Integer status, int pageNum, int pageSize) { try { PageHelper.startPage(pageNum, pageSize); List<SysUser> users = sysUserMapper.selectUserPage(status); return new PageInfo<>(users); } finally { PageHelper.clearPage(); } }

这段代码虽然短,但承载了很多细节,拆开说:

  • PageHelper.startPage(pageNum, pageSize):把分页参数塞进ThreadLocal。注意pageSize一定得是合法正整数,如果你传了0,插件会视为不合法,默认不再分页,这一点会直接导致查询返回全表。
  • sysUserMapper.selectUserPage(status):实际查询,触发拦截器执行count和分页查询。
  • new PageInfo<>(users):把分页数据连同元数据包装成PageInfo对象。
  • finally块中执行PageHelper.clearPage():清除ThreadLocal残留,防止异常情况下参数泄漏。

Controller层:

@GetMapping("/users") public Result<PageInfo<SysUser>> list(@RequestParam(required = false) Integer status, @RequestParam(defaultValue = "1") int pageNum, @RequestParam(defaultValue = "20") int pageSize) { return Result.success(userService.getUserPage(status, pageNum, pageSize)); }

前端拿到的响应就是一个标准的分页模型:total、pages、list、pageNum、pageSize,可以直接配合各类前端表格组件使用,基本不需要再二次加工。

4.4 执行日志与SQL输出分析

为了验证插件真的生效,建议在开发环境打开MyBatis的SQL日志。在application.yml里配置:

logging: level: com.example.mapper: debug

当你请求/users?status=1&pageNum=2&pageSize=10时,日志里会输出两条SQL。第一条count查询,大约长这样:

SELECT COUNT(*) FROM sys_user WHERE status = 1

第二条数据查询:

SELECT id, username, nickname, status, create_time FROM sys_user WHERE status = 1 ORDER BY create_time DESC LIMIT 10, 10

注意LIMIT的计算:(pageNum - 1) * pageSize = (2 - 1) * 10 = 10,所以是从第11条开始取10条。这两条SQL的日志顺序是先count再数据,因为插件要先知道总条数才能计算total,然后执行真正的数据查询。

有一点比较隐蔽,如果想禁用某次分页查询的count,可以通过PageHelper.startPage(pageNum, pageSize, false)来关闭。第三个参数count为false时,插件不会再发count查询,只改写SQL加分页,但PageInfo里的total会是0。这个适用于那些根本不需要总条数的场景,比如无限滚动加载、前端手动触底加载更多,省掉一次count查询能减轻数据库压力,尤其大数据量时收益明显。

5. 常见问题与排查技巧实录

5.1 分页不生效的典型原因与处理

这是群里被问得最多的一个问题:明明调了PageHelper.startPage(1, 10),查询结果还是全表返回。排查思路可以按顺序过一遍:

原因一:startPage之后紧跟着的查询不是Mapper查询。

PageHelper.startPage(1, 10); // 中间做了一些其他操作,比如调用了一个不带分页的查询,或者业务判断 someService.doSomething(); List<SysUser> users = userMapper.selectUserPage(status);

Pagehelper的拦截器取ThreadLocal时分页参数是有讲究的,它只对紧跟startPage之后的第一个Executor查询生效。如果你在中间穿插了其他查询,分页参数被那个查询消费掉了,后面的真正要分页的查询自然就没分页效果了。而且这个参数是一次性的:第一个查询会用掉它,用完即焚。

原因二:查询方法内部自己又调用了其他Mapper查询。

比如在一个Service方法里先查了角色表,再查用户表:

PageHelper.startPage(1, 10); roleMapper.selectAllRoles(); // 这个查询消费了分页参数 userMapper.selectUserPage(status); // 这个查询不再分页

这个坑特别隐蔽,尤其是Mapper方法里嵌套调用另一条查询,比如resultMap里有<collection>子查询,或者代码中调了另一个Mapper查询来补充数据,都会提前消费掉分页参数。

原因三:SQL里本身带了大偏移量的LIMIT。

<select id="selectUserPage" resultType="com.example.entity.SysUser"> SELECT ... FROM sys_user LIMIT 5 </select>

如果原始SQL里已经有了LIMIT关键字,插件改写时可能会冲突,最终结果无法保证。所以使用Pagehelper的分页查询,SQL里不要自己写LIMIT,这也算一条铁律。

原因四:插件没有被正确注册。

如果你用的是原生MyBatis而不带starter依赖,忘了在mybatis-config.xml里注册插件,或者属性配置名称写错(比如把helperDialect拼写成dialect),你会发现startPage完全无效。这种情况从日志里看不出来,只能检查配置是否确实加载了。

5.2 count查询结果不准的场景复盘

count查询结果不对,是另一个高频痛点。我遇到过的最典型情况是关联查询,比如:

SELECT u.id, u.username, r.role_name FROM sys_user u LEFT JOIN sys_role r ON u.role_id = r.id

这里如果sys_user和sys_role是一对多关系,一个用户有多个角色,那么count出来的是连表后的行数,而不是用户数。比如100个用户,其中20个用户各绑定了3个角色,原来count的结果会是100 + 20*2 = 140。

Pagehelper的count优化会尝试把外面的SELECT部分替换成SELECT COUNT(*),但它无法判断你的业务语义到底是想要用户数还是连表行数。这种场景我不建议依赖插件的自动count,更靠谱的做法是关闭自动count,自己另外写一条精确的count查询,把总数通过PageInfo或者自己封装的结果集里带回去。

另外有个参数countSuffix可以指定count查询的Mapper方法后缀,比如默认的count查询方法名带_COUNT后缀,你会发现某些复杂查询下插件生成不了count SQL,就会尝试去查一个名字类似的方法。如果实在搞不定,就是把count参数设为false,手动控制。

5.3 嵌套查询导致的数据重复问题

这个问题的场景一般是:分页查询的是主表,主表的resultMap里用<collection>嵌套了子表集合。举个例子,分页查订单,每条订单需要带出它的商品明细:

<resultMap id="OrderWithItems" type="com.example.entity.Order"> <id property="id" column="id"/> <result property="orderNo" column="order_no"/> <collection property="items" ofType="com.example.entity.OrderItem"> <id property="id" column="item_id"/> <result property="itemName" column="item_name"/> </collection> </resultMap> <select id="selectOrdersPage" resultMap="OrderWithItems"> SELECT o.id, o.order_no, i.id AS item_id, i.item_name FROM orders o LEFT JOIN order_item i ON o.id = i.order_id </select>

问题来了:一条订单有多条商品明细,关联查询得出来的行数会膨胀,比如10条订单、平均每单3条明细,查询结果SET就有30行。Pagehelper在改了SQL尾巴之后,数据查询出来的物理行数是30行,但映射到OrderWithItems后,Pagehelper只能感知到物理行数,它的count逻辑也容易把行数算成30,而不是10。

这种场景比较恶心,因为Pagehelper拿到的list在Mapper执行后,已经是MyBatis映射完成的对象了,实际上如果你用PageInfo去拿total,count SQL已经帮你执行完了,它统计的也是膨胀后的30。想要拿到真实的10条订单数,就得让count查询做一个子查询去重统计,比如:

SELECT COUNT(*) FROM ( SELECT o.id FROM orders o LEFT JOIN order_item i ON o.id = i.order_id GROUP BY o.id ) tmp

这种情况下,插件生成的count无法满足你的要求,同样是手动处理count,或者尽量把嵌套查询做成两条独立查询,在Service层做内存组装。这也是我后来常用的做法:能拆就拆,不要让分页SQL和嵌套映射混在一起,否则性能和准确性水平都很难控制。

5.4 多数据源环境下Pagehelper失效怎么办

多数据源项目里,如果配置了多个SqlSessionFactory,Pagehelper插件默认只会注册到Spring Boot自动装配的那个SqlSessionFactory上,另一个数据源没有拦截器,自然就不生效。

解决办法有两种。第一种是每个数据源单独配置插件:

@Bean(name = "primarySqlSessionFactory") public SqlSessionFactory primarySqlSessionFactory(@Qualifier("primaryDataSource") DataSource dataSource) throws Exception { SqlSessionFactoryBean factoryBean = new SqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); // 手动设置插件 factoryBean.setPlugins(new Interceptor[]{pageInterceptor()}); return factoryBean.getObject(); }

每个SqlSessionFactory都要注册同一个PageInterceptor实例。

第二种更省事,但也更需要注意:如果多数据源里只有一个是真正需要分页的,只需要在那个数据源的SqlSessionFactory里注册插件即可,别重复注册到所有数据源。因为分页插件会拦截所有通过该工厂执行的查询,如果你在某些只读库也不小心注册了,那里如果出现一个大家都不希望被分页的方法误触发了startPage,反而容易引入问题。

5.5 PageHelper与MyBatis 3.5.x版本冲突排查

之前提过,Pagehelper的不同版本对MyBatis的版本有要求。如果你把MyBatis从3.4.x升到3.5.x之后,分页突然失效或者直接报ClassNotFound,大概率是依赖冲突或者插件版本太老。

常见的报错有:

java.lang.NoSuchMethodError: org.apache.ibatis.session.Configuration.newMetaObject(Ljava/lang/Object;)Lorg/apache/ibatis/reflection/MetaObject;

这种就是Pagehelper版本跟MyBatis版本不匹配。升级方案建议是:MyBatis 3.5.x对应Pagehelper 5.3.x及以上,Spring Boot 2.x对应pagehelper-spring-boot-starter 1.4.x,Spring Boot 3.x对应2.1.x。升级后记得清一下本地Maven仓库缓存,避免命中了旧的依赖坐标。

另外,如果项目里还有其他分页插件(比如某些框架自带的分页逻辑),会造成重复分页,比如SQL后面被拼了两次LIMIT。这种情况多见于老项目改造过程中,检查pom.xml里是否同时存在多个拦截器实现,逐一定位排除。

6. 实战中的性能优化与避坑心得

6.1 大偏移量分页性能瓶颈与优化思路

Pagehelper的默认分页在数据量小的时候毫无压力,但一旦偏移量大起来,比如上万页之后,LIMIT 200000, 20会让MySQL扫描掉前面20万行再丢弃,性能骤降。优化思路不能指望分页插件本身解决,得从SQL侧入手。

常见的方案是延迟关联(Late Row Lookups),即先走覆盖索引查出当前页的主键,再用主键回表取完整数据:

SELECT u.* FROM sys_user u INNER JOIN ( SELECT id FROM sys_user WHERE status = 1 ORDER BY create_time DESC LIMIT 200000, 20 ) tmp ON u.id = tmp.id ORDER BY u.create_time DESC

不过这种优化在Pagehelper模式下不太好直接套用,因为分页SQL是插件拼的。如果你遇到了真正的大偏移量性能问题,我的建议是干脆不要用Pagehelper了,直接在SQL里自己写分页条件,配合延迟关联或**基于游标(Keyset Pagination)**的方式,也就是查询条件里带WHERE create_time < ? ORDER BY create_time DESC LIMIT 20,性能才能质变。

Pagehelper适合的体量是百万级以内,再大就要考虑换分页策略了。这不是插件不行,而是LIMIT offset模式本身的物理限制,谁也没办法。

6.2 分页查询常见的IO开销与count优化

COUNT(*)在InnoDB里不像MyISAM那样有现成的行数记录,每次都要扫描统计。即便走了索引,当表很大时count也是不小开销。虽然Pagehelper会尽量把count SQL简化为SELECT COUNT(*),但对于复杂查询它也无能为力。

生产环境里做过分页接口压测的同学应该深有体会:一个接口遇到了慢SQL,十有八九问题出在count查询上。优化count的基本思路可以总结为几个方向:

  • 尽量让count查询走上覆盖索引,避免回表。
  • 如果业务上可以接受近似总条数,对超大表可以关掉count,只在UI上显示"共xx+条"。
  • 对于运营后台这类低频分页,也可以考虑在Redis里缓存一段时间的总记录数,减少count对数据库的冲击。

6.3 分页参数校验与前端传参规范

最后聊一个偏工程实践的细节。后端接口一定要对pageNum和pageSize做参数校验,同时注意设置合理的上限。比如:

if (pageSize > 200) { pageSize = 200; }

200的硬顶是保护性措施,防止有人传一个pageSize=100000把整张表拉出去,直接把数据库打满。配合reasonable=true,页码的边界问题能自动兜底,但pageSize太大时要手动限制。这个看起来简单,但真的很多线上事故就是漏了这一行。

另外,如果前端分页组件传的字段名不是pageNum和pageSize,比如是current和limit,就需要在Controller层做一次映射转换,别把锅甩给Pagehelper。保持接口层的分页参数命名规则统一,对多项目协作时很重要。

7. 多场景使用实例与扩展思路

7.1 批量查询中的分页处理

有些内部批处理任务也需要分批拉取数据,比如定时任务要扫描一张大表里待处理的数据。这种场景不涉及用户交互,但同样需要分页控制内存占用。

int pageNum = 1; int pageSize = 500; while (true) { try { PageHelper.startPage(pageNum, pageSize); List<Task> tasks = taskMapper.selectPendingTasks(); if (tasks.isEmpty()) { break; } processTasks(tasks); // 处理当前批次 pageNum++; } finally { PageHelper.clearPage(); } }

这里要注意的是:当最后一页数据不足pageSize时,怎么判断是否继续循环?最简单的方式是通过tasks.size()是否小于pageSize来判断,但更稳妥的还是要靠PageInfo里的total或者当前页是否isLastPage。如果直接判断isEmpty(),万一某页正好过滤后没有数据但后面还有数据,循环就会提前终止,数据就丢了。

7.2 在MyBatis-Plus项目中是否能复用

很多用MyBatis-Plus的项目也有分页需求,MyBatis-Plus自带了一个分页插件PaginationInnerInterceptor,用法跟Pagehelper很像。如果你在MyBatis-Plus项目里想用Pagehelper,理论上可以加依赖并注册插件,但我个人不推荐两套分页插件混用,因为它们都会拦截Executor,执行顺序不同会导致SQL被改写多次。

选择哪套分页方案,建议看你的项目已有依赖:原生MyBatis或者升级旧项目,优先用Pagehelper;新项目本身就是MyBatis-Plus生态,直接用内置的分页插件更顺滑,没必要额外引Pagehelper。

7.3 分页结果在不同场景下的返回形式

不同场景对分页结果的返回形式要求不一样:

  • 面向管理后台的REST API,通常返回PageInfo全量字段,前端表格组件需要这些元数据。
  • 面向C端App的列表页,如果是触底加载更多模式,建议只返回list和hasNext两个字段,减轻网络传输。
  • 如果是下拉加载实现无限滚动,很多时候连total都不需要,直接count=false更合适。

由于Pagehelper的PageInfo字段比较多,直接暴露给前端显得冗余,可以自己封装一个统一返回VO:

public class PageResult<T> { private List<T> list; private long total; private int pages; private int pageNum; private int pageSize; private boolean hasNext; // 构造函数接收PageInfo,拷贝需要的字段 }

一方面避免前端误用了不该用的字段,另一方面也是让接口契约更稳定,以后就算底层换了分页插件,对外返回结构也不变。

8. 写在最后:我的一些体会

从刚接触MyBatis时手写LIMIT,到后来为了应付各种需求变化,改用Pagehelper到现在,中间踩过的坑确实不少。坦白说,Pagehelper并不是一个完美的分页方案,它最让人省心的是侵入性极低,写业务查询时不需要关心分页,让代码回归干净。但它也不是银弹,ThreadLocal机制需要使用者理解背后的消费顺序问题,count和SQL的改写逻辑在复杂查询下可能会给你惊喜,也可能会给你惊吓。

我个人的实战建议,一共三条:

第一,分页代码块务必放try-finally并手动clearPage(),这是成本最低、收益最稳的优化习惯。

第二,如果一个查询逻辑里包含嵌套集合映射、关联统计、子查询等复杂SQL,同时要分页时,提前评估插件的count和SQL改写是否符合预期,实在不行就放弃插件自动分页,自己控制分页SQL。

第三,参数校验一定不要偷懒,尤其是pageSize上限。

用了这么多年,实战项目的稳定运行证明,只要理解了它的核心机制,避开了那几个经典陷阱,Pagehelper依然是我在原生MyBatis项目里分页的首选。希望这篇文章里写到的那些经验和排查过程,能帮你少走一些弯路。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进