一个网站刚上线时,分页通常感觉不到任何问题。比如文章表只有几万条数据,执行 LIMIT 0, 20、LIMIT 20, 20,查询都可能非常快。但当数据库积累到几百万甚至上千万条记录以后,用户如果一路翻到第5000页,情况就完全不同了。
例如一个新闻网站每页显示20篇文章:
SELECT id, title, created_at
FROM news
ORDER BY created_at DESC
LIMIT 100000, 20;
用户真正需要的只有20条记录,但数据库为了得到这20条结果,通常仍然需要按照执行计划处理前面的100000条候选记录,然后再取后面的20条。随着 OFFSET 增大,查询工作量通常也会增加。它不是简单的“指数级变慢”,更准确地说,是随着偏移位置扩大而出现明显的性能衰减,而且最终速度还受到索引、排序、回表、磁盘IO、缓存命中率和查询条件等因素影响。
一、为什么 OFFSET 越大越容易慢
假设网站有100万篇文章,每页20篇。
第一页执行:
LIMIT 0, 20
数据库只需要找到最前面的20条结果。
如果用户打开第50000页:
LIMIT 999980, 20
这时候真正返回的仍然只有20条,但为了定位这20条,执行过程可能需要处理接近100万条候选记录。
如果 ORDER BY created_at DESC 有合适索引,数据库可以沿着索引扫描,而不是把整个表重新排序;但“沿索引向前走”并不意味着可以瞬间跳到第999980个符合条件的记录。对于 B+Tree 索引,定位索引起点和顺序扫描大量索引项是两回事。
更麻烦的是:
SELECT *
FROM news
ORDER BY created_at DESC
LIMIT 999980, 20;
如果索引无法覆盖查询需要的字段,数据库还可能产生大量回表访问。此时查询成本不仅来自 OFFSET 本身,还来自大量候选记录的读取。
因此,看到 LIMIT 100000,20 很慢时,不能简单地说“LIMIT 有问题”,而应该先看 EXPLAIN,确认数据库到底是在扫描什么、排序什么、回表多少。
二、真正适合大数据分页的是 Keyset Pagination
如果网站只是上一页、下一页,通常没有必要让数据库每次从第一页开始数。
例如新闻网站按照:
ORDER BY created_at DESC, id DESC
排序。
第一次请求直接:
SELECT id, title, created_at
FROM news
WHERE publish_status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;
返回结果后,程序记住最后一条记录的:
created_at = 2026-10-07 12:30:00
id = 381245
下一页就可以写成:
SELECT id, title, created_at
FROM news
WHERE publish_status = 1
AND (
created_at < '2026-10-07 12:30:00'
OR (
created_at = '2026-10-07 12:30:00'
AND id < 381245
)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;
这就是 Keyset Pagination,也经常被称为 Cursor Pagination。
它真正解决的问题不是“把 OFFSET 换成另一个参数”,而是告诉数据库下一页应该从哪里继续。
如果索引设计正确,数据库可以从游标所在的位置继续向后扫描,而不需要为了寻找第50001页,再处理前面的大量结果。
这也是为什么新闻流、评论流、订单列表、消息列表等“只需要继续往后看”的业务,非常适合这种分页方式。
三、为什么不能简单使用 id > last_id
很多教程会直接写:
WHERE id > last_id
ORDER BY created_at DESC
LIMIT 20;
这实际上存在排序逻辑不一致的问题。
假设:
id created_at
100 10:00
101 09:59
102 09:58
数据库按照 created_at DESC 排序,但分页条件却按照 id > last_id 判断,两者并不是同一个排序体系。
更合理的方法是让游标和排序字段保持一致。例如:
ORDER BY created_at DESC, id DESC
那么下一页就应该使用:
(created_at, id) < (:last_created_at, :last_id)
在 MySQL 中也可以使用展开后的条件:
WHERE created_at < :last_created_at
OR (
created_at = :last_created_at
AND id < :last_id
)
这里把 id 放在最后,是为了处理多个记录拥有相同时间戳的情况。
例如一秒钟内同时发布了20篇文章,如果只使用:
WHERE created_at < :last_created_at
就可能漏掉与游标具有相同时间戳的记录。
因此,实际系统中的分页游标通常需要一个稳定的排序键组合。
四、索引必须跟着查询方式设计
对于上面的新闻查询,如果经常执行:
WHERE publish_status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20
就应该根据实际数据分布和执行计划设计复合索引,例如:
CREATE INDEX idx_news_status_time_id
ON news (publish_status, created_at, id);
这样数据库可以利用索引处理过滤和排序。
但索引设计没有一个适用于所有网站的固定答案。假如查询条件、排序方式或者数据分布发生变化,最佳索引也可能变化。
例如另一个查询是:
WHERE category_id = 5
AND publish_status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;
它需要考虑的索引结构就与前面的查询不同。
因此不要看到某个字段经常出现在 WHERE 中,就机械地给它建立单列索引。更重要的是把真实查询拿出来,用 EXPLAIN 或 EXPLAIN ANALYZE 检查执行计划。
五、覆盖索引很有用,但不要把它理解成万能加速器
所谓覆盖索引,是指查询需要的列已经全部包含在索引中,数据库可以直接从索引读取结果,不必为了这些列回到聚簇索引记录中。
例如:
SELECT id, created_at
FROM news
WHERE publish_status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;
如果索引能够覆盖这些字段,查询可能非常高效。
但是,如果页面需要:
SELECT id, title, content, author, image, created_at
把这些字段全部塞进索引未必是好主意。索引越宽,占用空间越大,写入成本也越高。
实际网站更常见的做法是先用索引快速找到20个ID,然后再根据这些ID获取完整记录。
例如:
SELECT id
FROM news
WHERE publish_status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;
然后:
SELECT id, title, content, author, image, created_at
FROM news
WHERE id IN (...);
这种“两阶段查询”在某些宽表、复杂排序和大数据量场景下很有价值,但是否更快仍然应该通过实际执行计划和基准测试确认。
六、不是所有分页都应该改成 Cursor
如果用户的网站需要直接输入:
第1页
第50页
第100页
第500页
那么 Keyset Pagination 就没有 OFFSET 那么方便。
例如电商后台管理员可能要求直接跳到第37页。这种需求本身就需要定位一个绝对位置。
因此实际系统通常可以采用混合策略。
普通用户浏览新闻:
上一页 / 下一页
采用 Cursor。
后台管理系统:
第1页、第2页……第N页
可以继续使用 OFFSET,但限制最大可跳页数。
对于非常深的页面,则可以要求用户使用搜索条件、日期范围、分类、状态等缩小结果集,而不是让数据库承担:
LIMIT 5000000, 20
这样的深分页。
七、缓存不能解决错误的分页算法
Redis 可以缓存热门查询,但不能因此认为:
LIMIT 很慢 → Redis 全部缓存 → 问题解决
例如首页第一页每天有几十万次访问,把热门结果缓存起来非常合理。
但如果用户不停访问:
第1页
第2页
第3页
……
第5000页
缓存每一个分页结果,反而可能制造大量低命中率缓存。
对于动态新闻网站,更合理的做法通常是缓存热点页面、热点分类、热门文章ID列表等,而不是机械地缓存所有 OFFSET 分页。
同时还要考虑缓存失效。文章发布、删除、置顶、分类变化后,相关缓存是否及时更新,往往比“有没有使用Redis”更加重要。
八、数据量特别大时再考虑分片
Sharding 可以解决单表、单库规模继续增长的问题,但它不是 LIMIT 分页的第一优化手段。
如果一张表只有几百万行,却因为:
LIMIT 100000,20
查询很慢,就直接上分库分表,通常属于架构过度。
应该先解决:
查询条件
索引
排序
Keyset Pagination
返回字段
回表
缓存
这些基础问题。
只有当单机数据库的存储容量、写入能力、查询压力或者业务隔离需求真正达到瓶颈时,才应该考虑 Sharding。
而一旦分片,跨分片排序、全局分页、全局唯一ID、聚合查询都会变得更加复杂。
九、EXPLAIN 才是判断分页到底慢在哪里的工具
不要看到 LIMIT 很大就直接修改SQL。
例如:
EXPLAIN
SELECT id, title, created_at
FROM news
WHERE publish_status = 1
ORDER BY created_at DESC, id DESC
LIMIT 100000, 20;
重点应该观察:
key
type
rows
Extra
以及在需要的时候使用:
EXPLAIN ANALYZE
观察实际执行情况。
type = ALL 通常意味着全表扫描,但不能简单地把任何 ALL 都视为错误;小表、特殊查询或者优化器判断全表扫描成本更低时,都可能出现这种执行计划。
同样,Using filesort 也不等于“查询一定很慢”。它表示排序没有完全通过索引顺序完成,是否构成实际瓶颈还要结合数据量、排序规模和执行时间判断。
十、真正的大数据分页优化是什么
假设一个新闻网站已经有几千万篇文章,首页只需要最新20篇。
低效方案可能是:
SELECT *
FROM news
ORDER BY created_at DESC
LIMIT 1000000, 20;
更合理的方向是:
SELECT id, title, created_at
FROM news
WHERE publish_status = 1
ORDER BY created_at DESC, id DESC
LIMIT 20;
下一页使用上一页最后一条记录作为游标:
SELECT id, title, created_at
FROM news
WHERE publish_status = 1
AND (
created_at < :created_at
OR (
created_at = :created_at
AND id < :id
)
)
ORDER BY created_at DESC, id DESC
LIMIT 20;
然后针对这个查询建立与实际业务匹配的复合索引,再用 EXPLAIN ANALYZE 验证。
如果页面还需要大量正文、图片和其他字段,可以进一步采用“先取ID,再批量获取完整记录”的方式。
这才是从数据库执行层面减少无意义工作的办法。
MySQL 分页优化的核心,并不是简单地把 LIMIT 换成某一个所谓的“高级分页插件”,也不是给表疯狂增加索引。真正需要解决的是一个更具体的问题:用户只需要20条数据,数据库有没有被迫为了找到这20条数据而处理几十万、几百万甚至更多的无关记录。
如果业务允许顺序翻页,Keyset Pagination 通常是大数据分页最值得优先考虑的方案;如果业务必须支持任意页码,就需要接受 OFFSET 深分页的成本,并通过索引、查询条件、最大页码、缓存和数据范围限制控制它。
当数据从几万增长到几百万、几千万以后,最有价值的优化往往不是“换一个更快的LIMIT写法”,而是重新设计分页所依据的排序键、索引和查询路径。让数据库从正确的位置继续寻找下一页,而不是每次都从头数到目标位置,这才是大数据分页真正的优化方向。
MySQL LIMIT 分页为什么越翻越慢?大数据量网站可以怎样优化
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP