从2小时到3分钟:Spark SQL多维分析性能优化实战

背景

前一阵数分小姐姐跑路了把任务都交接给数仓了,最近发现其中的一个 hive 报表任务每天运行 1.5~2h 同时占用大量资源,晒下问题 sql:

sql 复制代码
INSERT OVERWRITE TABLE live_room_expo_day
SELECT
    '${date}' AS dt,
    NVL(country, 'total') AS country,
    NVL(platform, 'total') AS platform,
    'total' AS version,
    NVL(page, 'total') AS page,
    NVL(is_new, 'total') AS is_new,
    NVL(content_type, 'total') AS content_type,
    NVL(is_visitor, 'total') AS is_visitor,
    -- 曝光相关指标
    COUNT(DISTINCT IF(event IN('feed_exposure','banner_exposure'), lid, NULL)) AS exp_lives,
    COUNT(DISTINCT IF(event IN('feed_exposure','banner_exposure'), host_uid, NULL)) AS exp_host_uid,
    COUNT(DISTINCT IF(event IN('feed_exposure','banner_exposure'), uid, NULL)) AS exp_users,
    COUNT(DISTINCT IF(event IN('feed_exposure','banner_exposure'), CONCAT(lid,uid), NULL)) AS exp_cnt,

    ...省略其他32个count distinct指标

    -- 点击相关指标
    COUNT(DISTINCT IF(event IN('feed_click','banner_click') AND show_type='selective', lid, NULL)) AS selective_click_lives,
    COUNT(DISTINCT IF(event IN('feed_click','banner_click') AND show_type='selective', host_uid, NULL)) AS selective_click_host_uid,
    COUNT(DISTINCT IF(event IN('feed_click','banner_click') AND show_type='selective', uid, NULL)) AS selective_click_users,
    COUNT(DISTINCT IF(event IN('feed_click','banner_click') AND show_type='selective', CONCAT(lid,uid), NULL)) AS selective_click_cnt

FROM (
    SELECT
        uid,
        event,
        version,
        NVL(lid, rid) AS lid,
        page,
        platform,
        host_uid,
        is_pugc,
        name,
        live_dur_min,
        NVL(is_new, 0) AS is_new,
        IF(country IN('SA','AE','QA','KW'), country, 'other') AS country,
        NVL(content_type, 'live') AS content_type,
        aid,
        device_id,
        user_tag,
        CASE
            WHEN version >= '2.0.0' AND extend['show_type'] IS NOT NULL THEN extend['show_type']
            WHEN version >= '3.0.0' AND extend['page_chanel'] = 'live' THEN 'selective'
            WHEN version >= '3.0.0' AND extend['page_chanel'] = 'video' THEN 'engaging'
            WHEN version >= '2.0.0' THEN 'other'
            ELSE 'old version'
        END AS show_type,
        CASE
            WHEN version < '2.0.0' THEN 'old version'
            WHEN is_login = 1 THEN 'other'
            ELSE 'visitor'
        END AS is_visitor
    FROM live_room_exposure_detail
    WHERE dt = '${dt}'
        AND page <> 'test'
        AND country <> 'CN'
) a
GROUP BY country, platform, page, is_new, content_type, is_visitor WITH CUBE;

关键信息:

  1. 包含 6 个维度的 cube,也就是最后产出 2⁶=64 种粒度的数据。
  2. 这段 sql 的查询语句中用到 40 个 count distinct。

我凭感觉想到的就是不需要优化啦,直接使用 spark sql 引擎运行就好喽,因为在我的过往经验中,spark sql 的种种表现几乎是完全胜于 hive sql 的。

结果就是几乎所有这种类型的任务(对数分好多这种任务 😭)切换到 spark sql 跑几乎都全军覆没,根本跑不动。

神奇,到底是为什么呢?

结果在排查 Spark 执行计划时发现了端倪。请看:

这张表的数据量是82936714条,此时已经运行了 3.8h,已经远超 hive 任务的运行时长,但是目前只扫描了 1000 多 w 行数据,并且一共膨胀了 2560 倍,当前已膨胀条数 286 亿+条,预计共膨胀 82936714*2560=212,317,987,840,约等于 2123 亿条数据。。。果断把任务 kill 了。然后查找了相关资料对这两次膨胀才有了一定的了解。

两次 expand 背后的原理

第一次膨胀 64 倍

社区 Spark SQL 对多维分析采用 Expand 方式,一次读入数据,读入的每条数据会生成多条(2n,n 为维度大小)​。也就是说,Expand 方式会将数据放大 2n 倍,然后将放大后的数据进行聚合。例如,下面的这条 SQL 语句。

css 复制代码
select A,B, sum(C) from myTable group by A, B vwith cube

在 Spark SQL 默认的 Expand 执行方式中,数据将会被放大 4 倍,对应逻辑如下图所示。每条数据会按照(A,B)两列的组合,添加一个 Grouping ID,以区分所属的分组。最终执行等价于在 4 倍数据上的一次聚合操作。

Expand方式执行的Cube

然而,当维度比较大或初始数据量比较大时,在这种数据指数性膨胀的执行方式下,数据 Shuffl e 的性能往往会非常差,某些资源有限的情形下还会出现 OOM 的问题。因此,团队在 Spark SQL 中增加了多维分析的 Union 实现方式。Union 方式不是一次读入数据,而是读入 2n 次数据,分别计算,然后将计算的结果合并。对于案例 SQL,Union 方式的思路如下图所示。可以看到,Union 方式会读取 4 次数据表,然后将 4 次的求和结果合并到一起。

Union方式执行的Cube

Union 方式和 Expand 方式各有优缺点,Expand 方式读取数据的次数只有一次,但数据会膨胀 2n 倍,而 Union 方式会读取数据 2n 次。总体来说,Expand 方式适合维度小的多维分析,Union 方式适合维度大的多维分析。本质上,Union 方式是一种"细水长流"的策略,与常见的 IO 复用等优化思想相反。

第二次膨胀 40 倍

这里是由于 40 个 count(distinct)的 expand 导致数据膨胀 40 倍,为了不影响篇幅大小,之后会专门出一篇文章给大家讲解下。

解决方案

cube 改为 union all

尽管改成了把 cube 改成了 union all 但是,依然还是会触发 count(distinct)产生的 40 倍膨胀,而且由于底表的数据量已经达到了接近 1 亿条,可以从 spark UI 中很明显的看到膨胀的进程的速度是非常缓慢的,所以这个方案被否定。

优化掉 count(distinct)和 cube

从以上分析可以得知,任务很明显发生了大数据量维度爆炸的严重性能问题,spark 在对于大数据量并且多维的 cube 表现是很差的,所以需要规避掉这种情况。

直接上代码。

less 复制代码
  @staticmethod
  def generate_cube_combinations(dimensions):
      """
      生成CUBE的所有组合
      dimensions: 维度列表,如 ['country', 'platform', 'page', 'is_new', 'content_type', 'is_visitor']
      """
      combinations_list = []

      # 生成所有可能的组合(包括空组合)
      for r in range(len(dimensions) + 1):
          for combo in itertools.combinations(dimensions, r):
              combinations_list.append(list(combo))
      logging.info(f"生成组合数: {len(combinations_list)}")
      logging.info(f"组合列表: {combinations_list}")
      return combinations_list

  def main(self):
      # 定义维度和指标
      dimensions = ['country', 'platform', 'page', 'is_new', 'content_type', 'is_visitor']

      # 使用size(collect_set())替代count(distinct),参考SQL模板的优化
      metrics = {
          'exp_lives': "size(collect_set(case when event in('feed_exposure','banner_exposure') then lid end))",
          'exp_host_uid': "size(collect_set(case when event in('feed_exposure','banner_exposure') then host_uid end))",
          'exp_users': "size(collect_set(case when event in('feed_exposure','banner_exposure') then uid end))",
          'exp_cnt': "count(case when event in('feed_exposure','banner_exposure') then lid_uid_key end)",

          ...省略其他32个count distinct指标

          'selective_click_lives': "size(collect_set(case when event in('feed_click','banner_click') and show_type='selective' then lid end))",
          'selective_click_host_uid': "size(collect_set(case when event in('feed_click','banner_click') and show_type='selective' then host_uid end))",
          'selective_click_users': "size(collect_set(case when event in('feed_click','banner_click') and show_type='selective' then uid end))",
          'selective_click_cnt': "count(case when event in('feed_click','banner_click') and show_type='selective' then lid_uid_key end)"
      }

      # 第一步:创建基础数据
      temp_table_query = f"""
      CACHE TABLE base_temp OPTIONS ('storageLevel' 'MEMORY_AND_DISK')
      AS
      SELECT
          /*+repartition(50)*/
          uid,
          event,
          nvl(lid, rid) as lid,
          host_uid,
          nvl(is_new, 0) as is_new,
          platform,
          page,
          if(country in('SA', 'AE', 'QA', 'KW'), country, 'other') as country,
          nvl(content_type, 'live') as content_type,
          case
              when version >= '2.0.0' and extend['show_type'] is not null then extend['show_type']
              when version >= '3.0.0' and extend['page_chanel'] = 'live' then 'selective'
              when version >= '3.0.0' and extend['page_chanel'] = 'video' then 'engaging'
              when version >= '2.0.0' then 'other'
              else 'old version'
          end as show_type,
          case
              when version < '2.0.0' then 'old version'
              when is_login = 1 then 'other'
              else 'visitor'
          end as is_visitor,
          concat(nvl(lid, rid), uid) as lid_uid_key
      FROM live_room_exposure_detail
      WHERE dt='{self.dt}'
          AND page<>'test'
          AND country<>'CN'
          group by 1,2,3,4,5,6,7,8,9,10,11,12
      """

      self.spark.sql(temp_table_query)
      logging.info("成功创建临时表 base_temp")

      cube_combinations = self.generate_cube_combinations(dimensions)

      query = self.build_cube_query_batch(dimensions, "base_temp", cube_combinations, metrics)
      logging.info(query)
      self.spark.sql(query).repartition(1).write.mode("overwrite").insertInto("jaco_live_room_expo_day_cube")

  def build_cube_query_batch(self,dimensions, base_table, combinations_batch, metrics):
      """
      构建一批组合的查询
      """
      union_queries = []

      for combo in combinations_batch:
          # 构建SELECT字段
          select_fields = []
          group_fields = []

          for dim in dimensions:
              if dim in combo:
                  select_fields.append(f"nvl({dim}, 'total') as {dim}")
                  group_fields.append(dim)
              else:
                  select_fields.append(f"'total' as {dim}")

          for metric_name, metric_expr in metrics.items():
              select_fields.append(f"{metric_expr} as {metric_name}")

          group_by_clause = ", ".join(group_fields) if group_fields else ""

          if group_by_clause:
              query = f"""
              SELECT
                  '{self.date}' as dt,
                  {', '.join(select_fields)}
              FROM {base_table}
              GROUP BY {group_by_clause}
              """
          else:
              query = f"""
              SELECT
                  '{self.date}' as dt,
                  {', '.join(select_fields)}
              FROM {base_table}
              """

          union_queries.append(query)

      return " UNION ALL ".join(union_queries)

总结下以上代码:

  1. 分析 sql 后发现可以预先对 live_room_exposure_detail 对这张底表做一个去重从而减少一大半数据量,这一步很关键
  2. count distinct 替换成collect_set 操作,这一步其实还可以继续优化,思路是对需要 distinct 的字段,比如以上案例中的 lid,host_uid 以及 concat(lid,uid)做一个去重打标,然后再一个外层对打标数据进行 sum 汇总
  3. 将 cube 操作通过函数 generate_cube_combinations进行自动化维度组装,最终拼接成一个完整的 union all 语句

总结

通过这次 Spark SQL 多维分析优化实践,成功将原本需要 1.5-2 小时的 Hive 报表任务优化到了 3.2 分钟,性能提升了近 30 倍!我把这次的优化也同时进行了团队组内分享,得到了领导的赞扬并且大家也达成一致对于后续同类型的任务也沿用这套方法和代码进行优化啦!

核心优化策略回顾

  1. 数据预处理优化:对底表进行去重操作,大幅减少后续计算的数据量(我个人认为这一步是最重要的)
  2. 聚合函数替换 :将 COUNT DISTINCT 替换为 COLLECT_SET,避免了大量 count distinct 触发的底层 expand
  3. cube 重构:通过程序化生成 CUBE 维度组合,将复杂的多维分析转换为高效的 UNION ALL 查询

最后卖个关子,其实在代码中我还做了一个很有效的隐蔽的优化对性能有很大的提升,不知道细心的同学发现了没有?大家还有什么更好的解决方案吗,欢迎评论区留言!

更多 Flink / Spark / Doris / Clickhouse / Paimon 实战文章,请关注公众号 「数据慢想」

往期文章推荐

相关推荐
Blossom i35 分钟前
Python+spark2.0+Hadoop机器学习与大数据实战第十一章:Python Spark的集成开发环境
大数据·hadoop·spark
用户3610588626121 天前
Spark 核心之广播变量、累加器原理深度剖析
大数据·spark
用户3610588626122 天前
Spark 核心之二次排序、分组取 TopN 优化分析
大数据·spark
头茬韭菜3 天前
Apache Fluss 项目深度分析与技术文档系列计划
flink·spark·realtime·fluss
Gent_倪4 天前
Spark聚合算法:HashAggregate与SortAggregate详解
大数据·spark
用户3610588626125 天前
Spark 核心之任务调度角色划分以及资源调度角色划分详解
大数据·spark
用户3610588626126 天前
Spark 核心之 Stage 并行度划分及优化详解
spark
用户3610588626126 天前
Spark 核心之 Stage 切分划分与计算模式详解
spark
用户3610588626127 天前
Spark 核心之 RDD 窄依赖与宽依赖详解
spark
用户3610588626127 天前
Spark 核心之 Stage 和 Task 原理剖析
spark