ROW_NUMBER() 直接用于高基维长尾key会加剧数据倾斜,必须通过加盐两阶段编号:先打散热点key再局部排序,最后归并取代表;同时需过滤NULL/空字符串并避免COUNT(DISTINCT)误用。

ROW_NUMBER() 本身会加剧数据倾斜
直接用 ROW_NUMBER() OVER (PARTITION BY user_id) 处理高基维且长尾分布的 key(比如少数用户占点击量 90%),会让所有同 user_id 的行被拉到同一个 task,完全不 shuffle,等于把压力全堆给一个 executor。这不是“解决”倾斜,是主动制造单点瓶颈。Spark 不会对窗口函数做预聚合或分片,它只保证分区内部有序,所以一旦 PARTITION BY 字段存在热点,ROW_NUMBER() 就会卡死。
必须配合加盐(salting)做两阶段编号
核心思路是:先打散热点 key,再局部编号,最后归并取代表。不能跳过“拆”这一步。
- 第一阶段:对原始 key 加随机后缀,比如
CONCAT(user_id, '_', CAST(FLOOR(RAND() * 50) AS STRING)),把一个大user_id拆成最多 50 个子组 - 第二阶段:按新字段分组聚合基础指标(如最早时间、事件类型),再用
ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY salted_group)给每个用户下的子组排序 - 第三阶段:过滤出每用户下
ROW_NUMBER = 1的记录,作为该用户的代表样本或聚合入口
盐值数量选 10–50 足够,太多会导致小 key 的 group by 开销反超;太少则无法压平长尾。注意 RAND() 在 Spark SQL 中每次调用返回不同值,必须在子查询里固化一次,否则外层引用会不一致。
COUNT(DISTINCT) 场景下别硬套 ROW_NUMBER()
想靠 ROW_NUMBER() 实现“每个用户去重后的第 N 条记录”?这是典型误用。因为 ROW_NUMBER() 作用在原始行上,不去重,结果不可控。真实需求往往是:每个用户首次出现的某个设备 ID 或 去重后按时间排序的第三种行为。
- 若需精确去重后编号,先用
COLLECT_SET(device_id) OVER (PARTITION BY user_id)收集集合,再用SIZE()或element_at()取值——但必须确保device_id是有限枚举,否则集合膨胀 OOM - 若可接受近似,改用
APPROX_COUNT_DISTINCT(device_id) OVER (PARTITION BY user_id),支持窗口且误差可控 - 绝对不要写
COUNT(DISTINCT device_id) OVER (PARTITION BY user_id),Spark SQL 会报错或退化为全广播
容易忽略的 null 和空字符串陷阱
PARTITION BY 字段含大量 NULL 或空字符串时,它们会被归为同一分区,极易形成隐式热点。比如日志中 user_id IS NULL 的记录有 200 万条,ROW_NUMBER() OVER (PARTITION BY user_id) 会把这 200 万全塞进一个 task。
- 提前用
WHERE user_id IS NOT NULL AND user_id != ''过滤掉无效值 - 或把
NULL映射为随机值:COALESCE(user_id, CONCAT('null_', FLOOR(RAND() * 100))),避免集中 - 检查执行计划,确认
Window节点前是否有Filter或Project消除了空值
加盐和过滤必须在窗口计算之前完成,顺序错了就白忙。窗口函数不改变数据分布,只消费已有的分布——它永远不是起点,而是终点。

















