ROW_NUMBER()非银弹,需配合主键/索引过滤,否则深分页仍会断崖式下跌;OFFSET FETCH因强制扫描丢弃前N行而越翻越慢,即使有索引也无法避免线性开销。

ROW_NUMBER() 不是分页的银弹,直接套用会拖慢大偏移量查询——必须配合主键/索引字段过滤,否则跳过前 10 万行时性能会断崖式下跌。
为什么 OFFSET FETCH 写法在深分页时越来越慢
SQL Server 和 PostgreSQL 支持 OFFSET 100000 ROWS FETCH NEXT 20 ROWS ONLY,但数据库仍需扫描并丢弃前 10 万行。即使有索引,ORDER BY 字段上存在重复值时,ROW_NUMBER() 还可能因排序不确定性导致翻页错位。
- 执行计划里常看到
Top N Sort或Materialize节点,说明在内存中做了全量排序 - 如果
ORDER BY列没有索引,或索引未覆盖SELECT所有字段,还会触发回表(Key Lookup) - MySQL 8.0+ 虽支持
ROW_NUMBER(),但不支持OFFSET优化,同样面临深分页瓶颈
用 ROW_NUMBER() + 主键范围替代 OFFSET 的实操写法
核心思路:不跳行,而是记住上一页最后一条记录的排序键值,下一页只查“比它大”的数据。这要求 ORDER BY 至少包含一个唯一列(如主键)来消除歧义。
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY created_at DESC, id DESC) AS rn FROM orders ) t WHERE rn BETWEEN 100001 AND 100020;
⚠️ 这种写法依然会扫全表生成 10 万+ 行序号——真正高效的是下面这种:
SELECT TOP 20 *
FROM orders
WHERE (created_at, id) < ('2024-05-01', 98765)
ORDER BY created_at DESC, id DESC;- 把
ROW_NUMBER()的逻辑交给应用层记忆上一页末尾的(created_at, id)值 - 数据库只需走索引范围扫描,无需计算序号,响应时间稳定在毫秒级
- 必须确保
ORDER BY中的字段组合有联合索引,例如CREATE INDEX idx_created_id ON orders(created_at DESC, id DESC)
ROW_NUMBER() 在分页中的合理使用场景
它适合出现在「有限制的上下文」里,而不是裸用在分页主查询中:
- 做单页内排序编号:比如“显示当前页第 1~20 条,每条标上序号”,这时
ROW_NUMBER() OVER (ORDER BY score DESC)只作用于已筛选出的 20 行,开销可忽略 - 配合 CTE 实现带条件的 Top-N:如“每个用户最新 3 条订单”,此时
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC)是不可替代的 - 调试分页逻辑时临时加序号字段验证排序是否一致,上线后应删掉
真正卡住分页性能的从来不是函数本身,而是没意识到数据库不会“跳着读”——它只能从头数。所以关键不是怎么写 ROW_NUMBER(),而是怎么让下一页请求避开“数到 10 万”这个动作。

















