ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Spring Boot集成PageHelper分页:原理、配置与深分页优化实战

2026/9/10 6:12:23 拓冰建站 浏览量
Spring Boot集成PageHelper分页:原理、配置与深分页优化实战 1. 从“列表接口越来越慢”说起分页到底该怎么做做过管理后台的兄弟应该都有体会用户列表、订单列表、操作日志列表这些接口刚上线时都是“查全量”数据量顶天也就几千条前端拿到数组随便翻页体验还行。可一旦业务跑了两三年单表突破几十万甚至上百万行问题就来了——接口响应从几百毫秒慢慢涨到几秒数据库连接池被慢查询拖垮前端页面一翻页就转圈圈。这时候大家才想起来分页查询不是“可选项”而是系统的保命项。分页这件事听起来简单不就是LIMIT offset, pageSize嘛。但真做起来方案五花八门坑也从来不缺。我刚带项目那会儿团队里就有三种“分页风格”实现方式大致思路优点缺点手写LIMITSELECT * FROM user LIMIT #{offset}, #{pageSize}直观、性能可控每个查询都要算offset容易漏排序、拼错参数还有SQL注入风险MyBatis RowBoundsMapper方法传RowBounds框架底层处理不需要改SQL是内存分页数据全查出来再截取数据量一大直接内存溢出PageHelper插件通过拦截器自动改写SQL实现物理分页对业务代码侵入小分页和统计一次完成原理不熟悉的话数据错乱、分页失效等疑难杂症排查很费劲我个人的观点很明确如果是中小型项目、以MySQL为主、团队用的又是MyBatis这套技术栈PageHelper是综合成本最低的选择。它不需要你去维护分页SQL不需要手动写count前端要的总条数、总页数、当前页、每页条数它都能通过PageInfo对象一次性给你省心。但“省心”的前提是你得理解它内部那套机制否则一旦出现“分页失效”或者“count奇慢”排查起来真是能把人逼疯。这篇文章我就把Spring Boot集成PageHelper分页查询这件事彻底讲透包括原理、配置、完整代码以及我在生产环境里遇到过的一堆真实坑。顺便说一句很多文章一上来就丢依赖、丢配置、丢三行代码跑通了就完事。但实际项目里你迟早会碰到“为什么这个分页没生效”“为什么count查了5秒”“为什么有时候分页有时候不分页”之类的问题这些东西不搞懂原理光会抄配置早晚要还债。2. PageHelper的分页原理一个拦截器加一个ThreadLocal2.1 核心机制startPage把参数塞进了ThreadLocal拦截器负责消费PageHelper之所以用起来“玄学”是因为很多人根本不知道它底层干了两件事第一件PageHelper.startPage(pageNum, pageSize)这行代码会把分页参数放进当前线程的ThreadLocal里第二件MyBatis执行查询时PageHelper通过内置的PageInterceptor拦截器拦截Executor.query()方法一旦检测到ThreadLocal里有分页参数就先把当前SQL改写成分页SQL再执行。这就解释了PageHelper一个最著名的规矩startPage之后必须紧跟第一条SQL查询。这里用一个生活化的类比帮你理解ThreadLocal就是一个只能存一份快递的“临时储物柜”startPage就是往柜子里放了一份快递拦截器是快递员。快递员每次来取件取走就顺手把柜子清空。如果业务代码在startPage之后真正要分页的查询之前又执行了其他数据库操作比如查了一下当前登录用户、写了一条日志那快递员来取件时拿到的就是那个“错误的快递”分页参数直接消耗掉了。等真正轮到目标SQL执行时柜子已经空了自然不分页。实际排查时这也是我第一个会去查的点打开MyBatis日志看目标SQL执行时有没有LIMIT如果没有回头去看调用链里startPage和目标Mapper方法之间到底隔了什么。八成是中间混入了其他查询或者还有延迟加载、权限校验之类的逻辑。这类问题在Service层代码写得很长、业务分支多的时候特别容易踩。2.2 两条SQL的改写逻辑count和LIMIT都会由插件代劳当PageHelper成功拦截到目标SQL之后它会为你自动执行两个动作。第一个动作是执行count查询。比如说你写的SQL是SELECT * FROM user WHERE status 1 ORDER BY create_time DESCPageHelper会改写成类似这样的count语句SELECT count(0) FROM user WHERE status 1它会尽力把SELECT后面的列、ORDER BY、分页参数都去掉只留下一个轻量的count(0)。但注意我用的是“尽力”两个字——如果外层包了复杂的子查询、多表JOIN、或者是带GROUP BY的聚合查询count的改写逻辑往往就“偷懒”了直接把整条SQL包一层SELECT count(0) FROM ( SELECT ... FROM user u JOIN order o ON u.id o.user_id WHERE ... GROUP BY ... ) tmp这种嵌套count在大数据量多表JOIN的场景下性能会非常难看后面我会专门讲优化方案。第二个动作是执行分页查询。还是上面那条SQLPageHelper会根据数据库方言在末尾拼上分页子句MySQL就是SELECT * FROM user WHERE status 1 ORDER BY create_time DESC LIMIT 10, 10如果你是Oracle它就会改成基于ROWNUM或者OFFSET FETCH的写法如果你用的是PostgreSQL、SQL Server它也会自动切换对应的语法。这就是helper-dialect配置存在的意义。默认情况下PageHelper会自动识别数据源方言但如果你在配置里手动指定了错误的dialect那SQL改写就会出问题典型表现就是报SQL语法错误或者分页SQL拼接得驴唇不对马嘴。2.3 为什么市面上流传“分页失效”的经典原因都指向ThreadLocal搞懂了ThreadLocal机制很多所谓的灵异现象就顺理成章了。比如最常见的“Executor查询没有被拦截”本质是拦截器没有生效或者ThreadLocal里没有参数。还有“多数据源情况下部分数据源分页失败”本质是那个数据源对应的SqlSessionFactory没有配置PageInterceptor。另一个很隐蔽的坑是“用了异步线程池后分页失效”——你startPage是在主线程里执行的参数存在主线程的ThreadLocal里但实际查询却丢给子线程去执行子线程的ThreadLocal是空的自然分不了页。这一类问题的排查思路其实很一致先确认拦截器装了没有再确认startPage和目标查询在不在同一个线程、同一段调用链上。理解了原理你就有了一套方法论而不是出了问题瞎试。3. Spring Boot集成实操依赖、配置、一段可运行的接口3.1 版本矩阵Spring Boot 2.x和3.x要选不同的starter先聊版本这是我发现很多人在选型阶段最容易翻车的点。Spring Boot升级到3.x之后底层换成了Jakarta EEMyBatis和PageHelper的适配版本也跟着变了。如果你直接用Spring Boot 3.x去配老版本的pagehelper-spring-boot-starter启动阶段大概率会报各种ClassNotFound或者BeanDefinitionStoreException。以我项目里的实际组合为例Spring Boot版本推荐的pagehelper-spring-boot-starter版本说明2.7.x1.4.7目前Spring Boot 2.x里最稳的组合之一2.5.x ~ 2.6.x1.4.6或1.4.7均可正常使用3.0.x ~ 3.2.x2.1.0对应PageHelper 6.x要求JDK 17及以上这里多说一句Spring Boot 4.x如果在这个阶段看很多starter还没完全跟上节奏我的建议是核心链路相关的插件先保持观望不要为了追新而强行升级。生产环境稳定压倒一切真的。3.2 Maven依赖和yml配置在Spring Boot项目里引入PageHelper非常简单直接用官方starter即可。我的pom.xml里通常会这样加dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency如果你的项目是Spring Boot 3.x把版本号换成2.1.0即可其他代码不用改动。starter的好处是它会自动完成PageInterceptor的注册你不用手写配置类也不用去动SqlSessionFactory。然后看application.ymlpagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql这些配置项的用途我逐个说helper-dialect指定数据库方言。虽然PageHelper默认能根据数据源自动识别但多数据源或者某些代理数据源场景下手动确认一次更保险。值可以是mysql、oracle、postgresql等。reasonable开启合理化。pageNum 0时默认查询第一页pageNum超过总页数时自动查询最后一页。做前端列表时这个挺有用用户手滑把页码传成9999不至于返回空列表而是兜底给你最后一页数据。但这个设计不一定符合所有产品需求——有些接口希望“页码越界就返回空数组”那你就不开reasonable。support-methods-arguments支持从Mapper方法参数中取分页参数。这个功能要谨慎用后面踩坑部分我会详细讲。params当你启用supportMethodsArguments时通过这个参数自定义从方法入参里识别分页参数的键名。默认是pageNum和pageSize。3.3 一个完整的分页查询接口下面给一套可以直接跑通的最小实现从Mapper到Controller。这套结构我在项目里用了很久基本可以无脑复制。首先是实体类public class User { private Long id; private String username; private String email; private Integer status; // getter/setter 省略 }Mapper接口Mapper public interface UserMapper { ListUser selectByCondition(Param(username) String username); }Mapper XMLselect idselectByCondition resultTypecom.example.demo.entity.User SELECT id, username, email, status FROM user where if testusername ! null and username ! AND username LIKE CONCAT(%, #{username}, %) /if /where ORDER BY id DESC /selectService层Service public class UserService { Resource private UserMapper userMapper; public PageInfoUser pageQuery(String username, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); ListUser userList userMapper.selectByCondition(username); return new PageInfo(userList); } }Controller层RestController RequestMapping(/user) public class UserController { Resource private UserService userService; GetMapping(/page) public ResultPageInfoUser page(RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize, RequestParam(required false) String username) { PageInfoUser pageInfo userService.pageQuery(username, pageNum, pageSize); return Result.success(pageInfo); } }这样前端拿到的PageInfo结构里就有这些字段pageNum、pageSize、size当前页实际返回条数、total总记录数、pages总页数、list数据集合、isFirstPage、isLastPage、hasNextPage、prePage、nextPage等。对Vue2/Vue3管理后台来说这套字段几乎能直接对接绝大部分现成的分页组件不需要再做额外转换。这里有个小细节值得注意PageHelper.startPage(pageNum, pageSize)后面调用的Mapper方法返回值实际上已经不是纯粹的List了而是一个Page对象PageInfo就是把这个Page对象再包装一层。所以用的时候别做类似userMapper.selectByCondition(username)转成ArrayList再new PageInfo的操作那样会丢失总条数信息。4. 分页失效与性能问题排查我处理过的几个真实案例4.1 案例一startPage和查询之间混入了其他SQL这个坑我估计是PageHelper家族里出现频率最高的一个。有一次排查一个列表接口现象很奇怪其他页面分页都正常唯独订单详情页里的“相似订单”分页失效一次返回好几千条。Service层代码长这样public PageInfoOrder querySimilarOrders(Long orderId, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); User user userMapper.selectById(orderId); // 这里先查了一次用户信息 Order order orderMapper.selectById(orderId); ListOrder orderList orderMapper.selectSimilar(order.getCategoryId()); return new PageInfo(orderList); }问题一目了然startPage之后先执行了userMapper.selectById这次查询已经消费掉了ThreadLocal里的分页参数等orderMapper.selectSimilar真正需要分页时ThreadLocal已经被清空了所以分页逻辑没有生效。排查这类问题的标准动作是两步打开MyBatis的SQL日志确认目标SQL执行时有没有LIMIT关键字。没有LIMIT就回Service方法里把startPage到目标Mapper调用之间的所有代码过一遍看有没有其他的数据库访问。修复方式也很简单把PageHelper.startPage(pageNum, pageSize);挪到紧挨着orderMapper.selectSimilar(...)那一行再调用。要养成一个习惯startPage和你要分页的那条Mapper方法之间不写任何多余代码甚至不要有if分支。如果有条件判断导致不同的SQL执行那你就要在每个分支里分别startPage或者考虑把分页参数和查询条件一起封装后传入。4.2 案例二count查询特别慢一条分页接口卡了5秒分页接口除了查询目标数据还要执行一次count统计。很多情况下真正拉数据的SQL并不慢慢的是那条count。之前有个报表列表SQL里JOIN了四五张表还带GROUP BY。PageHelper自动生成的count是这样的SELECT count(0) FROM ( SELECT u.id, u.name, o.order_no, ... FROM user u JOIN orders o ON u.id o.user_id JOIN order_item oi ON o.id oi.order_id WHERE ... GROUP BY u.id ) tmp这条SQL把原本就很重的多表聚合查询完完整整地包了一层子查询数据库要把所有数据先算出来再在外层数一遍自然慢得离谱。优化思路有两个第一个思路在业务能接受的场景下跳过自动count。PageHelper.startPage有一个重载方法第三个参数可以控制是否执行countPageHelper.startPage(pageNum, pageSize, false);这样就会只执行分页查询不再执行countPageInfo里total会是-1。那页面上的总条数、总页数怎么办几种处理方式前后端改成“加载更多/无限滚动”模式压根不需要总条数。在异步任务里单独维护一个计数表定时刷新近似总量列表页展示“约XX条”。完全不展示总条数只给上一页/下一页按钮。第二个思路去优化原SQL本身。能提前过滤的JOIN条件提前过滤能改成EXISTS的不要用IN子查询尽量避免在分页SQL里做全量聚合。假如业务必须展示总数那就给关键查询字段建联合索引让count子查询能走上索引扫描。另外一个小技巧如果你的分页列表根本不需要按复杂条件排序可以在SQL里强制写ORDER BY id DESC这种基于主键的排序PageHelper在生成count时去ORDER BY会更容易性能也能提一点。如果你用的是ORDER BY RAND()这种随机排序那count和查询都会非常痛苦分页场景下直接放弃这种写法吧。4.3 案例三异步线程和线程池场景下的串页问题这个问题在并发稍微高一点的系统里特别容易出而且一旦出现往往是间歇性的很难复现。症状是A用户请求的分页数据里偶尔混进了B用户的数据或者某些时候分页彻底失效变成查全量。根源还是在ThreadLocal的线程隔离特性上。如果你做了这样的封装CompletableFuture.runAsync(() - { PageHelper.startPage(pageNum, pageSize); ListUser users userMapper.selectAll(); });startPage确实是在子线程里执行的ThreadLocal参数也放进去了看似没问题。但如果这个子线程来自一个复用线程池上一次任务的分页参数没被消费干净比如中间抛了异常SQL没执行参数一直留在ThreadLocal里下一次这个线程被复用去执行另一个查询时残留的参数就可能被“错误消费”导致那次查询莫名其妙被分页。我自己在项目里定了一个团队规范凡是涉及线程池、Async、CompletableFuture的Mapper查询一律不依赖PageHelper的隐式传参。把分页参数作为普通方法参数显式传给子线程里的查询用PageHelper.startPage(pageNum, pageSize)在子线程内部尽可能靠近Mapper方法的地方发起分页。如果业务上确实需要并行查询多个分页结果每个子线程里单独startPage不要在主线程统一startPage再异步执行那是给自己挖坑。4.4 案例四supportMethodsArguments引发的“灵异分页”support-methods-arguments这个配置我的态度很明确默认关掉别开。除非你特别清楚它的行为逻辑。这个配置的作用是PageHelper会检查Mapper方法的参数列表如果里面存在名为pageNum和pageSize的参数它会自动把这些参数作为分页参数不需要你显式调startPage。听起来很方便对吧但副作用也很明显第一如果业务参数里恰好有pageSize这个字段但它的含义不是“每页条数”而是“产品尺寸规格”那就会被PageHelper误认为分页参数导致查询出来的数据被莫名截断。 第二方法参数一旦多了分页参数这个Mapper方法每次调用都会被分页哪怕你只是想查个总数、查个全部也绕不开。你想通过传pageSize 0来逃过一劫PageHelper在reasonabletrue的情况下还会把0自动修正为第一页默认值更麻烦。我遇到过最夸张的一个案例是同事在DTO里定义了一个字段叫pageNum本来是“页码”没错但某个查询里它又充当了“区间编号”的业务语义结果开了supportMethodsArguments之后那个列表怎么查都只有10条查了半天才定位到是插件把业务参数当成页码消费了。如果你的项目里确实有大量“每个Mapper方法都要自带分页参数”的需求我更建议用PageHelper官方推荐的另一种方式在方法参数里声明Page对象PageHelper会自动识别。比如ListUser selectByPage(UserQuery query, Page? page);调用时PageUser page PageHelper.startPage(pageNum, pageSize, true); ListUser list userMapper.selectByPage(query, page);这种方式参数传递是显式的不会和业务参数冲突。4.5 排序参数的安全处理分页列表几乎一定带排序需求比如前端传orderBycreate_timeorderTypedesc。有些同学图省事直接把orderBy字段拼进SQLORDER BY ${orderBy} ${orderType}这种写法在PageHelper的分页SQL里也一样适用但它有一个致命隐患SQL注入那句老话就不说了更常见的是数据库语法层面的问题。用户传一个create_time; DROP TABLE user; --你能想象后果就算不搞恶意攻击传一个不存在的列名数据库也会当场报错。我的规范做法是白名单校验前端只能传我在代码里约定好的排序字段别名后端映射成实际数据库列名后再拼接进SQLprivate static final MapString, String ORDER_COLUMN_MAP new HashMap(); static { ORDER_COLUMN_MAP.put(createTime, create_time); ORDER_COLUMN_MAP.put(updateTime, update_time); ORDER_COLUMN_MAP.put(id, id); } public String resolveOrderColumn(String alias) { String column ORDER_COLUMN_MAP.get(alias); if (column null) { throw new BusinessException(不支持的排序字段: alias); } return column; }然后用ORDER BY ${column}的时候column一定是从白名单里映射出来的就不会有注入问题。永远不要用${orderType}去拼宁可把这个值固定成DESC/ASC二选一。后端能多做一层校验前端就少一个漏洞。5. 从“能用”到“好用”统一分页返回与深层性能演进5.1 统一PageResult返回结构别让前端适配十种格式项目初期可能每个部门各写各的返回格式有的返回PageInfo有的返回Map有的只返回list和total。时间一长前端换个人对接就骂娘。我比较推荐在项目里定义一套自己的统一分页结果结构用适配器把PageInfo转换一下public class PageResultT { private long total; private int pageNum; private int pageSize; private int pages; private ListT list; public static T PageResultT of(PageInfoT pageInfo) { PageResultT result new PageResult(); result.setTotal(pageInfo.getTotal()); result.setPageNum(pageInfo.getPageNum()); result.setPageSize(pageInfo.getPageSize()); result.setPages(pageInfo.getPages()); result.setList(pageInfo.getList()); return result; } // getter/setter 省略 }Controller里就可以这样GetMapping(/page) public ResultPageResultUser page(RequestParam(defaultValue 1) int pageNum, RequestParam(defaultValue 10) int pageSize, RequestParam(required false) String username) { PageInfoUser pageInfo userService.pageQuery(username, pageNum, pageSize); return Result.success(PageResult.of(pageInfo)); }这样做有三个好处第一PageInfo里那一大堆布尔字段isFirstPage、hasNextPage等不再原样暴露给前端接口只在需要时提供必要信息第二如果未来某个接口底层不再使用PageHelper你可以只改PageResult.of这一段适配逻辑外层返回结构完全不变第三前端拿到的是稳定、精简的JSON结构联调成本大大降低。5.2 数据量大了什么时候该“忘掉PageHelper”PageHelper好用但它不是万能的。当单表数据量突破百万级或者分页查询涉及多张大表JOIN、深度翻页频繁时PageHelper的LIMIT深分页性能问题就会暴露出来。什么叫深分页比如用户翻到第10万页SQL是SELECT * FROM user ORDER BY id DESC LIMIT 999990, 10数据库得先扫描、排序前999990行再往后数10行返回。这个扫描动作的代价跟表的大小成正比越到后面越慢哪怕你的查询条件走了索引ORDER BY和OFFSET也可能导致性能断崖。这不是PageHelper的问题是所有LIMIT offset, pageSize方案的共同瓶颈。我给的替代方案通常是keyset分页也叫seek方法或者“基于游标”的分页SELECT * FROM user WHERE id #{lastId} ORDER BY id DESC LIMIT 10前端不用传pageNum而是传上一页最后一条记录的id或者排序字段的值后端用或条件去定位起点性能稳定且不受翻页深度影响。这个方案的适用前提是排序字段必须稳定且唯一通常就是主键id或者(create_time, id)这种复合组合。哪些场景适合keyset分页呢典型的就是App“上拉加载更多”、内容流、消息通知列表——用户不需要跳转到第158页只需要不断往下翻。反过来管理后台那种带页码、可以任意跳转的列表还是得用PageHelper但可以配合“限制最大可翻页数”或者“在Count上做文章”来控制风险。我列一个不同量级和场景下的选型参考方便你对照自己的项目业务场景数据量级建议方案管理后台普通列表单表十万级以下PageHelper简单直接无需过度设计用户中心订单、日志列表单表单表十万到百万级PageHelper 合理索引 限制最大页数内容流/消息流/无限滚动百万级及以上连续翻页keyset分页基于id游标不要用深offset数据报表、聚合分析大数据集多表重度JOIN结果落库或数仓层计算避免实时count大表全文检索、海量日志千万级以上Elasticsearch等检索引擎使用search_after代替fromsize5.3 工程化的几个额外建议最后再补充几条我在实践中沉淀下来的工程化细节它们看起来不起眼但真能帮你少熬夜。第一分页查询字段尽量明确列出少用SELECT *这既减少网络传输也降低PageHelper改写count时误伤外层SQL的概率。第二不要在一个事务里开着大分页查询不放凡事尽早提交事务避免长事务拖垮连接池。第三给分页接口配慢SQL监控一旦发现执行时间超过阈值就告警很多深分页问题都是先从监控曲线里发现的。第四如果接口同时支持多条件筛选务必确认组合条件的索引设计分页查询性能的一半在SQL本身另一半在索引上。还有一个小技巧当你知道某个接口永远不需要count时记得用PageHelper.startPage(pageNum, pageSize, false)别小看这一次查询QPS高的时候少执行一个count能释放不少数据库压力。这也是我处理那些“只做下拉加载”接口时最常用的优化手段。