从一次深夜调试说起
上周三凌晨两点,集群告警突然响了。一个跑了六小时的Spark作业卡在99%,最后一个stage的200个task里总有那么三五个一直在挣扎。日志里满是FetchFailedException和ExecutorLost的报错,数据倾斜那熟悉的味道隔着屏幕都能闻到。这种场景你肯定也遇到过------数据分布不均匀,少数几个key扛了几千万条记录,几个倒霉的executor内存直接撑爆。今天我们就聊聊怎么用Spark Core和Spark SQL解决这类实战问题。
Spark Core:理解你的并行引擎
先看段真实的生产代码,这是出问题的那个stage的简化版:
scala
val rawRDD = sc.textFile("hdfs://data/logs/*.gz")
.map(line => parseLog(line)) // 解析日志,返回(key, value)
.filter(_ != null) // 过滤脏数据
// 问题就出在这个groupByKey上
val groupedRDD = rawRDD.groupByKey() // 这里踩过坑:groupByKey默认不进行map端合并
.mapValues(values => processBatch(values))
groupedRDD.saveAsTextFile("hdfs://output/result")
看起来挺干净是吧?问题在于groupByKey()会把某个key对应的所有values都拉到同一个节点上做聚合。如果某个key特别热,比如user_id=0(默认用户)或者city=unknown,那个节点就惨了。
改进方案一:用reduceByKey替代
scala
// 先做map端局部聚合,大幅减少shuffle数据量
val reducedRDD = rawRDD
.map(kv => (kv._1, 1)) // 假设我们只是计数
.reduceByKey(_ + _, 200) // 第二个参数是分区数,根据数据量调整
改进方案二:加盐打散倾斜key
scala
// 对热点key添加随机前缀
val saltedRDD = rawRDD.map {
case (key, value) =>
if (isHotKey(key)) {
val salt = (math.random * 100).toInt
(s"${key}_$salt", value)
} else {
(key, value)
}
}
// 第一次聚合
val firstAgg = saltedRDD.reduceByKey(mergeFunc)
// 去掉盐值,二次聚合
val finalResult = firstAgg.map {
case (key, value) =>
val originalKey = key.split("_")(0)
(originalKey, value)
}.reduceByKey(mergeFunc)
分区数设置是个经验活。我的一般原则是:每个分区的数据量控制在128MB以内,但分区总数不要超过executor_cores * executor_instances * 3。别设太大,调度开销会让你哭的。
Spark SQL:声明式编程的甜与苦
切换到Spark SQL后,代码清爽多了,但坑一点没少。有次我写了这么个查询:
sql
SELECT
user_id,
COUNT(*) as cnt
FROM logs
LEFT JOIN user_info ON logs.user_id = user_info.id
WHERE user_info.register_date > '2023-01-01'
GROUP BY user_id
ORDER BY cnt DESC
LIMIT 100
看起来标准吧?执行计划一出来就傻眼了------先做了全表JOIN,然后才过滤register_date,几百G的数据在集群里来回搬。Spark SQL的优化器没那么智能,你得手动引导。
优化后的写法:
sql
WITH active_users AS (
SELECT id FROM user_info
WHERE register_date > '2023-01-01' -- 先过滤,数据量降两个数量级
)
SELECT
logs.user_id,
COUNT(*) as cnt
FROM logs
INNER JOIN active_users ON logs.user_id = active_users.id -- 用INNER替代LEFT
GROUP BY logs.user_id
ORDER BY cnt DESC
LIMIT 100
这里还有个细节:默认的spark.sql.shuffle.partitions是200,对于大表JOIN来说太小了。我通常在作业开头加上:
scala
spark.conf.set("spark.sql.shuffle.partitions", "1000")
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "104857600") // 100MB,适当调大
广播JOIN用好了是神器,用不好就是OOM炸弹。小表确实可以广播,但别忘了算上序列化后的内存开销,实际占用可能比文件大小多30%。
调试技巧:看透Spark UI
遇到慢任务,别急着改代码,先看Spark UI。几个关键指标:
- Stage页面的Shuffle Read/Write:如果某个stage的write特别大,考虑加map端聚合
- Executor页面的GC时间:如果超过10%,调大executor内存或者换G1GC
- SQL页面的执行计划 :看到
BroadcastHashJoin就安心,看到SortMergeJoin就要警惕数据倾斜
我习惯在关键转换后加个cache(),但一定记得后面要unpersist()。见过太多作业因为缓存了中间RDD,内存占满后频繁spill到磁盘,反而更慢了。
配置经验谈
这些参数是我压测出来的黄金组合,适合大多数场景:
properties
spark.executor.memory=8g # 别设太大,YARN的NM会不高兴
spark.executor.memoryOverhead=2g # 堆外内存,Parquet操作很需要
spark.serializer=org.apache.spark.serializer.KryoSerializer
spark.sql.adaptive.enabled=true # 自适应查询执行,Spark 3.0后必开
spark.sql.files.maxPartitionBytes=134217728 # 128MB,和HDFS块对齐
最后说几句
Spark用起来像开车------自动挡简单,但想开得快还得懂手动模式。别迷信DataFrame API就一定比RDD快,复杂的多阶段处理里,RDD的精细控制反而更有效。生产环境永远先跑小样本数据,看看执行计划再全量跑。遇到数据倾斜别怕,sample()取样分析key分布,针对性打散。记住,集群资源是有限的,最优雅的实现往往不是最高效的,在简洁性和性能之间找到平衡点,这才是工程师的价值。
下次我们聊聊Spark Streaming的容错机制,那又是另一个深夜故事了。