Spark常见面试题

‌一、Spark核心概念与机制

‌1. Spark架构与执行流程

问题1:解释Spark Driver、Executor、Cluster Manager的职责与协作流程,如何动态调整Executor资源?
答案‌:

  • 职责‌:
    • Driver‌:负责解析用户程序,生成DAG执行计划,调度任务到Executor,并监控任务状态。
    • Executor‌:在Worker节点上执行Task,存储中间数据,向Driver汇报状态。
    • Cluster Manager‌:管理集群资源(如YARN、Kubernetes),分配和回收Executor资源。
  • 协作流程‌:
  1. Driver将用户代码转换为逻辑执行计划(RDD DAG);
  2. DAGScheduler将DAG划分为Stage,TaskScheduler分配Task到Executor;
  3. 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‌:
    1. 通过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)
    2. 从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实现高效数据清洗?
答案‌:

  • 清洗步骤‌:
    1. 去重‌:df.dropDuplicates("order_id");  
            df.dropDuplicates(subset=["order_id", "city_id"]);        // 对指定列去重
                 df.dropDuplicates();                                                        //对所有列去重
    2. 空值处理‌:df.na.fill("unknown", Seq("driver_id"));
    3. 格式标准化‌:统一时间格式为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方式原理‌

  1. ‌数据接收机制‌:
    • 使用Kafka高级Consumer API,在Executor中启动‌独立Receiver线程‌持续接收Kafka数据,数据默认缓存到Executor内存(存储级别为MEMORY_AND_DISK_SER_2)‌。
    • 接收的数据会‌备份到其他Executor节点‌,并通过预写日志(WAL)同步写入HDFS等可靠存储,确保故障恢复‌。
  2. ‌偏移量管理‌:
    • 数据备份完成后,Receiver将消费偏移量更新到‌Zookeeper‌,并向Driver的ReceiverTracker汇报数据位置‌。
    • ‌容错问题‌:若Driver故障,已备份但未处理的WAL数据可能导致重复消费(At-Least-Once语义)‌。
  3. ‌任务调度‌:
    • Driver根据数据本地化原则分发Task到Executor,处理缓存数据‌。

‌二、Direct方式原理‌

  1. ‌数据读取机制‌:
    • 摒弃Receiver,直接通过‌Kafka低级API‌按需拉取数据,每个Spark RDD分区与Kafka分区‌一一对应‌,实现并行度精准控制‌。
    • 数据不缓存到Executor内存,直接从Kafka读取并生成微批次(Batch)处理‌。
  2. ‌偏移量管理‌:
    • 偏移量由Spark自行管理,定期查询Kafka获取各分区最新Offset,处理完成后‌手动提交到外部系统‌(如Kafka自身、HBase等),支持Exactly-Once语义‌。
    • ‌容错恢复‌:故障时直接从Kafka重新读取未提交的Offset范围,无需依赖WAL‌。
  3. ‌任务调度‌:
    • 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的实现需‌输入源、计算过程、输出操作‌三部分协同保证‌。

  1. ‌输入源‌:需支持‌可靠数据重放‌(如Kafka)‌。
  2. ‌计算过程‌:需容错机制(如Checkpoint)确保中间状态一致性‌。
  3. ‌输出操作‌:需‌幂等性写入‌或‌事务性提交‌避免重复‌。

‌二、具体实现步骤‌

  1. ‌使用Direct API连接Kafka‌
    • 通过‌Kafka低级API‌直接读取数据,每个RDD分区对应一个Kafka分区,精准控制消费范围‌。
    • ‌手动管理Offset‌:将Offset存储到外部系统(如HBase、Redis或Kafka自身),并在数据处理完成后提交‌。
  2. ‌容错与状态恢复‌
    • ‌Checkpoint机制‌:定期保存处理状态(包括Offset和中间计算结果)到HDFS,用于故障恢复‌。
    • ‌数据重放‌:若处理失败,直接从Kafka重新读取未提交的Offset范围,重新处理‌。
  3. ‌输出操作的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提交与数据处理的事务性‌。

 

‌三、关键注意事项‌

  1. ‌输入源的可靠性‌
    • Kafka需保留足够历史数据(通过log.retention.hours配置),确保可回溯消费‌。
  2. ‌Checkpoint的局限性‌
    • Checkpoint可能因应用升级导致恢复失败,需结合外部Offset管理增强鲁棒性‌。
  3. ‌反压机制的影响‌
    • 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的作用。
架构设计‌:

  1. 数据源‌:
    • Kafka实时流:司机GPS轨迹、订单需求;
  2. 实时处理‌:
    • GPS轨迹聚合‌:使用Spark Streaming计算司机实时位置热力图;
    • ETA预测‌:结合历史路况数据,用MLlib训练回归模型预测到达时间;
  3. 输出‌:
    • 实时调度建议推送至司机APP;
    • 异常检测(如司机长时间停留)触发告警。

 

问题2:实时大屏数据波动如何排查?
排查步骤‌:

  1. 检查数据源‌:
    • Kafka是否有堆积(kafka-consumer-groups.sh查看延迟);
  2. 检查Spark任务‌:
    • 是否存在Task失败或Straggler(通过Spark UI查看Stage耗时);
  3. 检查Watermark设置‌:
    • 是否因Watermark延迟过高导致窗口结果延迟更新。

 

‌总结

以上答案结合技术原理、代码示例和滴滴业务场景,建议在面试中通过“问题分析→解决方案→业务价值”的逻辑展开,并强调实际优化效果(如性能提升、资源节省等)。

posted @ 2025-03-27 11:56  业余砖家  阅读(403)  评论(0)    收藏  举报