ARTICLE DETAIL

资讯详情

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

MyBatis分页插件PageHelper原理与避坑指南:从SQL改写到底层配置

MyBatis分页插件PageHelper原理与避坑指南:从SQL改写到底层配置 做Java后端的人几乎都跟分页打过交道。MyBatis官方自带的RowBounds看着省事用了才知道本质是内存分页数据量一大整表查出来后直接在内存里截取生产环境一个千万级表的分页查询足以把应用拖垮手写LIMIT又得在每个Mapper里维护一段重复SQL改个表名、加个筛选条件要翻好几个Xml文件一起动。所以PageHelper这种基于拦截器的物理分页插件一出来基本就成了MyBatis生态里的标配。我在生产项目里用PageHelper用了六七年从MyBatis 3.2一路用到3.5.x踩过的坑远不止startPage没放在查询前面这种入门级问题。很多问题不出在功能上而是出在大家对原理的认知有偏差它什么时候改写SQL、分页参数放在哪里、count语句怎么生成、遇到复杂SQL会怎么处理这些搞不清楚线上数据一出错就是事故级。这篇文章不只讲官方文档里已有的基础用法重点放在三块原理、配置、避坑。原理讲清楚了后面那些坑你自己就能推导出来配置讲清楚才知道哪些参数是保命用的避坑部分会把我实际遇到过的、以及社区里高频出现的典型问题连同排查思路一起写出来。看完你应该能做到什么时候放心用PageHelper什么时候需要绕开它出了问题第一反应该查哪里。1. 分页这件事为什么绕不开PageHelper先说个反直觉的事实分页看起来只是查前N条再跳过M条但在一套多表关联、复杂筛选条件的业务系统里把这件事做对远比想象的麻烦。你要考虑数据库方言差异MySQL写LIMIT offset, sizeOracle得写ROWNUM或者OFFSET FETCHSQLServer不同版本语法还不一样要考虑count查询和列表查询的SQL一致性总数不能和列表对不上还要考虑大offset时的性能退化。这些需求叠加在一起手写SQL的成本就非常高了。PageHelper的核心价值在于它把物理分页这个横切逻辑从业务SQL里抽离出来。业务代码里你只需要写最朴素的查询不用关心limit、不用关心rownum分页参数通过一个静态方法传递剩下的事插件在MyBatis执行链路里替你完成。这也是它能在国内Java社区流行这么多年的根本原因——侵入性小、上手快、和MyBatis的XML/注解两种风格都能配合。但这里有个微妙的代价正因为侵入性小很多人用了一年半载也不知道它到底改了什么。于是出现了一堆让人啼笑皆非的问题——分页失效、总数虚高、数据缺行、莫名其妙被截断。这些问题几乎都能从原理上推导出来。所以我给团队的规矩是任何引入第三方插件的项目第一件事是弄懂它的运行机制而不是先写业务代码。下面就把PageHelper的运行机制拆开讲。2. 一个分页请求的完整旅程PageHelper的运行机制先给结论PageHelper本质上是一个MyBatis插件Interceptor它靠三件事完成分页——拦截Executor的query方法、用ThreadLocal传递分页参数、用JSqlParser把原始SQL改写成count语句和limit语句。每一件单独看都不复杂但组合在一起衍生出一堆边界问题。2.1 拦截器是怎么混进MyBatis执行链的MyBatis的插件机制大家应该不陌生。MyBatis在创建Executor、StatementHandler、ParameterHandler、ResultSetHandler这四个核心对象时会给它们套上一层层的JDK动态代理每个插件的intercept方法就有机会在真正的方法执行前后插入逻辑。PageHelper拦截的是Executor的query方法签名大概是这样的Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class PageInterceptor implements Interceptor { // ... }新版PageHelper还会拦截带CacheKey和BoundSql的那一个query重载这是为了处理二级缓存场景。但核心逻辑一样当你的Mapper方法被调用时最终会走到Executor.queryPageInterceptor在这里拦截下来判断当前线程有没有分页参数。有就处理没有就放行完全不干扰原有查询逻辑。这里有个容易被忽略的点PageHelper和别的Executor级拦截器是串在同一个代理链上的。如果项目里还有自定义的Executor拦截器比如多租户SQL改写、数据权限过滤它们的执行顺序会和PageHelper互相影响不是加进去就能各干各的。这个放到第6章专门讲先记住结论。2.2 ThreadLocal分页参数是怎么跟到查询上的PageHelper最核心的设计就是把分页参数放在ThreadLocal里。调用PageHelper.startPage(1, 10)时它会new一个Page对象出来这个Page对象继承自ArrayList同时带上了pageNum、pageSize、count等属性然后被塞进当前线程的ThreadLocal里。接下来执行的第一个MyBatis查询PageInterceptor从ThreadLocal取到这个Page开始改写SQL查询结束后在finally块里调用PageMethod.clearPage()清掉ThreadLocal。用ThreadLocal的好处很直观你不需要改Mapper接口的签名不需要在XML里传什么pageNum、pageSize参数业务代码里先调startPage再正常写查询逻辑就行。代价也埋在这里——分页参数只在当前线程可见而且只在查询之后、线程归还线程池之前有效。一旦查询没执行、或者执行过程中抛异常、或者你把查询放到了别的线程里分页就丢了或者串了。这些问题在第4章会反复出现。另外还有一点每次startPage之后PageInterceptor在取到分页参数后会把它从ThreadLocal里拿出来所以正常路径下不会累积。但如果出现了没走完的异常路径ThreadLocal里残留的Page对象就会污染下一个被线程池复用的请求。2.3 SQL改写count语句和分页语句到底是怎么生成的当PageInterceptor确认当前有分页参数后它会做两件事生成count查询如果Page的count属性为true和生成带limit的分页查询。这一步用的是JSqlParser把原始SQL解析成语法树再根据数据库方言输出对应SQL。拿MySQL举例原始查询如果是SELECT * FROM user WHERE status 1 ORDER BY create_time DESCPageHelper生成的count语句简单场景下会直接替换查询列为count(0)SELECT count(0) FROM user WHERE status 1生成的翻页语句第二页每页10条MySQL的LIMIT是偏移量在前SELECT * FROM user WHERE status 1 ORDER BY create_time DESC LIMIT 10, 10但SQL一旦变复杂这个智能改写就不那么智能了。比如带了GROUP BY、DISTINCT、UNION或者SQL里本身有子查询、有开窗函数PageHelper会退而求其次把整个原SQL包一层子查询再countSELECT count(0) FROM ( SELECT dept_id, COUNT(*) FROM user GROUP BY dept_id ) table_count这种兜底方案在大多数情况下是对的但要注意两点第一包一层子查询会让count查询变慢尤其大表因为子查询要先算完整结果再数行数第二某些特殊写法下兜底也会误判比如UNION语句只拿第一段SELECT来自动count的旧版本问题以及带FOR UPDATE时ORDER BY的处理。第5章我会专门讲count优化的思路。3. 配置项逐个拆解每个参数背后都是性能取舍3.1 引入依赖和最小配置现在主流用法是Spring Boot项目引入starterdependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependencySpring Boot 3必须用2.x版本2.0.0开始支持对应MyBatis 3.5.x和JDK 17。传统Spring MyBatis项目就引pagehelper核心包在mybatis-config.xml里注册插件plugins plugin interceptorcom.github.pagehelper.PageInterceptor property namehelperDialect valuemysql/ /plugin /pluginsstarter方式的话最小配置一行都不用写PageHelper会自动根据连接URL判断方言、动态选择分页语法。但自动判断不是万能的遇到自定义代理数据源、方式比较特殊的数据库连接还是老老实实配helperDialect。我自己见过一个项目用的数据库连接池做了URL改写结果PageHelper识别成别的方言分页SQL直接语法错误排查了大半天最后手动指定方言解决。3.2 核心参数逐个拆直接给一张速查表后面挑几个重点展开参数默认值作用helperDialect空自动检测手动指定数据库方言reasonablefalse页码合理化pageNum小于1取第一页大于总页数取最后一页pageSizeZerofalsepageSize0时执行全部查询不分页supportMethodsArgumentsfalse从Mapper方法入参自动识别分页参数params空配合supportMethodsArguments指定参数名映射autoRuntimeDialectfalse多数据源场景下运行时动态选择方言countSuffix_count手动count方法的后缀closeConntrue连接使用后是否关闭useSqlserver2012falseSQLServer是否用OFFSET FETCH语法reasonable和pageSizeZero这两个参数争议很大。reasonabletrue看着贴心副作用是当页码超过总页数时PageHelper会悄悄帮你改成最后一页调用方并不知道。对前端来说可能体验好但对接口层来说它掩盖了页码越界这个本该被识别的异常场景。我项目里一直保持false越界就让上层抛异常或返回空页逻辑更干净。supportMethodsArguments是一个挺迷惑的选项。开启后PageHelper会去解析Mapper方法的参数对象如果里面有名为pageNum和pageSize的属性就自动分页连startPage都不用调。听着方便副作用同样明显所有Mapper方法都可能被误判哪怕那个方法只是普通查询只是恰好有个字段叫pageNum。所以我个人不推荐全局开这个宁可显式调startPage也不让框架猜。3.3 Spring Boot 3和MyBatis版本兼容版本兼容是新手最容易卡住的。PageHelper的版本号看着规律地涨但和Spring Boot的对应关系有个分水岭Spring Boot 2.x用pagehelper-spring-boot-starter的1.4.xSpring Boot 3.x必须用2.x版本否则启动就报MyBatis相关类找不到或拦截器注册失败。项目里手动组装MyBatis不走starter时还要注意PageInterceptor的注册顺序它必须在SqlSessionFactory构建之后、真正执行第一个查询之前注册好。XML方式配置插件是最不会出错的Java配置方式要小心bean创建顺序把插件作为SqlSessionFactoryBean的属性传进去。这个顺序错了最常见的现象是插件没生效分页照常跑但就是不分页连报错都没有。4. 高频翻车现场五类典型问题与完整排查过程这一章是全文的重头戏。每个问题按现象→排查链路→根因→解决方案来写你可以直接照着排查。4.1 翻车一startPage后插了别的操作分页静默失效现象很典型明明调了PageHelper.startPage(1, 10)接口返回了全量数据或者返回10条但总量不对。排查的时候第一件事就是看startPage和第一个MyBatis查询之间到底隔了什么。注意PageHelper的规则startPage之后的第一个查询被分页而且仅限Executor层的查询。如果你在中间调用了别的Mapper的update/insert/delete或者在同一个Service方法里先查了另一个表做校验那么被分页的可能就是那个别的查询。更隐蔽的是如果在startPage和查询之间抛了异常分页参数会残留在ThreadLocal里后面线程池复用这个线程时可能把一个完全无关的查询给截断。排查链路给这段代码加日志确认startPage之后执行的第一个MappedStatement的id是谁再去翻Mapper方法看是不是有额外的一条查询排在前面。最稳妥的修复是分层约定所有分页查询统一放在Service方法的最后一步前面只做参数组装和校验或者干脆约定查询上方三行内不允许出现任何其他数据访问操作。4.2 翻车二线程复用导致查询串了上一次的分页参数这个坑比第一个更隐蔽。现象是某个接口突然返回10条数据但调用方并没有要求分页过一会自己又好了。根因就是ThreadLocal。线程池技术让线程被反复利用而ThreadLocal是跟着线程走的。如果一个线程在处理请求A时startPage之后查询抛了异常而你的代码没有用finally清理上下文Page对象就留在了ThreadLocal里。下一个请求B恰好复用到这个线程PageInterceptor一查ThreadLocal里还有Page二话不说就开始改写SQLB就被意外分页了。我遇到的一次线上案例一个后台任务线程池和一个接口线程池共用后台任务里有一段分页遍历逻辑某次任务中途抛异常没清理上下文结果后续任务全部带上了上一次的分页参数批量处理只算完第一页就停了数据漏了一大批。这个问题的标准解法是手动兜底清理。所有startPage的分页代码建议统一写成PageHelper.startPage(pageNum, pageSize); try { ListUser list userMapper.selectByCondition(condition); return new PageInfo(list); } finally { PageHelper.clearPage(); }try-finally写起来多两行但能堵掉大部分ThreadLocal残留问题。这个模板我在团队里直接写成了规范新人也基本不会再踩这个坑。4.3 翻车三一对多嵌套映射下分页数据缺行这是社区提问频率极高的一类问题。你的SQL本身是联表查询实体里配置了collection嵌套集合MySQL返回的物理行因为一对多会重复MyBatis会在内存里把重复的父记录折叠成一条并把子集合装配进去。问题来了PageHelper的limit是在SQL层直接截断物理行数的不是按父记录数截断的。如果一页要10条父记录而每条父记录平均带3条子记录物理上LIMIT 10可能只覆盖了3到4条父记录折叠后这一页明显缺行。反过来count语句数的是物理行数子记录行数和前端期望的父记录条数对不上总数虚高。排查链路把MyBatis日志里打印的实际SQL拿出来看LIMIT截断的行数再和返回的List.size()对比基本就能确定是嵌套映射折叠导致的偏差。解决方案实际就两条路第一改成分页后再查子集——先用一个简单的分页查询拿到父记录的id列表再用IN去查子表应用层组装第二用子查询方式让物理行数等于父记录数比如在关联子表前先做去重子查询把父记录打平。核心原则一句话让分页截断的行语义和你的列表语义保持一致别让一对多的重复行干扰物理分页。4.4 翻车四GROUP BY、DISTINCT、UNION语句的count不准复杂SQL下count统计不准是我在生产里踩过最深的坑之一。典型场景是报表查询主查询按部门聚合每页应该按聚合后的组数来分页。PageHelper对GROUP BY的默认处理是包一层子查询去count逻辑是对的。但SQL里一旦混入UNION、DISTINCT、开窗函数不同版本PageHelper的处理逻辑有差异。旧版本曾经出现过UNION语句只拿第一段SELECT来count导致总数远小于实际的问题。还有一个容易被忽视的边界如果原SQL里有ORDER BYPageHelper在count时会尝试去掉它来提升性能但某些特殊结构下去掉ORDER BY反而会导致count结果和实际分页数据不一致。报表系统对不上数的时候一行行对比SQL才定位到这个地方。这一题的解法不是去研究JSqlParser的每一次判断而是直接上手动count第5章详细讲复杂SQL一律手工控制count逻辑把不确定性降到零。4.5 翻车五深分页越来越慢offset膨胀LIMIT 100000, 10这种深分页MySQL不是只取10条而是先扫描前100010条再丢掉前100000条数据量越大越慢。这个问题的排查很简单看explain的type和rows就能确认但解决思路需要一点取舍方案一是游标分页基于排序键做条件查询比如WHERE create_time ? ORDER BY create_time DESC LIMIT 10每次带上上一页最后一条的位置。缺点是需要客户端配合不适合随机跳页。方案二是延迟关联先只查主键id取了limit之后再回表查需要的列。这个方案对PageHelper同样适用只是SQL要自己写两段不能只靠startPage。方案三是给排序字段建联合索引这属于治本但见效周期可能长的优化。顺带说一句深分页本身不是PageHelper的问题但很多人看到变慢第一反应是怪插件这里帮忙正一下名。5. 生产环境的收尾工作count优化与边界控制5.1 手动count的正确姿势第4章反复提到手动count这里说具体怎么做。PageHelper支持通过countSuffix默认_count约定一个手写的count查询方法。比如你的分页查询Mapper方法是ListReportVO selectReportPage(Param(condition) ReportQuery query);你就在同一个Mapper接口里加一个对应方法Long selectReportPage_Count(Param(condition) ReportQuery query);XML里写你自己的countSQL返回值用Long或long。PageHelper执行分页时会先检查有没有这个方法名_count的MappedStatement有就用它没有才走自动生成的count。注意方法名后缀和参数类型必须和原方法完全一致否则PageHelper找不到还是会回退到自动count。我见过太多明明写了_count方法但没生效的案例基本全是参数类型没对上。手动count的价值在于复杂SQL下你清楚到底该数什么。比如分组报表你知道总数其实是组数手写count就明确数组比如联表查询要去重数客户数你可以在count里直接去掉不需要的join性能瞬间提升一个量级。5.2 不需要count的场景别硬扛有些前端分页组件只在滚动加载时用下一页根本不显示总条数。这时count查询就是纯浪费。pagehelper的startPage有第三个参数PageHelper.startPage(pageNum, pageSize, false);false表示只分页、不执行count查询。返回的Page对象里total会是0。这个参数在列表接口、下拉加载场景里非常实用一次请求直接省掉一次数据库查询。我习惯把所有不展示总条数的加载更多类接口都统一传false既减数据库压力也让接口响应更稳定。顺带提一句PageInfo和Page的取舍。Page对象本身继承了ArrayList直接把List返回给前端时会带上一串pageNum、pageSize、total、pages这类框架字段很多公司接口规范不允许。PageInfo是包装类更适合统一出参结构。如果不想引入PageInfo也可以自己封装一个分页VO把total、pages、list塞进去本质上都一样。5.3 大表分页的极限场景绕开PageHelper大表深分页优化到极致仍不满足的可以考虑绕开PageHelper自己做。MySQL 8.0.1之后支持窗口函数ROW_NUMBER() OVER()在某些场景下可以替代offset分页的语义也有团队直接用搜索引擎或数仓来扛。这已经超出了插件范畴但要记住PageHelper只是一个SQL改写工具它解决的是少写一堆重复分页代码的问题不解决数据库查询本身该高效的问题。任何时候分页性能的瓶颈都在SQL和数据模型上不在插件上。6. 多数据源、插件顺序、异步线程冷门但致命的问题6.1 多数据源下方言串味公司项目经常一个应用连多个库MySQL做主业务、Oracle做报表、PostgreSQL做日志Mapper混着写。PageHelper默认在第一次运行时根据连接URL判断方言并缓存下来多数据源场景下就容易串滋味——第一个连接是什么库后面所有分页都按那个方言生成SQLOracle的ROWNUM和MySQL的LIMIT混用分页直接报错或数据错乱。解法有两个一是启动时把每个数据源对应的方言都配好用helperDialect分别指定二是开启autoRuntimeDialecttrue让PageHelper在运行时根据当前实际连接再判断方言。注意autoRuntimeDialect是全局开关本身有轻微判断开销适合数据源个数不多、分页调用频繁的场景。配了多数据源的项目上线前务必写一个覆盖所有数据源的冒烟测试专门验证分页SQL在各库上都能跑通。6.2 Executor级拦截器互相覆盖MyBatis插件机制里一个容易踩的细节多个插件同时拦截Executor.query时它们的执行顺序取决于初始化时插件的包装顺序而PageHelper对SQL的改写依赖拿到原始SQL这个前提。如果另一个拦截器在PageHelper之前把BoundSql的SQL改成了别的东西比如拼了数据权限条件PageHelper解析和改写的其实是已经改过的SQL表面没报错逻辑却可能已经不对。遇到这类冲突我的经验是先把所有Executor拦截器列出来明确各自改SQL的职责边界如果必须共存保证PageHelper在职责链上的位置稳定一般让它处理最外层的分页权限过滤、租户过滤放在更内层完成。社区里有句话能不用多个Executor拦截器就不用少一个代理层少一类事故。6.3 异步线程与分页参数传递最后说一个很多人会踩的坑在Spring的Async方法里调用PageHelper或者把分页查询提交给线程池执行你会发现分页失效。原因前面已经说过——ThreadLocal是跟着原始线程走的子线程里根本拿不到父线程set的Page。这个问题没有万能解看业务如果分页参数是固定的就在子线程任务内部重新调用startPage再查询这最干净。如果确实需要把父线程的分页参数传进子线程可以用TransmittableThreadLocal这类工具在提交任务时显式复制上下文但代码侵入不小。我的建议所有分页查询尽量在同一个线程的同一段同步代码里完成异步只做数据的后处理别把分页塞进异步流程。这不是PageHelper的限制而是ThreadLocal模型的自然边界理解这一点很多诡异的线上问题都能提前规避。最后再分享一个我自己养成的习惯。新项目搭好分页基础设施后我会写一个专门的分页冒烟测试用例覆盖这些场景正常分页、count开关关闭、reasonable开启和关闭、一个方法内连续两次startPage、带一对多collection的查询、复杂GROUP BY的count。这个用例写起来半小时但后续每一次升级PageHelper版本、调整MyBatis配置时它都能第一时间帮你发现行为变化。分页插件看着简单真正吃透它的人线上是能少踩很多坑的。
返回列表