分布式计算有一个朴素的承诺:数据量翻倍,机器也翻倍,耗时就不变。只要数据分得均匀,性能问题大多可以靠加资源解决。可现实中总有这样的作业:加了一倍资源,耗时纹丝不动。原因通常只有两个:

  1. 数据分得不均:大部分数据挤在少数几个任务上,其他任务早早算完在等它,这就是数据倾斜;

  2. 数据搬得太多:为了把相同 key 的数据凑到一起,全量数据要在节点之间重新分发,这就是 Shuffle。

这篇文章就按这两条线索,从使用者的视角看怎么调优。

作业慢的排查顺序:先看各任务耗时是否悬殊,再看时间是否花在 Shuffle 上

一、数据倾斜:木桶由最短的那块板决定

一个 Stage 要等所有任务都完成才算完成。如果按 key 分配任务时,某个 key 的数据特别多,它所在的任务就会比其他任务慢很多倍,整个作业的耗时就等于这个最慢任务的耗时,加再多机器也没用。

下面的实验模拟了这个过程:有一部分数据都是同一个热点 key,它们只能落到同一个任务上。试着调大「热点数据比例」,看看负载会变成什么样;再调多「任务数量」,看看能不能救回来。

任务分布模拟器热点数据全部落到一个任务上时,每个任务分到多少条记录。

1,000 条记录 · 8 个任务 · 热点占比 50%

可以看到,热点比例一高,增加任务数只会让其他任务更闲,那个最忙的任务一点没变轻。所以解决倾斜的思路只有一个:把热点 key 的数据拆开,分给更多任务。

先确认是不是倾斜:看作业监控里各任务的处理条数或耗时,个别任务远超其他任务就是倾斜;再用一条简单的统计找出热点 key:

sql
SELECT user_id, COUNT(1) AS cnt
FROM orders
GROUP BY user_id
ORDER BY cnt DESC
LIMIT 20;

下面用两张表举例:订单表 orders 和用户表 users。

sql
CREATE TABLE orders (
    order_id BIGINT COMMENT '订单ID',
    user_id  BIGINT COMMENT '用户ID',
    amount   BIGINT COMMENT '订单金额',
    region   STRING COMMENT '所在地区'
);

CREATE TABLE users (
    user_id   BIGINT COMMENT '用户ID',
    user_name STRING COMMENT '用户名称'
);

关联时的倾斜

需求是给订单补上用户名:

sql
SELECT o.order_id, o.user_id, u.user_name, o.amount
FROM orders o
LEFT JOIN users u ON o.user_id = u.user_id;

Join 会把两边 user_id 相同的数据发到同一个任务上,所以倾斜来自某些 user_id 的订单特别多。分两种情况处理。

情况一:热点是无意义的值

最常见的是大量订单的 user_id 为空。空值本来就关联不上任何用户,没有必要把它们都送到同一个任务。可以把空值替换成随机的负数,既保证关联不上,又让它们均匀散开:

sql
SELECT o.order_id, o.user_id, u.user_name, o.amount
FROM orders o
LEFT JOIN users u
-- 空值变成随机负数:关联不上,而且分散到各个任务
ON IF(o.user_id IS NULL, -1 - FLOOR(1024 * RAND()), o.user_id) = u.user_id;

情况二:热点是真实的大客户

如果 user_id IN (10001, 10002) 两个大客户占了大部分订单,就不能丢掉它们了。思路是加盐:把大表里热点 key 的每条数据随机打上 0~1023 的后缀,让它们分散到 1024 个任务;小表里同一个 key 则复制 1024 份,每份带一个后缀,保证无论分到哪个任务都能关联上。

加盐:大表里热点 key 的数据随机加后缀分散到 1024 个任务,小表里对应的行复制 1024 份
sql
-- 第一步:大表中热点 key 随机加后缀
WITH salted_orders AS (
    SELECT
        CASE WHEN user_id IN (10001, 10002)
             THEN CONCAT(user_id, '_', CAST(FLOOR(RAND() * 1024) AS STRING))
             ELSE CAST(user_id AS STRING)
        END AS salted_user_id,
        order_id, user_id, amount
    FROM orders
),
-- 第二步:小表中热点 key 复制 1024 份,每份带一个后缀
salted_users AS (
    SELECT
        CASE WHEN user_id IN (10001, 10002)
             THEN CONCAT(user_id, '_', CAST(n AS STRING))
             ELSE CAST(user_id AS STRING)
        END AS salted_user_id,
        user_name
    FROM users
    LATERAL VIEW EXPLODE(
        CASE WHEN user_id IN (10001, 10002)
             THEN SEQUENCE(0, 1023)  -- 热点 key 复制 1024 份
             ELSE ARRAY(0)           -- 其他 key 只保留 1 份
        END
    ) t AS n
)
-- 第三步:按加盐后的 key 关联
SELECT o.order_id, o.user_id, u.user_name, o.amount
FROM salted_orders o
LEFT JOIN salted_users u ON o.salted_user_id = u.salted_user_id;

聚合时的倾斜

需求换成按地区统计订单金额和下单人数:

sql
SELECT
    region,
    COUNT(DISTINCT user_id) AS uv,
    SUM(amount) AS total_amount
FROM orders
GROUP BY region;

如果某个地区的订单特别多,它的所有数据都会涌向一个任务。解决办法是两阶段聚合:先按「地区 + 用户 ID 的哈希桶」做一次局部聚合,把一个大组拆成 1024 个小组;再按地区把局部结果加起来。

sql
-- 第一阶段:按 地区 + 分桶 局部聚合
WITH agg_partial AS (
    SELECT
        region,
        COUNT(DISTINCT user_id) AS partial_uv,
        SUM(amount) AS partial_sum
    FROM orders
    GROUP BY region, MOD(HASH_CODE(user_id), 1024)
)
-- 第二阶段:按地区汇总
SELECT
    region,
    -- 同一个 user_id 只会落进同一个桶,所以各桶的 UV 可以直接相加
    SUM(partial_uv) AS uv,
    SUM(partial_sum) AS total_amount
FROM agg_partial
GROUP BY region;

SUM 可以拆开再加,这很自然;COUNT(DISTINCT) 也能相加,靠的是按用户 ID 分桶:同一个用户只在一个桶里被计数,桶与桶之间没有重复。为什么去重计数通常不能相加、什么时候又可以,大数据去重统计一文有更详细的讨论。

两阶段聚合:先局部聚合拆开热点,再全局汇总

流计算里的思路相同。Flink SQL 的 Local-Global 聚合会在数据发往下游之前先在上游做一次局部聚合,相同 key 的数据先在本地合并,热点 key 发往下游的数据量就小多了:

Local-Global 聚合:上游先在本地合并相同的 key,热点 key 发往下游的数据少多了

引擎自带的开关

上面的改写手法要动 SQL,不少引擎已经内置了自动处理:

引擎

能力

开关

Spark 3.x

自适应执行,自动拆分倾斜的 Join 分区

spark.sql.adaptive.skewJoin.enabled

Hive

聚合倾斜时自动拆成两个作业

hive.groupby.skewindata

Flink SQL

微批 + 两阶段聚合

table.exec.mini-batch.enabled、table.optimizer.agg-phase-strategy

Flink SQL

拆分 COUNT DISTINCT

table.optimizer.distinct-agg.split.enabled

先试试这些开关,确实解决不了再手工改写。

二、少搬数据:MPP 引擎里的 Join

在 Doris 这样的 MPP 引擎里,Join 的主要成本往往不是计算,而是为了把相同 key 的数据凑到一起而发生的网络传输。所以优化 Join 的核心是:能不搬就不搬,必须搬就少搬。

选对分发方式

按需要搬动的数据量从多到少,常见的四种方式是:

Shuffle Join:两边都搬

最通用的方式:左右两张表都按关联键重新分发。两张表都很大、又没有其他条件可用时只能这样。

Shuffle Join:左右两表都按关联键重新分布

Broadcast Join:只搬小表

右表很小时,把整张小表复制给每个节点,大表原地不动。小表越小越划算;小表一旦变大,复制的成本会超过 Shuffle。

Broadcast Join:把小表广播到每个节点

Bucket Shuffle Join:只搬一边

如果关联条件包含左表的分桶列,左表的数据本来就按这个键分好了桶,只需要把右表按左表的分桶规则发过去。把大表放在左边,就可以避免搬动大表。

Bucket Shuffle Join:只把右表按左表的分桶规则发送

Colocate Join:都不搬

如果两张表在建表时就按同样的列、同样的桶数分桶,并放在同一个 Colocation Group 里,相同 key 的数据本来就在同一个节点上,Join 可以完全在本地完成。代价是建表时就要规划好。

Colocate Join:两表分桶方式一致,数据在本地关联

方式

需要搬动

适用条件

Colocate

都不搬

两表分桶列、桶数一致,且在同一 Colocation Group

Bucket Shuffle

只搬右表

关联条件包含左表的分桶列

Broadcast

复制右表

右表足够小

Shuffle

两表都搬

通用

Runtime Filter:先过滤,再搬

大表关联小表时,往往只有一小部分大表数据能关联上。Runtime Filter 会在执行时根据小表里实际出现的关联键,生成一个过滤条件(比如 IN 列表、Bloom Filter 或最大最小值),提前下推到大表的扫描节点。关联不上的数据在扫描时就被过滤掉,根本不会进入网络。

没有 Runtime Filter:大表全量扫描后再关联
有 Runtime Filter:小表的关联键提前下推,大表扫描时就过滤掉大部分数据

总结

物理学家 P. W. Anderson 在《More Is Different》里说,量变到一定程度就会产生质变。数据处理也一样:数据量小的时候怎么写都行,数据量大到一定程度,就必须了解数据怎么分布、引擎怎么执行、数据怎么在节点之间流动,才能把性能调好。

但最有效的优化永远是少处理数据:只读需要的列和分区,过滤条件尽量早,能预先聚合就预先聚合。数据少了,倾斜和 Shuffle 的问题也会跟着变小。

xkcd 的「纯度」漫画:学科之间的「纯度」排序

参考资料


全文评论

0

正在加载评论…