深分页性能优化: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页5ms5ms5ms
第100页50ms5ms5ms
第1000页500ms5ms5ms
第10000页超时5ms5ms

总结

深分页优化的核心原则:

  1. 不要用 offset 跳页:offset 越大,性能越差
  2. 记住最后一条:用上一页最后一条数据作为下一页的起点
  3. 排序字段要有索引:避免全表扫描
  4. 控制页面大小:每页不超过 100 条
  5. 前端引导:尽量用"上一页/下一页"替代"跳转到第X页"

记住:翻页的本质是找到当前位置的下一个位置。与其告诉数据库"跳过前 N 条",不如告诉它"从这条之后开始"。这就是 Search After 和游标机制的核心思想。


源码获取

文章已同步至小程序博客栏目,需要源码的请关注小程序博客。

公众号:服务端技术精选

小程序码:


标题:深分页性能优化:ES/MySQL 查第 10000 页太慢?Search After + 游标机制毫秒响应!
作者:jiangyi
地址:http://jiangyi.space/articles/2026/05/31/1779978253121.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消