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

相关推荐
Theo·Chan6 小时前
机房断电搞崩服务器 | 人大金仓 V8 全量备份跨实例完整恢复实录
sql·信创·kingbase·金仓
持敬chijing8 小时前
Web渗透之SQL注入总结
sql·安全·web安全·网络安全·网络攻击模型·web
davawang9 小时前
基于SQL实现分组的文字排序聚合
sql·分析函数·数据平台
可乐ea11 小时前
【Spring Boot + MyBatis|第4篇】MyBatis 动态 SQL:if、where、foreach 使用详解
java·spring boot·后端·sql·mybatis
IvorySQL12 小时前
PostgreSQL 技术日报 (6月8日)|索引预取迭代,AI 安全功能上新
数据库·人工智能·sql·安全·postgresql
持敬chijing12 小时前
Web渗透之SQL注入-SQLMAP使用笔记
数据库·sql·安全·web安全·网络安全·网络攻击模型
千里马学框架12 小时前
重学Perfetto浏览器在线抓取trace及高频sql分享
android·sql·智能手机·架构·aaos·perfetto·车机
极光代码工作室14 小时前
基于数据分析的电影票房预测系统
大数据·python·数据分析·spark·数据可视化
逍遥德14 小时前
PostgreSQL --- 二进制数使用详解
数据库·sql·postgresql
swordbob1 天前
MySQL字符集陷阱:从Oracle迁移踩坑到utf8mb4强制规范
数据库·sql