因为数据库必须扫描并丢弃前OFFSET行,如OFFSET 1000000 LIMIT 10需读取1000010行再丢弃前100万行,I/O与CPU开销线性增长;优化应采用游标分页(WHERE排序键>上一页末值)或延迟关联(子查询取主键再JOIN),且依赖正确索引设计。

为什么 OFFSET + LIMIT 深翻页会越来越慢
因为数据库必须先扫描并跳过前 OFFSET 行,哪怕你只要 10 条。比如 OFFSET 1000000 LIMIT 10,MySQL 或 PostgreSQL 都得把前一百万行全读出来、排序(如果带 ORDER BY)、再丢弃——磁盘 I/O 和 CPU 排序成本直线上升。
而 ROW_NUMBER() 本身不解决性能问题,它只是个窗口函数;真正起作用的是用它配合“游标式分页”(cursor-based pagination),绕过 OFFSET。
- 适用前提:排序字段必须有唯一性约束(如主键、或组合唯一索引),否则
ROW_NUMBER()可能生成重复序号,导致漏数据或重复 - 不能直接用
WHERE ROW_NUMBER() > 1000000—— 窗口函数不能在WHERE中引用,必须套子查询或 CTE - PostgreSQL 支持在
WHERE后直接用OFFSET,但深翻页依然慢;SQL Server 的OFFSET-FETCH同理
用 ROW\_NUMBER 实现游标分页的正确写法
核心思路:不记“第几页”,而记“上一页最后一条的排序值”。例如按 created_at DESC, id DESC 排序,上一页最后一条是 created_at = '2024-05-01 10:23:45', id = 8872,那下一页就查:WHERE (created_at, id) 。
ROW_NUMBER() 在这里只用于调试或兼容旧逻辑,比如你要验证某条记录是不是第 1000005 行:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY created_at DESC, id DESC) AS rn FROM posts ) t WHERE rn BETWEEN 1000001 AND 1000010;
但这个写法仍需全表扫描排序,仅适合一次性校验,不可用于线上分页接口。
- 生产环境务必用游标(即基于排序字段的条件过滤),
ROW_NUMBER()不参与分页逻辑 - 如果排序字段有重复值(如多个记录
created_at相同),必须加入一个唯一字段(如id)组成复合排序,否则游标无法精确定位 - MySQL 8.0+、PostgreSQL 8.4+、SQL Server 2005+ 都支持
ROW_NUMBER(),但语法细节略有不同(如 MySQL 不支持ROWS UNBOUNDED PRECEDING的显式声明,默认就是)
ROW\_NUMBER 配合索引才能不拖慢查询
即使用了游标,如果排序字段没索引,ROW_NUMBER() 内部的 ORDER BY 仍会触发文件排序(Using filesort)。必须确保 OVER (ORDER BY a, b) 的字段顺序,和已有索引的最左前缀完全一致。
- 错误索引:
INDEX(b, a)→ 对ORDER BY a, b无效 - 正确索引:
INDEX(a, b)或INDEX(a, b, id)(如果游标含id) - PostgreSQL 还可建表达式索引,比如
CREATE INDEX idx_posts_created_desc_id_desc ON posts ((created_at DESC), (id DESC)); - 执行前一定看
EXPLAIN:确认type是range或index,且Extra没有Using temporary; Using filesort
容易被忽略的边界情况
游标分页不是银弹。以下情况不处理好,会导致数据错乱或跳页:
- 新插入记录可能挤进旧页面中间:比如按时间倒序,用户 A 在第 5 页看到某条 10:00 的记录,后台又插入一条 10:05 的记录,下次刷新时这条新记录会顶到第 1 页,而原来第 5 页的记录可能变成第 6 页——这是正常行为,但前端要接受“刷新后位置偏移”
- 删除记录会导致游标“跨空”:上一页最后一条被删了,下一页查询可能直接跳过若干条。解决方案是查不到预期数量时,自动向前回溯(例如多取 10 条,剔除已删 ID)
-
ROW_NUMBER()在存在PARTITION BY时重置计数,如果误加,会导致序号不连续,无法用于全局分页定位
真正稳定的深分页,靠的是游标 + 唯一排序 + 覆盖索引。把 ROW_NUMBER() 当成调试工具就好,别让它出现在线上分页的主 SQL 里。

















