做过列表页开发的人大概率遇到过这种诡异现象:第一页和第二页出现了同一条记录,或者统计总数是 1000 条,翻到最后一页却总共只显示 960 条。数据既没丢也没多,问题其实出在分页 SQL 本身。当排序字段不唯一时,数据库对相同排序值的行返回顺序是不确定的,每次执行 LIMIT 查询都可能拿到不同的排列,于是跨页就出现了重复或遗漏。ROW_NUMBER() OVER 窗口函数正是解决这类问题的利器,它能为每一行数据生成一个确定的、全局唯一的序号,让分页变得可预测。

传统分页为什么会重复或丢数据
最经典的分页写法是 ORDER BY 加 LIMIT 加 OFFSET,例如 MySQL 中的 SELECT * FROM orders ORDER BY create_time DESC LIMIT 20 OFFSET 40。这种写法隐含了一个前提:排序必须是全序的,也就是任意两行通过排序键都能分出先后。可一旦排序字段存在大量重复值,比如很多订单的 create_time 完全相同,数据库在相同值内部的排序顺序就没有保证了。
具体来说,数据库执行 LIMIT 加 OFFSET 查询时,理论上会先对全表按排序键排好,再跳过 OFFSET 指定的行数取后续数据。但实际执行计划往往不会真的完整排序,特别是在有索引可用的情况下,优化器可能借助索引扫描加过滤的方式提前终止。不同的执行路径对相同排序值的处理可能不同,这就导致第一次查询第 2 页跳过的 40 行,和第二次查询第 3 页跳过的 60 行中,那批 create_time 相同的记录排列顺序不一致,结果就是你翻页时看到重复。
还有一个更隐蔽的问题:如果数据在翻页期间发生变化,比如新订单插入导致数据整体后移,传统分页会出现整块数据被跳过的现象。这两个问题的根源是一样的——分页依据的行位置不是一个稳定值。解决办法就是给每一行一个不随查询变化的序号,这就是 ROW_NUMBER() 的用武之地。
ROW_NUMBER() OVER 的基本用法与稳定排序
ROW_NUMBER() 是一个窗口函数,作用是按照 OVER 子句指定的规则给分区内的每一行编号,从 1 开始连续递增。它最大的特点是编号与行的对应关系在单次查询内是确定的,只要我们把它作为子查询结果再进行过滤,分页边界就完全可控了。
先看一个基础模板,这里以 MySQL 8.0 以上版本为例,SQL Server 和 PostgreSQL 语法基本一致:
SELECT *
FROM (
SELECT
t.*,
ROW_NUMBER() OVER (ORDER BY t.create_time DESC, t.id DESC) AS rn
FROM orders t
) AS x
WHERE x.rn > 40 AND x.rn <= 60;这里的关键细节是 ORDER BY 里除了业务排序字段 create_time,还追加了主键 id 作为兜底排序。这一步千万不能省,只有加上唯一字段,整个排序才是全序,ROW_NUMBER 生成的序号才唯一且稳定。即便 create_time 完全相同的两行,也会按 id 的大小分出先后,翻页时不会互相串位。
需要强调的是,ROW_NUMBER 生成的序号只在当前查询结果集内有效,它不是持久化的物理位置。所以这个方案解决的是排序不稳定导致的重复问题,如果数据在翻页过程中大量增删,序号会整体偏移。对实时性要求极高的场景,更推荐后面提到的键集分页方案。
结合 PARTITION BY 实现分组去重分页
窗口函数真正强大的地方在于 PARTITION BY 分区能力。很多业务场景下,原始数据是一对多关系,比如一个用户有多条订单记录,列表页却只想按用户展示最新一条。传统做法是用 GROUP BY 配合子查询关联,写法繁琐且性能一般,用 ROW_NUMBER 加 PARTITION BY 就非常自然。
典型写法如下:
SELECT user_id, order_id, amount, create_time
FROM (
SELECT
o.*,
ROW_NUMBER() OVER (
PARTITION BY o.user_id
ORDER BY o.create_time DESC, o.id DESC
) AS rn
FROM orders o
WHERE o.status = 1
) AS t
WHERE t.rn = 1
ORDER BY t.create_time DESC, t.id DESC
LIMIT 20 OFFSET 0;这段 SQL 的执行逻辑分两步理解。内层查询按 user_id 分区,在每个用户自己的订单集合内按时间倒序编号;外层取 rn 等于 1 的记录,即每个用户最新的一条有效订单。这样得到的就是一个已经去重的用户级列表,再在外层做常规分页即可。
如果想在一个查询里同时完成去重和分页,还可以嵌套两层 ROW_NUMBER,内层负责去重编号,外层对去重后的集合重新编号再过滤页码:
SELECT *
FROM (
SELECT
d.*,
ROW_NUMBER() OVER (ORDER BY d.create_time DESC, d.id DESC) AS page_rn
FROM (
SELECT o.*,
ROW_NUMBER() OVER (
PARTITION BY o.user_id
ORDER BY o.create_time DESC, o.id DESC
) AS rn
FROM orders o
) AS d
WHERE d.rn = 1
) AS p
WHERE p.page_rn > 20 AND p.page_rn <= 40;这种写法的好处是逻辑集中、一次查询完成,缺点是嵌套层次多,数据量大时全表都要参与窗口计算。如果 orders 表达到千万级,建议给 user_id、create_time 建立联合索引,或者先把去重结果落到临时表、缓存中再分页。
性能优化与键集分页的取舍
ROW_NUMBER 分页虽然稳定,但它有一个无法回避的开销:即使只要第 100 页的 20 条数据,数据库也得先为前面所有行编号。页码越深,扫描的行越多,深分页场景下性能会明显下降。可以对比看一下两种方式在千万级表上的表现差异。
| 分页方式 | 第 1 页耗时 | 第 1000 页耗时 | 数据一致性 |
|---|---|---|---|
| LIMIT OFFSET | 快 | 明显变慢 | 排序不稳定时重复 |
| ROW_NUMBER 过滤 | 较快 | 变慢但可控 | 序号确定,无重复 |
| 键集分页 | 快 | 始终快 | 需要记录游标位置 |
对于深分页且数据频繁变动的场景,键集分页(也叫游标分页)是更优解。思路是不再按偏移量定位,而是记住上一页最后一条记录的排序值,下一页直接从这个值往后取:
-- 假设上一页最后一条记录的 create_time 和 id 分别是 '2024-06-01 12:00:00' 和 8899 SELECT * FROM orders WHERE create_time < '2024-06-01 12:00:00' OR (create_time = '2024-06-01 12:00:00' AND id < 8899) ORDER BY create_time DESC, id DESC LIMIT 20;
注意条件里的复合比较写法,因为排序键是 create_time 加 id 两个字段,边界条件也必须覆盖两个字段,否则相同 create_time 的记录还是会漏。这种写法能直接命中 (create_time, id) 联合索引,无论第几页性能都恒定,天然避免重复。
总结一下选型建议:普通列表页、需要跳页的场合,用 ROW_NUMBER 加唯一字段兜底排序,稳定且兼容性好;去重类需求叠加 PARTITION BY;App 端无限下滑、不需要跳页的场景,优先考虑键集分页。理解了序号稳定性这个核心问题后,三种方案可以灵活组合,跨页重复的坑基本就填平了。
ROW_NUMBERSQL分页窗口函数去重修改时间:2026-09-03 04:02:48