一条 LIMIT 1000000, 20 拖垮数据库:深分页的四种解法,以及为什么说它多半是个伪需求
慢 SQL 告警响了,点开一看:
SELECT * FROM orders ORDER BY create_time DESC LIMIT 1000000, 20,执行 8 秒。表也有索引,SQL 也没写错,为什么翻到第 5 万页就把库拖垮了?这篇文章讲清楚深分页慢的真正原因、四种解法各自的适用边界(游标分页给出从 SQL、后端到前端可直接照抄的完整实现 ),以及一个更扎心的问题:用户真的在翻第 5 万页吗?
一、先搞清楚:LIMIT 1000000, 20 到底慢在哪
很多人以为 LIMIT offset, size 是"跳过 offset 条,取 size 条",跳过总该很快吧?错在"跳过"两个字上------MySQL 没有跳过这个能力,它是老老实实取出前 1000020 条,然后扔掉前 100 万条。
具体到执行过程,以二级索引 idx_create_time 为例:
- 在
idx_create_time索引树上顺序扫描 1000020 个索引项; - 每个索引项只有
create_time和主键 id,SELECT *需要的其他字段没有------于是每一条都要拿着主键回表去聚簇索引查整行,这是 100 万次随机 IO; - 100 万行完整数据取出来后,丢掉前 100 万条,返回 20 条。
慢的核心是回表 100 万次,其次是白扫 100 万行。更糟的是,当优化器发现回表太多,可能直接放弃索引改走全表扫描 + filesort,雪上加霜。
用 EXPLAIN 验证一下,rows 列会诚实地告诉你它打算扫多少行------offset 越大,这条 SQL 的成本随页码线性增长,翻得越深越慢,这就是"深分页"问题。
二、解法一:游标分页(Keyset Pagination)------从 SQL 到前端的完整闭环
思路转换:不告诉数据库"跳过多少条",而是告诉它"从哪条之后开始"。前端每次翻页把上一页最后一条的排序键带回来。它性能是天花板(后面细讲),但要真正落地,SQL、接口契约、前端三层都得配合,下面给一套可以直接抄走跑起来的完整实现。
2.1 SQL 层:起点定位代替偏移扫描
sql
-- 第一页
SELECT * FROM orders ORDER BY id DESC LIMIT 20;
-- 下一页:带上上一页最后一条的 id = 8964213
SELECT * FROM orders WHERE id < 8964213 ORDER BY id DESC LIMIT 20;
WHERE id < ? 直接在索引树上定位起点,扫 20 行就结束------无论翻到第几页,成本恒定,第 1 页和第 100 万页一样快。
一个必须注意的细节:排序键必须唯一 ,否则翻页会丢数据。按 create_time 排序时,同一秒可能有多条记录,游标切在中间就漏了。解法是加 id 做二级排序:
sql
-- 需要联合索引:ALTER TABLE orders ADD INDEX idx_ct_id(create_time, id);
SELECT * FROM orders
WHERE create_time < '2026-08-24 10:00:00'
OR (create_time = '2026-08-24 10:00:00' AND id < 8964213)
ORDER BY create_time DESC, id DESC
LIMIT 20;
也可以写成行值比较 (create_time, id) < (?, ?),更简洁,但 MySQL 部分版本对行值比较的索引利用不佳,上面展开写法的索引命中最稳,下文代码统一用展开写法。
2.2 接口契约:游标要做成"不透明 token"
接口契约是闭环的关键。很多团队第一版直接让前端传 lastId + lastCreateTime 两个参数,能跑,但有两个隐患:排序规则一改前端全得跟着改;字段裸奔在 URL 里,用户能篡改。正确姿势是把游标编码成不透明字符串(opaque cursor),前端只负责原样带回,不理解、不拼接、不修改:
arduino
GET /api/orders?cursor=eyJ0IjoiMjAyNi0wOC0yNCAxMDowMDowMCIsImlkIjo4OTY0MjEzfQ&size=20
响应:
{
"code": 0,
"data": {
"list": [ ... 20 条订单 ... ],
"nextCursor": "eyJ0IjoiMjAyNi0wOC0yNCAwOTo1OToxMiIsImlkIjo4OTY0MTg3fQ",
"hasMore": true
}
}
约定三条:首页不传 cursor;下一页传上次响应的 nextCursor;hasMore 为 false 就停。
2.3 后端完整实现(Spring Boot + MyBatis)
一共五个类,从下往上给全。
① 游标对象 Cursor------就是排序键的快照:
java
public class Cursor {
/** 上一页最后一条的 create_time */
private LocalDateTime t;
/** 上一页最后一条的 id,同一时刻多条记录时用它切分 */
private Long id;
public Cursor() {}
public Cursor(LocalDateTime t, Long id) {
this.t = t;
this.id = id;
}
public LocalDateTime getT() { return t; }
public void setT(LocalDateTime t) { this.t = t; }
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
}
② 编解码器 CursorCodec------JSON 序列化后走 URL 安全的 Base64,前端拿到的就是一段无意义字符串:
java
public class CursorCodec {
private static final ObjectMapper MAPPER = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
/** 生成游标:入参是本页最后一条记录 */
public static String encode(Order last) {
try {
Cursor c = new Cursor(last.getCreateTime(), last.getId());
byte[] json = MAPPER.writeValueAsBytes(c);
return Base64.getUrlEncoder().withoutPadding().encodeToString(json);
} catch (JsonProcessingException e) {
throw new IllegalStateException("cursor encode failed", e);
}
}
/** 解析游标:首页(null/空串)返回 null;非法游标按参数错误处理 */
public static Cursor decode(String cursor) {
if (cursor == null || cursor.isBlank()) {
return null;
}
try {
byte[] json = Base64.getUrlDecoder().decode(cursor);
return MAPPER.readValue(json, Cursor.class);
} catch (Exception e) {
throw new BizException(ErrorCode.INVALID_CURSOR);
}
}
}
③ 通用返回结构 CursorPage<T>------所有游标分页接口共用:
java
public class CursorPage<T> {
private List<T> list;
private String nextCursor; // hasMore=false 时为 null
private boolean hasMore;
public static <T> CursorPage<T> of(List<T> list, String nextCursor, boolean hasMore) {
CursorPage<T> page = new CursorPage<>();
page.list = list;
page.nextCursor = nextCursor;
page.hasMore = hasMore;
return page;
}
public List<T> getList() { return list; }
public String getNextCursor() { return nextCursor; }
public boolean isHasMore() { return hasMore; }
}
④ Mapper------接口加 XML,游标为 null 走首页分支:
java
@Mapper
public interface OrderMapper {
List<Order> listByCursor(@Param("c") Cursor cursor, @Param("limit") int limit);
}
xml
<select id="listByCursor" resultType="com.demo.entity.Order">
SELECT id, order_no, user_id, amount, status, create_time
FROM orders
<where>
<if test="c != null">
create_time < #{c.t}
OR (create_time = #{c.t} AND id < #{c.id})
</if>
</where>
ORDER BY create_time DESC, id DESC
LIMIT #{limit}
</select>
⑤ Service + Controller ------size + 1 判断 hasMore,避免 COUNT:
java
@Service
public class OrderQueryService {
@Resource
private OrderMapper orderMapper;
public CursorPage<OrderVO> listOrders(String cursor, int size) {
Cursor c = CursorCodec.decode(cursor); // 首页为 null
// 多查一条,用于判断后面还有没有,省掉一次 COUNT(*)
List<Order> rows = orderMapper.listByCursor(c, size + 1);
boolean hasMore = rows.size() > size;
if (hasMore) {
rows = rows.subList(0, size);
}
String next = hasMore
? CursorCodec.encode(rows.get(rows.size() - 1))
: null;
List<OrderVO> voList = rows.stream().map(OrderVO::from).toList();
return CursorPage.of(voList, next, hasMore);
}
}
java
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Resource
private OrderQueryService orderQueryService;
@GetMapping
public Result<CursorPage<OrderVO>> list(
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "20") @Max(100) int size) {
return Result.ok(orderQueryService.listOrders(cursor, size));
}
}
(Order 实体、OrderVO、Result 是各项目都有的常规代码,按你们自己的写法来即可;size 记得加上限,防止一次拉几万条。)
三个设计要点:
size + 1判断 hasMore :多取一条就知道后面还有没有,深分页场景下COUNT(*)本身就是慢查询,能省则省;- 游标无状态:内容就是排序键快照的 Base64,不占服务端存储,天然支持多端并发翻页;在意篡改就在编码时追加一段 HMAC 签名、解码时校验;
- 排序规则变更只改后端:游标怎么编解码前端毫不知情,这就是"不透明"的价值。
2.4 前端层:无限滚动 + 三个必做的状态控制
前端拿到的心智模型很简单:一个 nextCursor 一路传下去。以最常见的无限滚动为例:
javascript
const state = {
list: [],
cursor: null, // 首页为 null
hasMore: true,
loading: false, // 防重复请求
};
async function loadMore() {
// ① 没有更多、或上一次请求还没回来,直接不发
if (!state.hasMore || state.loading) return;
state.loading = true;
try {
const { data } = await fetch(
`/api/orders?size=20${state.cursor ? `&cursor=${encodeURIComponent(state.cursor)}` : ''}`
).then(r => r.json());
state.list.push(...data.list); // ② 追加而不是替换
state.cursor = data.nextCursor; // ③ 只保存,不解析
state.hasMore = data.hasMore;
} finally {
state.loading = false;
}
}
// 触底加载(IntersectionObserver 监听列表底部的哨兵元素)
new IntersectionObserver(
entries => entries[0].isIntersecting && loadMore(),
{ rootMargin: '200px' } // 提前 200px 预加载,滚动不断流
).observe(document.querySelector('#sentinel'));
// 下拉刷新:三个状态一起重置,回到首页
function refresh() {
state.list = [];
state.cursor = null;
state.hasMore = true;
return loadMore();
}
对应的 HTML 骨架只需要一个列表容器和一个哨兵元素:
html
<ul id="order-list"><!-- 渲染 state.list --></ul>
<div id="sentinel"></div> <!-- 进入视口就触发 loadMore -->
前端必须做对的三件事,少一件就会出线上问题:
loading锁防重复请求:快速滚动会连续触发触底,不加锁就会用同一个 cursor 发两次请求,列表出现重复数据;- 刷新必须重置 cursor:下拉刷新只清列表不清 cursor,用户会从中间某页开始看,还以为丢数据了;
- cursor 只存不解析:任何"从 cursor 里抠 id"的代码都是给未来埋雷,后端一改编码就崩。
另外两个加分项:游标分页天然对插入友好------传统 offset 分页在翻页间隙有新数据插入时会整体后移导致重复/漏读,游标分页锚定的是"某条数据之后",新插入的数据不会挤乱后面的页;列表项用稳定 key(业务 id)渲染,即使偶发重复也不至于闪屏。
2.5 这套闭环的边界
它天然不支持跳页 ------你只有"下一页",没法直接跳到第 500 页,因为你不知道第 500 页的游标是什么。这不是实现缺陷,是原理决定的。所以它最适合:信息流、App 无限下拉、订单/消息列表------这些场景用户本来就只会连续翻页,而这恰好覆盖了 C 端绝大多数列表。
三、解法二:延迟关联------必须支持跳页时的次优解
后台管理系统经常真的需要"跳到第 N 页",游标方案用不了。这时用延迟关联(deferred join):先在覆盖索引里把 20 个主键找出来(不回表),再用这 20 个主键去取整行:
sql
SELECT o.*
FROM orders o
INNER JOIN (
SELECT id FROM orders
ORDER BY create_time DESC
LIMIT 1000000, 20
) tmp ON o.id = tmp.id;
子查询只扫 (create_time, id) 索引本身,索引项小、顺序 IO、零回表,100 万条扫得飞快;回表从 100 万次降到 20 次。实测千万级表上,8 秒的查询能降到几百毫秒。
它没有改变"扫描量随页码线性增长"的本质,只是把最贵的回表干掉了------所以是"次优解":能接受、不完美,适合必须跳页的后台场景。
四、解法三:产品层面掐掉深分页------大厂的真实选择
翻一下你每天在用的产品:
- Google / 百度搜索结果,最多只让你翻几十页;
- 淘宝商品列表,翻到 100 页就到底了;
- Elasticsearch 干脆内置了
max_result_window = 10000,from + size 超过一万条直接报错。
这不是他们不会优化,而是他们想明白了一件事:没有正常用户会一页页翻到第 5 万页。 会翻到那么深的,要么是爬虫,要么是想要全量数据的人(见解法四)。所以最省钱的方案是产品经理在需求评审时把"支持任意跳页"划掉,限制最大页数 + 引导用户用筛选条件缩小范围。
顺带说 ES:需要深翻的场景用 search_after(原理就是游标分页),全量遍历用 PIT + search_after,别硬调 max_result_window------那是把 OOM 风险调大的开关。
五、解法四:识破伪装------"翻到底"的用户其实想要导出
一个真实规律:凡是执着于翻到很深页码的用户,真实诉求几乎都是"我想要全部数据"------对账、审计、做报表。让他一页页翻是双输:他翻得手酸,你的库被深分页查询打穿。
正确姿势是把这个需求显式化:提供异步导出 。用户点导出,后台任务用游标分页(WHERE id > ? LIMIT 1000)批量捞数据写文件,完了通知下载。库的压力平稳可控,用户拿到的还是完整数据,体验反而更好。
六、决策表
| 场景 | 推荐方案 | 成本随页码增长? |
|---|---|---|
| 信息流 / App 下拉 / 只需上下页 | 游标分页(keyset),接口用不透明 cursor | 否,恒定 |
| 后台系统必须跳页 | 延迟关联 + 限制最大页数 | 线性,但斜率小得多 |
| 搜索类产品 | 限制页数 + 筛选引导(ES 用 search_after) | --- |
| 用户想要全量数据 | 异步导出任务(内部走游标批量取) | 否 |
一句话总结:
深分页优化的最高境界,是让深分页请求根本不发生。 技术上,游标分页把 O(offset) 变成 O(1)------但它是一个前后端契约,SQL 换写法、接口给不透明 cursor、前端管好 loading/cursor/hasMore 三个状态,闭环才算成立;延迟关联把回表砍到只剩一页。而更重要的是产品上先问一句------用户到底是想看第 5 万页,还是想要全部数据?前者限制页数,后者给导出。SQL 调优解决的是症状,需求澄清解决的才是病根。
最后
去你们的慢 SQL 平台搜一下 LIMIT 后面跟着六位数的查询,看看它们来自哪个页面、真实用户是谁------大概率你会发现,要么是爬虫,要么是一个本该做成导出功能的列表页。
你们项目里深分页是怎么处理的?有没有被 LIMIT 100万 打穿过?评论区聊聊。