深分页性能优化:ES/MySQL 查第 10000 页太慢?Search After + 游标机制毫秒响应!
做分页功能的同学肯定都遇到过这个问题:项目初期数据量小,分页查询秒级响应,产品经理说加上分页功能吧,很简单。结果上线后数据量上来,第 100 页就开始慢了,到第 10000 页直接超时。
我之前就遇到过这样一个案例:一个订单查询系统,用户反馈查第 1000 页以后的数据要 30 秒+,根本没法用。排查后发现,后端用的就是最常见的 LIMIT 10000, 20。
今天我们就来聊聊深分页的性能问题,以及如何用 Search After 和游标机制让第 100 万页也能毫秒响应。
深分页的性能问题根源
1. 传统分页的致命缺陷
很多开发者用的分页方式是这样的:
-- MySQL
SELECT * FROM orders ORDER BY create_time DESC LIMIT 10000, 20;
-- Elasticsearch
GET /orders/_search {
"from": 10000,
"size": 20,
"sort": [{ "create_time": "desc" }]
}
这种方式在小数据量时没问题,但数据量上来后就是灾难:
问题场景:1000万数据,查第10000页
MySQL 执行过程:
1. 扫描前 10020 行数据
2. 回表查询所有字段
3. 丢弃前 10000 行
4. 返回 20 行
这就是问题所在:需要跳过前 10000 行才能开始返回!
2. 为什么 LIMIT offset, n 会变慢
性能劣化原理:
LIMIT offset, n 的执行成本 = offset + n
当 offset = 10000, n = 20 时:
- 需要扫描并丢弃 10000 行
- 然后再读取 20 行
- 即使只返回 20 行,也要处理 10020 行
当 offset = 1000000, n = 20 时:
- 需要扫描 1000020 行
- 丢弃 1000000 行
- 数据库压力巨大
offset 越大,需要扫描的行数越多,性能越差!
3. 订单列表的典型场景
案例:订单列表查询
初始数据量:1万条 → 第100页很快(毫秒级)
半年后数据量:100万条 → 第1000页变慢(秒级)
一年后数据量:1000万条 → 第10000页超时(30秒+)
原因分析:
- 订单按时间倒序排列
- 用户想看历史订单
- 越旧的订单,offset 越大
- 每次翻页都要跳过大量数据
解决方案:Search After + 游标机制
1. 核心设计思想
Search After 原理:
不记录 offset,而是记录上一页最后一条数据的关键值
下一页基于这个值继续往后查
传统方式:跳过前N条 → 获取第N+1到N+20条
Search After:记住最后一条 → 获取该条之后的20条
示例:
第1页:查询时间 > 0 的前20条,返回最后一条时间 = T1
第2页:查询时间 > T1 的前20条,返回最后一条时间 = T2
第3页:查询时间 > T2 的前20条,返回最后一条时间 = T3
...
不管在哪一页,都只查询20条数据!
2. MySQL 游标方案
MySQL 游标分页伪代码:
第一页查询:
SELECT * FROM orders
WHERE create_time <= NOW()
AND status = 'SUCCESS'
ORDER BY create_time DESC
LIMIT 20;
返回结果:[{ ..., create_time: T1 }, { ..., create_time: T2 }, ...]
第二页查询(使用上一页最后一条的时间):
SELECT * FROM orders
WHERE create_time < {T1} -- 使用上一页最后一条的时间作为游标
AND status = 'SUCCESS'
ORDER BY create_time DESC
LIMIT 20;
返回结果:[{ ..., create_time: T21 }, { ..., create_time: T22 }, ...]
3. Elasticsearch Search After 方案
ES Search After 伪代码:
第一页查询:
GET /orders/_search
{
"size": 20,
"sort": [
{ "create_time": "desc" },
{ "_id": "desc" }
],
"query": {
"bool": {
"filter": [
{ "term": { "status": "SUCCESS" }}
]
}
}
}
返回结果:hits + sort_values(游标值)
第二页查询(带上 sort_values):
GET /orders/_search
{
"size": 20,
"sort": [
{ "create_time": "desc" },
{ "_id": "desc" }
],
"search_after": [T1, "order_id_1"], -- 上一页最后一条的 sort 值
"query": {
"bool": {
"filter": [
{ "term": { "status": "SUCCESS" }}
]
}
}
}
4. 为什么要用两个排序字段
双重排序必要性:
只用 create_time 的问题:
- 如果两条订单时间完全相同
- 分页结果可能不一致(取决于数据库内部顺序)
解决方案:
- 第一个排序字段:业务字段(create_time)
- 第二个排序字段:唯一字段(_id)
- 确保每条数据都有唯一的排序值
排序值 = create_time + _id 的组合
例如:
{ "create_time": 1717000000000, "_id": "order_123" }
{ "create_time": 1717000000000, "_id": "order_456" }
这两条数据有明确的先后顺序,不会出现歧义
实战方案
方案一:MySQL 时间戳 + ID 游标
适用场景:
- 数据有明确的时间字段
- 时间字段有索引
- 数据按时间排序
实现要点:
1. 查询时带上时间条件和 ID 条件
2. 上一页最后一条的时间作为下一页的起点
3. 使用覆盖索引,避免回表
SQL 示例:
SELECT * FROM orders
WHERE (create_time, id) < ({last_time}, {last_id})
ORDER BY create_time DESC, id DESC
LIMIT 20
方案二:ES Search After
适用场景:
- Elasticsearch 查询
- 数据量巨大(千万级以上)
- 需要高性能深分页
实现要点:
1. 第一次查询带上 sort 参数
2. 提取返回的 sort_values 作为游标
3. 下一页查询带上 search_after 参数
优势:
- 不受限于 from/size 的 10000 限制
- 每次查询只处理 size 条数据
- 性能与页码无关
方案三:伪列分页(适用于无排序字段场景)
适用场景:
- 无法确定排序字段
- 数据经常变化
- 需要稳定的前后翻页
实现思路:
1. 给表添加一个自增的伪列 page_marker
2. 每次查询记录当前页的 page_marker
3. 下一页基于 page_marker 继续查询
缺点:
- 需要修改表结构
- 数据变化时 marker 会错位
- 不适合高并发写入场景
方案四:区间分页(适用于翻页不多但跳转多的场景)
场景描述:
用户不是逐页翻,而是直接跳转到第1000页
实现思路:
1. 先定位第1000页所在的区间(通过粗略查询)
2. 再在区间内精确查询
例如:
- 跳转到第1000页,每页20条
- 需要跳过 1000 * 20 = 20000 条
- 先用主键范围快速定位
- 再在范围内精确查询
方案对比
| 方案 | 原理 | 性能 | 适用场景 | 缺点 |
|---|---|---|---|---|
| LIMIT offset,n | 跳过前N行 | O(offset+n) | ❌ 不推荐 | offset越大越慢 |
| 时间戳游标 | 基于时间过滤 | O(n) | 时间排序 | 需要时间字段 |
| Search After | 基于排序值过滤 | O(n) | ES深分页 | 需要唯一排序值 |
| 区间分页 | 区间定位+精确查 | O(区间) | 跳页场景 | 实现复杂 |
最佳实践
1. 索引设计
索引设计要点:
1. 排序字段要有索引
- 单独索引:CREATE INDEX idx_time ON orders(create_time)
- 组合索引:CREATE INDEX idx_time_id ON orders(create_time, id)
2. 覆盖索引优化
- 索引包含所有查询字段
- 避免回表查询
3. 索引顺序
- 等值查询字段在前
- 排序字段在后
2. 查询优化
查询优化建议:
1. 使用延迟关联
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders
WHERE create_time < {cursor}
ORDER BY create_time DESC
LIMIT 20
) t ON o.id = t.id
2. 避免 SELECT *
只查询需要的字段,让索引覆盖
3. 合理设置页面大小
每页不超过 100 条,建议 20-50 条
3. 前端适配
前端改造建议:
1. 不提供"跳转到第X页"功能
- 深页翻页性能差
- 用户也很难记住页码
2. 只提供"上一页/下一页"
- 体验流畅
- 性能稳定
3. 提供"回到首页"
- 用户想重新浏览时可以从头开始
- 首页性能最好
4. 考虑使用"瀑布流"替代传统分页
- 无限滚动加载
- 用户体验更好
4. 监控告警
监控指标:
1. 分页深度分布
- 统计各页码的查询频率
- 重点优化高频深页
2. 分页查询耗时
- 设置慢查询阈值(如 1 秒)
- 超过阈值立即告警
3. 空结果率
- 深层页面空结果过多
- 说明用户有深度翻页习惯
- 需要优化或引导用户
效果对比
| 页码 | 传统 LIMIT | 时间戳游标 | Search After |
|---|---|---|---|
| 第1页 | 5ms | 5ms | 5ms |
| 第100页 | 50ms | 5ms | 5ms |
| 第1000页 | 500ms | 5ms | 5ms |
| 第10000页 | 超时 | 5ms | 5ms |
总结
深分页优化的核心原则:
- 不要用 offset 跳页:offset 越大,性能越差
- 记住最后一条:用上一页最后一条数据作为下一页的起点
- 排序字段要有索引:避免全表扫描
- 控制页面大小:每页不超过 100 条
- 前端引导:尽量用"上一页/下一页"替代"跳转到第X页"
记住:翻页的本质是找到当前位置的下一个位置。与其告诉数据库"跳过前 N 条",不如告诉它"从这条之后开始"。这就是 Search After 和游标机制的核心思想。
源码获取
文章已同步至小程序博客栏目,需要源码的请关注小程序博客。
公众号:服务端技术精选
小程序码:
标题:深分页性能优化:ES/MySQL 查第 10000 页太慢?Search After + 游标机制毫秒响应!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/05/31/1779978253121.html
公众号:服务端技术精选
- 深分页的性能问题根源
- 1. 传统分页的致命缺陷
- 2. 为什么 LIMIT offset, n 会变慢
- 3. 订单列表的典型场景
- 解决方案:Search After + 游标机制
- 1. 核心设计思想
- 2. MySQL 游标方案
- 3. Elasticsearch Search After 方案
- 4. 为什么要用两个排序字段
- 实战方案
- 方案一:MySQL 时间戳 + ID 游标
- 方案二:ES Search After
- 方案三:伪列分页(适用于无排序字段场景)
- 方案四:区间分页(适用于翻页不多但跳转多的场景)
- 方案对比
- 最佳实践
- 1. 索引设计
- 2. 查询优化
- 3. 前端适配
- 4. 监控告警
- 效果对比
- 总结
- 源码获取
评论
0 评论