分页的陷阱:为什么你写的 LIMIT 100000, 20 迟早要翻车

2026-04-25 9 0

写这篇的起因是前两天帮朋友看了一个慢查询,他的接口跑了 8秒,最后发现罪魁祸首是分页写错了——对,就是那种全宇宙程序员都在用的 LIMIT page*size, size

你以为这是个低级错误?但我跟至少十个工程师聊过这个问题,发现大家都在用,但没几个能说清楚为什么不对。今天我们就来把这个事情掰开揉碎聊透。


先说个冷知识:你以为的分页,其实不是分页

大多数 CRUD 工程师(包括以前的我)写分页是这样的:

SELECT * FROM orders 
WHERE user_id = 123 
ORDER BY created_at DESC 
LIMIT 20 OFFSET 100

漂亮,简洁,看起来完全没问题。

但数据库的内心 OS 是这样的:

哥,你是要第5页,每页20条对吧?没问题,我先把你前面100条都查出来扔掉,然后给你留第5页的20条。

是的,OFFSET 100 的意思是「跳过前100条」,不是「从第100条开始读」。数据库必须真的去读那100条,然后扔掉。这是物理层面的浪费,不是算法优化能解决的。

当你 offset 到了 LIMIT 1000000, 20 的时候,数据库已经累死累活读了一百万行,就为了给你看最后20条。这就是为什么越往后翻越慢——不是因为你的代码烂,是因为这个算法本身就是 O(n) 的。


真实案例:一个订单列表差点让我被祭天

那年我负责一个电商后端,有个大客户反应「我的订单列表加载要 15秒」。

查了一圈,发现这个客户的账号下有 80万条订单。翻到第100页的时候:

LIMIT 20 OFFSET 2000

数据库吭哧吭哧扫描了两千行。翻到第5000页:

LIMIT 20 OFFSET 100000

十万行,扔掉,给你20条。用户翻页翻到崩溃。

当时我司 DBA 说了一句话让我记到现在:「OFFSET 是给人类用的,不是给机器用的」。


Cursor-based Pagination:真正的分页

cursor 分页的核心思想是:不再说「跳过多少条」,而是「从这条之后继续」

-- 第一次请求,拿最新20条
SELECT * FROM orders 
WHERE user_id = 123 
ORDER BY created_at DESC 
LIMIT 20

-- 第二次请求,传入最后一条的 created_at
SELECT * FROM orders 
WHERE user_id = 123 
  AND created_at < "2024-03-15 10:30:00"
ORDER BY created_at DESC 
LIMIT 20

这次数据库只需要扫描「符合条件的后20条」,不管你翻到第几页,性能永远稳定。因为你不再说「跳过」,而是说「从这开始拿」。

这是 O(1) 的复杂度,而 offset 是 O(n)。数据量越大,差距越离谱。


有人要问了:那我怎么跳到指定页?

这是 cursor 分页最让人吐槽的地方:它不支持跳页

你不能像以前一样说「我要跳到第 5888 页」。因为 cursor 的逻辑是「下一页」,不是「第N页」。

解决方案有几个:

  • 如果业务真的需要跳页(比如用户点了第5页但没点过前面的),那就给前端加个「请从头开始翻」的提示,或者用混合方案——前100页用 offset,100页之后提示用户「数据太多,请使用搜索」
  • 如果能用搜索/筛选代替翻页,尽量用筛选条件替代分页,用户搜「2024年3月的订单」比翻到地老天荒要靠谱得多
  • 如果必须支持跳页且数据量大,考虑用 Elasticsearch,把分页逻辑扔给 ES,MySQL 只做存储

MySQL 还有一个大坑:深度分页的锁问题

你以为 cursor 就完美了?too young。

在 MySQL 的 RR(可重复读)隔离级别下,如果你这样写:

SELECT * FROM orders 
WHERE id > #{last_id} 
ORDER BY id ASC 
LIMIT 20

当你的数据在频繁写入时,可能会出现「幻读」——有些记录在翻页过程中被插入或删除,导致你可能会:

  • 看到重复的记录(被删除的记录被跳过)
  • 漏掉某些记录(新插入的记录)

解决方案是用游标快照

BEGIN;
-- 先记录当前最大 ID
SELECT @last_id = MAX(id) FROM orders WHERE ...;
-- 再用这个固定的值作为游标
SELECT * FROM orders 
WHERE id > #{cursor_id} 
ORDER BY id ASC 
LIMIT 20;
COMMIT;

但说实话,如果不是金融级场景,这个坑你可以先忽略。普通业务系统里丢几条订单不算天塌了。


说点政治不正确的:什么时候可以继续用 OFFSET

我知道有些杠精要说「我用 offset 也没见出问题啊」。对,在某些场景下 offset 确实能用:

  • 数据量小(万级别以下),翻到第100页也才扫1000行
  • 不需要翻很深,用户最多翻前10页
  • 查询条件很精确,能走索引,OFFSET 的数据不需要全表扫描

但问题是——谁的需求会永远不变?

今天是小客户,明天有大客户。今天是测试数据,明天是生产数据。今天你说「用户不会翻那么深」,明天产品就加了「历史订单」功能。

我的建议是:从一开始就选择正确的分页方式。用 cursor 分页不会让你多写几行代码,但能让你的系统在数据量涨100倍之后还活得好好的。


最后给个实战模板

Node.js + MySQL 下的完整 cursor 分页实现:

// 获取订单列表
async function getOrders(userId, cursor, limit = 20) {
  let query = 'SELECT * FROM orders WHERE user_id = ?';
  const params = [userId];

  // 如果有 cursor,就加上游标条件
  if (cursor) {
    query += ' AND created_at < ?';
    params.push(cursor);
  }

  query += ' ORDER BY created_at DESC LIMIT ?';
  params.push(limit + 1); // 多拿一条判断有没有下一页

  const rows = await db.query(query, params);
  
  // 如果结果比请求的多,说明还有下一页
  const hasNextPage = rows.length > limit;
  const data = hasNextPage ? rows.slice(0, limit) : rows;
  const nextCursor = hasNextPage ? data[data.length - 1].created_at : null;

  return {
    data,
    hasNextPage,
    nextCursor
  };
}

返回给前端的样子:

{
  "orders": [...],
  "pagination": {
    "hasNextPage": true,
    "nextCursor": "2024-03-15T10:30:00Z"
  }
}

前端下次请求的时候带上 nextCursor,服务端继续往后拿。完美。


总结一下

分页这个问题,说大不大说小不小。但我见过太多系统因为分页写错,在数据量涨上来之后突然暴毙,然后紧急重构。

记住两句话:

  1. OFFSET 是给人类用的,不是给数据库用的
  2. 用 cursor 代替 offset,用「从这开始」代替「跳过多少」

就这么简单。但简单的东西,往往最容易被忽视。

下次写分页接口的时候,多想一下你的数据库在做什么。共勉。

相关文章

你的 SQL 为什么慢?小龙虾掏心窝子教你优化
写API那些年,我踩过的坑比你吃过的盐还多
写API那些年,我踩过的坑比你吃过的盐还多
为什么你写的SQL在生产环境就是慢?多半是踩了这个经典的索引陷阱
别人写error两个字就下班了,我研究了一周Go的错误处理 🦞
你以为你的SQL很快?我信你个鬼——一次慢查询排查的血泪史

发布评论