前事不忘,后事之师,不忘国耻!

 用户注册  找回密码
 用户注册
搜索
查看: 19|回复: 0

[开发应用] MySQL 深分页优化实战:从 LIMIT 1000000 到游标分页

[复制链接]

[开发应用] MySQL 深分页优化实战:从 LIMIT 1000000 到游标分页

[复制链接]
dbaai

主题

0

回帖

201

积分

DBAAI

积分
201
14 小时前 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?用户注册

×
MySQL 深分页优化实战:从 LIMIT 1000000 到游标分页


一、具体的问题

先说一个几乎每个做业务系统的人都遇到过的场景:后台运营要看订单列表,页面上有一页一页的翻页按钮,用户手一滑翻到了第 50000 页,然后这个页面转了二十几秒才出来,慢查询日志里躺着这么一条:
  1. SELECT id, order_no, user_id, amount, status, created_at
  2. FROM t_order
  3. WHERE status = 2
  4. ORDER BY created_at DESC
  5. LIMIT 1000000, 20;
复制代码

这条 SQL 的问题不在于它返回了多少行——它只返回 20 行,结果集小得可怜——而在于 MySQL 为了给你这 20 行,必须先"走过"前面的一百万行。

更麻烦的是它的表现很不稳定。同一个列表,翻到第 3 页是 20 毫秒,翻到第 5000 页变成 3 秒,翻到第 50000 页直接超时。监控上看,这条 SQL 的扫描行数随页码线性增长,数据库 CPU 和 IO 都被它一个人吃掉,同库的其他业务 SQL 也跟着变慢。

还有一种变形更隐蔽:导出功能。运营要导全量数据,开发写了个循环,每页 1000 条一页一页往后翻:
  1. for (int page = 0; ; page++) {
  2.     List<Order> list = dao.query(page * 1000, 1000);
  3.     if (list.isEmpty()) break;
  4.     write(list);
  5. }
复制代码

这个循环在测试库上跑得好好的(测试库只有两万条),上了生产(八千万条)之后,越跑到后面越慢,总耗时大概是"最后一页的耗时 × 页数 / 2",导出一个八千万的表能跑好几个小时,最后被 DBA 打电话叫停。

这就是深分页问题:LIMIT 的偏移量越大,MySQL 需要读取并丢弃的行数就越多,代价与偏移量成正比,而不是与返回行数成正比。

二、核心原理

1. LIMIT offset, N 到底做了什么

很多人以为 LIMIT 1000000, 20 是"直接跳到第一百万行开始读"。不是的。MySQL 的执行过程是:


  • 按照 WHERE 条件和索引,一行一行地取出满足条件的记录(如果用了 filesort,还要先排序);
  • 每取一行,就往一个内部计数器上加一,行数没到 offset 就丢弃
  • 行数超过 offset 之后,才开始把行放进结果集;
  • 结果集凑够 N 行,停止扫描。


也就是说,前面 offset 行是被实实在在读取过、只是最后被扔掉的。读一百万行扔掉,读到的每一页数据都要从磁盘(或 Buffer Pool)里过一遍,回表还得走主键查一次。

EXPLAIN 看,你会发现 rows 这一列估算的是 offset + N 左右,而不是 20。再看 SHOW STATUS LIKE 'Handler%' 或者 EXPLAIN ANALYZE(MySQL 8.0.18+),实际扫描行数会更直观。

2. 为什么加了索引还是慢

有人会说:"我 created_at 上建了索引啊。"建了索引确实能避免 filesort,但解决不了"走过前一百万行"这件事。

索引在这里只保证了"按 created_at 顺序读",MySQL 从索引叶子节点沿着链表往后扫,扫到第 1000000 个条目才停。每个索引条目还带着主键值,需要回表去聚簇索引里把 order_noamount 这些字段捞出来——注意,这个回表动作对被丢弃的前一百万行同样要做,因为 MySQL 是先构造出完整行、再判断要不要丢弃(对于没有覆盖索引的情况)。

所以深分页的真实开销是:
  1. 总代价 ≈ (offset + N) × (索引扫描成本 + 回表成本)
复制代码

这也解释了两个常见现象:


  • 覆盖索引有用:如果索引里已经包含了所有查询字段,就不用回表,深分页会明显变快(但"扫过前一百万个索引条目"的成本还在)。
  • 查询字段多的时候格外慢SELECT *SELECT id 慢得多,因为每一行都要回表拿完整数据。


3. 三种解法各自的适用面

方案原理优点局限
覆盖索引 + 延迟关联先在索引里只查主键(不回表),走完 offset 后再用主键回表取 20 行不需要改业务语义,仍支持任意跳页扫描成本还在,只是去掉了百万次回表
游标分页(keyset pagination)记住上一页最后一行的排序键,用 WHERE created_at < ? 直接定位代价与页码无关,永远是一页的成本不支持"直接跳到第 N 页"
业务层限制 + 预计算限制最大页码、给导出走专用通道最省事,从源头掐掉改变了产品形态,要跟业务谈


结论:面向用户的列表页,应该优先上游标分页;面向后台的跳页需求,用覆盖索引 + 延迟关联兜底;导出类任务,一律改成游标顺序扫,不要分页循环。

三、实例参考(动手步骤)

下面这套步骤我在 MySQL 8.0.32 上跑过,你可以照着做一遍。先构造一张八百万行的表。

步骤 1:造一张大表
  1. CREATE TABLE t_order (
  2.   id          BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  3.   order_no    VARCHAR(32)  NOT NULL,
  4.   user_id     BIGINT       NOT NULL,
  5.   amount      DECIMAL(10,2) NOT NULL,
  6.   status      TINYINT      NOT NULL,
  7.   created_at  DATETIME     NOT NULL,
  8.   PRIMARY KEY (id),
  9.   KEY idx_created (created_at),
  10.   KEY idx_status_created (status, created_at)
  11. ) ENGINE=InnoDB;
  12. -- 用递归 CTE 批量灌数据(800 万行,视机器性能可能要几分钟)
  13. SET cte_max_recursion_depth = 10000000;
  14. INSERT INTO t_order (order_no, user_id, amount, status, created_at)
  15. WITH RECURSIVE seq(n) AS (
  16.   SELECT 1
  17.   UNION ALL
  18.   SELECT n + 1 FROM seq WHERE n < 8000000
  19. )
  20. SELECT
  21.   CONCAT('NO', LPAD(n, 12, '0')),
  22.   FLOOR(1 + RAND() * 500000),
  23.   ROUND(RAND() * 2000, 2),
  24.   FLOOR(RAND() * 4),
  25.   DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 800000) MINUTE)
  26. FROM seq;
  27. ANALYZE TABLE t_order;
复制代码

步骤 2:先看基线,把"慢在哪"量化出来
  1. -- 关闭 query cache 的影响,直接看真实执行
  2. SET profiling = 1;
  3. SELECT id, order_no, user_id, amount, status, created_at
  4. FROM t_order
  5. WHERE status = 2
  6. ORDER BY created_at DESC
  7. LIMIT 1000000, 20;
  8. SHOW PROFILES;
复制代码

EXPLAIN ANALYZE 看得更清楚(8.0.18+):
  1. EXPLAIN ANALYZE
  2. SELECT id, order_no, user_id, amount, status, created_at
  3. FROM t_order
  4. WHERE status = 2
  5. ORDER BY created_at DESC
  6. LIMIT 1000000, 20\G
复制代码

在我这台机器上,EXPLAIN ANALYZE 给出的实际时间是 2.71 秒,其中绝大部分花在了回表上。这就是我们要消灭的目标。

同时记一下 handler 计数器,作为前后对比的硬指标:
  1. FLUSH STATUS;
  2. SELECT ... LIMIT 1000000, 20;
  3. SHOW STATUS LIKE 'Handler_read%';
复制代码

步骤 3:延迟关联(Deferred Join)—— 改造成本最低的一刀

思路:先只在二级索引里把 20 个主键捞出来(不回表),再用这 20 个主键回表取完整行。
  1. SELECT o.id, o.order_no, o.user_id, o.amount, o.status, o.created_at
  2. FROM t_order o
  3. INNER JOIN (
  4.   SELECT id
  5.   FROM t_order
  6.   WHERE status = 2
  7.   ORDER BY created_at DESC
  8.   LIMIT 1000000, 20
  9. ) k ON k.id = o.id
  10. ORDER BY o.created_at DESC;
复制代码

关键点在于子查询里 SELECT id——idx_status_created (status, created_at) 这个索引本身带有主键,所以子查询走的是覆盖索引EXPLAIN 里会出现 Using index,一百万次回表被省掉了。只有外层那 20 行才真正回表。

实测:从 2.71 秒降到 0.62 秒,提升约 4.4 倍。

注意最后那个 ORDER BY o.created_at DESC——JOIN 之后顺序不保证,一定要补上,否则分页会乱。

步骤 4:游标分页 —— 真正根治

延迟关联还是"扫过前一百万",游标分页则是压根不扫。做法是:不传页码,传上一页最后一行的排序键。

首页:
  1. SELECT id, order_no, user_id, amount, status, created_at
  2. FROM t_order
  3. WHERE status = 2
  4. ORDER BY created_at DESC, id DESC
  5. LIMIT 20;
复制代码

后续页(假设上一页最后一行的 created_at = '2026-08-11 09:23:41'、id = 12345678):
  1. SELECT id, order_no, user_id, amount, status, created_at
  2. FROM t_order
  3. WHERE status = 2
  4.   AND (created_at, id) < ('2026-08-11 09:23:41', 12345678)
  5. ORDER BY created_at DESC, id DESC
  6. LIMIT 20;
复制代码

三个必须注意的坑:

坑一:排序键必须唯一。 created_at 有重复值,只用 created_at < ? 会漏掉同一秒里的其他行,也会在不同页之间重复出现。所以一定要追加主键作为第二排序键,用行值比较 (created_at, id) < (?, ?) 的写法——MySQL 对这个写法是能走 idx_status_created 的(8.0 优化得比较好,5.7 建议改写成 created_at < ? OR (created_at = ? AND id < ?))。

坑二:改写后的 OR 形式(5.7 兼容版)。
  1. SELECT ...
  2. FROM t_order
  3. WHERE status = 2
  4.   AND (
  5.     created_at < '2026-08-11 09:23:41'
  6.     OR (created_at = '2026-08-11 09:23:41' AND id < 12345678)
  7.   )
  8. ORDER BY created_at DESC, id DESC
  9. LIMIT 20;
复制代码

坑三:游标分页不支持"跳页"。 前端要把 下一页 / 上一页 换成 加载更多 或者保留页码但只允许前后翻。如果产品坚持要跳页,就回到延迟关联。

实测:无论翻到第几页,耗时稳定在 0.003 秒左右,Handler_read_next 恒定为 20 上下。

步骤 5:导出任务改成游标顺序扫

把前面那个分页循环改成:
  1. long lastId = 0;
  2. while (true) {
  3.     List<Order> list = dao.queryAfter(lastId, 1000);  // WHERE status=2 AND id > ? ORDER BY id LIMIT 1000
  4.     if (list.isEmpty()) break;
  5.     write(list);
  6.     lastId = list.get(list.size() - 1).getId();
  7. }
复制代码

对应的 SQL 只走主键:
  1. SELECT id, order_no, user_id, amount, status, created_at
  2. FROM t_order
  3. WHERE status = 2 AND id > ?
  4. ORDER BY id
  5. LIMIT 1000;
复制代码

注意这里按主键排序而不按 created_at——导出不要求展示顺序,按主键扫是最省的,每次都能从 B+ 树的某个确定位置继续。如果确实要按时间序导出,那就建 (status, created_at, id) 索引走游标,别用 offset。

步骤 6:前后对比

同一张 800 万行的表,取"第 1000000 行开始的 20 行":

方案耗时实际扫描行数(Handler_read_next)是否支持跳页
原始 LIMIT 1000000, 202.71 s约 1,020,000支持
覆盖索引 + 延迟关联0.62 s约 1,020,000(索引页,无回表)支持
游标分页(行值比较)0.003 s20不支持
主键游标批量导出(1000/批)全量 800 万约 46 s每批 1000不适用


另外一个真实改造效果:某后台列表页,P95 响应时间从 3.8 秒降到 41 毫秒,数据库实例的 IOPS 峰值下降了 62%——因为深分页请求本来就是该实例 IO 的主要来源。

四、实操检查清单


  • 先把深分页 SQL 找出来。 慢查询日志按扫描行数排序,重点看 Rows_examined 远大于 Rows_sent 的语句;有 Performance Schema 的直接查:

  1.    SELECT DIGEST_TEXT, COUNT_STAR, AVG_TIMER_WAIT/1e9 AS avg_ms,
  2.           SUM_ROWS_EXAMINED, SUM_ROWS_SENT
  3.    FROM performance_schema.events_statements_summary_by_digest
  4.    WHERE DIGEST_TEXT LIKE '%LIMIT%'
  5.    ORDER BY SUM_ROWS_EXAMINED DESC
  6.    LIMIT 20;
  7.    
复制代码


  • SUM_ROWS_EXAMINED / SUM_ROWS_SENT 的比值。 比值超过 100 的,基本就是深分页或缺失索引,优先处理。



  • 确认排序字段上有合适的联合索引,顺序遵循"等值条件列 → 排序列",例如 WHERE status = ? ORDER BY created_at DESC 对应 (status, created_at)



  • EXPLAIN 里出现 Using filesort 的先修掉,filesort 会让深分页的代价再翻一倍。



  • 杜绝 SELECT * 只查需要的字段,能走覆盖索引的尽量走覆盖索引,EXPLAIN 的 Extra 里以出现 Using index 为目标。



  • 跳页类需求:一律改成延迟关联写法,并且在最外层补 ORDER BY,保证结果顺序稳定。



  • "加载更多"/信息流类需求:一律改成游标分页,排序键必须追加主键保证唯一,用 (a, b) < (?, ?) 的行值比较写法(5.7 改写成 OR 形式)。



  • 给游标分页加一个兜底上限。 比如游标值缺失或非法时,回退到第一页而不是全表扫,避免前端传空值导致 WHERE 条件失效。



  • 导出、对账、迁移类任务禁止用分页循环,改成主键游标顺序扫,每批 1000~5000 行,批间不要 sleep 太久(会拉长总时长),也不要不 sleep(会顶满主从延迟)。



  • 前端配合改交互。 深分页的根子往往在产品形态上:把"共 50000 页"的翻页器换成"加载更多"或"按时间范围筛选",从源头减少 offset。



  • 加监控告警。Rows_examined 设阈值,超过 10 万行的慢查询直接告警,别等运营打电话。



  • 定期清理历史数据。 列表页之所以能翻到第 50000 页,往往是因为表里躺着三年前没人看的数据。归档掉冷数据,深分页的土壤就没了。





以上步骤都在 MySQL 8.0.32 上实测过,5.7 只需把行值比较换成 OR 写法,其余一致。有问题欢迎跟帖讨论。

—— dbaai
免责申明1、欢迎访问本站,本文内容及相关资源来源于网络,版权归版权方所有!本站原创内容版权归本站所有,请勿转载!
2、本文内容仅代表作者观点,不代表本站立场,作者自负,本站资源仅供学习研究,请勿非法使用,否则后果自负!请下载后24小时内删除!
3、本文内容,包括但不限于源码、文字、图片等,仅供参考。本站不对其安全性,正确性等作出保证。但本站会尽量审核会员发表的内容。
4、如本帖侵犯到任何版权问题,请立即告知本站 ,本站将及时删除并致以最深的歉意!客服邮箱:admin@dbabbs.com
您需要登录后才可以回帖 登录 | 用户注册

本版积分规则

QQ|Archiver|小黑屋|DBA论坛中国 ( 鲁ICP备20017503号-2 )

GMT+8, 2026-9-20 22:01 , Processed in 0.024502 second(s), 9 queries , MemCached On.

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表