必须用row_number()去重,因其为每行生成唯一序号,而rank()/dense_rank()在排序值相同时会分配相同序号,导致rn=1匹配多行;需配合partition by分组、order by(含兜底字段)排序,并通过cte或子查询筛选rn=1。

窗口函数去重必须用 ROW_NUMBER(),别用 RANK() 或 DENSE_RANK()
因为去重目标是“每组只留一行”,而 RANK() 和 DENSE_RANK() 在排序值相同时会分配相同序号,导致 rn = 1 可能匹配多行。只有 ROW_NUMBER() 保证每行编号唯一,哪怕 ORDER BY 字段完全相同,也会按物理顺序(或优化器决定的顺序)强加一个唯一序号。
常见错误是直接套用排名函数却不验证重复序号:
- 写
RANK() OVER (PARTITION BY user_id ORDER BY login_time DESC)→ 若两人同秒登录,可能两个都得rn = 1,WHERE rn = 1就漏删了 - 正确做法是加兜底排序:例如
ORDER BY login_time DESC, login_id DESC,确保排序键组合唯一
PARTITION BY 字段必须和业务去重逻辑严格一致
比如要“每个用户每天只保留一条浏览记录”,PARTITION BY 就得是 user_id, DATE(login_time);如果只写 user_id,就会把同一天多次浏览全压成一条,违背需求。
容易踩的坑:
- 忽略隐式类型转换:比如
PARTITION BY user_id中user_id是字符串,但值里有前后空格,会导致同个用户被分到不同组 → 先清洗再分区 - 时间字段没截断:用
login_time分区但实际要按天去重 → 必须写PARTITION BY user_id, CAST(login_time AS DATE)或DATE(login_time) - MySQL 8.0+ 支持,但旧版不支持窗口函数 → 先查
SELECT VERSION()确认环境
CTE + WHERE rn = 1 是最安全的写法,别省略 CTE
直接在主查询里嵌套 ROW_NUMBER() 会导致语法错误(多数数据库不允许在 WHERE 中引用窗口函数结果),必须用 CTE 或子查询包裹。
正确结构:
WITH ranked AS (
SELECT *,
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY login_time DESC, login_id DESC) AS rn
FROM logins
)
SELECT user_id, login_time, login_id
FROM ranked
WHERE rn = 1;
注意点:
- CTE 中必须显式列出所有需要的字段,别用
*后又在外部SELECT *—— 某些引擎(如 Presto)会报错 - 如果原表很大,先用
WHERE过滤再进 CTE,比如只处理近30天数据,避免全表扫描 -
rn = 1不能写成rn ,虽等价但可读性差,且部分优化器不会识别这种等价
性能关键:给 PARTITION BY + ORDER BY 字段建联合索引
窗口函数执行时,数据库需对每个分区做排序。若没有合适索引,会触发大量临时磁盘排序,慢得明显。
比如语句中是 PARTITION BY user_id ORDER BY login_time DESC,最优索引是:
CREATE INDEX idx_user_login ON logins (user_id, login_time DESC);
其他情况:
- 如果还用了
login_id作第二排序字段,索引应为(user_id, login_time DESC, login_id DESC) - PostgreSQL 支持降序索引,MySQL 8.0+ 也支持,但 MySQL 5.7 不支持
DESC关键字,只能建(user_id, login_time)然后靠排序优化器处理 - 别只建单列索引:
user_id和login_time各自的索引对窗口排序帮助很小
PARTITION BY 和 ORDER BY 的组合——多一个字段、少一个字段、顺序颠倒,结果就可能全错。写完一定要用小样本数据手工核对几组,尤其关注边界情况:空值、时间相同、字符编码差异。











