大数据面试高频题:row_number() 数据倾斜到底怎么解决?

1.前言

大家需要注意,实际上,row_number() 倾斜不是一个 SQL 语法问题,而是一个"窗口分区内数据无法并行"的问题。解决它的关键不是简单加盐,而是先判断业务场景属于哪一类:全量排序、首末记录、TopN、还是只需要去重(后面会分场景分别给大家介绍不同场景的处理思路)。

上面这个判断很重要,很多同学一看到数据倾斜就想到了加盐处理,但是所有场景都能加盐吗?至少对于 row_number() 数据倾斜而言,加盐不是万能的!

举个例子哈,看下面这段代码:

复制代码
row_number() over(partition by user_id order by event_time)

如果业务要求的是每个 user_id 下完整、连续、准确的全量编号,那不能随便对 user_id 加随机盐,因为随机盐会破坏同一个用户下的全局顺序。

所以,如果遇到一个关于 row_number() 倾斜的优化问题,第一步不是写SQL,而是先去尝试判断一下:这个 row_number() 到底是不是真的有必要存在?

2.row_number() 为什么会发生数据倾斜?

row_number() 的执行通常会经历两个核心动作:

  1. 按 partition by 字段把数据 shuffle 到不同 task / reducer。

  2. 在每个分区内部按照 order by 字段排序,再生成编号。

可是问题就在于:例如,row_number() over(partition by department_id order by hire_date),如果 90% 的数据都属于同一个 department_id = 'a',那么这 90% 的数据最终还是会被打到同一个分区里排序。

也就是说:

  1. 小部门:1 万行 -> 一个 task 很快完成

  2. 普通部门:20 万行 -> 一个 task 正常完成

  3. 超级部门:1 亿行 -> 一个 task 排序很慢,甚至 OOM

这就是 row_number() 倾斜的本质(当然,其他倾斜也是这个原因,例如使用Join操作):partition by 的 key 不均匀,导致某个窗口分区过大,最终变成单点排序瓶颈。

多问一句:增加 reducer 数量能不能解决 row_number() 倾斜?

答:不能,因为同一个 partition by key 的数据必须进入同一个窗口分区,否则无法保证该 key 下的排序结果正确。增加 reducer 只能缓解普通 key 的并行度,但无法把一个超级大 key 自动拆开。

给大家用一张图来理解一下这个过程:

案例1:全局排序

有一张员工入职时间表table_1,如下:

要求:按照部门已入职员工的入职时间排序,给每位员工编号。

思路一:

复制代码
select
     employee_id
    ,department_id
    ,hire_date
    ,row_number() over(partition by department_id order by hire_date) as rn
from table_1
-- PS:假如部门a的人数特别多,上述代码使用department_id分区,会产生数据倾斜问题,那怎么解决呢?

思路二:

复制代码
with hire_table AS (
SELECT 1001 as employee_id ,'a' as department_id, '2024-11-16' as hire_date
UNION ALL
SELECT 1002 as employee_id ,'a' as department_id, '2024-11-25' as hire_date
UNION ALL
SELECT 1003 as employee_id ,'a' as department_id, '2024-11-30' as hire_date
UNION ALL
SELECT 1004 as employee_id ,'a' as department_id, '2024-12-01' as hire_date
UNION ALL
SELECT 1005 as employee_id ,'a' as department_id, '2024-12-02' as hire_date
UNION ALL
SELECT 1006 as employee_id ,'a' as department_id, '2024-10-01' as hire_date
UNION ALL
SELECT 1007 as employee_id ,'a' as department_id, '2024-10-02' as hire_date
)
, local_rn_table as (
--1 先使用`department_id+入职年月`分区,局部编号
    select
        employee_id
        ,department_id
        ,hire_date
        ,date_format(hire_date, 'YYYY-MM') as months
        ,row_number() over(partition by department_id, date_format(hire_date, 'YYYY-MM') order by hire_date) as local_rn
    from hire_table
)
, tmp2 as (
--2 计算每个`department_id+入职年月`分区,对应的起始编号
    select department_id
          ,months
          ,month_cnt
          ,sum_monthg_cnt
          ,lag(sum_monthg_cnt, 1, 0) over (partition by department_id order by months) as lag_sum_monthg_cnt
    from (
        select department_id
              ,months
              ,month_cnt
              ,sum(month_cnt) over (partition by department_id order by months rows between unbounded preceding and current row) as sum_monthg_cnt
        from (
                select department_id
                      ,date_format(hire_date, 'YYYY-MM') as months
                      ,count(1) as month_cnt
                from hire_table
                group by department_id, date_format(hire_date, 'YYYY-MM')
        ) as t1
    ) as t2
)
select t1.employee_id
      ,t1.department_id
      ,t1.hire_date
      ,local_rn + lag_sum_monthg_cnt as res
from local_rn_table as t1
left join tmp2 as t2
on t1.department_id = t2.department_id
and t1.months = t2.months

总结一下,思路二的核心思想在于分治的思想,充分利用集群并行的优势,将庞大的计算任务拆成多个小的任务执行,进而解决倾斜问题。当然,补充一点哈,如果某个月份本身仍然很大,比如 2024-11 有 5000 万行+,那么按月拆还不够,还可以继续按天、小时、或者更细的有序区间继续拆。反正思想是一样的:拆分字段必须能够表达全局排序顺序,不能随便用随机数拆

当然,如果题目需要表达全局的排序顺序,且题目的字段中没有其他可用的有序字段,那这个方式就不行了哦~

案例2:Top1问题

需求描述:有一张用户登陆表 table_2 ,包含用户ID,登陆日期,在线时长,表的粒度为用户ID+登陆日期,需求是想要拿到每个用户最早一次登陆日期及对应的在线时长。

表结构如下:

思路一(绝大多数人都会想到的思路,直接row_number()+where,这也是我们课程里提到的一种思路):

复制代码
select
    user_id
    ,login_date
    ,online_time
from
  (
    select
       user_id
       ,login_date
       ,online_time
       ,row_number() over(partition by user_id order by login_date) as rn
    from user_log
  ) a
where rn = 1
-- PS:如果存在部分头部用户数据量特别大,使用row_number()容易出现数据倾斜问题,该如何解决呢?

思路二:

复制代码
with table_2 AS (
    SELECT 1001 as user_id ,'2024-12-01' as login_date, 10 as online_time
    UNION ALL
    SELECT 1001 as user_id ,'2024-12-02' as login_date, 20 as online_time
    UNION ALL
    SELECT 1001 as user_id ,'2024-12-03' as login_date, 30 as online_time
    UNION ALL
    SELECT 1002 as user_id ,'2024-11-01' as login_date, 50 as online_time
    UNION ALL
    SELECT 1002 as user_id ,'2024-11-09' as login_date, 10 as online_time
)

select
  user_id
  ,substr(first_login_online_time, 1, 10) as login_date
  ,cast(substr(first_login_online_time, 12) as bigint) as online_time
from
  (
    select
           user_id
           ,min(concat(login_date, '_', online_time)) as first_login_online_time
    from table_2
    group by
      user_id
  ) a

思路2这里利用了Map端预聚合的优化,减少后续shuffle时候的数据量,提高计算效率。此外,后续遇到【首/末次发生某种行为】的场景,也要先思考一下,是否能通过 max/min 的方式解决,尽量避免开窗函数带来的开销。

小结一下,如果 row_number() 只是为了取首条、末条、最大值对应行、最小值对应行,优先考虑用 group by、min、max、min_by、max_by 替代窗口排序。

案例3:TopN问题

案例1是使用了数据中本来就自带的顺序信息,也即日期;案例2是将开窗转换成了其他操作,来实现问题的求解

那假设问题不能转换,且数据中没有顺序信息或者日期不可用,该怎么求解呢?

在数据开发过程中,经常会遇到取某个维度下的Top数据:比如要取类目下的TopN的属性值,通用的方法是使用row_Number排序,然后再取Top。

常规写法:

复制代码
select    cate_id,
          property_id,
          value_id
from      (
          select    cate_id,
                    property_id,
                    value_id,
                    row_number() over (partition by cate_id order by  property_id asc, value_id asc) as rn
          from      one_plat.ads_tb_sycm_cate_xx
          where     ds = '${date}'
          ) p
where     rn <= N;

倾斜问题:

row_number按照 cate_id 分组,在每个分组内进行排序,如果一个 cate_id 下有大量的属性值就会发生倾斜,那么如何才能既防止倾斜,又能实现排序呢?

优化思路:

  1. 大量的数据分发到同一个分组,我们可以考虑对这个分组再做一次分发,这样就能把大的分组拆分成N个小分组。

  2. 可以使用两次row_number(),第一次按照 cate_id 和 加盐随机数 进行分发,第二次再使用 cate_id 进行分发,求最终的TopN

  3. 关于加盐随机数的选择:

    • ceil(rand()*100):生产0-100的随机整数

    • 关于rand()后面应该乘以多少?可以大概探查一下倾斜的cate_id的数据量

    • 假设有1亿行数据,且每个排序分区处理100万行数据是比较合理的,那么就用 1亿/100w = 100,因此乘以100是比较合理的

优化后的写法1:

复制代码
select
  cate_id,
  property_id,
  value_id
from
  (
    select
      cate_id,
      property_id,
      value_id,
      row_number() over(
        partition by cate_id
        order by
          property_id asc,
          value_id asc
      ) as rn -- 第二次排序
    from
      (
        select
          cate_id,
          property_id,
          value_id
        from
          (
            select
              cate_id,
              property_id,
              value_id,
              row_number() over(
                partition by cate_id,
                sec_part
                order by
                  property_id asc,
                  value_id asc
              ) as rn -- 第一次排序
            from
              (
                select
                  cate_id,
                  property_id,
                  value_id,
                  ceil(rand() * 100) as sec_part -- 生成随机数,取值为0-100
                from
                  one_plat.ads_tb_sycm_cate_xx
                where
                  ds = '${date}'
              ) s
          ) p
        where
          rn <= N
      ) part
  ) a
where
  rn <= N

关于这个思路为什么是对的:

  • 对于某个 cate_id 的全局 TopN 中任意一条记录,它前面最多只有 N-1 条记录比它更靠前。当我们把数据随机拆成多个盐桶后,这条记录所在的盐桶里,比它更靠前的数据只会更少,不可能超过全局范围内的 N-1 条。因此,全局 TopN 中的记录一定会出现在某个盐桶的局部 TopN 中。所以先取局部 TopN,再取全局 TopN,不会丢失正确答案。

优化后的写法2(核心思想是:热点 key 拆分,不要全量加盐):

生产中还有一个非常实用的优化方式:只对热点 key 做特殊处理。

比如 cate_id 有 100 万个,但真正倾斜的可能只有 10 个。如果对所有 cate_id 都加盐,会让普通 key 的计算变复杂,还会增加不必要的数据量。

更好的方式是:

  1. 先统计每个 key 的数据量。

  2. 找出热点 key。

  3. 热点 key 走加盐 TopN 或范围分治。

  4. 普通 key 继续走原来的 row_number()。

    with hot_key as (
    select
    cate_id
    from
    one_plat.ads_tb_sycm_cate_xx
    where
    ds = '{date}' group by cate_id having count(1) > 1000000 -- 定义热点key的数量级 ), normal_data as ( select t.* from one_plat.ads_tb_sycm_cate_xx t left join hot_key h on t.cate_id = h.cate_id where t.ds = '{date}'
    and h.cate_id is null
    ),
    hot_data as (
    select
    t.*
    from
    one_plat.ads_tb_sycm_cate_xx t
    join hot_key h on t.cate_id = h.cate_id
    where
    t.ds = '{date}' ) -- 普通 key 用原始 row_number select cate_id, property_id, value_id from ( select cate_id, property_id, value_id, row_number() over( partition by cate_id order by property_id asc, value_id asc ) as rn from normal_data ) t where rn <= {n}
    union all
    -- 热点 key 用加盐局部 topn + 全局 topn
    select
    cate_id,
    property_id,
    value_id
    from
    (
    select
    cate_id,
    property_id,
    value_id,
    row_number() over(
    partition by cate_id
    order by
    property_id asc,
    value_id asc
    ) as rn
    from
    (
    select
    cate_id,
    property_id,
    value_id
    from
    (
    select
    cate_id,
    property_id,
    value_id,
    row_number() over(
    partition by cate_id,
    sec_part
    order by
    property_id asc,
    value_id asc
    ) as local_rn
    from
    (
    select
    cate_id,
    property_id,
    value_id,
    pmod(abs(hash(property_id, value_id)), 100) as sec_part -- 这个地方改成了hash,因为hash加盐比较稳定,而rand()是随机的,每次执行结果可能不同。
    from
    hot_data
    ) s
    ) p
    where
    local_rn <= {n} ) x ) y where rn <= {n};

案例4:去重类问题

很多 row_number() 的真实用途其实是去重,比如下面这种写法:

复制代码
select
  *
from
  (
    select
      *,
      row_number() over(
        partition by user_id,
        item_id
        order by
          update_time desc
      ) as rn
    from
      table_a
  ) t
where
  rn = 1;

遇到任何问题,不要先着急想怎么解决,可以先尝试反问自己:这个问题一定要有吗?可不可以不让这个问题出现

对于上面这个场景来说,我们可以先确认两个问题:

  1. 是否真的需要按照 update_time 取最新?

  2. 还是只要每组保留任意一条?

如果只是保留任意一条,完全没必要排序,可以直接这样写:

复制代码
select
    user_id,
    item_id,
    max(col1) as col1,
    max(col2) as col2
from table_a
group by user_id, item_id;

如果要取最新记录,可以考虑:

复制代码
select
  user_id,
  item_id,
  max(update_time) as max_update_time
from
  table_a
group by
  user_id,
  item_id;

小结一下:去重场景要先区分"任意去重"和"按规则去重"。任意去重不要用 row_number();按规则去重也要优先考虑聚合替代窗口排序。

PS:当然,row_number() 也有自己的优势,那就是开窗不会对数据行数产生影响,写法更加简单一些,不需要过多子查询。

3.总结

row_number() 数据倾斜的本质,是某些 partition key 下的数据量过大,导致窗口排序无法充分并行。解决这个问题时,不要一上来就加盐,而是先判断业务需求属于哪一类。如果只是取首条、末条、最大值、最小值,要优先用聚合替代窗口函数;如果是 TopN,可以用加盐拆桶,先局部 TopN,再全局 TopN;如果是全量连续编号,则必须按照有序字段进行分治,并通过前缀累计量补齐全局编号。

真正成熟的 SQL 优化,不是把所有问题都套进一个模板,而是先理解业务语义,再选择成本最低、结果正确、稳定可维护的实现方式。

相关推荐
weixin_446729165 小时前
注解和反射
java·开发语言
摇滚侠5 小时前
HashMap 源码解析 底层原理 面试如何回答
java·面试·职场和发展
lizhihai_995 小时前
股市学习心得-技术指标学习(布林线+MACD)
大数据·人工智能·学习
凯瑟琳.奥古斯特5 小时前
常见加密算法及应用
java·开发语言·网络·网络协议·职场和发展
devilnumber5 小时前
java的lambda妙用举例
java·lambda
胡耀超5 小时前
《设计数据密集型应用》(DDIA, 2nd ed.) 心智模型导览——《Designing Data-Intensive Applications》书介绍导航
大数据·数据库·分布式·ai·架构·数据
invicinble5 小时前
springboot提供的机制大全
java·spring boot·后端
Han_han9195 小时前
题⽬ 4:订单商品统计:
java
一个数据大开发5 小时前
数据中台架构中本体驱动的语义治理实践
大数据·架构