我不会起名字322 · 后端 / 算法 / 数据库
文章目录
- [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 还要额外承担回表开销(下面第二节),所以真实环境里差距只会更大。绝对值取决于表大小、缓存命中率和硬件,请用自己的表跑一遍确认(文末给了可复制的验证脚本)。
两个结论很直观:
- OFFSET 的耗时随深度线性增长(0 → 10 万 → 50 万 → 90 万:0.1 → 2.3 → 11.7 → 22.1 ms);
- 游标写法是恒定成本 ,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:覆盖索引 + 业务妥协
如果上面两种都不合适(必须跳页、且表很大),可以从业务上做三个妥协,每一个都能显著降低成本:
- 限制可翻深度:搜索类结果最多给 100 页,超出的引导用户加筛选条件(Google、淘宝都是这么做的);
- 用筛选替代翻页:让用户按时间/状态筛选,把 100 万行切成若干小段;
- 预计算 / 冗余表:把分页结果按页缓存到 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 两种分页实现")
}
两条使用提醒:
- 游标里的时间要用数据库的时区 ,前后端传递建议直接用
id做游标(最简单也最不容易错),时间只作为排序展示; LIMIT ? OFFSET ?一定要参数化,别把页码直接拼进 SQL。
六、4 步优化步骤
第 1 步:先用 EXPLAIN ANALYZE 量化问题。
确认 rows / 实际扫描行数是不是远大于返回行数。如果不是(比如 rows=20),那慢的原因不是深分页,去查别的东西。
第 2 步:确认排序字段有索引,且没有 filesort。
EXPLAIN 的 Extra 里有 Using filesort 说明还要额外排序。给排序字段建索引(复合排序就建复合索引,注意字段顺序和升降序一致)。
第 3 步:按业务场景选改写方案。
能改成游标就改(性能最好);必须跳页就用延迟关联;都不行就限制可翻深度。
第 4 步:改完必须回到第 1 步复测。
同样深度再跑一次 EXPLAIN ANALYZE,对比扫描行数和耗时。只看「页面是不是变快了」不可靠------缓存预热、并发波动都会干扰感受。
七、总结
OFFSET N的代价是扫描并丢弃前 N 行,不是「直接跳到第 N 行」。实测 100 万行表上,OFFSET 从 0 到 90 万,耗时从 ~0ms 涨到 22ms,而游标写法恒定在 0.02ms(约 1000 倍差距)。- MySQL 上最贵的是回表:用二级索引时,OFFSET 会对前 N 行逐行回表。延迟关联把这 N 次回表压缩成 20 次,是必须跳页场景的首选。
- 3 种改写:① 延迟关联(保留跳页,砍回表);② 游标/书签(成本恒定,不能跳页,排序字段要唯一有序);③ 业务妥协(限深度/加筛选/预计算)。
- 复合排序要用复合游标 :
ORDER BY create_time DESC, id DESC对应的游标条件是(create_time, id) < (?, ?),只用时间会漏数据。 - 改完必须复测 :用
EXPLAIN ANALYZE(8.0.18+)对比扫描行数,别凭感觉判断优化是否生效。
本文实测数字来自本机 SQLite(100 万行)复现 OFFSET 扫描代价机制;MySQL 机制相同且多了回表开销。请用自己的表和
EXPLAIN ANALYZE复测绝对值。