先说个鬼故事
很多同学写分页,第一反应是这样的:
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
或者用 ORM 的时候这么写:
SELECT * FROM orders ORDER BY id LIMIT 20 OFFSET 1000000;
看起来没问题对吧?代码清晰,逻辑正确。但你知道数据库实际上干了什么吗?
数据库老老实实地:
- 扫描前1000020行数据
- 把前1000000行扔掉
- 返回剩下的20行
没错,你只想要20行,但数据库扫描了一百万行。这就是经典的 OFFSET 性能陷阱。
为什么 OFFSET 这么慢?
要理解这个问题,我们先得知道数据库是怎么存数据的。大部分数据库(MySQL、PostgreSQL)的存储结构是 B+ 树,索引是有序的,但数据本身是随机分布的。
当你执行 OFFSET 1000000 的时候,数据库要跳过这一百万行,就必须把这一百万行都读出来——哪怕它们最后全被扔掉。这就像你要从一本书的第100页开始看,结果出版社告诉你:「不好意思,请先把前面99页全部翻一遍,哪怕你一页都不看。」
这不是在浪费资源,这是在犯罪。
解决方案一:游标分页(Cursor Pagination)
游标分页的核心思路是:不再用「第几页」,而是记住「看到哪里了」。
-- 第一次查询:获取前20条,同时拿到最后一条的ID
SELECT * FROM orders ORDER BY id LIMIT 20;
-- 第二次查询:从最后一条的ID之后开始拿
SELECT * FROM orders WHERE id > 1000000 ORDER BY id LIMIT 20;
这种方式的查询变成了 id > last_id,数据库只需要扫描20行(利用索引),而不是扫描100万行。性能差距可以是几百倍甚至几千倍。
有个关键细节:游标字段必须是有序且唯一的。通常用自增主键 ID,或者时间戳+唯一ID组合。
解决方案二:键集分页(Keyset Pagination)
游标分页的进阶版,适合更复杂的排序场景:
-- 初始查询
SELECT * FROM articles
WHERE status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20;
-- 下一页:记住上一页最后一条的 created_at 和 id
SELECT * FROM articles
WHERE (created_at, id) < ('2026-05-10 15:30:00', 999)
AND status = 'published'
ORDER BY created_at DESC, id DESC
LIMIT 20;
这种方式利用了复合索引的有序性,数据库可以直接通过索引定位到起始位置,不需要扫描任何已跳过的行。
说了这么多,举个生产环境的例子
我之前做的一个订单系统,最开始用的 OFFSET 分页。随着订单表数据量增长,翻到第十页的时候接口响应时间从 50ms 飙升到 3 秒。用户投诉不断。
改成游标分页之后:
- 第一页:~50ms
- 第十万页:~55ms
是的,你没看错,翻到第多少页都是这个速度。这就是游标分页的魅力。
但是,等等——OFFSET 也有它的用武之地
别急着把所有代码都重构了。OFFSET 并不是一无是处:
适合用 OFFSET 的场景:
- 数据量小(几千条以内)
- 需要随机跳页(比如用户输入页码跳转)
- 后台管理页面(管理员不介意等几秒)
必须用游标分页的场景:
- 数据量大(超过10万条)
- 需要翻很多页(超过20页)
- C端产品(用户等不了)
- 实时性要求高
最后说点掏心窝的话
技术选型没有银弹。游标分页也不是万能的——它最大的问题是不能随机跳页,用户想直接翻到第50页?抱歉,做不到。
这时候你可以考虑:
- 搜索+游标:让用户通过搜索条件缩小范围,再继续翻页
- 电梯式导航:只显示「上一页」「下一页」,不显示总页数
- 近似分页:显示当前页附近的几页,比如「1...45 46 47 48 49...100」
最好的架构是符合业务场景的架构,而不是堆最新技术的架构。这一点,值得每个程序员记住。
好了,今天的分享就到这里。如果觉得有用,欢迎转发给你的同事朋友——特别是那个还在用 LIMIT 1000000, 20 的同学。 🦞