重复订单号的排查,是数据清洗中的常见场景。很多人第一反应是用 GROUP BY + HA VING COUNT > 1 来找出哪些订单号重复了,但这样只能得到一个聚合结果,看不到每条记录的具体细节。更高效的做法,是借助窗口函数 ROW_NUMBER() 给每个订单号下的记录打上“第几次出现”的标签——序号大于1的,自然就是重复项。
实操时有三点需要注意:
PARTITION BY order_no是必须的,否则整个表只排一次序,分组就失去了意义。ORDER BY子句要明确:推荐用时间字段(如created_at),避免无序导致序号不稳定;如果没时间字段,至少加个id兜底。- 千万别在
WHERE里直接写rn > 1——窗口函数不能在WHERE中引用,必须套一层子查询或 CTE。
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY order_no ORDER BY created_at, id) AS rn FROM orders) t WHERE t.rn > 1;

为什么不用 COUNT() OVER?它更适合“标记所有重复行”场景
COUNT() OVER (PARTITION BY order_no) 返回的是每个订单号的总出现次数。比如某个 order_no 出现了3次,那这3行的计数都是3。它不区分“首次”还是“后续”,适合需要保留全部重复记录,并且标注“这个订单号一共出现了X次”的场景。
需要留意的是:COUNT(*) 和 COUNT(order_no) 在 order_no 为 NULL 时行为不同——前者统计所有行,后者忽略 NULL。业务中订单号一般非空,但如果存在脏数据,建议显式写成 COUNT(*) 来避免歧义。
SELECT *, COUNT(*) OVER (PARTITION BY order_no) AS cntFROM ordersWHERE COUNT(*) OVER (PARTITION BY order_no) > 1;
⚠️ 这条语句在部分数据库(比如 MySQL 8.0 以前的版本)会直接报错,因为窗口函数不能放在 WHERE 里。稳妥的写法仍然是子查询或 CTE。
查出重复后,怎么快速定位问题源头?加 LEAD/LAG 看相邻记录差异
光知道哪些订单号重复还不够,得看它们是不是来自同一渠道、同一用户,或者时间间隔异常短。这时候,LEAD(order_user_id, 1) OVER (PARTITION BY order_no ORDER BY created_at) 能拿到同订单号下一条记录的用户ID,对比看是否一致;用 LAG(created_at) 算出时间差,就能发现秒级连发的异常下单行为。
几个关键点:
LEAD/LAG的偏移量默认是1,想往前看两条就写成LAG(created_at, 2)。- 如果分区内的行数不够(比如某个订单号只出现一次),
LEAD会返回NULL,需要配合COALESCE或条件判断来避免逻辑断裂。 - 时间差的计算依赖数据库函数:
EXTRACT(EPOCH FROM ...)(PostgreSQL)、TIMESTAMPDIFF(SECOND, ...)(MySQL)、DATEDIFF(second, ...)(SQL Server)——注意别混用。
性能陷阱:没索引的 PARTITION BY 字段会让窗口函数慢十倍
如果 order_no 没建索引,尤其当表超过百万行时,PARTITION BY order_no 会导致全表扫描加内部排序,执行时间可能从毫秒级直接跳到分钟级。这不是窗口函数本身的问题,而是数据库无法高效定位相同 order_no 的连续块。
验证方法很简单:
- 跑一把
EXPLAIN(或者EXPLAIN ANALYZE),看有没有Sort节点且 cost 极高。 - 检查
order_no列是否已存在索引:SELECT * FROM pg_indexes WHERE tablename = 'orders' AND indexdef LIKE '%order_no%';(PostgreSQL 的示例,其他数据库自查)。 - 如果没有,马上加索引:
CREATE INDEX idx_orders_order_no ON orders(order_no);(B-tree 即可,不需要唯一)。
一个容易忽略的细节是:即使你只查最近7天的数据,只要 PARTITION BY 字段没索引,数据库仍可能扫全表——因为窗口函数的分组逻辑优先于 WHERE 条件执行。索引建好了,性能才能稳。
