Spark常见面试题
一、Spark核心概念与机制
1. Spark架构与执行流程
问题1:解释Spark Driver、Executor、Cluster Manager的职责与协作流程,如何动态调整Executor资源?
答案:
- 职责:
- Driver:负责解析用户程序,生成DAG执行计划,调度任务到Executor,并监控任务状态。
- Executor:在Worker节点上执行Task,存储中间数据,向Driver汇报状态。
- Cluster Manager:管理集群资源(如YARN、Kubernetes),分配和回收Executor资源。
- 协作流程:
- Driver将用户代码转换为逻辑执行计划(RDD DAG);
- DAGScheduler将DAG划分为Stage,TaskScheduler分配Task到Executor;
- Executor执行Task,返回结果给Driver。
- 动态调整Executor:
启用动态资源分配(spark.dynamicAllocation.enabled=true),根据任务负载自动增减Executor。
示例配置:
spark-submit --conf spark.dynamicAllocation.enabled=true \
--conf spark.dynamicAllocation.minExecutors=2 \
--conf spark.dynamicAllocation.maxExecutors=10
业务场景:滴滴在早晚高峰时段动态扩容Executor以应对突增的订单数据处理需求。
问题2:Transformation与Action的区别?为什么只有Action操作会触发实际计算?
答案:
- 区别:
- Transformation:定义数据转换逻辑(如map、filter),生成新的RDD,但不立即执行。
- Action:触发实际计算并返回结果(如count、saveAsTextFile)。
- 懒加载机制:
Spark通过懒加载优化执行计划,只有遇到Action时才会生成物理执行计划并提交任务,避免重复计算和资源浪费。
示例:
# Transformation:不会触发计算
rdd = sc.textFile("hdfs://logs/order")
filtered = rdd.filter(lambda x: "completed" in x)
# Action:触发计算
count = filtered.count() # 此时才会真正执行filter和count
2. RDD与DStream
问题1:RDD的弹性特性如何实现容错?列举RDD转换为DStream的方法。
答案:
- 容错机制:
RDD通过血统(Lineage)记录数据变换历史。若某个分区丢失,可根据血统重新计算。
示例:
rdd = sc.parallelize([1, 2, 3])
mapped = rdd.map(lambda x: x * 2)
# 若mapped分区丢失,重新从原始rdd执行map操作
- RDD转DStream:
- 通过ssc.queueStream从内存队列生成;
// 初始化 Spark 配置和 Streaming 上下文 val conf = new SparkConf().setMaster("local").setAppName("RDDToDStreamDemo") val ssc = new StreamingContext(conf, Seconds(1)) // 批次间隔1秒 // 创建队列并填充 RDD(模拟数据源) val rddQueue = new scala.collection.mutable.Queue[RDD[String]]() for (i <- 1 to 5) { rddQueue.enqueue(ssc.sparkContext.parallelize(Seq(s"Data_$i", s"Value_$i"))) } // 将队列转换为 DStream val inputStream = ssc.queueStream(rddQueue, oneAtATime = false)
- 从Kafka消费数据生成(KafkaUtils.createDirectStream)。
// 1. 初始化 Spark Streaming 上下文 val conf = new SparkConf().setAppName("KafkaDirectStreamDemo") .setMaster("local[*]") // 本地模式,生产环境需注释此行 val ssc = new StreamingContext(conf, Seconds(5)) // 批次间隔5秒 // 2. 配置 Kafka 参数 val kafkaParams = Map[String, Object]( "bootstrap.servers" -> "kafka1:9092,kafka2:9092", "key.deserializer" -> classOf[StringDeserializer], "value.deserializer" -> classOf[StringDeserializer], "group.id" -> "spark-streaming-group", "auto.offset.reset" -> "latest", "enable.auto.commit" -> (false: java.lang.Boolean) ) // 3. 定义 Kafka 主题 val topics = Array("test-topic") // 订阅的 Kafka 主题 // 4. 创建 Direct DStream val kafkaStream = KafkaUtils.createDirectStream[String, String]( ssc, LocationStrategies.PreferConsistent, // 均匀分配分区到 Executor ConsumerStrategies.Subscribe[String, String](topics, kafkaParams) ) // 5. 数据处理与偏移量管理 kafkaStream.foreachRDD { rdd => // 获取当前批次的偏移量范围 val offsetRanges = rdd.asInstanceOf[HasOffsetRanges].offsetRanges // 业务处理(如解析JSON、过滤异常数据) val data = rdd.map(record => parseLog(record.value())) // 手动提交偏移量到外部存储(如Kafka/HBase) saveOffsetsToDatabase(offsetRanges) } // 6. 启动流式计算 ssc.start() ssc.awaitTermination() // 阻塞等待停止信号
问题2:窗口操作的作用是什么?如何设置滑动间隔与窗口长度?
答案:
- 作用:对实时数据流按时间窗口聚合(如每分钟订单量)。
- 参数设置:
- 窗口长度(window duration):统计的时间范围(如5分钟);
- 滑动间隔(slide duration):窗口触发计算的间隔(如1分钟)。
示例(Structured Streaming):
val windowedCounts = kafkaStream .groupBy(window($"timestamp", "5 minutes", "1 minute"), $"city_id") .count()
业务场景:滴滴实时统计每5分钟各城市的订单量,每隔1分钟更新一次结果。
二、数据处理与性能优化
1. 数据倾斜解决方案
问题1:处理数据倾斜时,如何通过repartition、自定义分区或salting优化?
答案:
- Repartition:
增加分区数分散数据,适用于分区不均但无单一热点Key。
df.repartition(200, col("city_id")) // 按city_id重新分区
- 自定义分区:
重写Partitioner,分散热点Key到多个分区。
class CityPartitioner(override val numPartitions: Int) extends Partitioner {
override def getPartition(key: Any): Int = {
key match {
case "beijing" => 0 // 北京数据分配到独立分区
case _ => (key.hashCode % (numPartitions - 1)) + 1
}
}
}
- Salting(加盐):
为热点Key添加随机前缀,分散计算后合并。
示例:
-- 原始Key:city_id
SELECT CONCAT(city_id, '_', FLOOR(RAND() * 10)) AS salted_key, COUNT(*)
FROM orders
GROUP BY salted_key
-- 最终合并结果
SELECT REPLACE(salted_key, '_\\d+', '') AS city_id, SUM(count)
FROM temp
GROUP BY city_id
问题2:若Shuffle阶段出现OOM,如何调整spark.sql.shuffle.partitions参数?
答案:
- 原因:默认200个分区可能导致单个分区数据量过大。
- 优化方法:
增加分区数,减少单个分区的数据量。
spark.conf.set("spark.sql.shuffle.partitions", 1000)
业务场景:滴滴订单表按城市分组聚合时,调整分区数避免OOM。
2. 内存与计算效率优化
问题1:如何通过persist()减少重复计算?对比MEMORY_ONLY与MEMORY_AND_DISK。
答案:
- 持久化场景:
对需要多次使用的RDD/DataFrame调用persist(),避免重复计算。
filtered = rdd.filter(lambda x: x > 0).persist(StorageLevel.MEMORY_ONLY)
filtered.count() # 第一次触发计算并缓存
filtered.sum() # 直接读取缓存数据
- 存储级别对比:
- MEMORY_ONLY:仅内存缓存,速度快,但内存不足时分区丢失需重新计算。
- MEMORY_AND_DISK:内存不足时溢写到磁盘,避免重复计算,适合大数据集。
问题2:解释Broadcast变量原理,举例说明其在Join操作中的应用优势。
答案:
- 原理:将小数据集广播到所有Executor内存,避免Shuffle。
- 示例:
val smallTable = spark.table("dim_city").filter("is_active=1")
val broadcastTable = broadcast(smallTable)
// 订单表与城市维度表关联
val result = largeOrdersDF.join(broadcastTable, Seq("city_id"))
自动广播阈值:默认10MB(通过spark.sql.autoBroadcastJoinThreshold
配置)。
若表大小(序列化后)超过该值,需手动调大参数或使用BROADCAST
提示强制广播。
对略超阈值但内存充足的小表,使用/*+ BROADCAST(table) */
提示强制广播。
spark.conf.set("spark.sql.autoBroadcastJoinThreshold", "10485760") // 10MB调整为100MB
spark.conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")
spark.conf.set("spark.sql.sources.compression", "snappy")
优化效果:避免Shuffle 10GB订单数据,任务耗时减少50%。
三、数据仓库场景结合
1. 数仓分层与Spark应用
问题1:在ODS→DWD层处理中,如何用Spark实现高效数据清洗?
答案:
- 清洗步骤:
- 去重:df.dropDuplicates("order_id");
df.dropDuplicates(subset=["order_id", "city_id"]); // 对指定列去重
df.dropDuplicates(); //对所有列去重 - 空值处理:df.na.fill("unknown", Seq("driver_id"));
- 格式标准化:统一时间格式为yyyy-MM-dd HH:mm:ss。
示例代码:
val dwdOrders = odsOrders
.filter(col("order_time").isNotNull)
.withColumn("order_date", to_date(col("order_time")))
.write.format("parquet").save("/dwd/orders")
问题2:为什么DWS层需使用Spark进行轻度聚合?如何设计中间表?
答案:
- 原因:DWS层面向主题分析,轻度聚合(如按城市+日期统计订单量)可加速上层查询。
- 中间表设计:
CREATE TABLE dws_city_daily
USING parquet
AS
SELECT city_id, order_date, COUNT(*) AS order_count
FROM dwd_orders
GROUP BY city_id, order_date
业务价值:实时查询效率提升5倍。
2. Spark SQL与外部系统集成
问题1:如何通过Spark SQL实现Hive表与Kafka流数据的联合分析?
答案:
// 读取Hive静态表
val hiveTable = spark.sql("SELECT * FROM dwd.orders")
// Kafka 配置
val kafkaParams = Map[String, String](
"kafka.bootstrap.servers" -> "kafka-broker1:9092,kafka-broker2:9092",
"subscribe" -> "user_activity_topic",
"startingOffsets" -> "latest"
)
// 读取Kafka实时流
val kafkaStream = spark.readStream
.format("kafka")
.option(kafkaParams)
.load()
// 如果Kafka数据为JSON格式,需要进行解析
// 流批联合查询
val joined = kafkaStream
.join(hiveTable, Seq("order_id"), "left_outer")
问题2:解释JDBC连接Spark时的并行度优化方法(如partitionColumn参数)。
答案:
- 并行读取:通过partitionColumn将数据分片并行拉取。
val jdbcDF = spark.read
.format("jdbc")
.option("url", "jdbc:mysql://host:3306/db")
.option("dbtable", "orders") // 读取的表名
.option("partitionColumn", "id") // 分区列的列名
.option("lowerBound", 1) // 分区列的最小值
.option("upperBound", 100000) // 分区列的最大值
.option("numPartitions", 10) // 分区数
.option("fetchsize",10000) // 每次读取的行数
.load()
优化策略:
1、通过 partitionColumn、lowerBound、upperBound 和 numPartitions 四个参数控制数据分片策略,实现并行读取:
参数名 |
作用 |
示例值 |
partitionColumn |
用于分区的列名(必须是数值、日期或可哈希的列) |
id |
lowerBound |
分区列的最小值(决定分片起始点) |
1 |
upperBound |
分区列的最大值(决定分片终点) |
10000 |
numPartitions |
分区数(即并行读取的 Task 数) |
4 |
说明:若数据分布不均(如时间戳列),可结合 动态边界值,即先查询实际边界值。
2、设置 fetchsize:调整单次请求的数据量(默认值较低,可适当增大):
注意: 确保 partitionColumn 有索引,避免全表扫描。
四、实时计算与业务场景
1. Spark Streaming/Kafka集成
问题1:对比Receiver与Direct方式的优缺点,如何保证Exactly-Once语义?
一、Receiver方式原理
- 数据接收机制:
- 使用Kafka高级Consumer API,在Executor中启动独立Receiver线程持续接收Kafka数据,数据默认缓存到Executor内存(存储级别为MEMORY_AND_DISK_SER_2)。
- 接收的数据会备份到其他Executor节点,并通过预写日志(WAL)同步写入HDFS等可靠存储,确保故障恢复。
- 偏移量管理:
- 数据备份完成后,Receiver将消费偏移量更新到Zookeeper,并向Driver的ReceiverTracker汇报数据位置。
- 容错问题:若Driver故障,已备份但未处理的WAL数据可能导致重复消费(At-Least-Once语义)。
- 任务调度:
- Driver根据数据本地化原则分发Task到Executor,处理缓存数据。
二、Direct方式原理
- 数据读取机制:
- 摒弃Receiver,直接通过Kafka低级API按需拉取数据,每个Spark RDD分区与Kafka分区一一对应,实现并行度精准控制。
- 数据不缓存到Executor内存,直接从Kafka读取并生成微批次(Batch)处理。
- 偏移量管理:
- 偏移量由Spark自行管理,定期查询Kafka获取各分区最新Offset,处理完成后手动提交到外部系统(如Kafka自身、HBase等),支持Exactly-Once语义。
- 容错恢复:故障时直接从Kafka重新读取未提交的Offset范围,无需依赖WAL。
- 任务调度:
- Driver触发Job时,Executor直接连接Kafka分区,按Offset范围拉取数据并处理,减少中间环节延迟。
三、核心差异总结
维度 |
Receiver方式 |
Direct方式 |
数据接收 |
持续接收并缓存到内存,依赖WAL容错 |
按批次直接拉取,无中间缓存 |
偏移量管理 |
依赖Zookeeper,更新延迟可能导致重复 |
手动管理,支持精准提交至外部系统 |
性能与扩展性 |
低(单Receiver瓶颈、WAL开销) |
高(并行度高,无冗余存储) |
四、适用场景
- Receiver方式:适用于简单消费场景,需容忍潜在重复数据。
- Direct方式:适用于高吞吐、强一致性要求的场景,需开发者自行实现Offset管理。
简单总结:
- Receiver模式:
- 优点:自动管理偏移量;
- 缺点:可能数据重复(Receiver故障时),且需WAL(Write Ahead Log)保证可靠性。
- Direct模式:
- 优点:直接管理偏移量,无Receiver单点故障;
- 缺点:需手动提交偏移量。
Spark实现Exactly-Once语义的核心方法
一、整体框架要求
Exactly-Once的实现需输入源、计算过程、输出操作三部分协同保证。
- 输入源:需支持可靠数据重放(如Kafka)。
- 计算过程:需容错机制(如Checkpoint)确保中间状态一致性。
- 输出操作:需幂等性写入或事务性提交避免重复。
二、具体实现步骤
- 使用Direct API连接Kafka
- 通过Kafka低级API直接读取数据,每个RDD分区对应一个Kafka分区,精准控制消费范围。
- 手动管理Offset:将Offset存储到外部系统(如HBase、Redis或Kafka自身),并在数据处理完成后提交。
- 容错与状态恢复
- Checkpoint机制:定期保存处理状态(包括Offset和中间计算结果)到HDFS,用于故障恢复。
- 数据重放:若处理失败,直接从Kafka重新读取未提交的Offset范围,重新处理。
- 输出操作的Exactly-Once保障
- 幂等性写入:对输出目标(如数据库)设计唯一键或覆盖逻辑,例如:
INSERT INTO error_log (log_time, error_count)
VALUES ('2023-10-01 10:00:00', 5)
ON DUPLICATE KEY UPDATE error_count = VALUES(error_count);
确保重复写入不会导致数据错误。
- 事务性提交:将数据处理与Offset更新绑定为原子操作,例如:
- 使用支持事务的存储(如MySQL),在事务中同时更新结果表和Offset表。
- 通过Kafka事务API实现Offset提交与数据处理的事务性。
三、关键注意事项
- 输入源的可靠性
- Kafka需保留足够历史数据(通过log.retention.hours配置),确保可回溯消费。
- Checkpoint的局限性
- Checkpoint可能因应用升级导致恢复失败,需结合外部Offset管理增强鲁棒性。
- 反压机制的影响
- Direct模式天然支持动态反压,避免因处理延迟导致数据堆积,间接提升Exactly-Once的可靠性。
四、总结
实现维度 |
具体方案 |
依赖技术 |
输入源 |
Kafka Direct API + 手动Offset管理 |
Kafka低级API、外部存储(如HBase) |
计算过程 |
Checkpoint + 数据重放 |
HDFS、Spark容错机制 |
输出操作 |
幂等性写入/事务性提交 |
数据库事务、Kafka事务API |
通过以上三部分协同,Spark可在高吞吐场景下实现端到端Exactly-Once语义,适用于金融交易、实时统计等强一致性需求场景。
什么是幂等性?
幂等性指多次执行同一操作与执行一次的效果相同,即使操作因网络重试、系统故障等被重复执行,也不会导致数据错误或状态不一致。
幂等性写入通过设计“可重复执行但结果一致”的操作,解决了分布式系统中因重试、故障恢复导致的数据重复问题,是流处理端到端Exactly-Once的核心技术之一。
幂等性的实现方式
方法 |
说明 |
示例 |
唯一键约束 |
利用数据库主键或唯一索引,重复写入时触发覆盖或报错 |
MySQL的ON DUPLICATE KEY UPDATE、HBase的RowKey唯一性 |
版本号控制 |
为每条数据附加版本号,仅接受更高版本的更新 |
Kafka消息头中携带版本号,写入时校验版本是否递增 |
条件更新 |
基于数据当前状态判断是否允许更新(如CAS操作) |
Redis的SET key value NX(仅当key不存在时生效) |
幂等性API |
使用支持幂等性的存储系统或接口(如AWS S3的PutObject) |
S3的PutObject同一对象多次写入,最终结果为最后一次的值 |
问题2:如何用Structured Streaming实现实时订单窗口统计?
示例代码:
val windowedCounts = kafkaStream
.selectExpr("CAST(value AS STRING)")
.select(from_json(col("value"), schema).alias("data"))
.withColumn("timestamp", to_timestamp(col("data.order_time")))
.withWatermark("timestamp", "10 minutes")
.groupBy(window(col("timestamp"), "5 minutes", "1 minute"), col("data.city_id"))
.count()
windowedCounts.writeStream
.outputMode("update")
.format("console")
.start()
2. 业务场景设计题
问题1:设计滴滴司机调度系统的实时数仓,说明Spark的作用。
架构设计:
- 数据源:
- Kafka实时流:司机GPS轨迹、订单需求;
- 实时处理:
- GPS轨迹聚合:使用Spark Streaming计算司机实时位置热力图;
- ETA预测:结合历史路况数据,用MLlib训练回归模型预测到达时间;
- 输出:
- 实时调度建议推送至司机APP;
- 异常检测(如司机长时间停留)触发告警。
问题2:实时大屏数据波动如何排查?
排查步骤:
- 检查数据源:
- Kafka是否有堆积(kafka-consumer-groups.sh查看延迟);
- 检查Spark任务:
- 是否存在Task失败或Straggler(通过Spark UI查看Stage耗时);
- 检查Watermark设置:
- 是否因Watermark延迟过高导致窗口结果延迟更新。
总结
以上答案结合技术原理、代码示例和滴滴业务场景,建议在面试中通过“问题分析→解决方案→业务价值”的逻辑展开,并强调实际优化效果(如性能提升、资源节省等)。
本文来自博客园,作者:业余砖家,转载请注明原文链接:https://www.cnblogs.com/yeyuzhuanjia/p/18795709