一个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 会员订阅渠道,有需要可自取。