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的容错机制,那又是另一个深夜故事了。

相关推荐
黄俊懿3 小时前
MySQL主从复制:从“异步“到“GTID“,数据同步的进化之路
数据库·sql·mysql·oracle·架构·dba·db
Bechamz4 小时前
大数据开发学习Day23
大数据·学习·ajax
看海的四叔4 小时前
【SQL】SQL-管好你的字符串
大数据·数据库·hive·sql·数据分析·字符串
@小柯555m5 小时前
MySql(高级操作符--高级操作符练习(2))
数据库·sql·mysql
渣渣盟5 小时前
大数据技术栈全景图:从零到一的入门路线(深度实战版)
大数据·hadoop·python·flink·spark
Mr_linjw5 小时前
MySQL 中监控和优化慢 SQL & 索引小知识
数据库·sql·mysql
雾岛听风6915 小时前
Sql server
数据库·sql·sqlserver
橙子圆1236 小时前
Mybatis之动态sql
sql·tomcat·mybatis
hsD5mSMu57 小时前
从零开始学Flink:Flink SQL 极简入门
大数据·sql·flink
许彰午8 小时前
我手写了一个 Java 内存数据库(四):索引引擎、SQL 解析与总结
java·数据库·sql