考察点
去重是数仓开发的家常便饭,面试官想看你是否理解「去重」其实有多种语义:完全重复行、业务键重复留最新、重复留最早、按优先级留。追问方向:为什么不用 distinct、rn=1 和 max 回 join 哪个快、多版本数据怎么取最新。
参考答案
基准模板:保留每个业务键的最新一条
CDC 同步、拉链表快照、重复上报清洗,最常见的需求是「同一个订单有多条记录,留更新时间最新的」:
select order_id, status, amount, update_time
from (
select *,
row_number() over (partition by order_id order by update_time desc) as rn
from orders_cdc
) t
where rn = 1;
三个要素:partition by 业务键(判重的粒度)、order by 决定留谁(desc 留最新,asc 留最早)、rn = 1 取头名。这个模板是所有变体的母体。
变体一:多字段优先级排序
更新时间可能相同,需要加二级甚至三级排序键保证结果确定:
row_number() over (
partition by order_id
order by update_time desc, version desc, id desc
) as rn
生产上去重必须让排序完全确定,否则两次跑的结果可能不同,下游对不上账。常见 tiebreaker:自增 id、binlog 位点、数据版本号。面试时主动提「排序键要唯一确定」是加分项。
变体二:留最早 / 留指定状态
- 留最早一条(首单标记):order by 改 asc。
- 留某个状态优先的一条(比如「支付成功」优先于「已取消」):用 case 构造优先级
order by case when status='SUCCESS' then 0 else 1 end, update_time desc。 - 每组留前 N 条(不是去重是截断):rn <= N,比如「每个设备保留最近 10 次定位」。
变体三:保序去重(distinct 的数组版)
行转列场景里,要「去重但保持出现顺序」,collect_set 去重不保序,collect_list 保序不去重。组合拳:
select user_id,
concat_ws(',', sort_array(collect_list(concat(lpad(rn, 6, '0'), '_', tag)))) ...
更干净的写法是先用 row_number 给每个 (user_id, tag) 首次出现的位置编号,取 rn=1 再按编号排序拼接。本质还是 row_number 去重,只是排序键换成了「首次出现顺序」。
和 distinct、group by 的选型
- 整行完全重复:
select distinct *或group by 全部列,最简单,引擎做一次哈希去重。 - 只要业务键和聚合值,不要完整行:
group by 业务键 + max/min 聚合,比如select user_id, max(login_date) from t group by user_id,一次聚合搞定,比窗口函数省排序。 - 要完整行、按规则留一条:必须 row_number(或者 max 回 join)。distinct 做不到「留最新」,group by 拿不到整行。
三者的执行代价也不同:distinct/group by 是哈希聚合,数据可以被 combiner 部分预聚合;row_number 需要 partition shuffle + 全排序,代价更高。所以能用 group by 表达的需求别上窗口函数,只有需要整行明细或复杂留存规则时才用。
替代写法:max 回 join
select o.*
from orders o
join (select order_id, max(update_time) as mt from orders group by order_id) m
on o.order_id = m.order_id and o.update_time = m.mt;
老系统没有窗口函数时的唯一解。缺点:max 值并列时返回多条(不是严格去重),多一次 join shuffle。优点是逻辑直观,而且当只需要少数列时 group by 那半边的数据量远小于全表窗口排序。超大表上两种写法都值得跑 explain 对比。
性能要点
row_number 去重慢,八成是 partition key 倾斜——某个业务键有几千万条重复(比如脏数据主键全 null)。处理:先过滤明显脏数据,或者对倾斜 key 加盐两阶段处理。另外,分区裁剪要在窗口之前做,where 先过滤掉不需要的分区,减少参与排序的数据量。
可能的追问
- row_number 去重和 dropDuplicates 一样吗? Spark 的
dropDuplicates(键)语义相同但实现是哈希聚合,保留哪条不确定(通常是先到的);row_number 配合确定性排序键才能控制留哪条。要「留最新」必须用后者。 - 去重后发现 rn=1 的数据还是被更新了怎么办? 说明数据有乱序/迟到,CDC 场景常见。方案:去重逻辑放到定时任务里按分区重跑覆盖,或者上游用 Hudi/Iceberg 的 upsert 能力在写入侧保证主键唯一。
- 一百万个业务键、每个键平均两条,去重快吗? 这种分布很健康,row_number 一次 shuffle 就完事。真正慢的是键分布极端倾斜或者单行特别宽(排序要搬整行),后者可以先对窄列去重拿 id 再回表取明细。