订阅更新

每次都治标——为什么不从数据写入阶段就把倾斜消灭掉?分桶和预聚合怎么做?

数据倾斜手册 第 9 题 约 11 分钟读完

本文目录
  1. 先说结论
  2. 分桶:从数据写入阶段消除JOIN倾斜
  3. 分桶的局限性和适用场景
  4. 预聚合:从数据写入阶段消除GROUP BY倾斜
  5. 物化视图:自动维护预聚合
  6. 面试高频追问
  7. 实战踩坑
  8. 总结

先说结论

分桶(Bucketing):建表时按JOIN/GROUP BY的key做哈希分桶,同一个key的数据写入同一个桶。查询时相同桶号的数据做Bucket Join,无需Shuffle。前提是两张表按同样的key分相同数量的桶。

预聚合(Pre-Aggregation):把高频查询的聚合结果预计算并写入汇总表(DWS层),查询时直接读汇总表而不是原始明细表,从根源减少GROUP BY的数据量和倾斜风险。

选择原则:如果倾斜来自JOIN,优先考虑分桶;如果倾斜来自GROUP BY聚合,优先考虑预聚合。两者不冲突,可以组合使用。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

前面几篇讲的倾斜处理方案,都是「遇到倾斜再处理」——加盐打散、两阶段聚合、过滤NULL。这些是治标。真正的治本是:从数据写入阶段就设计好,让数据天然分布均匀,让热门查询的JOIN和GROUP BY从一开始就没有倾斜。这一篇讲两个从根源消除倾斜的方法:分桶设计和预聚合物化视图。我在某元宇宙社交应用和某出海硬件品牌都用过这两种方法,效果比事后处理好得多。

分桶:从数据写入阶段消除JOIN倾斜

分桶的核心思想:建表时就按JOIN的key做哈希分区,把数据写成固定数量的桶文件。查询时,两张按相同key、相同桶数分桶的表做JOIN,相同桶号的数据天然在一起,不需要Shuffle就能Join。

-- Spark SQL建分桶表(CLUSTERED BY)
CREATE TABLE orders_bucketed
USING PARQUET
CLUSTERED BY (user_id) INTO 200 BUCKETS  -- 按user_id分200个桶
AS SELECT * FROM orders;
 
CREATE TABLE users_bucketed
USING PARQUET
CLUSTERED BY (user_id) INTO 200 BUCKETS  -- 必须和orders_bucketed桶数相同!
AS SELECT * FROM users;
 
-- 开启Bucket Join优化
SET spark.sql.sources.bucketing.enabled = true;
 
-- 查询时:Spark检测到两表按同样的key分了相同数量的桶
-- 自动使用Bucket Join,无需Shuffle
SELECT o.order_id, o.amount, u.user_name
FROM orders_bucketed o
JOIN users_bucketed u ON o.user_id = u.user_id;
-- 执行计划里会显示BucketedScan而不是Exchange(Shuffle)

Bucket Join的效果:完全消除JOIN的Shuffle,每个Task只处理对应桶号的数据,天然均匀分布(前提是key的哈希值分布均匀)。但有一个重要前提:两张表必须按相同的key、相同的桶数分桶,否则Spark不会使用Bucket Join,退化为普通Shuffle Join。

分桶的局限性和适用场景

分桶不是万能的,有明确的适用边界:

维度适合分桶不适合分桶
查询模式JOIN/GROUP BY的key固定,高频查询查询key多变,每次不同
数据写入批量写入,能提前分桶实时流写入,无法预分桶
表大小大表(GB级以上)收益明显小表(MB级)用Broadcast更简单
桶数选择桶数=并行度,选合适的质数桶数太少分桶效果差,太多小文件多
数据倾斜key分布相对均匀的倾斜NULL多/热点key极度倾斜时效果有限
-- 分桶设计的注意事项
 
-- 1. 桶数选择:通常选并行度附近的值,推荐2的幂次或质数
--    数据量100GB,并行度200,选200桶(每桶约500MB)
 
-- 2. 桶数必须一致:
--    orders_bucketed 200桶 JOIN users_bucketed 100桶 → 退化为Shuffle Join
 
-- 3. 分桶key要和后续的JOIN/GROUP BY key一致:
--    按user_id分桶,但查询里按product_id JOIN → 无效,还是Shuffle Join
 
-- 4. 检查Spark是否真正使用了Bucket Join:
EXPLAIN SELECT o.order_id, u.user_name
FROM orders_bucketed o JOIN users_bucketed u ON o.user_id = u.user_id;
-- 看执行计划里有没有BucketedScan
-- 如果显示Exchange(Shuffle)就说明Bucket Join没生效

预聚合:从数据写入阶段消除GROUP BY倾斜

预聚合的核心思想:把高频的聚合查询预先计算好,写入DWS层(汇总层)的宽表。查询时直接读汇总结果,不需要对原始明细数据做大规模GROUP BY。这不只是消除倾斜,也是数仓分层设计的核心思想——DWD存明细,DWS存预聚合结果,ADS直接从DWS取数。

-- 场景:每天都要统计各渠道的GMV、订单数、UV
-- 原始查询(每次都对明细数据GROUP BY,容易倾斜):
SELECT
  channel_id,
  SUM(amount) AS gmv,
  COUNT(*) AS order_cnt,
  COUNT(DISTINCT user_id) AS uv
FROM dwd_orders
WHERE dt = '2024-08-01'
GROUP BY channel_id;
 
-- 预聚合方案:每天用Spark作业写入DWS汇总表
INSERT INTO dws_channel_daily
SELECT
  dt,
  channel_id,
  SUM(amount) AS gmv,
  COUNT(*) AS order_cnt,
  COUNT(DISTINCT user_id) AS uv  -- 预聚合时算一次,查询时不再重算
FROM dwd_orders
GROUP BY dt, channel_id;
 
-- 查询时直接读DWS(无GROUP BY,无倾斜风险):
SELECT channel_id, gmv, order_cnt, uv
FROM dws_channel_daily
WHERE dt = '2024-08-01';
-- 数据量:从10亿行明细 → 1000行渠道汇总
-- 查询速度:从分钟级 → 秒级

物化视图:自动维护预聚合

Spark 3.x和部分OLAP引擎(Doris、StarRocks)支持物化视图(Materialized View),可以自动维护预聚合结果,比手动建DWS表更灵活:

-- Doris里创建物化视图(自动维护,数据写入时自动更新聚合)
CREATE MATERIALIZED VIEW mv_channel_daily
AS
SELECT
  dt,
  channel_id,
  SUM(amount) AS gmv,
  COUNT(*) AS order_cnt,
  bitmap_union(to_bitmap(user_id)) AS user_bitmap  -- 精确UV用Bitmap
FROM dwd_orders
GROUP BY dt, channel_id;
 
-- 查询时Doris自动识别并路由到物化视图:
SELECT channel_id, SUM(amount), COUNT(*), bitmap_count(bitmap_union(to_bitmap(user_id)))
FROM dwd_orders
WHERE dt = '2024-08-01'
GROUP BY channel_id;
-- Doris的查询优化器会自动把这个查询改写为查mv_channel_daily
-- 无需改SQL,透明加速

物化视图 vs 手动DWS表:物化视图自动维护,查询透明改写,但灵活性较低(聚合粒度固定)。手动DWS表更灵活,可以按任意维度聚合,但需要维护调度任务。生产中两者结合:高频固定查询用物化视图,灵活查询用手动DWS表。

面试高频追问

追问一:分桶表的数据倾斜和普通表的倾斜有什么不同?

分桶表的倾斜本质上还是key分布不均,但和普通表的区别在于:普通表倾斜是Shuffle时动态形成的,每次查询都可能倾斜;分桶表的「倾斜」在建表时就已经固化——如果某个key的数据本来就多,它会集中在某个桶里,导致这个桶文件特别大。读这个桶的Task数据量比其他Task大,形成读阶段的倾斜。处理方法:分桶数量选够大(让每个桶不会太大),或者对于已知的热点key在写入时做特殊处理(比如加盐打散写入多个桶)。

追问二:预聚合的DWS表,如果业务突然要求按新维度分析,DWS没有这个维度怎么办?

这是DWS层最常见的挑战。有几个处理策略:1)DWS表设计时要有一定的「维度冗余」,把可能用到的维度都加进去,宁可多存不用的,不要到时候改表;2)对于低频的临时分析,允许回溯原始DWD明细表(接受慢一点);3)提前做维度的正交组合:按(dt, channel, country)等多维组合预聚合,覆盖大多数分析需求;4)OLAP引擎(Doris/StarRocks)的Rollup或物化视图可以按需新增维度,比改DWS表的ETL任务更灵活。设计DWS时要和业务对齐,充分了解未来3-6个月的分析需求。

追问三:分桶和分区有什么区别?能一起用吗?

分区(Partition)和分桶(Bucketing)是两个层次的数据组织:分区是粗粒度的,通常按时间(dt)或地区(country)分,主要用于分区裁剪(Partition Pruning),减少读取的数据量;分桶是细粒度的,按具体的业务key(user_id等)做哈希分桶,主要用于Bucket Join和局部采样。两者可以同时使用:按dt分区,分区内再按user_id分桶。这样查询时先分区裁剪减少数据量,再利用分桶做Join优化。Hive和Spark都支持分区+分桶的组合,是大表设计的最佳实践之一。

实战踩坑

我在某元宇宙社交应用做实时数仓建设时,有一张「用户礼物记录表」,每天数据量约5亿行。有一个高频查询:按用户统计送礼金额的排行榜,每小时跑一次。最初直接对原始明细表做GROUP BY + ORDER BY,每小时这个作业要跑15-20分钟,而且特别不稳定——某些土豪用户的礼物记录是普通用户的10万倍,GROUP BY时严重倾斜。

-- 原始方案(每小时跑,直接GROUP BY明细表):
SELECT
  sender_id,
  SUM(gift_amount) AS total_sent
FROM gift_records
WHERE ts >= current_timestamp - INTERVAL 1 HOUR
GROUP BY sender_id
ORDER BY total_sent DESC
LIMIT 100;
-- 问题:5亿行明细,某些土豪用户倾斜,每次15-20分钟
 
-- 优化方案:两层预聚合
-- 第一层:Flink实时做分钟级预聚合,写入DWS表
-- (Flink消费Kafka,每分钟聚合一次)
-- dws_gift_minute: (sender_id, minute_ts, partial_amount)
 
-- 第二层:批处理每小时合并分钟级数据
SELECT
  sender_id,
  SUM(partial_amount) AS total_sent  -- 直接SUM预聚合结果
FROM dws_gift_minute
WHERE minute_ts >= date_trunc('hour', current_timestamp)
GROUP BY sender_id  -- 行数:从5亿行 → 约300万行(用户数)
ORDER BY total_sent DESC
LIMIT 100;
-- 优化后:从15分钟降到45秒,而且几乎不再有倾斜
-- 因为每分钟聚合后,每个用户每分钟只有1行,数据量大幅压缩

关键洞察:倾斜的根源是「土豪用户有10万行礼物记录」,预聚合到分钟级后,每个用户每分钟只有1行,数据分布变得均匀了。预聚合不只是提速,更是从数据分布层面消除了倾斜的土壤。

总结

从数据写入阶段消除倾斜比事后处理更彻底:分桶设计让JOIN无需Shuffle,天然均匀;预聚合让GROUP BY操作在数据量小得多的汇总表上进行,从源头减少倾斜风险。两者的共同前提是:要对查询模式有清晰的认知,根据高频查询来设计数据的物理存储。面试时能说出「治本vs治标」的区别,以及分桶和预聚合的设计原则,展示了数仓架构层面的思维,而不只是SQL调优技巧。

下一篇聊数据倾斜的「发现」问题——生产环境里倾斜怎么提前发现?有没有系统化的监控方案?等倾斜爆发再处理已经太晚了。

你们的数仓设计里有分桶表吗?分桶key是怎么选的?有没有遇到分桶但Bucket Join没生效的情况?评论区聊聊~

关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长

刷题进度保存在当前浏览器