十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

MyBatis + PageHelper 分页查询实战:从集成到深分页优化

MyBatis + PageHelper 分页查询实战:从集成到深分页优化 分页查询这事说大不大说小不小但几乎每个做后端的人都被它磨过。前段时间我在整理一个老项目的分页逻辑顺手把MyBatis和PageHelper这套组合又重新捋了一遍顺手记录了一些踩坑和优化思路。这篇文章就来聊聊分页查询功能相关的那些事重点围绕MyBatis和PageHelper展开涵盖集成方式、使用姿势、常见坑位排查和一点点原理层面的理解帮你在实际开发里少走弯路。先说清楚这篇文章适合谁。如果你刚接触Spring Boot MyBatis想看分页怎么写最省事或者你已经在用PageHelper但遇到过分页失效、统计不对、深分页慢这些奇奇怪怪的问题再或者你打算在自己的项目里做分页组件的选型想搞明白PageHelper到底值不值得用——那这篇文章都值得你花十分钟扫一遍。1. 分页方案的选型与PageHelper的工作方式1.1 为什么首选PageHelper而不是手写LIMIT分页这个需求太常见了常见到很多人条件反射直接写LIMIT offset, size。手写LIMIT本身没有错但一旦你的查询条件变多、查询场景变多你会发现自己被迫写大量重复的分页样板代码而且统计总条数的SQL往往和列表查询的SQL长得一模一样只是目标字段不同维护起来非常别扭。PageHelper解决的核心痛点是分页逻辑与业务查询解耦。你在业务代码里只需要写普通的列表查询然后在查询前调用一行PageHelper.startPage(pageNum, pageSize)PageHelper就会在SQL执行阶段自动帮你在SQL上拼接LIMIT同时还会自动生成一条COUNT查询去拿总数。这一点在实际项目中非常受用尤其是查询条件复杂、动态SQL多的场景下你不需要去维护两套SQL或手动拼接分页后缀。另外还有个容易被忽视的点PageHelper对多种数据库方言做了适配。你的项目今天用MySQL明天也许要兼容PostgreSQL或OraclePageHelper会自动识别数据库类型生成对应方言的分页语句手写LIMIT在换库时就要全部重改。虽然大多数项目一辈子不会换数据库但这一层方言适配在集成测试和本地开发换数据库时仍然能省不少事。1.2 PageHelper在MyBatis执行链路里的位置想要用好一个工具搞明白它在框架里的位置比死记API重要得多。PageHelper并不是独立工作的它本质上是一个MyBatis的Interceptor插件通过拦截MyBatis的Executor来改变SQL的执行行为。简单画一下调用链路你在Service层调用Mapper方法时你先调用了PageHelper.startPage(pageNum, pageSize)这一步会把分页参数存进ThreadLocal。紧接着调用Mapper的查询方法MyBatis内部会创建SqlSession并执行SQL。PageHelper的拦截器在Executor.query()执行前拦截到这次调用从ThreadLocal里取出分页参数。判断当前查询是否需要分页这一步很关键后文会展开说。需要分页的话PageHelper会改写原始SQL生成分页SQL和计数SQL分别执行。这个机制决定了PageHelper的一个显著特征PageHelper只对紧随其后的第一条查询语句生效。如果你在startPage和Mapper查询之间插入了其他查询操作分页参数会被别的查询消耗掉当前Mapper查询就不会分页。这是新手最容易踩的坑之一后面会在问题排查部分专门讲。1.3 PageHelper与MyBatis-Plus内置分页的取舍既然搜热词里也有MyBatis-Plus就顺带对比一下。MyBatis-Plus内置了分页插件PaginationInnerInterceptor它的用法和PageHelper有些类似但又完全不同。PageHelper的思路是“自动拦截自动改SQL”而MyBatis-Plus的分页则需要你传一个Page对象作为Mapper方法的参数由框架解析Page对象来拼分页SQL。两者各有拥趸但我的体感是如果你的项目就是原生MyBatis的写法没有引入MyBatis-Plus那引入PageHelper非常轻量不会改变你原有的Mapper写法。如果你的项目已经用了MyBatis-Plus那就没必要再引入PageHelper了用MyBatis-Plus自带的分页插件更统一避免两套分页机制在同一个上下文里互相干扰。从可控性角度讲MyBatis-Plus的显式传Page参数更直白分页逻辑一眼可见PageHelper则更“魔法”一行startPage就生效但出了问题也更隐蔽。我做技术选型时有个习惯不重复引入功能重叠的组件。同一套代码里又用PageHelper又用MyBatis-Plus分页一旦分页异常光排查是哪个插件在起作用就够你喝一壶的。2. 集成PageHelper的实操步骤与配置解读2.1 依赖引入与基本配置项目基于Spring Boot的话引入PageHelper的依赖非常简洁。我用的是Spring Boot 2.x MyBatis Starter的组合Maven里加这一条dependency groupIdcom.github.pagehelper/groupId artifactIdpagehelper-spring-boot-starter/artifactId version1.4.7/version /dependency注意这里用的是pagehelper-spring-boot-starter它已经帮你自动装配了拦截器不需要再手动在MyBatis配置里添加Plugin。如果你是非Spring Boot的传统项目那需要在MyBatis的配置文件中手动注册PageInterceptorplugins plugin interceptorcom.github.pagehelper.PageInterceptor property namehelperDialect valuemysql/ property namereasonable valuetrue/ /plugin /pluginsSpring Boot模式下推荐直接在application.yml里配置相关参数pagehelper: helper-dialect: mysql reasonable: true support-methods-arguments: true params: countcountSql auto-runtime-dialect: true配置项不多但每个都可能影响运行结果我逐一说一下。2.2 核心配置项的含义与推荐值helper-dialect指定分页方言可以填mysql、oracle、postgresql等。如果不填PageHelper会自动检测但显式指定可以避免某些场景下自动识别不准的问题。如果你配置了auto-runtime-dialect: true运行时会在多个数据源之间自动识别这在多数据源项目里更稳。reasonable这个参数建议开启为true它做两件好事一是当你传入的页码小于1时自动查询第一页二是当页码大于总页数时自动查询最后一页。比如用户直接手改URL里的pageNum999如果不开这个配置数据库会扫描一个极大的偏移量既慢又没有意义开了之后PageHelper会直接把页码规整到最后一页对前端展示很友好。support-methods-arguments开启后支持在Mapper方法参数里直接通过Param(pageNum)和Param(pageSize)来接收分页参数而不需要显式调用startPage。这个特性看个人习惯我倾向于在Service层显式调用startPage因为分页的入口在业务代码里会更清晰也方便在分页前做一些参数校验。params这个配置用来定义count参数的获取方式countcountSql允许你通过Page对象里的countSql属性控制是否执行count查询。在需要精细控制count场景的性能时比较有用。这里说一个我自己的偏好reasonable和helper-dialect是我在新项目里必配的两项前者防止接口被恶意页码打出深分页后者避免数据库方言识别多一次探测开销。2.3 最简单的分页代码长什么样配置搞定后分页的使用就非常直接了。这是一个最常见的Service层示例public PageInfoUserDO pageUserList(String keyword, int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); ListUserDO users userMapper.selectUserListByKeyword(keyword); return new PageInfo(users); }对应的Mapper接口和XML写法完全不用为分页做任何特殊处理ListUserDO selectUserListByKeyword(Param(keyword) String keyword);select idselectUserListByKeyword resultTypecom.example.domain.UserDO select id, name, age, email from user where if testkeyword ! null and keyword ! and name like concat(%, #{keyword}, %) /if /where order by id desc /selectPageHelper.startPage执行后紧接着的selectUserListByKeyword查询就会被自动分页返回的List实际上是一个Page对象把它传给new PageInfo(list)即可获得total、pages、pageNum、pageSize等完整分页信息。有一个细节要注意PageInfo和List里承载的数据是同一份引用在获取PageInfo之后不要再对原List做二次修改否则会把分页结果也改掉。有人习惯对查询结果做Stream转换再封装返回如果是这样建议先new PageInfo(list)拿到分页信息再用转换后的List单独做返回结构。3. 分页查询的代码实践与场景扩展3.1 动态SQL条件下的分页写法实际业务里列表查询十有八九带筛选条件而且条件数量不是固定的。PageHelper在这种场景的优势最能体现因为不管你的where里拼了多少条件分页逻辑都不用动。举个复杂一点的例子一个订单列表支持按订单号、用户ID、下单时间区间、状态多条件组合查询select idselectOrderPage resultTypecom.example.domain.OrderDO select o.id, o.order_no, o.user_id, o.status, o.amount, o.create_time from order o where if testorderNo ! null and orderNo ! and o.order_no #{orderNo} /if if testuserId ! null and o.user_id #{userId} /if if teststartTime ! null and o.create_time gt; #{startTime} /if if testendTime ! null and o.create_time lt; #{endTime} /if if teststatus ! null and o.status #{status} /if /where order by o.create_time desc /selectService层依然不需要改动结构public PageResultOrderDO queryOrderPage(OrderQuery query) { PageHelper.startPage(query.getPageNum(), query.getPageSize()); ListOrderDO list orderMapper.selectOrderPage(query); PageInfoOrderDO pageInfo new PageInfo(list); return PageResult.of(pageInfo.getTotal(), list); }这种写法有个天然的好处将来新增筛选条件你只需要在OrderQuery里加字段、在XML里加if判断Service层和Mapper接口签名都不用动分页功能天然跟着走了。维护成本非常低这其实就是PageHelper这类“分离式分页”设计的价值所在。3.2 关联查询与分页结果的坑位提示列表查询一旦关联了子表情况会变得微妙。最常见的坑是因为关联产生的重复数据导致总数不对。举个例子查询用户列表并关联每个用户的订单数量select idselectUserWithOrderCnt resultTypemap select u.id, u.name, count(o.id) as order_cnt from user u left join order o on o.user_id u.id group by u.id, u.name order by u.id /select这个SQL本身没什么问题但如果你把left join写成了inner join的写法或者某条用户没有订单导致join后行数膨胀就会影响总数。PageHelper的count查询始终基于原SQL生成它生成的count语句大致是select count(0) from (原SQL) tmp_count如果原SQL里本身有group by或distinctcount结果可能是正确的但性能会变差如果原SQL里不加group by导致笛卡尔积count数量就会虚高。解决这类问题有几个思路分页的主体查询和统计查询分开写统计用专门的Mapper方法。PageHelper支持你传一个Page对象并用countSql参数控制是否执行自动count。如果只是列表展示不需要精确总数可以禁止count只做limit分页前端用“加载更多”代替页码。对于复杂的报表类查询不要过度依赖PageHelper的自动count手写统计SQL会更可控。我个人的建议是常规多表列表查询放心交给PageHelper复杂报表查询自己掌控SQLPageHelper只用来做limit拼接count单独写。3.3 自定义count查询的姿势PageHelper提供了一个能力当你觉得自动生成的count语句不理想时可以自己写一个count查询通过Param(countId)指定或者直接传入Page对象设置countSql。实际操作中如果你在Mapper接口里定义了两个方法一个查询列表一个查询countPageHelper 5.x版本之后可以通过官方的Page对象方式控制。更简单的做法是使用PageHelper.startPage(pageNum, pageSize, false)关闭count查询然后自己调用count查询方法拿总数。// 关闭自动count PageHelper.startPage(pageNum, pageSize, false); ListOrderDO list orderMapper.selectOrderPage(query); long total orderMapper.countOrderPage(query);这个模式我经常在复杂报表中使用因为自动count生成的SQL在极端情况下可能比手写的统计慢一个数量级自己控制以后SQL执行计划完全在掌控之中。3.4 分页结果的统一封装为了不让分页逻辑散落在各个Service里我习惯抽一个简单的分页结果对象public class PageResultT { private long total; private int pages; private ListT records; public static T PageResultT of(long total, ListT records) { PageResultT result new PageResult(); result.total total; result.records records; result.pages (int) (total % 10 0 ? total / 10 : total / 10 1); return result; } }注意这里pages计算用的是硬编码的10实际使用中可以传入pageSize或者直接从PageInfo拿现成的getPages()。我给出的方式只是示意正式代码里建议用PageInfo自带的pages属性避免重复造轮子。4. 坑位复盘PageHelper使用中的典型问题与排查4.1 分页失效最常见的ThreadLocal错位问题分页失效这个问题我在各种技术群里见过的次数最多。典型表现是调用startPage之后查询返回了全量数据完全没分页。绝大多数原因可以归为一类startPage后面的第一条查询不是预期的Mapper查询。我之前在排查一个同事的代码时发现他是这样写的public ListUserDO pageQuery(int pageNum, int pageSize) { PageHelper.startPage(pageNum, pageSize); // 这里竟然先调了一次别的查询 UserDO admin userMapper.selectByRole(admin); // 这条才是真正想分页的查询但分页参数已经被上面那条消耗掉了 return userMapper.selectUserList(); }由于PageHelper的分页参数保存在ThreadLocal里并且只对紧随其后的第一条查询生效上面的写法会导致selectByRole被分页而selectUserList返回全量。如果selectByRole没有limit需求那它就会带着一个多余的limit执行结果你可能还发现不了。还有一种类似的情况Service里在startPage之前调用了其他查询比如先查一遍权限、再分页查列表这本来没问题因为startPage在权限查询之后执行。但如果你在startPage和主查询之间打印日志时不小心触发了数据库查询操作比如MyBatis延迟加载触发也会干扰ThreadLocal。我的经验是startPage和Mapper查询之间只允许出现纯内存操作不要出现任何可能触发数据库访问的代码。注释里最好也提示后来的维护者这个位置是雷区。4.2 count查询总是不对聚合与分组时的注意点count不准的另一个高发场景是原SQL里带GROUP BY。PageHelper的count改写会把原SQL包一层select count(0) from (...) tmp_count在MySQL里这个写法对带group by的SQL会返回分组后的行数通常是对的。但如果你还用了DISTINCT又或者left join造成了行数膨胀count和列表行数就会对不上。举个例子一个查询商品列表并关联商品标签表的SQL如果一个商品有多个标签join之后会产生多行列表展示的时候如果用了某种行合并实际展示条数少于查询条数count也会虚高。这种情况下最直接有效的排查方式是打印PageHelper生成的SQL。在MyBatis的日志配置里开启SQL打印后你能清楚地看到PageHelper为count生成了什么样的语句一眼就能看出它包了哪一层。4.3 深分页性能差的优化方案分页查询一旦翻到后面性能急剧下降是必然的。LIMIT 100000, 20意味着数据库要扫描前100020行再丢掉前100000行这种浪费在数据量大时非常致命。PageHelper本身不做深分页优化它只是帮你拼SQL。所以优化深分页要靠SQL层面一种常用思路是延迟关联先查主表的主键或覆盖索引再回表查详情。写成XML大概是这样select idselectOrderPageByOptimize resultTypecom.example.domain.OrderDO select o.id, o.order_no, o.user_id, o.status, o.amount, o.create_time from order o inner join ( select id from order where create_time gt; #{startTime} order by create_time desc limit #{offset}, #{pageSize} ) tmp on tmp.id o.id order by o.create_time desc /select另一种思路是基于游标分页也就是网上常说的“keyset”方式不需要传页码靠上一页最后一条记录的排序字段值来做筛选where create_time #{lastCreateTime} order by create_time desc limit #{pageSize}这两种方式PageHelper都不直接支持需要你手动构造Mapper方法。如果有这种需求我的建议是放弃PageHelper直接在XML里手写分页SQL可控性更高。4.4 多数据源下的分页方言问题有些项目配置了多数据源主库MySQL从库可能混着PostgreSQL或Oracle。如果你在application.yml里硬编码了helper-dialect: mysql切换到其他数据库方言的数据源时分页SQL就会生成错误。解决方式就是开启auto-runtime-dialect: true让PageHelper在运行时根据当前数据源连接自动识别方言。如果你的项目用了dynamic-datasource这类动态数据源框架尤其建议开启否则你会看到一些很诡异的语法错误。这里还给一个实用建议分页插件和动态数据源的加载顺序有时会影响拦截器获取到的连接。如果你配置了多数据源又发现分页方言始终识别不对检查一下PageHelper的拦截器是否是在数据源切换之后才生效的。常见的做法是把pagehelper的依赖和配置文件放在主数据源配置之后加载或者通过配置AutoConfigureAfter调整自动装配顺序。4.5 分页参数大小的参数校验这个不属于PageHelper本身的问题但属于分页功能必备的防御性编程。线上接口如果允许前端随意传pageSize传一个10000进来你的一次分页查询可能就把数据库打挂了。我一般在Controller或Service层统一做校验public static void checkPageParam(int pageNum, int pageSize) { if (pageNum 1) { throw new BusinessException(页码必须大于0); } if (pageSize 1 || pageSize 200) { throw new BusinessException(每页条数必须在1-200之间); } }虽然reasonabletrue会帮你把页码规整到合理范围但它不会限制pageSize的上限。对pageSize做上限控制是对数据库最基本的保护。5. 从源码角度看PageHelper的关键实现5.1 ThreadLocal分页参数的传递机制PageHelper的分页参数传递机制核心就是ThreadLocal。PageHelper.startPage(pageNum, pageSize)会创建一个Page对象其中包含了pageNum、pageSize和是否count等参数然后把这个Page对象存入一个静态的ThreadLocal字段里。Page对象本身是个ArrayList的子类这也就解释了为什么执行查询后返回的List可以强转成Page。MyBatis在执行完查询后会把查询结果填充到Page的List里同时Page还持有了total等分页信息new PageInfo(list)实际就是从Page对象里读取这些元数据。有一点值得注意ThreadLocal是线程隔离的。如果项目里使用了线程池异步执行SQL子线程里是拿不到父线程的分页参数的这会导致异步分页查询失效。这个坑比较隐蔽一旦遇到需要你把分页参数显式传给子线程处理。5.2 拦截器与Executor的交互PageHelper实现SQL改写的关键在于MyBatis的插件机制。它在初始化时定义了对Executor接口的query方法的拦截Intercepts({ Signature(type Executor.class, method query, args {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}) }) public class PageInterceptor implements Interceptor { // ... }当MyBatis执行任何一个Mapper查询时都会先经过这个拦截器。拦截器内部会做几件事从ThreadLocal取出Page对象。如果Page为空直接放行不改变原SQL。用MetaObject获取当前MappedStatement的SQL信息交给PageAutoDialect去生成对应的分页方言实现。生成分页SQL后替换BoundSql中的原始SQL并创建新的MappedStatement。这段逻辑的复杂度不低但对我们使用的人来说只需要理解一个结论PageHelper拦截的是Executor层的query请求而不是Mapper层面所以它对嵌套查询、存储过程等复杂调用的处理方式需要特别小心。5.3 为什么PageHelper不建议在循环里调用因为分页参数存放在ThreadLocal且需要被后续的第一次查询消费如果你在循环里反复调用startPage和查询虽然逻辑上能跑通但会频繁创建Page对象和改写SQL性能开销不小。更重要的是循环内一旦出现判断分支某个查询可能不被执行或执行顺序变化会导致分页参数被错误消耗产生不可预期的数据。我在一个批处理任务里看到过这种写法循环遍历供应商列表对每个供应商查询它的商品分页列表结果某些供应商的数据时有时无排查到最后就是循环里startPage的调用和查询没有严格配对中间插了一个其他查询。最终改成了在循环内单独封装一个分页查询方法把startPage和查询放一起问题就消失了。6. 分页查询性能排查的实操建议6.1 开启慢SQL日志和分页SQL排查很多分页问题不是逻辑错误而是性能问题。建议在项目里开启MyBatis的SQL日志打印并配合慢查询日志定位那些SQL耗时长的问题。Spring Boot中配置MyBatis SQL打印可以这样logging: level: com.example.mapper: debug这样设置后控制台会打印Mapper包下每个SQL的预处理语句和参数。配合PageHelper使用时你可以看到原SQL和PageHelper改写后的分页SQL方便判断分页SQL是否符合预期。对于线上环境不建议打印全量SQL因为并发高的时候日志量会非常大磁盘和CPU都可能被拖垮。一般做法是开启数据库层的慢查询日志只记录超过阈值的语句。6.2 深分页的索引优化实例分页查询性能差很多时候不是分页组件的问题而是索引没设计好。拿最典型的订单列表来说如果查询条件是status 1 order by create_time desc limit 100000, 20且表里只有create_time索引那数据库大概率会走filesort排序产生很差的执行计划。比较理想的索引设计是联合索引(status, create_time)既能过滤状态又能让排序走索引。创建索引后同样一条分页SQL执行时间往往能从几秒降到几十毫秒级别。关于深分页的优化我之前在一个百万级数据量的表上做过一次真实对比查询方式耗时普通LIMIT 100000, 20约1.8s子查询INNER JOIN分页约0.12s游标方式基于上次排序值约0.06s虽然不同环境数据量、配置会有差异但结论是明确的深分页场景下LIMIT偏移量越大性能下降越明显延迟关联和游标方式都值得考虑。6.3 分页结果集过大的缓存设计如果分页列表的数据不怎么变化或者变化频率很低为了避免每次请求都打数据库可以考虑在Service层加缓存。但这里有一个关键问题分页结果要不要整体缓存我建议分页结果不要整体缓存。原因有两个一是分页数据的总数和当前页数据经常同步变化整体缓存容易造成数据不一致二是分页参数组合非常多缓存命中率往往不高反而占用大量内存。实际做法通常是把底层的基础数据缓存起来分页查询只对变化的那一部分走数据库。比如商品列表分页可以把商品基本信息放在Redis里缓存分页查询只查ID列表再根据ID批量从缓存中取详情缓存未命中的再回源数据库查这样分页和后端数据源都能得到性能优化。7. 结个尾我的分页实践心得回到最开始说的那个项目我最后把整个分页逻辑统一到了Service层入口处严格要求startPage与Mapper查询相邻封装了统一的分页校验方法并在复杂报表场景中放弃了自动count。这套规范沉淀到团队之后分页相关的Bug数量直接下降了一大截。说句掏心窝子的话分页查询看起来是所有后端功能里最简单的需求之一但真正在生产环境跑起来从SQL到索引、从ThreadLocal到拦截器每一层都有可以琢磨的细节。PageHelper帮我们省掉了大量重复代码但它并不是“一配永逸”的神器只有理解了它的设计思路和边界坑位才能在遇到问题时第一时间定位并且写出更健壮的分页代码。最后分享一个小技巧每当你准备用PageHelper.startPage时问自己一句——“下一条SQL一定是我要分页的那条吗”这么一句自我提醒至少能帮你避免后来版本维护时一半的分页失效问题。
返回列表