Java

MyBatis 分页为什么慢?深分页问题怎么优化

从 limit offset 的执行代价入手,系统梳理 MyBatis 分页在大数据量场景下变慢的常见原因、排查思路和优化方向;它更适合作为 deep page 补充内容,而不是通用分页教程。

  • MyBatis
  • 分页
  • SQL优化
  • 数据库
13 分钟阅读

分页查询几乎是后台系统里最常见的需求之一,所以很多项目在一开始都会很自然地写出类似这样的 SQL:

select *
from orders
order by create_time desc
limit 0, 20;

前几页看起来通常都没问题,于是大家会默认认为分页已经解决了。但当数据量慢慢变大,页码继续往后翻,性能问题就会开始出现:前几页还好,越往后越慢,到了比较深的页数时,接口 RT 明显上升,数据库压力也跟着变重。

这类问题在 MyBatis 项目里尤其常见,不是因为 MyBatis 本身把分页做慢了,而是因为很多分页写法底层还是那套数据库执行逻辑。你在代码里看到的是 pageNumpageSize,数据库看到的却是:为了给你第 N 页,它可能得先扫掉前面大量数据。

所以这篇文章真正想回答的,不是“分页插件怎么用”,而是:为什么深分页会慢,慢在哪,以及到了大数据量场景应该怎么改。

深分页为什么慢,从哪一步看起

你现在更像哪种情况更适合先看哪篇为什么
页码越往后越慢,怀疑是 deep page 或 limit offset 本身太贵继续看本文这正是本文的深分页角色
你还没确认是不是分页主因,只知道慢 SQL 整体变慢MySQL 慢查询怎么定位:从执行计划到真实瓶颈先做总纲判断,再确认是否切到分页
你已经看到 Using filesort,更像排序路径有问题MySQL 里 order by 为什么会慢?一套分析思路讲清楚那篇更偏排序细节
你需要先看懂 typerowsExtraMySQL EXPLAIN 详解:type、rows、extra 到底怎么看先把执行计划读明白

这篇文章处理到哪一步

如果你已经把问题收窄到“页码越往后越慢,且大概率和 deep page 有关”,这篇就正合适。

它更聚焦一个具体问题:当列表越翻越慢时,如何判断深分页是不是主因,以及何时该改 SQL、改翻页模型或直接收窄交互。

一、有个基本认识:MyBatis 分页慢,很多时候慢的不是框架,而是 SQL 执行路径

很多人排查分页问题时,第一反应是:

  • 是不是 MyBatis 插件有问题
  • 是不是 ORM 生成 SQL 不够优雅
  • 是不是框架多做了什么事

但大多数情况下,真正的瓶颈并不在 MyBatis,而在数据库执行这条分页 SQL 时本身就很贵。

例如:

select id, order_no, amount, create_time
from orders
where status = 'PAID'
order by create_time desc
limit 100000, 20;

这条 SQL 的关键问题不是“取 20 条”这么简单,而是:

  • 数据库通常要先找到满足条件的数据
  • 按排序规则排好
  • 再跳过前面 100000 条
  • 最后才把后面 20 条返回给你

也就是说,用户看到的是“我只要第 5001 页的 20 条”,数据库看到的是“我得先处理前面那 100000 条,才能把你真正要的那 20 条拿出来”。

这就是深分页变慢的根本原因。

二、为什么 limit offset 在深分页场景下会越来越慢

分页 SQL 里最常见的写法就是:

limit offset, size

例如:

limit 100000, 20

它的含义是:

  • 跳过前 100000 条
  • 再取 20 条

为什么这会慢

因为“跳过”并不等于“不处理”。数据库通常仍然要:

  • 扫描这些记录
  • 按排序规则组织结果
  • 然后把前面的结果丢掉

offset 很小时,这个成本不明显;但当 offset 越来越大时,前面被扫掉的数据就越来越多。

尤其在这些场景里更明显

  • 表本身数据量很大
  • 查询条件过滤效果一般
  • 排序字段没吃到索引
  • 查询字段很多,需要频繁回表

这几个问题一叠加,深分页就会从“有点慢”变成“明显拖接口”。

三、一个常见误区:用了索引,不代表深分页就一定没问题

很多人会说:

  • 我已经给 create_time 建索引了
  • 我不是全表扫
  • 为什么还是慢

这里要注意一个区别:

  • 用了索引
  • 不等于 深分页成本就低了

例如:

select id, order_no, create_time
from orders
where status = 'PAID'
order by create_time desc
limit 100000, 20;

即使 create_time(status, create_time) 上有索引,数据库也可能仍然需要沿着索引往后扫描很多条记录,直到跳过前面的 100000 条,才能拿到你真正要的 20 条。

也就是说,索引能让“怎么找”更高效,但并不会神奇地让“前面那 100000 条不存在”。

四、为什么 select * 会把分页问题进一步放大

分页慢的另一个高频放大器是:

select *

例如:

select *
from orders
where status = 'PAID'
order by create_time desc
limit 100000, 20;

它为什么更贵

因为很多时候即使排序和过滤走了索引,select * 仍然意味着:

  • 每条候选记录都可能要回表
  • 读出完整行数据
  • 随着 offset 增大,回表次数也跟着增加

深分页本来就已经要扫描很多记录了,如果每一条还都要回表,成本会进一步放大。

更稳妥的习惯

  • 先只查当前页面真正需要的字段
  • 优先让列表页查询更接近覆盖索引
  • 把大字段、详情字段延迟到明细查询里再取

这类优化不一定能根治深分页,但能明显减轻它的副作用。

五、MyBatis 项目里,分页为什么更容易在业务层被忽视

MyBatis 本身并不会自动制造深分页问题,但它很容易让问题在业务层被包装得比较“无害”。

例如代码里可能只是:

PageHelper.startPage(pageNum, pageSize);
orderMapper.queryPaidOrders();

或者:

queryWrapper.last("limit " + offset + ", " + size);

从 Java 代码角度看,这只是一个普通分页参数;但从数据库角度看,它可能已经变成了一个很重的深分页 SQL。

这也是为什么很多分页问题不是一开始就被发现,而是等数据量上来、用户真的翻到后面很多页时才暴露。

六、怎么判断问题是不是深分页导致的

如果你怀疑某个列表接口慢和分页有关,可以重点看这些信号。

1. 越往后页码越慢

这是最典型的现象。

  • 第 1 页快
  • 第 10 页还行
  • 第 100 页开始明显变慢
  • 第 1000 页几乎不可接受

2. EXPLAIN 里扫描行数明显偏大

即使结果只返回 20 条,但执行计划预估扫描量很大,说明数据库为了这 20 条已经绕了很远的路。

3. SQL 本身没有特别复杂,但 RT 随 offset 增长明显上升

这时通常不需要怀疑太多“玄学”,深分页本身就是高度可疑方向。

4. 列表页很依赖排序

尤其是:

  • 按时间倒序
  • 按热度排序
  • 按某些业务字段排序

如果排序路径又不够理想,深分页成本会更高。

七、最常见的优化方向 1:用基于游标或主键的翻页,替代大 offset

这是深分页最常见、也最有效的优化思路之一。

例如不要写:

limit 100000, 20

而是改成“记住上一页最后一条记录的位置”,例如:

select id, order_no, create_time
from orders
where status = 'PAID'
  and create_time < '2026-03-20 12:00:00'
order by create_time desc
limit 20;

或者按主键:

where id < last_id
order by id desc
limit 20

为什么这种方式更快

因为它不是让数据库“跳过很多条”,而是直接从某个锚点之后开始继续取。

这本质上把:

  • 大量无效扫描

变成了:

  • 顺着索引继续往后读

它适合什么场景

  • 时间线式列表
  • 按 ID、时间排序的翻页
  • 无限下拉、加载更多

它不太适合什么场景

  • 用户强依赖“精确跳到第 500 页”
  • 排序规则复杂且不稳定

所以这是一种非常有效,但带有交互取舍的优化方式。

八、最常见的优化方向 2:先查主键,再回表拿详情

如果业务上必须保留 limit offset,可以考虑拆成两步。

第一步:先用较轻的查询拿主键

select id
from orders
where status = 'PAID'
order by create_time desc
limit 100000, 20;

第二步:再按这些主键回表查详情

select id, order_no, amount, create_time
from orders
where id in (...)

为什么这种方式有时有效

因为第一步只查较轻字段,更容易利用索引,减少大范围回表。

虽然第一步的 offset 成本仍然存在,但至少把“扫描很多条 + 每条都回表”的双重成本拆开了。

适合什么场景

  • 列表页字段较多
  • 主查询回表成本明显
  • 不能轻易改成游标分页

它不是最理想方案,但在保留页码语义的前提下,经常是比原始 select * 深分页更现实的折中。

九、最常见的优化方向 3:限制深页访问,别把不合理需求直接交给数据库硬扛

有些深分页问题,技术上能优化,但产品上可能根本没必要允许。

例如后台系统里:

  • 真的会有人看第 1000 页吗
  • 用户是要精确翻页,还是只是找最近数据
  • 是不是可以改成时间筛选、条件缩小范围

很多时候更合理的做法是

  • 限制最大页码
  • 引导用户加筛选条件
  • 改成“加载更多”而不是无限翻页
  • 用导出或离线任务代替超深列表查询

这不是偷懒,而是承认一个现实:

有些需求不是数据库查不出来,而是在线实时查它不划算。

十、最常见的优化方向 4:把排序和过滤路径设计得更贴合索引

深分页成本通常和排序关系很大。

例如:

where status = 'PAID'
order by create_time desc

如果有更贴合的联合索引:

(status, create_time)

数据库就更容易顺着索引完成过滤和排序。

这能解决什么

  • 减少额外排序成本
  • 减少候选集规模
  • 提高列表查询路径的稳定性

但也要记住

即使索引设计合理,深 offset 依然会带来扫描成本。索引优化通常是“减轻”,不一定是“根治”。

十一、一个典型场景:为什么后台列表第 1 页很快,第 500 页就开始卡

假设有一张订单表,后台列表按创建时间倒序分页。

第 1 页

limit 0, 20

很快,因为数据库几乎不需要跳过什么。

第 500 页

limit 9980, 20

已经开始变重,但可能还勉强能接受。

第 5000 页

limit 99980, 20

这时数据库已经要为了 20 条结果处理前面大量记录。

如果再叠加:

  • select *
  • 排序没完全吃到索引
  • where 条件过滤一般

接口 RT 就会明显拉长。

这类问题不是“突然坏了”,而是分页方式本身在数据量放大后自然暴露成本。

十二、几个非常常见的误区

1. 以为分页本身一定轻量

前几页轻量,不代表深页也轻量。

2. 以为用了 MyBatis 分页插件,性能问题就被框架处理掉了

插件只是帮你生成分页 SQL,不会自动消除深分页成本。

3. 以为有索引就不会慢

索引能改善路径,但挡不住 offset 本身的扫描代价。

4. 所有列表都坚持精确页码跳转

很多场景业务上并不真的需要跳到很深的页数。

5. 只优化 SQL,不回看交互设计

有些深分页从产品交互上改掉,收益比 SQL 微调更大。

十三、最后留一份够用的排查 checklist

以后再遇到分页慢,可以按这个顺序想:

第 1 步:看是不是深分页

  • 页码越靠后越慢吗
  • offset 是否已经很大

第 2 步:再看 SQL 路径

  • 排序字段是否走索引
  • 查询字段是否过多
  • 有没有大量回表
  • EXPLAIN 扫描行数大不大

第 3 步:选优化方向

  • 能不能改成游标分页
  • 能不能先查 ID 再回表
  • 能不能限制深页访问
  • 能不能通过筛选条件缩小范围

第 4 步:最后再决定是否保留原交互

  • 用户真的需要精确跳页吗
  • 还是“加载更多”已经够用了

十四、FAQ:深分页优化里最常被问到的几个问题

1. 用了 MyBatis 分页插件,是不是就自动解决了深分页问题?

不是。分页插件只是帮你生成 limit offset 这类 SQL,它不会自动消除 offset 前面那段无效扫描成本。

2. 游标分页是不是一定比页码分页更好?

在性能上通常更稳,但不一定适合所有交互。如果业务强依赖精确跳页、稳定页码和任意回跳,游标分页就需要配合产品交互一起调整。

3. 先查 ID 再回表,是不是总会更快?

也不是总会。它更适合字段多、回表成本高、又暂时不能改成游标分页的场景;如果原查询本来就很轻,拆两段反而可能增加复杂度。

4. 深分页问题优先改 SQL,还是优先改交互?

两者都可能,但如果用户并不真的需要翻到特别深的页数,优先收窄交互、增加筛选或改成“加载更多”,往往比继续让数据库硬扛更值钱。

十五、最后总结:MyBatis 分页慢,真正慢的是数据库为了深页付出的无效代价

MyBatis 分页本身不是问题,真正的问题是:当业务继续沿用 limit offset 去查越来越深的页时,数据库往往要为少量结果处理大量前置数据。

所以更值得建立的,不是“分页插件怎么配”的思路,而是这一句:

深分页的核心成本,不在返回多少条,而在为了返回这几条,数据库被迫跳过了多少条。

只要这条主线想清楚了,你就会知道什么时候该补索引、什么时候该拆查询、什么时候该改成游标分页,什么时候干脆该回头改交互。

这篇放在哪条问题线上

  • 数据库与 MySQL 性能问题

如果你准备继续收这类分页问题