ChatGPT、Codex数据库实战:MyBatis-Plus第一页查询正常,翻到第10000页为什么越来越慢?

一个Java开发人员最近遇到一个比较典型的生产问题。

公司后台订单管理系统上线已经有一段时间,刚开始数据量不大,运营人员查询订单时,不管翻到第几页,接口基本都能在几十毫秒内返回。

随着订单数量逐渐增加,问题开始出现。

第一页查询仍然很快,但翻到第100页后,接口明显变慢;继续翻到第1000页,等待时间开始达到秒级;部分运营人员直接输入第10000页,页面甚至需要等待好几秒才能加载出来。

开发人员检查了SQL,发现已经建立了订单创建时间索引,MySQL执行计划也能看到索引被使用。

项目使用的是Spring Boot、MyBatis-Plus和MySQL,分页查询本身并没有复杂的业务逻辑。

于是开发人员让Codex检查分页代码,Agent发现项目使用的是常见的LIMIT OFFSET方式,并建议进一步优化索引。

但问题是,索引已经存在,为什么翻页越靠后,查询还是越来越慢?

继续检查后才发现,真正的问题并不只是有没有索引,而是深分页本身的执行成本。

第一页查询快,不代表第10000页也能保持相同的查询效率。索引能够帮助MySQL找到数据,却不能让它自动跳过前面大量需要扫描的位置。

一、先看现场:为什么同一条SQL,页码越大越慢?

后台订单列表通常使用MyBatis-Plus分页插件实现。

开发人员只需要传入页码和每页数量,插件就可以生成对应的分页SQL。

例如:

第一页:

复制代码
SELECT id, order_no, amount, create_time
FROM orders
ORDER BY create_time DESC
LIMIT 20 OFFSET 0;

第10000页:

复制代码
SELECT id, order_no, amount, create_time
FROM orders
ORDER BY create_time DESC
LIMIT 20 OFFSET 199980;

两条SQL看起来几乎一样,区别只是OFFSET发生了变化。

但数据库执行时需要处理的记录数量并不相同。

第一页只需要从排序结果中获取前20条记录。

第10000页则需要跳过前面199980条符合条件的记录,再返回后面的20条。

这里需要注意,MySQL并不是简单地通过页码计算出磁盘中的某个位置,然后直接读取对应数据。

它仍然需要按照执行计划访问符合条件的记录,完成必要的扫描、过滤和排序,再跳过OFFSET指定的记录。

当数据量越来越大,深分页的成本自然会增加。

LIMIT限制的是最终返回多少条记录,不代表数据库只需要处理这么多记录。

这就是为什么同一套分页代码,在数据量较小时表现正常,随着订单持续增加却开始出现明显性能问题。

二、为什么已经建立索引,深分页还是很慢?

很多开发人员遇到分页慢,第一反应就是增加索引。

但索引主要解决的是数据定位和排序问题,并不能完全消除OFFSET带来的扫描成本。

假设订单表已经建立了创建时间索引:

idx_create_time(create_time)

MySQL可以利用索引按照创建时间查找数据。

但当查询第10000页时,即使使用了索引,仍然需要从符合条件的记录中向后遍历,跳过大量数据。

如果查询还包含其他过滤条件,例如订单状态、用户ID或商户ID,实际执行成本还可能进一步增加。

另外,如果排序字段没有合适的索引,数据库可能需要额外排序。

例如:

ORDER BY create_time DESC, id DESC

如果索引没有覆盖相应的排序需求,MySQL可能需要额外执行排序操作。

所以排查深分页,不能只看有没有索引,而要看执行计划是否真正利用了合适的索引,以及扫描了多少记录。

建议重点观察EXPLAIN中的:

  • key:实际使用的索引。

  • type:访问类型。

  • rows:优化器估算的扫描行数。

  • Extra:是否出现额外排序、临时表等信息。

如果有条件,还可以结合实际执行时间和扫描记录数进一步判断。

索引命中不等于查询成本足够低,尤其不能用第一页的执行计划表现代替深分页性能评估。

三、MyBatis-Plus分页插件有没有问题?

这次开发人员首先怀疑MyBatis-Plus分页插件。

实际上,分页插件主要负责生成和处理分页SQL,并不是深分页慢的根本原因。

例如,常见的分页实现会使用PaginationInnerInterceptor,根据分页对象中的当前页码和每页数量生成LIMIT相关语句。

一个典型配置如下:

复制代码
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
    MybatisPlusInterceptor interceptor =
        new MybatisPlusInterceptor();

    interceptor.addInnerInterceptor(
        new PaginationInnerInterceptor(DbType.MYSQL)
    );

    return interceptor;
}

具体配置还需要结合MyBatis-Plus版本。较新的版本中,分页插件可能需要单独引入对应的JSqlParser依赖。

需要区分的是:分页插件可以帮助开发人员统一分页处理,却不能改变OFFSET深分页的基本执行成本。

即使把分页代码封装得再简洁,最终生成的SQL仍然可能需要扫描和跳过大量记录。

还有一个经常被忽略的参数:maxLimit。

它可以限制单次查询的最大返回记录数,防止用户一次请求几万条数据。

但它主要控制每页返回数量,不等于限制OFFSET深度。

因此,不能认为配置了maxLimit,深分页问题就已经解决。

MyBatis-Plus解决的是分页开发的便利性,深分页性能仍然需要从SQL执行方式入手。

四、另一个容易忽略的瓶颈:COUNT查询可能比列表查询还慢

很多后台系统除了查询当前页数据,还需要显示总记录数和总页数。

例如,页面上显示:

共100万条记录,当前第100页。

为了得到这个数字,分页插件可能额外执行COUNT查询。

例如:

复制代码
SELECT COUNT(*)
FROM orders
WHERE status = 1;

如果订单表数据量很大,过滤条件复杂,COUNT查询本身也可能产生较高的扫描成本。

于是一个分页请求可能实际上执行两条SQL:

第一条统计符合条件的总记录数。

第二条查询当前页的数据。

当用户翻页时,COUNT查询可能不断重复执行。

这意味着即使列表SQL已经优化,页面仍然可能因为统计总数而变慢。

MyBatis-Plus分页插件提供了COUNT相关优化能力,例如优化部分JOIN查询,减少不必要的关联。

但这并不代表所有COUNT查询都能自动变成低成本操作。

如果业务只需要判断是否存在下一页,实际上不一定需要每次都精确统计总记录数。

可以考虑查询每页数量加1条记录,通过额外的一条数据判断是否还有下一页。

如果页面确实需要精确总数,则应单独评估COUNT查询的执行成本,必要时考虑缓存、统计表或其他适合业务的数据统计方案。

列表查询慢和COUNT查询慢是两个不同问题,排查时必须分别观察。

五、第一种优化:延迟关联,先定位主键再读取完整数据

如果业务必须保留传统页码跳转,例如运营人员需要直接输入第5000页,那么可以考虑延迟关联。

它的核心思路是:

先通过索引定位当前页需要的主键,再根据这些主键读取完整记录。

原来的查询可能直接读取所有需要展示的字段:

复制代码
SELECT id, order_no, amount, create_time
FROM orders
ORDER BY create_time DESC, id DESC
LIMIT 199980, 20;

可以尝试改为:

复制代码
SELECT o.id, o.order_no, o.amount, o.create_time
FROM orders o
JOIN (
    SELECT id
    FROM orders
    ORDER BY create_time DESC, id DESC
    LIMIT 199980, 20
) t ON o.id = t.id
ORDER BY o.create_time DESC, o.id DESC;

这样可以先利用索引获取当前页的主键,再读取完整记录。

对于记录较宽、回表成本较高的表,可能减少前面大量无用数据的完整行读取成本。

但这里有一个边界:

延迟关联并没有消除OFFSET本身的扫描成本。

内部子查询仍然需要定位并跳过前面大量索引记录。

因此,它更适合减少大表深分页中的部分回表开销,而不是让任意页码查询都变成固定时间。

如果已经有合适的覆盖索引,优化收益也可能有限。

实际是否有效,需要通过EXPLAIN和真实数据压测确认。

六、第二种优化:游标分页,避免每次从第一页开始跳过

如果业务不要求用户直接跳转到任意页,而是主要通过"上一页"和"下一页"浏览数据,那么游标分页通常更值得考虑。

它不再依赖OFFSET记录位置,而是使用上一页最后一条记录作为下一次查询的起点。

例如,订单列表按照创建时间和ID倒序排列。

第一页查询:

复制代码
SELECT id, order_no, amount, create_time
FROM orders
ORDER BY create_time DESC, id DESC
LIMIT 20;

假设最后一条记录的创建时间为某个时间,ID为1000。

下一页可以使用:

复制代码
SELECT id, order_no, amount, create_time
FROM orders
WHERE create_time < :lastTime
   OR (create_time = :lastTime AND id < :lastId)
ORDER BY create_time DESC, id DESC
LIMIT 20;

这样数据库可以从上一页最后一条记录之后继续查找,不需要每次都跳过前面大量记录。

这里有两个重要条件。

第一,排序必须稳定。

如果只按照create_time排序,而多条记录的时间相同,分页边界就可能出现重复或遗漏。

因此通常需要增加唯一性较好的辅助排序字段,例如ID。

第二,查询条件和排序条件必须保持一致。

如果上一页按照创建时间和ID排序,下一页却只使用ID作为游标,就可能产生错误的分页结果。

对于创建时间可能被修改的业务,还要评估数据变化带来的影响。

游标分页也有自己的限制:它不适合直接跳到任意页码。

如果业务主要是连续浏览,游标分页通常比OFFSET更适合大数据量场景;如果必须支持任意页码,则需要重新评估产品交互和查询方案。

七、为什么分页优化后,仍然可能出现重复和遗漏?

这是深分页改造中容易被忽略的边界问题。

假设用户打开订单列表,第一页已经查询出20条数据。

此时,其他用户又新增了10条订单。

如果下一页仍然使用OFFSET=20,前面数据的位置可能已经发生变化,用户可能再次看到第一页出现过的记录。

也可能跳过部分原本应该看到的数据。

这不是MyBatis-Plus分页插件独有的问题,而是数据持续变化时,基于位置分页本身可能遇到的一致性问题。

游标分页可以减少这类位置偏移,但也不能自动保证整个浏览过程完全一致。

如果业务要求用户浏览的是某一时刻的固定数据集合,例如财务对账、批量导出,就需要进一步考虑数据快照、固定查询边界或适当的事务隔离方案。

对于普通运营后台列表,通常可以接受数据实时变化,但需要明确分页结果不一定代表同一时刻的完整快照。

对于财务、审计等高要求场景,则不能只依靠普通分页查询保证一致性。

八、让Codex排查深分页,应该提供什么信息?

如果只告诉Codex"订单分页越来越慢,帮我优化",Agent很可能直接建议增加索引或者改成游标分页。

但这些方案不一定适合当前业务。

更有效的方式是提供:

  • 订单表结构和索引。

  • 当前MyBatis-Plus分页代码。

  • 第一页与深分页实际生成的SQL。

  • 两种页码下的EXPLAIN结果。

  • COUNT查询耗时。

  • 实际数据量和过滤条件。

  • 业务是否必须支持任意页码跳转。

让Codex先分析当前分页执行路径,再区分瓶颈究竟来自OFFSET、排序、回表还是COUNT查询。

如果业务需要任意页码,就评估延迟关联和索引优化。

如果主要是连续浏览,就评估游标分页。

如果总数统计占用大量时间,就单独处理COUNT查询。

修改完成后,再让Codex生成不同页码、不同数据规模和并发条件下的回归测试。

不要让Agent直接把所有分页统一替换成游标分页,因为不同业务的交互要求和一致性要求并不相同。

九、用一个指标判断优化是否有效

这篇只突出一个核心指标:不同页码下的P99查询耗时。

它用于观察分页深度增加后,接口尾部延迟是否持续恶化。

例如,可以分别测试第1页、第100页、第1000页和第10000页,在相同数据量、过滤条件和并发负载下记录P99。

优化前,第1页可能只需要几十毫秒,而第10000页需要数秒。

优化后,如果采用延迟关联,深分页耗时有所下降,就说明回表成本可能得到改善。

如果采用游标分页,连续翻页的查询耗时更加稳定,则说明扫描位置的问题得到缓解。

这些数字必须通过实际测试获得,不能只根据SQL看起来更简洁就判断优化成功。

同时还要验证数据正确性,特别是新增记录、相同排序时间和连续翻页情况下是否存在重复或遗漏。

真正有效的分页优化,不是第一页更快,而是不同访问深度下的性能和结果都能够满足业务要求。

十、Plus和Pro怎么判断?

如果主要使用ChatGPT、Codex排查一个Spring Boot项目中的MyBatis-Plus分页、MySQL执行计划和索引问题,补充SQL优化与回归测试,Plus通常够用。

如果需要跨多个大型项目分析分页架构、复杂SQL、数据规模变化和数据库性能问题,并持续进行多轮优化与压力测试,Pro更适合高频、长任务的工程工作流。

需要注意,ChatGPT Plus、Pro与数据库实际执行能力没有直接关系,套餐升级不能代替真实SQL分析和压测。

最后

MyBatis-Plus第一页查询正常,翻到第10000页为什么越来越慢?

因为传统LIMIT OFFSET分页需要定位并跳过前面大量符合条件的记录。即使SQL已经使用索引,也不能完全消除深分页带来的扫描成本。

真正可靠的优化路径是:

检查执行计划 → 区分列表与COUNT查询 → 评估索引 → 选择延迟关联或游标分页 → 验证并发下的数据一致性 → 比较不同页码的P99耗时。

ChatGPT、Codex可以帮助开发人员分析SQL、检查分页实现和生成优化方案,但最终是否有效,必须通过真实数据规模和执行计划验证。

分页优化不是简单地给LIMIT增加一个索引,而是根据业务访问方式,选择合适的数据定位机制。

持续分享 Codex、大模型开发与 AI 编程实战内容

也整理了稳定的 Plus/Pro 会员订阅渠道,有需要可自取。

相关推荐
Decay__34 分钟前
17_MySQL 监控体系构建 —— mysqld_exporter + Prometheus + Grafana 全栈实战
运维·数据库·sql·mysql·架构
Franciz小测测1 小时前
MySQL 增量备份实践:文件都在,为什么仍可能恢复失败?
数据库·mysql·binlog
幽灵诶12 小时前
Mysql(基于测试的学习)
数据库·mysql
宇宙无敌暴龙战士66613 小时前
MySQL 8 库表操作学习笔记:SQL 基础、库表管理、约束与用户授权全流程
数据库·sql·mysql·学习笔记·后端开发
猿究院_夏燊13 小时前
MySQL 的四种隔离级别
java·数据库·mysql
paopaokaka_luck15 小时前
灾害应急物资管理系统(AI应急助手、Echarts可视化数据看板、捐赠入库联动、 物资全流程管理、库存变动追溯、灾情资讯与互动反馈、物资发放公示)
java·前端·javascript·spring boot·mysql·tomcat·echarts
这个DBA有点耶16 小时前
为什么删掉索引后,查询瞬间变快了?探讨MySQL的随机I/O与顺序I/O博弈
数据库·mysql·代码规范
GreatSQL18 小时前
GreatSQL 8.0回流MySQL 5.7主从复制中断分析排查过程
mysql
数据库小学妹18 小时前
MySQL磁盘IO瓶颈:iostat与刷脏fsync
数据库·mysql·性能优化