1. 参赛前的审视那套让人血压升高的分页代码我去年参加公司内部组织的代码重构美学大赛抽到的题目就是分页这块。本来以为翻新一下查询接口就行结果一打开老项目血压直接上来了——一套业务系统里居然有五种分页写法有的用PageHelper有的用MyBatis-Plus的Page有的干脆手写LIMIT offset, size往XML里拼接还有拿List.subList在内存里凑数的。更夸张的是同一个分页接口A页面传的是current/sizeB前端传的是pageNum/pageSize后端全部靠RequestParam硬接。这场重构的核心目标很明确用MyBatis-Plus的分页能力统一所有查询入口消灭重复代码顺手把那些隐藏的分页失效和性能问题一并挖出来。先说个反直觉的结论很多人觉得MyBatis-Plus分页就是配个拦截器然后调用page()方法完事实际上坑都在后面。分页失效、总数不对、大偏移量查询越来越慢、连表分页结果错乱这四类问题我在这轮重构里全遇到了后面我会拆开讲。如果你手里也有一套分页逻辑混乱、想统一规范的老系统或者正准备从头搭一个用MyBatis-Plus分页的新项目这篇文章应该能让你少走不少弯路。2. 重构前先盘点老代码到底烂在哪里2.1 五花八门的分页传参方式先看老代码里最常见的三种分页写法。第一种Controller直接用HttpServletRequest拿参数GetMapping(/list) public Result list(HttpServletRequest request) { int pageNum Integer.parseInt(request.getParameter(pageNum)); int pageSize Integer.parseInt(request.getParameter(pageSize)); // 后面就是手写 LIMIT 拼接 }第二种用PageUtils工具类传递public class PageUtils { private int page 1; private int rows 10; private String sidx; // 排序字段 private String sord; // asc/desc }第三种字段直接散落在DTO里每个查询DTO都自己定义pageNum、pageSize、orderBy没有任何复用。前端配合起来也得看不同接口的文档才知道该传什么。说实话分页参数本身很简单但每个接口都重新定义一次就必然导致各写各的。比如有的接口用limit这个字段名有的用length因为对接过DataTables有的用offset直接传偏移量。我这次重构做的第一件事就是统一成一套基类分页请求对象约定大家都遵守的契约。2.2 手写LIMIT的祖传SQL老项目里手写分页的代码大概长这样select idselectUserList resultTypemap SELECT * FROM sys_user WHERE del_flag 0 if testname ! null and name ! AND name LIKE CONCAT(%, #{name}, %) /if ORDER BY create_time DESC LIMIT #{offset}, #{size} /select一眼看上去没什么问题但仔细想想有几个隐患。第一每个自定义SQL都要自己在Java代码里计算offsetoffset (pageNum - 1) * pageSize漏算、算错的情况经常发生第二如果查询逻辑改动导致ORDER BY被去掉分页顺序就不可控了第三COUNT查询通常还要单独写一条SQL有时候忘了改查询条件列表和总数就对不上前端显示共20条但实际筛出来是200条。MySQL的LIMIT offset, size语法其实还隐藏着一个性能问题offset越大扫描的废弃行越多。这个我放到后面性能章节单独说。2.3 内存分页数据量小的时候的定时炸弹最坑的一种写法是用Java代码分页。我见过有人把一个可能返回几千条数据的selectList结果直接丢到内存里然后ListUser pageList list.stream() .skip((pageNum - 1) * pageSize) .limit(pageSize) .collect(Collectors.toList());数据量在几百条的时候确实看不出问题等表数据涨到几十万查询接口的响应时间直接撑爆网关超时。这种写法的本质是把数据库该干的活搬到应用内存里属于典型的能用但绝不该用。重构的时候我没有手软全部换成数据库物理分页。这里也顺便聊聊物理分页和逻辑分页的区别。MyBatis-Plus原生的分页插件是物理分页意思是SQL层面就加了LIMIT之类的方言语句数据库只返回那一页的数据逻辑分页则是查出全部数据再用代码截取。物理分页对数据库压力更小响应更快也是我们这次统一采用的方式。3. 先把MyBatis-Plus分页插件的工作机制搞清楚3.1 分页插件到底做了什么很多人用MyBatis-Plus分页只知道加一个PaginationInnerInterceptor就行却不知道它在底层是怎么工作的。理解原理之后排查分页失效会容易得多。MyBatis-Plus的分页核心是MybatisPlusInterceptor它是一个MyBatis的Interceptor拦截器专门拦截Executor的query方法。当你的Mapper方法参数里带有IPage类型时拦截器会拦截到这条SQL然后做两件事第一生成并执行COUNT查询。插件会拿原始SQL解析出一条SELECT COUNT(*)语句先把总数查出来放到Page对象的total字段里。第二改写原始SQL拼接分页方言。比如MySQL就拼LIMITOracle就拼ROWNUMPostgreSQL拼LIMIT/OFFSETSQL Server用OFFSET FETCH。拿一条简单SQL举例SELECT * FROM user WHERE age 18 ORDER BY create_time DESCMySQL方言下分页插件最终执行的SQL相当于SELECT COUNT(*) FROM user WHERE age 18; SELECT * FROM user WHERE age 18 ORDER BY create_time DESC LIMIT ?, ?;这个阶段最容易忽略的一个点是分页插件是依赖于数据库方言的。配置的DbType如果和实际数据库不一致生成的方言SQL可能直接报错或者语法不对。比如明明是Oracle库但你配了DbType.MYSQLSQL就会在Oracle上直接执行LIMIT必然报ORA-00933。3.2 COUNT查询的自动优化老项目手写count的痛在MyBatis-Plus这里被大幅缓解。分页插件内置了CountSqlParser它会自动把原来复杂的查询SQL改写成一个更高效的count语句。它做了哪些优化主要是移除ORDER BY因为count不需要排序把LEFT JOIN等可以简化的关联查询替换掉去掉HAVING里不影响行数的条件等。比如SELECT u.*, o.order_no FROM user u LEFT JOIN order o ON u.id o.user_id WHERE u.age 18 ORDER BY u.create_time DESC改写后的count可能就是SELECT COUNT(*) FROM user u WHERE u.age 18但注意不是所有SQL都能自动优化。如果连表查询里有GROUP BY、DISTINCT、多表JOIN后的去重逻辑count解析可能依旧准确但性能未必理想。这种情况下MyBatis-Plus允许你手写一个专门的count方法比如selectUserPageCount插件会自动识别并优先使用你自定义的count SQL。这个技巧在后面重构老系统时会非常管用。3.3 分页插件和拦截器之间的爱恨情仇我见过很多分页不生效的排查贴最后发现是配置顺序问题。MybatisPlusInterceptor本身是一个拦截器它内部还可以挂多个InnerInterceptor比如分页的PaginationInnerInterceptor、乐观锁的OptimisticLockerInnerInterceptor、防全表更新的BlockAttackInnerInterceptor。多个InnerInterceptor的执行顺序是有讲究的。比如分页拦截器和数据权限拦截器数据权限通常需要先改写SQL加上权限过滤条件再交给分页插件去统计总数和拼接分页方言。如果顺序反了总数可能就统计了没有过滤权限条件的数据造成越权数据泄露或者总数错误。有一个比较容易踩的坑如果你的项目同时引入了pagehelper和mybatis-plus两个分页插件会在一次查询里同时生效结果是SQL被拼接两次LIMIT直接报SQL语法错误。这类问题排查起来极其痛苦因为两个插件各自的日志看起来都正常。我的建议是一个项目只保留一个分页插件这次重构里我们把老代码中的PageHelper调用点全部替换成了MyBatis-Plus的Page体系。4. 重构落地从Page到统一分页体系的设计4.1 统一分页请求基类再也不用自己算offset我设计了一个PageQuery基类所有分页查询的入参DTO都继承它public class PageQuery implements Serializable { private static final long serialVersionUID 1L; /** 当前页码从1开始 */ private long pageNum 1; /** 每页条数 */ private long pageSize 10; /** 排序字段仅允许传白名单内的字段 */ private String orderBy; /** 是否升序默认false */ private boolean asc; public long getPageNum() { return Math.max(pageNum, 1); } public long getPageSize() { // 防止有人传超大pageSize拖垮数据库 return Math.min(Math.max(pageSize, 1), 200); } }这里有两个关键设计第一个是pageSize上限控制。有的前端在做导出全部的时候喜欢传pageSize999999这在数据量小的系统里没事数据量大了直接就是一个深分页灾难而且LIMIT 999990, 10这类写法会让数据库扫描大量无用的行。所以我在基类里就对最大值做了限制比如上限200超出就按200算。真正要做导出的话建议走异步导出任务而非分页接口。第二个是排序字段白名单。把orderBy作为字符串从前端直接拼进SQL有SQL注入风险虽然MyBatis的#{}能防参数注入但ORDER BY后的字段名和方向是拼进SQL片段的无法用预编译参数规避。所以排序字段必须先做白名单校验比如在Service层维护一个ALLOWED_ORDER_COLUMNS集合不在名单里的直接忽略或者抛出参数异常。这一点在重构里属于必须做的安全底线。4.2 统一分页返回结构前端不需要自己拼装接口统一返回PageResultpublic class PageResultT implements Serializable { private static final long serialVersionUID 1L; private ListT records; private long total; private long current; private long size; private long pages; public static T PageResultT of(IPageT page) { PageResultT result new PageResult(); result.setRecords(page.getRecords()); result.setTotal(page.getTotal()); result.setCurrent(page.getCurrent()); result.setSize(page.getSize()); result.setPages(page.getPages()); return result; } }这样Controller返回给前端的JSON结构完全一致不管底层用的是数据库分页还是后面提到的自定义分页前端看到的永远是{records, total, current, size, pages}五件套。前端团队也省事一个分页组件到处复用。4.3 Service层统一封装一行代码完成分页在Service层我封装了一个通用方法把LambdaQueryWrapper和分页参数绑定到一起public T PageResultT pageQuery(PageQuery query, LambdaQueryWrapperT wrapper) { PageT page new Page(query.getPageNum(), query.getPageSize()); // 安全性排序字段白名单校验 if (StringUtils.hasText(query.getOrderBy()) ALLOWED_ORDER_COLUMNS.contains(query.getOrderBy())) { wrapper.orderBy( true, query.isAsc(), columnNameToLambda(query.getOrderBy()) ); } IPageT result baseMapper.selectPage(page, wrapper); return PageResult.of(result); }这样一来业务代码里查用户列表就是public PageResultUserVO listUsers(UserPageQuery query) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); wrapper.like(StringUtils.hasText(query.getName()), User::getName, query.getName()) .eq(query.getStatus() ! null, User::getStatus, query.getStatus()); PageResultUser page pageQuery(query, wrapper); return convertToVO(page); }4.4 自定义分页SQL的正确姿势IPage必须放第一位不是所有查询都能用LambdaQueryWrapper表达。连表查询、子查询、复杂聚合这类SQL还是得写在Mapper XML里。MyBatis-Plus对自定义分页SQL有一条硬性规定IPage参数必须放在方法参数的第一位否则分页插件不识别分页不会生效。// 正确写法IPage在第一参数 IPageUserVO selectUserDetailPage(IPageUserVO page, Param(name) String name); // 错误写法IPage放第二个分页插件无法生效 IPageUserVO selectUserDetailPage(Param(name) String name, IPageUserVO page);这条规则踩到的人特别多因为编译不会报错但运行时返回的是全部数据。XML里写起来也简单你只需要写原始SQL不用手动拼LIMITselect idselectUserDetailPage resultTypecom.example.vo.UserVO SELECT u.id, u.name, o.order_no FROM user u LEFT JOIN order o ON u.id o.user_id WHERE u.del_flag 0 if testname ! null and name ! AND u.name LIKE CONCAT(%, #{name}, %) /if ORDER BY u.create_time DESC /select分页插件会自己完成count和LIMIT拼接前提是Mapper接口方法的第一个参数必须是IPage。这里还有一个经验之谈自定义分页SQL如果连表字段有重复列名比如两边都有id插件解析count时偶尔会生成带歧义列名的SQL。我的建议是给连表查询的每个列都加上表别名既方便阅读也减少分页插件解析出错的可能。4.5 实体、DTO、VO三层分离老代码里常见一个User实体走天下数据库映射用它接口返回它前端展示它也用它甚至把密码hash都返回给了前端。这次重构我强制做了分层Entity对应数据库表结构继承Model或者直接用注解映射DTO承载查询条件继承PageQueryVO返回给前端的数据视图只包含前端需要的字段。配合MyBatis-Plus的BeanCopier或者MapStruct做对象转换。比如PageResultUser转换为PageResultUserVO我写了一个通用转换工具public static T, V PageResultV convert(PageResultT page, FunctionT, V converter) { PageResultV result new PageResult(); result.setTotal(page.getTotal()); result.setCurrent(page.getCurrent()); result.setSize(page.getSize()); result.setPages(page.getPages()); result.setRecords(page.getRecords().stream().map(converter).collect(Collectors.toList())); return result; }4.6 前端配合页码从0开始还是从1开始重构接口的时候还有个容易忽略的点前端的Element UI的el-pagination默认current-page从1开始但某些第三方组件比如老版Bootstrap Table是0基页码。前后端如果各自理解不同出来的列表永远是错位的——第一页数据正常第二页就从第二条开始了。我在PageQuery里直接兜底pageNum小于1就按1算。同时要求前端在请求头或JSDoc里明确标注起始页凡是0基的前端统一在拦截器里1。这个约定必须在重构文档里写清楚否则联调阶段会反复扯皮。5. 性能优化大表分页卡顿与COUNT的隐形开销5.1 深分页为什么会越来越慢分页重构不只是把代码写得好看还得解决一个实际问题深分页的性能退化。假设有一张用户表有100万数据要看最后一页写法是SELECT * FROM user ORDER BY create_time DESC LIMIT 999980, 20;MySQL执行这条SQL时需要先扫描前999980行然后丢弃掉只返回最后20行。扫描的行数远超实际返回的行数所以offset越深查询越慢。这个现象在Oracle里如果你用ROWNUM嵌套查询也一样存在。MyBatis-Plus分页插件本身不解决深分页问题它只是忠实地把LIMIT拼上去。所以真正需要重构业务的时候要结合查询场景做进一步优化。我在这轮重构里对几张大表采用的一个手段是延迟关联延迟joinSELECT u.* FROM user u INNER JOIN ( SELECT id FROM user ORDER BY create_time DESC LIMIT 999980, 20 ) t ON u.id t.id子查询只取主键ID在索引上做排序和offset回表读取的数量只有20条性能会好很多。不过这种做法在分页插件里没法自动实现所以我在具体业务里针对高频大表做了定制SQL。还有一种是游标分页keyset pagination适用于那种只往前翻的列表页比如时间线、消息流。不用LIMIT而是记录上一页最后一条数据的排序字段值SELECT * FROM user WHERE create_time #{lastCreateTime} ORDER BY create_time DESC LIMIT 20;这种分页方式在offset很大的时候性能稳定而且不会因为新的数据插入导致页码错乱。如果你们的业务允许强烈建议对加载更多类的交互使用游标分页。5.2 COUNT查询也很贵分页查询一共两次SQL一次count一次list。很多人只盯着list的性能其实count在复杂查询场景下同样贵。尤其连表查询SELECT COUNT(*)和SELECT *的成本差别不大都需要扫完关联后的结果集。MyBatis-Plus自动生成的count SQL通常会简化掉ORDER BY但它没法智能判断哪些JOIN可以去掉。如果业务SQL里有多个LEFT JOINcount会很慢。这时候我一般手动写一个极简的count查询让分页插件走自定义countlong selectUserPageCount(Param(name) String name); // 在Mapper.xml里单独写一个轻量级count SQL只要方法名满足实体名PageCount的命名规律MyBatis-Plus会自动优先使用自定义count不再解析生成。比如分页查询方法叫selectUserDetailPage对应的count方法就叫selectUserDetailPageCount。5.3 非分页缓冲池占用过高从分页重构引出的另一个问题重构之前我顺手排查了一个线上问题数据库Oracle的非分页缓冲池占用很高内存一直在涨。当时查下来罪魁祸首之一就是系统里大量未显式关闭的游标以及频繁执行的、排序昂贵的分页SQL。Oracle的UGA用户全局区里非分页缓冲池主要缓存排序、哈希连接等操作的临时数据。当并发分页请求太多每条都带大排序操作非分页缓冲池的占用就会被顶上去。排查思路大概是先查V$SQL里排序次数sorts高的SQL再用V$SESSTAT按会话统计非分页缓冲池的消耗最后定位到某些深分页查询没有走索引排序导致排序集很大。这类问题不能光靠加内存解决核心还是减少全量排序和减少不必要的排序操作。分页查询里ORDER BY字段一定要建索引如果没有合适的索引宁可不排序返回默认顺序也别让数据库在内存或者临时表里做大规模排序。这个案例也提醒我分页重构不只是换个API接口壳子它的下游还关联着数据库游标管理、排序内存和会话并发。我自己在重构清单里专门加了一条分页SQL的排序字段必须有索引支撑否则不通过评审。6. 让分页失效的那些坑一次完整的排查链路6.1 现象返回了全部数据但总数也是全量重构进行到一半有个接口分页失效了无论传pageNum2还是pageNum100返回的都是同一批数据total还是全表数据量。这种问题如果不按链路排查很容易在代码里反复横跳浪费时间。我整理了一套固定的排查路径这里完整分享。第一步确认拦截器是否配置。很多非SpringBoot项目或自研框架MyBatis-Plus的拦截器没有被Spring容器管理MybatisPlusInterceptor压根没有注册到SqlSessionFactory。检查方式是在mybatis-plus配置里打断点看PaginationInnerInterceptor的beforeQuery方法有没有被调用。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }第二步确认IPage是否在Mapper方法参数的第一位。这是最常见的原因。我把IPage从第一个参数挪到第二个之后分页插件会静默失效——注意它不会报错而是直接返回被查询出来的所有数据非常隐蔽。第三步确认SQL本身是否含有聚合函数。GROUP BY、DISTINCT这类查询如果插件自动生成的count SQL无法正确处理会导致total异常或分页失效。我遇到的一次select后跟着HAVING的查询count总是多算后来就是自定义count解决的。第四步排查是否存在嵌套结果映射。这是最阴间的一种情况。当你的查询涉及一对多比如一个订单挂多个商品Mapper里配置了collection嵌套映射。分页插件执行的是物理分页LIMIT只作用于订单这层表但嵌套查询出来的子集合会被填充到每个父对象里结果就是records里只有几条父记录每条父记录下面挂了几十个子对象。这种分页看起来像失效实际上是一对多导致的结果集折叠。解决方案有几种改成主子表分开查询再手动组装或者用单条SQL使用GROUP_CONCAT把子集合聚合成一个字段或者干脆把子查询改成单独的分页子查询。6.2 现象总数对但当前页数据其实不对另一种失效形态是前端的上一页/下一页错乱。这种多半是排序不稳定造成的。如果ORDER BY的字段有大量重复值比如按status排序而status只有几种取值数据库每次返回的顺序不固定第二页和第一页可能就有重复数据或漏数据。解决方法是在排序字段后面补一个唯一键作为次级排序ORDER BY status ASC, id ASCMyBatis-Plus里可以通过wrapper.orderByAsc(status, id)实现。这个习惯一旦养成分页结果会稳定很多。6.3 MyBatis-Plus 3.4以后的拦截器顺序问题MyBatis-Plus 3.4版本之后分页推荐统一使用MybatisPlusInterceptor老版本的PaginationInterceptor虽然还能用但已不推荐。如果项目里同时存在两个分页相关拦截器还可能互相覆盖出现在某些查询里分页正常、在另一些查询里返回全表的诡异情况。重构时我的做法是全局搜PaginationInterceptor确认没有旧拦截器残留只保留一套新的。6.4 多数据源场景下的分页失效多数据源比如读写分离、分库分表场景里拦截器是对每个SqlSessionFactory分别配置的。也就是说如果只给主数据源配置了MybatisPlusInterceptor从数据源查询的分页就不会生效。我在重构里遇到过主库分页正常、从库分页直接返回全量数据的问题排查了半天最后发现是第二个数据源的SqlSessionFactory没有注册这个拦截器。如果你在用dynamic-datasource这类框架记得对每个数据源检查一遍拦截器注册情况。7. 代码重构美学不只看功能还要看可读性与可维护性7.1 把分页参数从散装DTO收拢到统一基类重构之后新增一个分页查询接口的模板基本固定了定义查询DTO继承PageQuery加上自己的查询条件字段Service里写一个LambdaQueryWrapper组装查询条件调用通用的pageQuery方法Mapper XML里只有特殊情况才写自定义分页SQL返回PageResultT给前端。这套模板熟悉之后一个带分页、筛选、排序的列表接口开发时间能缩短到原来的一半以下。我在重构排期里给新需求预估的时间也从一天一个列表变成了半天一个列表。7.2 评审视角重构后的代码为什么好看参加大赛的时候评委关注的不只是能不能跑还包括几个层面的代码美学可读性一眼能看到分页处理在哪里、查询条件在哪里、排序规则在哪里。老代码那种翻到第二屏才看到LIMIT的写法定然不受欢迎可扩展性新加一个查询条件只需要在DTO和Wrapper里各加一行不用改动Controller层签名和前端接口约定一致性所有列表接口的请求和响应结构完全一致前端封装分页组件才能做到真正的复用健壮性pageSize上限控制、排序白名单、默认排序兜底这些防御性设计不需要客服和技术支持去背锅。7.3 保留一个重构不改变行为的回归基线重构最怕的是功能没变性能反而变差了。我的做法是先拿线上请求日志做一批典型接口的基线数据平均响应时间、95分位响应时间、慢SQL数量、每接口的SQL执行次数。重构完再跑一遍同样的压测和日志分析对比前后的差异。这次分页重构之后有几个接口的性能反而提升了因为之前的内存分页改成了物理分页网络传输的数据量大幅下降手写count改成插件自动count并移除了ORDER BY之后聚合查询的DB CPU消耗明显降低。当然也有变慢的案例就是之前我说的深分页后来用延迟关联和游标分页做了专项处理才压回去。8. 重构后的收益不只是代码变好看了最后分享几个这次分页重构带来的真实收益都是我在复盘文档里写到过的数据分页相关代码量缩减了差不多60%全项目统一使用PageQuery和PageResult新同学上手时只需要看这两个类的注释就能明白分页约定线上分页相关的故障工单数量明显下降特别是前端显示页数和后端返回不一致、导出全部时接口超时这两类问题基本绝迹慢SQL数量下降大偏移量的深分页查询被专项治理后P95响应时间降了一截因为排序字段做了白名单约束顺手堵住了一个可能被利用的SQL注入隐患安全评审那边也给了好评。我在实际项目中最大的体会是分页重构表面上是换代码风格实际上是换一种思考方式——从每个接口自己搞定分页变成全团队共享一套分页契约从列出数据就行变成meter一点性能和安全。如果你也正在重构类似的分页逻辑建议先花一天时间盘点所有分页入口和隐藏问题再动手改代码不要一上来就替换API。毕竟分页这事看着简单但它横跨Controller、Service、Mapper、数据库方言和前端组件任何一层掉链子用户看到的都是转圈圈和错乱的数据。