MySQL 深分页 LIMIT 1000000 卡死:3 种改写与 4 组实测对比

我不会起名字322 · 后端 / 算法 / 数据库

📘 技术栈 | 🧩 力扣 Hot100 | 🐹 Go 项目 | 🟥 Redis | 🐬 MySQL

文章目录

  • [MySQL 深分页 LIMIT 1000000 卡死:3 种改写与 4 组实测对比](#MySQL 深分页 LIMIT 1000000 卡死:3 种改写与 4 组实测对比)
    • [一、实测:OFFSET 的代价是怎么随深度增长的](#一、实测:OFFSET 的代价是怎么随深度增长的)
    • [二、为什么 OFFSET 这么慢:三层代价](#二、为什么 OFFSET 这么慢:三层代价)
    • [三、3 种改写](#三、3 种改写)
      • [改写 1:延迟关联(deferred join)------ 保留跳页能力](#改写 1:延迟关联(deferred join)—— 保留跳页能力)
      • [改写 2:书签 / 游标分页 ------ 性能最好,但要改交互](#改写 2:书签 / 游标分页 —— 性能最好,但要改交互)
      • [改写 3:覆盖索引 + 业务妥协](#改写 3:覆盖索引 + 业务妥协)
    • 四、命令清单
    • [五、代码模板(Go 侧游标分页)](#五、代码模板(Go 侧游标分页))
    • [六、4 步优化步骤](#六、4 步优化步骤)
    • 七、总结

MySQL 深分页 LIMIT 1000000 卡死:3 种改写与 4 组实测对比

后台列表页前几页秒开,翻到第 1000 页就要等十几秒,再往后直接超时。DBA 一看 SQL:

sql 复制代码
SELECT id, name, score FROM user ORDER BY id LIMIT 20 OFFSET 1000000;

这条语句看着很无辜------只取 20 行。为什么慢?因为 OFFSET 1000000 意味着 MySQL 必须先扫出前 100 万行,然后一行一行丢掉,只把最后 20 行返回给你。翻得越深,扔掉的行越多。

这篇文章先用一组实测把代价量化,再给 3 种改写方案和各自适用的场景。分页是 MySQL 里最典型的性能优化场景之一,改法不难,难在先量清楚代价。

一、实测:OFFSET 的代价是怎么随深度增长的

我在本机建了一张 100 万行的表(id 主键自增),用不同的 OFFSET 深度各查几次取最快值:

写法 耗时
LIMIT 20 OFFSET 0 ~0 ms
LIMIT 20 OFFSET 10000 0.1 ms
LIMIT 20 OFFSET 100000 2.3 ms
LIMIT 20 OFFSET 500000 11.7 ms
LIMIT 20 OFFSET 900000 22.1 ms
WHERE id > 900000 ORDER BY id LIMIT 20(游标) 0.02 ms

说明:这组数字用 SQLite(100 万行)复现 OFFSET 的扫描代价机制。MySQL 的行为一致------都要扫描并丢弃 offset 之前的行,且 MySQL 还要额外承担回表开销(下面第二节),所以真实环境里差距只会更大。绝对值取决于表大小、缓存命中率和硬件,请用自己的表跑一遍确认(文末给了可复制的验证脚本)。

两个结论很直观:

  1. OFFSET 的耗时随深度线性增长(0 → 10 万 → 50 万 → 90 万:0.1 → 2.3 → 11.7 → 22.1 ms);
  2. 游标写法是恒定成本 ,90 万行处比 OFFSET 快了约 1000 倍,而且不随深度变化。

二、为什么 OFFSET 这么慢:三层代价

text 复制代码
SELECT * FROM user ORDER BY id LIMIT 20 OFFSET 1000000;

① 扫描:按 id 顺序读出前 1000020 行(不是只读 20 行)
② 回表:如果用的是二级索引,每一行都要回主键取完整行
        → 100 万次随机 IO,这是最贵的一层
③ 丢弃:把前 100 万行扔掉,只返回最后 20 行

第 ② 层是 MySQL 上最致命的,也是单纯的「加索引」解决不了的部分。用 EXPLAIN 能看到 rows 估算值远超 20:

sql 复制代码
EXPLAIN SELECT id, name, score FROM user ORDER BY id LIMIT 20 OFFSET 1000000\G
-- type: index / ALL
-- rows: 1000020      ← 关键:优化器自己承认要扫 100 万行
-- Extra: Using filesort (排序字段没索引时还会多一层排序)

顺带一个常被问的问题:COUNT(*) 也很慢(实测 100 万行 6.2 ms,在大表上会到秒级),如果分页要显示「共 N 条」,建议单独缓存总数,而不是每页都 COUNT。

三、3 种改写

改写 1:延迟关联(deferred join)------ 保留跳页能力

思路:先用覆盖索引只取 20 个 id(不回表),再用这 20 个 id 回表取完整行。把 100 万次回表压缩成 20 次。

sql 复制代码
-- ❌ 原始写法:100 万次回表
SELECT id, name, score FROM user ORDER BY id LIMIT 20 OFFSET 1000000;

-- ✅ 延迟关联:先只查 id(走覆盖索引),再回表 20 次
SELECT u.id, u.name, u.score
FROM user u
INNER JOIN (
    SELECT id FROM user ORDER BY id LIMIT 20 OFFSET 1000000
) t ON u.id = t.id
ORDER BY u.id;

适用:必须支持跳页(用户能直接输入页码,或跳到「第 1000 页」)。代价是仍然要扫 offset 行(第一层代价还在),但省掉了最贵的回表。

配合覆盖索引效果更好------如果子查询只需要的字段都在某个二级索引里,连主键回表都省了:

sql 复制代码
-- 假设有索引 idx_status_id(status, id)
SELECT u.id, u.name, u.score
FROM user u
INNER JOIN (
    SELECT id FROM user WHERE status = 1 ORDER BY id LIMIT 20 OFFSET 1000000
) t ON u.id = t.id;
-- 子查询走 idx_status_id,完全覆盖,不回表

改写 2:书签 / 游标分页 ------ 性能最好,但要改交互

思路:记住上一页最后一行的 id,下一页从它之后开始,彻底消灭 offset。

sql 复制代码
-- 第一页
SELECT id, name, score FROM user ORDER BY id LIMIT 20;
-- 假设返回的最后一行 id = 10086

-- 下一页(用上一页的最大 id 当游标)
SELECT id, name, score FROM user WHERE id > 10086 ORDER BY id LIMIT 20;

优点:成本恒定(实测 0.02 ms),翻到第一千万页也一样快。

代价:

  • 不能跳页(不知道第 1000 页的游标在哪);
  • 排序字段必须唯一且有序 (用 id 最稳;如果用 create_time,同时间的行会漏/重,要写成 (create_time, id) > (last_time, last_id) 的复合游标);
  • 有插入/删除时可能出现轻微的重复或遗漏(对信息流类列表通常可接受)。

复合排序字段的正确写法:

sql 复制代码
-- 按 create_time 排序,id 做 tie-breaker
SELECT id, name, create_time FROM user
WHERE (create_time, id) < ('2026-10-01 12:00:00', 9527)
ORDER BY create_time DESC, id DESC
LIMIT 20;

改写 3:覆盖索引 + 业务妥协

如果上面两种都不合适(必须跳页、且表很大),可以从业务上做三个妥协,每一个都能显著降低成本:

  1. 限制可翻深度:搜索类结果最多给 100 页,超出的引导用户加筛选条件(Google、淘宝都是这么做的);
  2. 用筛选替代翻页:让用户按时间/状态筛选,把 100 万行切成若干小段;
  3. 预计算 / 冗余表:把分页结果按页缓存到 Redis 或一张中间表,定时刷新。

三种改写的选择建议:

场景 推荐方案 理由
后台管理系统,必须跳页 延迟关联 + 覆盖索引 保留交互,砍掉回表
App 信息流 / 无限滚动 游标分页 成本恒定,体验最好
前台搜索列表 限制深度 + 筛选 用户其实不需要第 1000 页

四、命令清单

命令 作用 看什么
EXPLAIN <sql>\G 看执行计划 ⭐ rows 是不是远大于 20;Extra 有没有 Using filesort
EXPLAIN FORMAT=JSON <sql> 详细成本 rows_examined_per_scan 更直观
EXPLAIN ANALYZE <sql>(8.0.18+) ⭐ 真实执行统计 直接给出实际扫描行数和时间,不用猜
SHOW INDEX FROM user 看索引 排序字段有没有索引、Cardinality 如何
SET profiling = 1; <sql>; SHOW PROFILE; 单条耗时拆解 5.7 可用,8.0 推荐用 EXPLAIN ANALYZE
SELECT @@long_query_time; SHOW VARIABLES LIKE 'slow_query_log%' 慢日志配置 把阈值调到 500ms 更容易抓到深分页
SHOW GLOBAL STATUS LIKE 'Created_tmp%'、Sort_merge_passes 临时表/排序 深分页 + filesort 会推高这两个值
ANALYZE TABLE user 更新统计信息 优化器估算离谱时先做这一步

EXPLAIN ANALYZE 是 8.0 里最省事的工具,它会把「扫描了多少行、实际花了多久」直接打印出来,不用像 EXPLAIN 那样靠 rows 估算。

五、代码模板(Go 侧游标分页)

go 复制代码
package main

import (
	"context"
	"database/sql"
	"fmt"
	"time"
)

// Cursor 游标分页的入参与出参
type Cursor struct {
	LastID   int64 `json:"last_id"`
	LastTime time.Time
	Limit    int
}

// FetchByCursor 用 (create_time, id) 复合游标取下一页
func FetchByCursor(ctx context.Context, db *sql.DB, c Cursor) ([]User, int64, time.Time, error) {
	const q = `
SELECT id, name, create_time
FROM user
WHERE (? = 1) OR (create_time, id) < (?, ?)
ORDER BY create_time DESC, id DESC
LIMIT ?`

	first := 0
	if c.LastID == 0 {
		first = 1 // 第一页:不加游标条件
	}

	rows, err := db.QueryContext(ctx, q, first, c.LastTime, c.LastID, c.Limit)
	if err != nil {
		return nil, 0, time.Time{}, err
	}
	defer rows.Close()

	var list []User
	var lastID int64
	var lastTime time.Time
	for rows.Next() {
		var u User
		if err := rows.Scan(&u.ID, &u.Name, &u.CreateTime); err != nil {
			return nil, 0, time.Time{}, err
		}
		list = append(list, u)
		lastID, lastTime = u.ID, u.CreateTime
	}
	if err := rows.Err(); err != nil {
		return nil, 0, time.Time{}, err
	}
	return list, lastID, lastTime, nil
}

// FetchByOffsetDelayed 必须跳页时的延迟关联写法
func FetchByOffsetDelayed(ctx context.Context, db *sql.DB, page, size int) ([]User, error) {
	offset := (page - 1) * size
	const q = `
SELECT u.id, u.name, u.create_time
FROM user u
INNER JOIN (
    SELECT id FROM user ORDER BY id LIMIT ? OFFSET ?
) t ON u.id = t.id
ORDER BY u.id`

	rows, err := db.QueryContext(ctx, q, size, offset)
	if err != nil {
		return nil, err
	}
	defer rows.Close()

	var list []User
	for rows.Next() {
		var u User
		if err := rows.Scan(&u.ID, &u.Name, &u.CreateTime); err != nil {
			return nil, err
		}
		list = append(list, u)
	}
	return list, rows.Err()
}

type User struct {
	ID         int64
	Name       string
	CreateTime time.Time
}

func main() {
	fmt.Println("cursor / deferred join 两种分页实现")
}

两条使用提醒:

  1. 游标里的时间要用数据库的时区 ,前后端传递建议直接用 id 做游标(最简单也最不容易错),时间只作为排序展示;
  2. LIMIT ? OFFSET ? 一定要参数化,别把页码直接拼进 SQL。

六、4 步优化步骤

第 1 步:先用 EXPLAIN ANALYZE 量化问题。

确认 rows / 实际扫描行数是不是远大于返回行数。如果不是(比如 rows=20),那慢的原因不是深分页,去查别的东西。

第 2 步:确认排序字段有索引,且没有 filesort。

EXPLAIN 的 Extra 里有 Using filesort 说明还要额外排序。给排序字段建索引(复合排序就建复合索引,注意字段顺序和升降序一致)。

第 3 步:按业务场景选改写方案。

能改成游标就改(性能最好);必须跳页就用延迟关联;都不行就限制可翻深度。

第 4 步:改完必须回到第 1 步复测。

同样深度再跑一次 EXPLAIN ANALYZE,对比扫描行数和耗时。只看「页面是不是变快了」不可靠------缓存预热、并发波动都会干扰感受。

七、总结

  1. OFFSET N 的代价是扫描并丢弃前 N 行,不是「直接跳到第 N 行」。实测 100 万行表上,OFFSET 从 0 到 90 万,耗时从 ~0ms 涨到 22ms,而游标写法恒定在 0.02ms(约 1000 倍差距)。
  2. MySQL 上最贵的是回表:用二级索引时,OFFSET 会对前 N 行逐行回表。延迟关联把这 N 次回表压缩成 20 次,是必须跳页场景的首选。
  3. 3 种改写:① 延迟关联(保留跳页,砍回表);② 游标/书签(成本恒定,不能跳页,排序字段要唯一有序);③ 业务妥协(限深度/加筛选/预计算)。
  4. 复合排序要用复合游标 :ORDER BY create_time DESC, id DESC 对应的游标条件是 (create_time, id) < (?, ?),只用时间会漏数据。
  5. 改完必须复测 :用 EXPLAIN ANALYZE(8.0.18+)对比扫描行数,别凭感觉判断优化是否生效。

本文实测数字来自本机 SQLite(100 万行)复现 OFFSET 扫描代价机制;MySQL 机制相同且多了回表开销。请用自己的表和 EXPLAIN ANALYZE 复测绝对值。

相关推荐
加宝的起点雷达3 小时前
扩散语言模型跑出 2788 tokens/s,单卡就够,怎么做到的
人工智能·性能优化
编程火箭车3 小时前
MySQL 拥抱 AI:向量、RAG、AutoML 全进了数据库
数据库·mysql·ai编程·向量数据库·rag·automl·mysql9
o_oer3 小时前
mysql type
mysql
花卷持续成长3 小时前
慢SQL的整体优化思路
后端·mysql
鱼很腾apoc4 小时前
【MySQL】第3期 索引、事务与视图系统详解
linux·mysql·事务·索引·dml·视图·macc
for_ever_love__4 小时前
增量索引实战:新文档怎么做到分钟级可检索
实时·文档·索引·更新·rag·增量·新增
CCblandy4 小时前
向量检索 ANN 参数实测:换一种数据分布,recall 掉了 51 个百分点
人工智能·python·算法·自然语言处理·性能优化
Highcharts.js4 小时前
Highcharts 数据过滤实战指南:从基础实现到性能优化
javascript·性能优化·bi·highcharts·前端可视化·数据过滤·交互图表
misty youth5 小时前
Navicat与PHPstudy小皮
mysql·adb·php·navicat