ROW_NUMBER() OVER 不能直接与 DISTINCT 共用,因二者逻辑执行顺序冲突;正确做法是先去重再编号,推荐用 PARTITION BY + ROW_NUMBER() 打标后取 rn=1,并采用游标分页替代 OFFSET 避免并发漏数。

ROW_NUMBER() OVER 去重分页为什么不能直接用 DISTINCT
因为 ROW_NUMBER() 是窗口函数,必须作用于已确定的行集;而 DISTINCT 是去重操作,发生在逻辑查询处理顺序的更后阶段(在 ORDER BY 之后),两者无法直接共存于同一层 SELECT。强行写成 SELECT DISTINCT ..., ROW_NUMBER() OVER (...) FROM ... 会报错或语义错误——数据库不知道该先去重还是先编号。
正确做法:先去重再编号,用子查询或 CTE 包裹
核心思路是把去重逻辑提前到最内层,确保 ROW_NUMBER() 作用在已经去重后的结果上。常见写法有两种:
- 用
GROUP BY替代DISTINCT,并选一个确定性聚合(如MIN(id))保留下每组代表行 - 用
ROW_NUMBER() OVER (PARTITION BY ... ORDER BY ...)在去重字段上打标记,外层只取rnum = 1
推荐后者,可控性强。例如按 user_id 和 event_type 去重,保留最新一条:
WITH deduped AS (
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY user_id, event_type
ORDER BY created_at DESC, id DESC
) AS rn
FROM events
)
SELECT id, user_id, event_type, created_at
FROM deduped
WHERE rn = 1
ORDER BY created_at DESC
OFFSET 20 ROWS FETCH NEXT 10 ROWS ONLY;
跨页分页时 OFFSET 的陷阱与替代方案
上面的 SQL 看似可行,但 OFFSET 20 是对「去重后结果集」跳过前 20 行,而用户通常想要的是「第 3 页、每页 10 条」——这要求前后页之间严格可衔接。问题在于:如果去重逻辑导致第 2 页末尾和第 3 页开头的边界数据在不同查询中因并发写入发生变动(比如新插入一条更高 created_at 的同 user_id/event_type 记录),OFFSET 分页会出现漏数或重复。
- 真正可靠的跨页分页必须基于游标(cursor-based pagination),即用上一页最后一条的排序键作为下一页起点
- 例如上一页最后一条是
(created_at = '2024-05-01 10:20:30', id = 1005),下一页应查:WHERE (created_at, id) ,再套去重逻辑 -
OFFSET/FETCH只适合静态快照场景,不建议用于高并发、实时性要求高的列表
性能关键点:索引必须覆盖 PARTITION BY + ORDER BY 字段
ROW_NUMBER() OVER (PARTITION BY a, b ORDER BY c DESC) 的执行效率极度依赖索引。如果没有合适索引,数据库会强制排序整个中间结果集,去重分页可能从毫秒级退化到秒级甚至超时。
- 理想索引:
CREATE INDEX idx_events_de_dup ON events (user_id, event_type, created_at DESC, id DESC); - 注意字段顺序:PARTITION BY 列必须在前,ORDER BY 列紧随其后,且方向一致(ASC/DESC 要匹配)
- 如果
created_at有大量重复,务必加入一个唯一列(如id)做二级排序,避免窗口函数内部排序不稳定
去重分页不是加个 ROW_NUMBER() 就完事,真正难的是让去重逻辑稳定、分页边界可预测、执行路径能走索引。这三个条件缺一不可。










