如何用 ROW_NUMBER() OVER 实现跨页去重分页

来源:Vuejs教程作者:乙爱丽丝头衔:网络博主
导读:本期聚焦于乙爱丽丝创作的《如何用 ROW_NUMBER() OVER 实现跨页去重分页》,敬请观看详情。分页查询时发现每页数据加起来比总数多,翻页还出现重复记录,这类问题十有八九和排序不稳定有关。本文围绕 ROW_NUMBER() OVER 窗口函数展开,先解释传统 LIMIT 加 OFFSET 分页为什么会出现重复或丢失数据,再介绍用 ROW_NUMBER() 为每行生成全局唯一序号的思路,配合去重逻辑实现稳定分页。文中给出 MySQL、SQL Server 等数据库的完整写法,包含按业务字段去重、键集分页优化等常见方案,并对比不同场景下的性能差异,帮助你彻底解决跨页重复数据的困扰。

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

如何用 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

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260903/49316.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。