025、分布式计算实战:Spark Core与Spark SQL

从一次深夜调试说起

上周三凌晨两点,集群告警突然响了。一个跑了六小时的Spark作业卡在99%,最后一个stage的200个task里总有那么三五个一直在挣扎。日志里满是FetchFailedExceptionExecutorLost的报错,数据倾斜那熟悉的味道隔着屏幕都能闻到。这种场景你肯定也遇到过------数据分布不均匀,少数几个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。几个关键指标:

  1. Stage页面的Shuffle Read/Write:如果某个stage的write特别大,考虑加map端聚合
  2. Executor页面的GC时间:如果超过10%,调大executor内存或者换G1GC
  3. 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的容错机制,那又是另一个深夜故事了。

相关推荐
xiaoyaohou112 小时前
024、大数据技术栈概览:Hadoop、Spark与Flink
大数据·hadoop·spark
路ZP2 小时前
放大镜下拉框
java·数据库·sql
chatexcel2 小时前
【实战教程】ChatDB 入门:基于自然语言的无 SQL 数据库操作实践
数据库·sql·oracle
泷羽Sec-静安2 小时前
AICTFer一天速成指南
python·sql·ctf
2501_948114242 小时前
Muse Spark 闭源转型背后的系统化演进:PAO 架构、KV Cache 压缩与聚合接入实践
大数据·架构·spark
流觞 无依2 小时前
DedeCMS plus/digg.php 顶踩注入(SQL注入)修复教程
sql·安全·php
Henb9293 小时前
# Spark 内核级调优源码分析
大数据·ajax·spark
薛定猫AI3 小时前
【深度解析】Meta Muse Spark:原生多模态推理模型与多智能体编排的工程化实践
大数据·分布式·spark
xiaoyaohou113 小时前
026、流式计算:Kafka与Spark Streaming实时处理
spark·kafka·linq