
接手一个内部订单后台的分页接口时第一眼看到的情况并不复杂MyBatis-Plus 分页插件配上Page对象常规写法数据库表也就几十万行数据。但压测一跑第 1 页 30 毫秒第 100 页直接飙到 1.2 秒再往后每次翻页都在涨。后来检查慢日志发现罪魁祸首不是 SQL 本身而是分页插件默认自动拼接的那条COUNT查询和巨大OFFSET的深翻页。这篇文章是系列记录的第 28 篇就把我当时针对 MyBatis-Plus 分页优化做的整套方案完整梳理一遍从分页插件的工作机制、性能瓶颈到深分页的几种标准解法最后是生产环境里踩过的一堆坑全部一次性讲透希望能帮你少走弯路。1. 分页插件的工作机制与性能瓶颈在哪1.1 分页插件到底帮你做了什么MyBatis-Plus 的分页能力来自PaginationInnerInterceptor很多老项目还在用已被标记过期的PaginationInterceptor。这个拦截器在 MyBatis 执行 SQL 前先对Executor的query方法做拦截然后根据数据库方言拼接分页语句。举个例子你写的 Mapper 方法是IPageOrderDO selectOrderPage(PageOrderDO page, Param(status) Integer status);对应 XML 里只有一句select idselectOrderPage resultTypeorderDO SELECT * FROM t_order WHERE status #{status} ORDER BY create_time DESC /select传入Page后拦截器会做两件事一是把原 SQL 包装为SELECT COUNT(*) AS total FROM t_order WHERE status ?执行一次总数统计二是把原 SQL 改写为SELECT ... ORDER BY create_time DESC LIMIT ?,?查询当前页数据。两条 SQL 分别填充Page的total和records字段。这个机制本身没有太大问题单表小数据量场景下非常省心。问题在于很多开发者在复杂查询场景下仍然无脑使用默认行为性能瓶颈就一点点累积起来了。1.2 默认 COUNT 查询的隐形成本PaginationInnerInterceptor的执行机制决定了每次分页查询都会执行一次总数统计即使前端只是想要第一页 20 条数据。可能你会说 COUNT 不是很快吗确实单表 COUNT 在千万级以下通常还能接受但有几个前置条件第一COUNT要扫描满足WHERE条件的全部记录。MySQL 基于 InnoDB 的 MVCC 机制无法像 MyISAM 那样直接从元数据中读取总行数必须走索引扫描或全表扫描。如果WHERE条件没有命中索引或者命中到的索引覆盖范围很大这条 COUNT 的耗时往往比主查询还高。第二MyBatis-Plus 默认的optimizeCountSql只处理了ORDER BY它会自动把ORDER BY从 COUNT 语句中移除但对JOIN、GROUP BY、子查询等复杂结构并不会做深度优化。你手写的分页 SQL 越复杂这条自动 COUNT 就越容易出现重复扫描、排序丢弃不彻底等问题。第三如果你用了DISTINCT或者关联表查询自动生成的COUNT(*)统计的是关联后的行数而不是业务上期望的主表行数。总数偏大前端就会多出空白页数据对不上用户的第一反应是“系统有 bug”。所以在做分页优化时第一条要养成的习惯就是明确哪些场景必须用总数哪些场景可以直接SELECT * LIMIT n1来判断是否有下一页从而彻底免掉 COUNT 查询。MyBatis-Plus 的Page支持传入searchCount false这个参数在高频分页接口里值得认真评估它能省掉很多不必要的数据库压力。1.3 深分页的“延迟增长”效应分页慢的另一个核心原因是深分页。MySQL 中LIMIT 1000000, 20的执行逻辑并不是直接跳到第 1000000 行开始读而是先按照排序条件扫描前 1000020 行再丢弃前 1000000 行。也就是说OFFSET 越大扫描丢弃的行就越多耗时就越高而且这个增长不是线性的是随数据量和排序成本的叠加近似线性增长你翻到第 100 页和翻到第 1000 页的耗时完全不是一个量级。我用实际测试数据说明下一张 500 万行的订单表ORDER BY create_time DESC LIMIT 20, 20耗时约 18 毫秒换成LIMIT 100000, 20耗时约 420 毫秒再换成LIMIT 4000000, 20直接飙到 6 秒以上。这个案例里主键是自增 IDcreate_time有普通索引但排序后需要回表读取完整行数据回表成本在深翻页场景被无限放大。更麻烦的是很多前端分页组件尤其是一些老的后台管理系统默认就是带页数跳转的用户一旦点“跳到第 500 页”后端就会被迫执行一次极深的 OFFSET 查询。我见过一些项目重构时DBA 直接把深分页 SQL 定义为慢 SQLDBA 能做的只是告诉你这条 SQL 有问题但代码怎么改仍然需要后端自己想办法。2. 几种主流分页优化方案的适用场景分析2.1 简单有效的 COUNT 优化方案先说说最容易落地的 COUNT 优化。分页插件虽然会自动生成 COUNT但有两种方式可以让你接管这条查询。第一种是手动指定 COUNT 语句。在 Mapper 方法中单独写一个countXxx()方法只查必要的过滤条件long count orderMapper.countByStatus(status); PageOrderDO page new Page(current, size, count);注意这里的total不再由插件自动统计而是由你事先查出来传进Page对象。如果你仍然想在同一个方法里完成分页可以把 Mapper 方法的第一个参数设为Page同时保证对应 XML 的 SQL 以COUNT开头MyBatis-Plus 会识别到这是手动 COUNT 而跳过自动统计不过这种方式维护性偏差我在实际项目里更常用显式分离的方式。第二种是评估清楚业务是否真的需要 total。如果前端做的是“加载更多”型交互知乎信息流、电商商品列表滚动加载每次只需要LIMIT size1多取一条判断是否还有下一页即可这一条在 MyBatis-Plus 里可以这样处理int pageSize 20; PageOrderDO page new Page(1, pageSize 1); page.setSearchCount(false); orderMapper.selectOrderPage(page, status); boolean hasNext page.getRecords().size() pageSize;searchCount(false)就是把那条 COUNT 完全干掉读取 21 条判断是否有更多。对于高 QPS 的查询接口这个改动通常能让数据库压力瞬间下降 40% 以上因为很多分页慢 SQL 的耗时主要在 COUNT 而不是主查询。2.2 覆盖索引与延迟关联深分页第一选择如果一个接口确实需要跳页同时总数据量在百万级以上延迟关联Deferred Join是性价比最高的方案。核心原理是先在一棵或几张较小的索引树上完成排序和定位拿到本页需要的主键集合再拿这些主键回表取完整行避免在排序阶段就回表读取大量无关数据。具体在 MyBatis-Plus 项目里怎么落地先写一个查询主键 ID 的 SQLselect idselectIdPage resultTypejava.lang.Long SELECT id FROM t_order WHERE status #{status} ORDER BY create_time DESC LIMIT #{offset}, #{size} /select注意这里只查id和排序字段create_time如果有索引这个查询可以直接走索引排序避免回表。得到主键集合后再用主键去查详细行select idselectBatchByIds resultTypeorderDO SELECT * FROM t_order WHERE id IN foreach collectionids itemid open( separator, close) #{id} /foreach ORDER BY FIELD(id, foreach collectionids itemid separator,#{id}/foreach) /selectORDER BY FIELD是为了保证结果顺序和主键顺序一致否则前端翻页时数据顺序会错乱。两条 SQL 合起来总耗时通常能从 1 秒降回几十毫秒。这里有一个关键细节第 1 条 SQL 里的 OFFSET 仍然是存在的但它只作用于索引扫描MySQL 在二级索引上做排序和丢弃操作比聚簇索引回表后再丢弃要快得多。如果数据量极大连索引扫描的 OFFSET 也不可接受了就需要换游标分页。2.3 Keyset 游标分页彻底摆脱 OFFSETKeyset 分页也叫 Seek Method、游标分页的思路是不使用页码而是每次根据上一次返回的最后一条记录用条件定位到“下一页”的起点。比如按id排序SELECT * FROM t_order WHERE status #{status} AND id #{lastId} ORDER BY id ASC LIMIT 20这条 SQL 在 InnoDB 中会直接通过id主键定位到lastId之后的第一个位置然后顺序读取 20 条数据库完全不需要扫描和丢弃 100 万条记录。时间复杂度与当前翻到第几页无关即便数据量增长到千万级只要id走主键索引耗时也基本恒定在三五十毫秒以内。MyBatis-Plus 里实现非常直接不需要分页插件参与直接把lastId当普通参数传进去ListOrderDO nextPage orderMapper.selectNextPage(status, lastId, 20);对应的 XML 就是上面那种WHERE id #{lastId} ORDER BY id ASC LIMIT #{size}。Keyset 分页有几个必须注意的约束排序字段必须有唯一性兜底。单纯按create_time排序不安全因为同一秒内可能有多条记录翻页时会出现重复或遗漏。标准做法是ORDER BY create_time DESC, id DESC并在 WHERE 条件里同时带上这两个字段的游标值。只能“下一页”无法直接跳页。如果你的产品交互强制要求页码跳转Keyset 需要配一个“跳到第 N 页”的特殊处理先查出该页起始游标的位置这本身又回到深分页了。所以这个方案最适合“加载更多”和“上一页/下一页”交互。需要处理字段索引。组合游标比如(create_time, id)最好建一个联合索引。没有索引的情况下靠文件排序也能工作但性能保证不了。我在实际项目中落地过这种方案某订单查询列表原来翻到第 500 页要 4.7 秒改成 Keyset 后稳定在 45 毫秒左右而且数据库的慢查询日志里几乎再也看不到这条分页 SQL。代价是前端交互从“任意页码”改成了“加载更多”但这个取舍对绝大多数 To B 后台完全值得。2.4 流式查询应对数据导出的正确姿势很多人一遇到“导出全量数据”的需求第一反应是写个分页循环每次查 1 万条、循环 100 次然后把数据拼接成文件。这个思路在数据量不大时勉强可用但一旦单表数据上千万循环分页会有两个问题一是每次查询都有一次完整的 SQL 执行和结果集传输1000 次查询的网络开销和数据库负载都很高二是深分页的 OFFSET 随着循环次数越来越大最后几页的耗时成倍增长整个导出任务可能跑上几个小时。MyBatis 提供了Cursor接口配合 MyBatis-Plus 的 Mapper 可以做到流式读取CursorOrderDO cursor orderMapper.scanAllActiveOrders(status); try (cursor) { for (OrderDO order : cursor) { // 逐条写入 CSV/Excel } }关键是使用Cursor时必须保持数据库连接不关闭、事务不提交否则游标会失效。所以这段代码要放在一个Transactional方法里执行同时游标读取期间不要让数据库连接池把连接收回。用Cursor做导出数据库只是顺序扫描一次内存中同时只保留当前处理的一条或一批数据几十万、上百万条数据导出内存占用仍然是稳定的。这个方案和分页优化属于同一个话题因为很多人分页慢其实就是被“伪分页导出”拖垮的。把导出和在线分页从设计上拆开在线接口的压力会大幅下降。3. MyBatis-Plus 分页优化的核心实操步骤3.1 先做分页查询分类与参数梳理优化前先别急着改代码把现有接口按场景分个类。我在项目里通常按 3 个维度梳理按是否需要跳页分支持页码跳转的列表还是只支持“加载更多”前者要考虑深分页优化后者直接上 Keyset 或LIMIT size1。按是否需要总数分表格上要显示“共 X 条”还是只需要“有更多”判断有硬性总数需求的保留 COUNT但要评估 COUNT 成本。按单次查询数据量分单表查询还是多表关联多表关联是否有集合字段一对多如果是一对多还用了分页通常说明设计有问题后面详说。我通常用一张表把这些信息记下来然后逐条确定优化方案。下面是一个简化的示例场景是否跳页是否要总数主要瓶颈推荐方案订单列表后台是是深分页覆盖索引 延迟关联消息列表APP否否COUNT游标分页报表导出否否深分页循环Cursor 流式管理端日志查询是是COUNT 大字段索引优化 精简字段分类做完后你才清楚哪些接口真正需要动刀哪些只需要按个按钮改个参数就能解决。3.2 配置层优化分页插件的关键参数调整即使不动代码逻辑单是配置层面的优化也能见效。第一个参数是maxLimit。PaginationInnerInterceptor提供了setMaxLimit方法用来限制单页最大查询条数。我见过有人把分页 size 传成 10000直接把数据库搞挂的。加了maxLimit(500)之后超过 500 会被拦截避免误操作打爆数据库。PaginationInnerInterceptor paginationInterceptor new PaginationInnerInterceptor(DbType.MYSQL); paginationInterceptor.setMaxLimit(500L); mybatisPlusInterceptor.addInnerInterceptor(paginationInterceptor);注意设置maxLimit后如果前端某次合法请求的单页条数确实超过 500比如报表预生成会被误拦截。所以这个参数要做成配置中心的动态配置而不是写死进代码里。第二个是overflow参数。它的作用是当请求页码超过总页数时是否自动回退到最后一条可用的页数据。默认是 false也就是超过最后页时返回空数据。某些场景下用户删了多页数据后点击“下一页”会突然看到空白页体验糟糕。开启overflow后插件会自动把 current 调整为最后一页不过这个功能在内部分页组件和前端页码不一致时反而会掩盖 bug建议审计清楚前端组件的页码生成逻辑后再决定。还有一点常被忽略多数据源环境下每个数据源都要拿到对应的DbType。比如 MySQL 配了DbType.MYSQL但另一个 PostgreSQL 连接也用了同一个拦截器LIMIT 语法就会拼错。用MybatisPlusInterceptor按数据源分别配置拦截器实例而不是全局套一个不区分方言的拦截器。3.3 代码层优化单表查询深分页的延迟关联落地以一张订单表为例完整演示延迟关联如何在 MyBatis-Plus 环境里落地。假设表结构大致是CREATE TABLE t_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL, amount DECIMAL(10,2) NOT NULL, create_time DATETIME NOT NULL, KEY idx_status_time (status, create_time), KEY idx_user_time (user_id, create_time) ) ENGINEInnoDB;原来的查询SELECT * FROM t_order WHERE user_id #{userId} ORDER BY create_time DESC LIMIT #{offset}, #{size};即使idx_user_time存在ORDER BY 的时候也需要根据 create_time 排序后回表取完整行OFFSET 越大回表的行就越多。优化第一步写一个只取主键和排序字段的查询select idselectOrderIdsByPage resultTypejava.lang.Long SELECT id FROM t_order WHERE user_id #{userId} ORDER BY create_time DESC LIMIT #{offset}, #{size} /select由于id在二级索引idx_user_time里已经存在InnoDB 二级索引的叶子节点会带上主键这个查询只需要读二级索引不需要回表。SQL 的执行计划中Extra字段可以看到Using index这就说明覆盖索引生效了。第二步拿这批 id 再去查完整订单行同时用ORDER BY FIELD保持顺序select idselectByIdsOrdered resultTypeorderDO SELECT * FROM t_order WHERE id IN foreach collectionidList itemid open( separator, close) #{id} /foreach ORDER BY FIELD(id, foreach collectionidList itemid separator, #{id} /foreach ) /select如果你的 SQL 里还有状态条件、时间范围条件第 1 步的 WHERE 里都要带上确保分页后数据稳定性。注意这里有个细节如果第 1 步是用SELECT SQL_CALC_FOUND_ROWS id FROM ...还要统计总数那么第 1 步会失去覆盖索引的优化效果SQL_CALC_FOUND_ROWS通常要扫描更多行所以建议第 1 步分页和总数统计的 SQL 拆开分别优化。两段式查询在代码里也不是非要用两个 Mapper 方法。你可以写一个 Service 方法先调用selectOrderIdsByPage再调用selectByIdsOrdered中间判断idList为空时直接返回空列表避免第二次 IN 查询因空集合导致 SQL 语法报错。3.4 JOIN 场景的分页优化一对多问题的正确解法很多接口慢在 JOIN分页和 JOIN 结合后问题更隐蔽。最常见的错误场景是订单表和订单明细表一对多关联后再分页SELECT o.*, od.item_name FROM t_order o LEFT JOIN t_order_detail od ON o.id od.order_id WHERE o.user_id #{userId} ORDER BY o.create_time DESC LIMIT #{offset}, #{size}这时分页插件生成的 COUNT 会是SELECT COUNT(*) FROM t_order o LEFT JOIN t_order_detail od ON o.id od.order_id WHERE o.user_id #{userId}一个订单有 4 条明细这个 COUNT 统计出来的 total 就是订单数乘以明细数总数虚高分页数据也会因为明细条数不固定而出现“同一订单的明细被拆到不同页”的问题。我的建议是先分页主表 ID再关联明细表。也就是分页查询只针对t_orderSELECT id FROM t_order WHERE user_id #{userId} ORDER BY create_time DESC LIMIT #{offset}, #{size}拿到这一页的订单 ID 集合后再去查这些订单对应的明细在应用层完成主表和子表的拼装。这样做以后PAGE 和 COUNT 都只作用于主表总数准确、翻页稳定、性能更好。如果你一定要在 SQL 里用 JOIN 形式分页至少写成子查询先分页主表再关联SELECT o.*, od.item_name FROM ( SELECT id FROM t_order WHERE user_id #{userId} ORDER BY create_time DESC LIMIT #{offset}, #{size} ) t JOIN t_order o ON t.id o.id LEFT JOIN t_order_detail od ON o.id od.order_id这样外层 JOIN 只面对一页主键数据而不是全量 JOIN 后再 LIMIT性能差距非常大。MyBatis-Plus 在 XML 里写这段子查询是可以和Page参数直接配合的拦截器会自动在外面加 COUNT 包装不过我通常手动关掉自动 COUNTsearchCount(false)再用独立 COUNT 语句统计主表行数。3.5 游标分页在 MyBatis-Plus 中的落地代码游标分页的实现其实很简单但有一个地方要特别注意排序字段的稳定性。如果你的列表排序是ORDER BY create_time DESC相同时间戳的记录有很多条时只靠WHERE create_time ?游标会漏数据。正确姿势是组合条件SELECT * FROM t_order WHERE user_id #{userId} AND ( create_time #{lastCreateTime} OR (create_time #{lastCreateTime} AND id #{lastId}) ) ORDER BY create_time DESC, id DESC LIMIT #{size}这就是典型的 keyset 多字段游标条件。用 Java 给 Service 层返回一个游标对象通常是上一次结果的最后一个元素的排序字段 主键调用方下次请求时带上即可。很多团队会封装一个CursorPageResultpublic class CursorPageResultT { private ListT records; private String nextCursor; private boolean hasMore; }nextCursor生成时用 Base64 或 JSON 序列化游标字段避免前端篡改。这个方案在列表类接口的体验很好而且数据库侧完全不受深分页影响。从 MyBatis-Plus 的角度这个方法甚至不需要Page对象就是普通查询所以也不存在自动 COUNT 的问题。可以说是“无分页插件的分页”也是我当时最推荐给新人的入门优化方案。4. 常见问题排查与避坑指南4.1 COUNT 总数与列表数据对不上这个问题在 MyBatis-Plus 分页里极其常见尤其多表 JOIN 后。上面说过了一对多带来的总数膨胀。排查方法是拿到自动生成的 COUNT SQL手动在数据库客户端执行一次把结果和 Page 里返回的 total 对比再拿主表单独 COUNT 一次对比两者差异。绝大多数情况是 JOIN 造成的。解决方案优先级如下如果是 Page 查询结果总数不对首选“先分页主表 ID 再查询明细”的重构方案。如果是 COUNT 速度慢但数据量不大可以直接指定COUNT为手动 SQL。如果只是分页插件自动生成的 COUNT 不够优化可以打开optimizeCountSql试一下但它对JOIN、GROUP BY之外的优化的确很有限别指望它解决所有问题。4.2 分页没有生效或结果集异常分页不生效的常见原因大致有两种。第一种是方法签名问题。MyBatis-Plus 的分页拦截器要求Page参数必须是 Mapper 方法的第一个参数而且要放在能被插件识别的位置。如果方法有多个参数且没有用Param标注插件内部可能找不到IPage类型的参数就不会做分页改写。规范做法是IPageOrderDO selectOrderPage(PageOrderDO page, Param(userId) Long userId);第二、多数据源场景。如果项目中配置了多个数据源而分页拦截器只加在了其中一个 SqlSessionFactory 上另一个数据源的 Mapper 调用就不会分页。检查时直接去看目标数据源对应的 SqlSessionFactory 是否注入了MybatisPlusInterceptor。第三种是原生 SQL 里写了LIMIT同时又在 XML 或注解里传入了 Page两条 LIMIT 会互相干扰最终结果是奇怪的限制。你应该让分页插件统一接管 LIMIT不要在 SQL 里手动写 LIMIT除非你确定屏蔽了该查询的分页拦截。4.3 深分页优化后反而变慢了有朋友试过延迟关联发现某些场景下比原来的分页还慢。为什么延迟关联的核心收益是减少回表次数。如果原来的分页查询本身就在覆盖索引里比如查询字段极少SELECT 里只有 id 和 create_time那延迟关联毫无优势还会多执行一次查询。上面案例里原查询是SELECT *而优化后第 1 步只查 ID第 2 步只查 20 条完整行。两步加起来的逻辑读和网络传输都低于原来的全量扫描。反过来如果原查询其实只需要 20 条记录的 3 个字段且这 3 个字段都在索引里那么优化后的两步查询反而多了一次网络往返。所以做优化前先看清执行计划Extra里有没有Using filesort或Using temporary这两项才是深分页的元凶。没有文件排序时OFFSET 深分页的代价主要由“扫描并丢弃的行数”主导延迟关联依然有效但如果本身排序字段就在索引里回表成本又没有高到离谱那么合理设置单页条数可能就够了。4.4 深分页替代方案导致排序不稳定Keyset 分页要求排序字段唯一且稳定。如果你的排序字段是amount这种存在相同值的列上面那套(field, id)组合游标是唯一正确写法。还有一点容易被忽略如果排序字段会更新比如按“最后登录时间”排序用户翻页时上一条记录的最后登录时间变了下一页的游标条件就可能出现错乱。这种情况下最好固定用id或create_time这种不变量做排序兜底业务上的排序需求换成二级排序字段。如果业务真的要求按某种实时变化的排序字段做分页比如热榜那么技术上很难做绝对的稳定分页只能接受“翻页时可能有轻微重复/遗漏”或者退回到页码分页。这种产品需求和数据完整性天生有冲突需要跟产品经理对齐。4.5 分页插件与逻辑删除、租户插件的顺序问题MyBatis-Plus 的MybatisPlusInterceptor可以添加多个拦截器比如租户插件、逻辑删除插件、分页插件。它们的执行顺序是有讲究的。通常建议顺序是租户插件 → 分页插件 → 其他。如果顺序不对租户插件在分页改写之后执行LIMIT 里的参数可能没拼对或者分页的 COUNT 里漏加了租户条件导致跨租户数据泄露。排查方式比较直接打开 SQL 日志观察最终执行的 SQL 里有没有带上租户 ID 条件同时确认 LIMIT 参数是否被正确替换。如果发现 SQL 里 LIMIT ? 没有替换成具体数字多半是拦截器没生效如果租户条件没拼进去考虑调整拦截器顺序。4.6 数据的“分页缓存”问题最后分享一个生产环境里的实际坑某个热门接口明明做了延迟关联优化压测时 P99 还是到了 800ms。后来定位发现是前端每次翻页时都重新请求全部数据接口里还包含了一次全表 COUNT 加一次主数据查询外加Page对象里塞进了一个BigDecimal金额字段的序列化。COUNT 耗时 300 毫秒数据查询 200 毫秒结果集序列化又花了 200 毫秒。这个案例提醒我分页优化不只是 SQL 侧的事数据返回结构和序列化成本同样重要。优化方向上可以把大字段从列表查询中拆出来列表接口只返回摘要字段详情走单独接口Page.getRecords()中的对象也别直接返回 JPA/MyBatis 实体否则序列化会把Page对象里orders、optimizeCountSql这些内部字段也参与处理耗时和传输体量都会增加。规范做法是返回 VO/DTO。5. 优化效果的验证与长期保障优化做完之后需要一整套验证手段保证效果能长期保持。首先是性能基线。我建议每个分页接口都建立压测基线至少覆盖第 1 页、第 50 页、第 200 页三个档位。记录优化前和优化后的 QPS、P99、平均耗时和数据库慢查询次数。表格化记录后后续如果数据量增长导致性能回退能快速判断是哪个环节退化。其次是慢 SQL 监控。MySQL 开启慢查询日志阈值按业务峰值定。如果深分页优化得当慢日志里的分页 SQL 应该消失。如果还会出现多半说明某些场景没有走到优化分支需要代码审查时重点核对。第三是代码 Review 规范。我给团队定的分页开发规范如下这里直接列出来供你参考新增分页接口前先回答三个问题需要跳页吗需要总条数吗单表还是多表禁止在 Service 里循环调用分页 Mapper。禁止在 XML 的查询 SQL 中手写 LIMIT 时又传入 Page 对象两者只能二选一。分页排序字段必须包含唯一字段通常为主键防止排序抖动。深分页场景必须使用延迟关联或 Keyset且要在代码注释里说明原因避免后来的人“优化”回去。连接多个表的分页优先拆分成两步查询。这些规则可以沉淀成团队的开发文档而不是只靠口头交代。我个人的实际使用感受是MyBatis-Plus 分页插件解决的是“能用”而“用得好”必须结合具体的业务场景和数据库特性去调整。如果只是图省事把 Page 到处乱传等到生产环境数据量上来了再翻过头来优化代价往往是要改动大量调用方代码。所以最值得推荐的做法是新接口从第一天起就按照上面的场景分类来设计老接口则在每次报表或者列表响应变慢时顺手做一个“延迟关联 手动 COUNT”的小改造积累几个月后整个项目的分页能力会非常健康。最后分享一个小经验无论你选择哪种分页优化方案优化后一定要记得观察 MySQL 的Sort_merge_passes和磁盘临时表相关状态变量。如果优化后排序仍然产生大量磁盘临时表说明索引设计还有问题需要继续推进联合索引的落地。分页的底层核心最终都会落到索引上——MyBatis-Plus 只是让 SQL 写起来方便数据库的物理执行效率终究还是要靠对索引的理解来兜底。