第一次在千万级数据表上执行distinct操作时,我盯着那个卡住不动的进度条整整十分钟。作为从传统数据库转战大数据平台的老DBA,这种性能落差让我意识到:在分布式环境下,看似简单的去重操作背后藏着完全不同的游戏规则。
Spark SQL的distinct操作本质上是一个特殊的聚合运算,它需要将所有数据按目标字段重新洗牌(shuffle)到相同节点进行比较。当处理GB级以上的数据时,这种全局排序去重的代价会呈指数级增长。去年我们团队就遇到过生产事故:一个本该30分钟完成的报表任务,因为开发人员随意使用了多个distinct,最终导致集群资源耗尽超时失败。
用EXPLAIN EXTENDED观察一个简单查询:
sql复制SELECT DISTINCT department FROM employees
在Spark 3.2的执行计划中会显示:
code复制HashAggregate(keys=[department#20], functions=[])
+- Exchange hashpartitioning(department#20, 200)
+- HashAggregate(keys=[department#20], functions=[])
+- FileScan parquet [department#20]...
这个计划揭示了两阶段处理:
假设我们处理1亿条记录,每条记录的去重字段平均占用16字节:
这就是为什么在spark-defaults.conf中需要配置:
properties复制spark.sql.shuffle.partitions=200 # 根据集群规模调整
spark.executor.memoryOverhead=1g # 预防OOM
我们在TPC-DS 100GB数据集上测试不同方案:
| 方案 | 执行时间 | Shuffle数据量 | CPU耗时 |
|---|---|---|---|
| 直接DISTINCT | 78s | 12.4GB | 214s |
| GROUP BY替代 | 65s | 11.8GB | 198s |
| 预聚合+广播变量 | 41s | 2.3GB | 157s |
| 布隆过滤器预处理 | 53s | 8.7GB | 182s |
sql复制-- 坏味道
SELECT DISTINCT user_id FROM logs
-- 优化后
SELECT DISTINCT user_id FROM logs
WHERE dt BETWEEN '2023-01-01' AND '2023-01-31'
sql复制-- 原始写法
SELECT DISTINCT product_id, category FROM sales
-- 优化写法(减少一次聚合计算)
SELECT product_id, category FROM sales
GROUP BY product_id, category
python复制# 先在维度表去重
dim_df = spark.sql("SELECT DISTINCT dept_id FROM departments")
dim_df.persist(StorageLevel.MEMORY_AND_DISK)
# 广播到事实表关联
fact_df = spark.sql("SELECT * FROM transactions")
result = fact_df.join(broadcast(dim_df), "dept_id")
bash复制spark-submit \
--conf spark.sql.adaptive.enabled=true \
--conf spark.sql.adaptive.coalescePartitions.enabled=true \
--conf spark.sql.shuffle.partitions=300 \
--conf spark.executor.memory=8g \
--conf spark.sql.skewJoin.skewedPartitionFactor=5 \
your_app.py
当遇到数据倾斜时,可以通过采样分析键值分布:
python复制from pyspark.sql.functions import col
# 找出热点键值
skew_keys = (df.groupBy("department")
.count()
.orderBy(col("count").desc())
.limit(5)
.collect())
处理方案:
sql复制-- 原始倾斜SQL
SELECT DISTINCT user_id FROM click_logs
-- 加盐优化版
SELECT split(user_id, '_')[0] AS real_user_id
FROM (
SELECT concat(user_id, '_', ceil(rand()*10)) AS user_id
FROM click_logs
)
GROUP BY split(user_id, '_')[0]
python复制# 第一阶段局部聚合
stage1 = df.groupBy(
col("product_id"),
(rand() * 10).cast("int").alias("salt")
).agg(count("*").alias("cnt"))
# 第二阶段全局聚合
stage2 = stage1.groupBy("product_id").agg(sum("cnt").alias("total"))
使用Delta Lake的Z-Ordering加速distinct:
python复制delta_df = spark.read.format("delta").load("/data/events")
delta_df.optimize().executeZOrderBy("user_id")
实测效果:
在Spark UI中重点关注:
配置Prometheus监控:
yaml复制rules:
- alert: DistinctShuffleTooLarge
expr: spark_shuffle_read_bytes > 5e9
for: 5m
labels:
severity: warning
案例1:OOM错误
log复制java.lang.OutOfMemoryError: Java heap space
at java.util.HashMap.resize(HashMap.java:704)
at org.apache.spark.util.collection.ExternalAppendOnlyMap.insertAll(ExternalAppendOnlyMap.scala:152)
解决方案:
spark.sql.objectHashAggregate.sortBased.fallbackThreshold(默认128)spark.executor.memoryOverhead为堆内存的30%案例2:数据倾斜
log复制18/01/01 15:33:21 WARN scheduler.TaskSetManager:
Stage 3 contains a task of very large size (5324 KB)
解决方案:
spark.sql.adaptive.enabled=truespark.sql.adaptive.advisoryPartitionSizeInBytes=128MB字段选择黄金法则:
sql复制-- 精确去重(资源消耗大)
SELECT COUNT(DISTINCT user_id) FROM logs
-- 近似去重(误差0.1%以内)
SELECT approx_count_distinct(user_id, 0.001) FROM logs
执行计划检查清单:
Exchange表示有shuffleHashAggregate比SortAggregate更高效Filter操作在distinct之前执行参数调优经验值:
properties复制# 中小集群(20节点以下)
spark.sql.shuffle.partitions=集群核数×3
# 大集群(100节点以上)
spark.sql.adaptive.coalescePartitions.minPartitionNum=2000
一个真实故障复盘:
某次促销活动分析中,开发人员写了如下SQL:
sql复制SELECT DISTINCT a.user_id, b.product_id, c.category
FROM clicks a JOIN orders b ON a.user_id=b.user_id
JOIN products c ON b.product_id=c.id
优化后方案:
sql复制WITH user_products AS (
SELECT user_id, product_id
FROM orders GROUP BY user_id, product_id
)
SELECT a.user_id, a.product_id, p.category
FROM user_products a JOIN products p ON a.product_id=p.id
优化效果: