Spark 核心之广播变量、累加器原理深度剖析

摘要 :Spark 分布式计算的本质是"数据分发 + 并行处理"。但在 Executor 之间共享变量时,常规做法(如闭包引用)会导致严重的性能问题------大变量重复序列化传输、更新不可见。Spark 提供了两种分布式共享变量 解决此难题:广播变量(Broadcast) 实现大对象的单向高效分发,累加器(Accumulator) 实现跨任务的聚合计数。本文从底层机制、源码实现、最佳实践三个维度,配合 2 张原创深色架构图,彻底拆解 TorrentBroadcast 的 BitTorrent 分块分发机制和 AccumulatorV2 的 copyAndReset → merge 回传模型。

关键词:Spark Broadcast, TorrentBroadcast, 广播变量, AccumulatorV2, 累加器, 分布式共享变量, P2P 块交换, Task 重试一致性问题


一、开篇:一个常见但致命的错误

scala 复制代码
// ❌ 错误示范:大变量直接放闭包------每个 Task 都会序列化一份!
val bigDict = loadHugeDictionary()  // 100MB 的 IP 库
val result = rdd.map(ip => bigDict.lookup(ip))  // 100MB × N个Task 序列化!

这个看似无害的 map 操作,暗藏着 Spark 分布式编程中最经典的性能陷阱。当我们写 bigDict.lookup(ip) 时,Lambda 被 序列化 到每个 Task。如果 bigDict 有 100MB,Executors 有 100 个 Task,网络传输量将达到 10GB

幸运的是,Spark 设计了两类分布式共享变量来优雅地解决此类问题:

scss 复制代码
Spark 分布式共享变量
├── 广播变量 (Broadcast):Driver → Executors 单向分发,只读共享 ------ 大数据分发
└── 累加器 (Accumulator):Executor → Driver 单向聚合,只写计数   ------ 状态统计

二、广播变量原理深度剖析

2.1 为什么需要广播变量?

在分布式系统中,每个 Executor 是一个独立的 JVM 进程,Driver 与 Executor 之间仅通过序列化/反序列化通信。当 Task 闭包引用外部变量时:

变量传递方式 序列化次数 网络传输量 Executor 间共享?
闭包引用(❌) 每个 Task 1次 变量大小 × Task数 否,各自独立
广播变量(✅) Executor 级别 变量大小 × Executor数 BlockManager 缓存

2.2 TorrentBroadcast 核心机制

Spark 2.x 起默认使用 TorrentBroadcast(替代 HttpBroadcast),核心思想借鉴了 BitTorrent 的 P2P 协议:

scala 复制代码
// 源码:TorrentBroadcast.writeBlocks()
// org.apache.spark.broadcast.TorrentBroadcast

// Step 1: Driver 序列化 + 压缩
val ser = SparkEnv.get.serializer.newInstance()
val (compressed, size) = SparkEnv.get.compressManager.compress(
  ser.serialize(value)
)

// Step 2: 分块 (Chunking) --- 默认 4MB 一块
val blockSize = conf.get(config.BROADCAST_BLOCK_SIZE)  // 4MB 默认
val blocks = compressed.grouped(blockSize.toInt).toArray

// Step 3: 每块写入 Driver 的 BlockManager
// 其他 Executor 可以通过 BlockManager 远程拉取
blocks.zipWithIndex.foreach { case (block, i) =>
  blockManager.putSingle(
    BroadcastBlockId(id, s"piece$i"),
    block,
    StorageLevel.MEMORY_AND_DISK_SER,  // 注意:序列化存储
    tellMaster = false
  )
}

关键设计决策

yaml 复制代码
TorrentBroadcast 分发流程
  Driver: 序列化 → 压缩 → 分块(4MB) → BlockManager.putSingle()
  Executor-1: readBroadcastBlock() → 先查本地 → 向 Driver 拉取 Block-0
  Executor-2: readBroadcastBlock() → 先查本地 → 向 Executor-1 拉取 Block-0(P2P!)
  Executor-N: 同上,不依赖单一数据源

每个 Executor 首次访问 broadcastVar.value() 时,TorrentBroadcast.readBroadcastBlock() 被调用:

  1. 先查本地 BlockManager 缓存 ------ 命中直接返回
  2. 未命中 → 向 Driver 或已持有该块的 Peer Executor 请求
  3. 获取后立即缓存到本地 BlockManager(MEMORY_AND_DISK_SER

2.3 BitTorrent 风格的"块交换"

TorrentBroadcast 的真正威力在于 Peer-to-Peer 块交换。当 Executor-1 持有 Block-0/1,Executor-2 持有 Block-2/3 时,新加入的 Executor-3 可以从两者并行拉取不同块:

scala 复制代码
// 源码:TorrentBroadcast.readBlocks()
// 并行读取所有广播块 --- 利用线程池并发拉取
val futures = blocks.indices.map { i =>
  executor.submit(new Callable[Option[ChunkedByteBuffer]] {
    override def call(): Option[ChunkedByteBuffer] = {
      blockManager.getRemoteBytes(BroadcastBlockId(broadcastId, s"piece$i"))
    }
  })
}
futures.foreach(_.get())  // 并行等待所有块读取完成

这形成了天然的负载均衡 ------ 不会出现所有 Executor 都"挤"到 Driver 获取数据的单点瓶颈。

2.4 广播变量的生命周期

scala 复制代码
// ✅ 使用广播变量的完整生命周期
val dict = Map("CN" -> "中国", "US" -> "美国", ...)  // 大字典
val broadcastDict = sc.broadcast(dict)                // ① 创建

rdd.map(ip => broadcastDict.value.getOrElse(ip, "未知")).collect()  // ② 使用

// ③ 显式销毁(可选,系统也会自动清理)
broadcastDict.destroy()
// 或批量销毁
// broadcastDict.destroyAll()

广播变量通过 ContextCleaner 异步清理:Driver 通过 BlockManagerMaster 向所有 Executor 发送 RemoveBroadcast(broadcastId) 消息。


三、累加器原理深度剖析

3.1 累加器的本质:Executor 只写 → Driver 只读

广播变量是 Driver → Executor (单向分发),累加器则是 Executor → Driver(单向聚合):

scala 复制代码
// 累加器的信息流向
val totalErrors = sc.longAccumulator("errorCounter")  // Driver 创建

rdd.foreach { record =>
  if (record.isInvalid) {
    totalErrors.add(1)  // Executor 端写入(无网络通信)
  }
}

// Action 完成后,Driver 读取最终值
println(s"错误记录总数: ${totalErrors.value}")  // Driver 端读取

核心设计 :Executor 端累加器是 Driver 端元对象的 copyAndReset(深拷贝+置零) ,Task 完成后再 merge(合并) 回 Driver。这意味着 Executor 端 add() 操作 零网络开销

3.2 AccumulatorV2 接口深度解析

Spark 2.x 统一了累加器 API 为 AccumulatorV2[IN, OUT],支持自定义聚合逻辑:

scala 复制代码
// 源码:AccumulatorV2 核心抽象
abstract class AccumulatorV2[IN, OUT] extends Serializable {
  // ------ Executor 端 ------
  def add(v: IN): Unit          // Task 内部调用,本地累加

  // ------ 合并操作 ------
  def merge(other: AccumulatorV2[IN, OUT]): Unit  // Driver 端合并子副本
  def copy(): AccumulatorV2[IN, OUT]               // 深拷贝(保留当前值)
  def copyAndReset(): AccumulatorV2[IN, OUT]       // 深拷贝并置零 → 给 Task

  // ------ 查询 ------
  def value: OUT                // 读取当前累加值
  def isZero: Boolean           // 是否为初始零值
  def reset(): Unit             // 重置为零
}

Task 序列化时的关键流程

arduino 复制代码
DAGScheduler.submitMissingTasks()
  → 序列化 Task(ShuffleMapTask / ResultTask)
    → Task 中的累加器使用 accum.copyAndReset()
    → Executor 反序列化得到独立零值副本(id 相同但值独立)
      → Task 执行 → acc.add(...) 本地累加
        → Task 完成 → TaskResult 带回 execAccumUpdates
          → Driver: accum.merge(execCopy) → 合并到 Driver 元对象

3.3 源码追踪:从创建到读取

scala 复制代码
// ① Driver 端创建
val counter = sc.longAccumulator("myCounter")
// 内部:new LongAccumulator() → AccumulatorContext.register(acc)

// ② Task 序列化 → copyAndReset
// 内部:new LongAccumulator() → copy(零值) → 独立副本

// ③ Executor 端累加
counter.add(1L)
// LongAccumulator.add: sum += v  (原子操作,但跨 Task 不共享)

// ④ Task 完成 → ExecutorBackend.statusUpdate(TaskResult)
// TaskResult 中携带: accumulatorUpdates = Map[Long, Any]
//   Long = accumulator id(全局唯一)
//   Any = Task 本地累加的结果值

// ⑤ Driver 端 TaskSchedulerImpl.statusUpdate() 
// → dagScheduler.taskEnded()
//   → 遍历 accumulatorUpdates
//     → accum.merge(execCopy)  // 合并到 Driver 元对象

// ⑥ 读取最终值
println(counter.value)  // 读取合并后的值

3.4 自定义累加器示例

scala 复制代码
// 自定义累加器:统计 (min, max, sum, count)
class StatsAccumulator extends AccumulatorV2[Double, (Double, Double, Double, Long)] {
  private var minVal: Double = Double.MaxValue
  private var maxVal: Double = Double.MinValue
  private var sumVal: Double = 0.0
  private var countVal: Long = 0L

  override def isZero: Boolean = countVal == 0L

  override def copy(): AccumulatorV2[Double, (Double, Double, Double, Long)] = {
    val cp = new StatsAccumulator
    cp.minVal = this.minVal
    cp.maxVal = this.maxVal
    cp.sumVal = this.sumVal
    cp.countVal = this.countVal
    cp
  }

  override def reset(): Unit = {
    minVal = Double.MaxValue; maxVal = Double.MinValue
    sumVal = 0.0; countVal = 0L
  }

  override def add(v: Double): Unit = {
    minVal = math.min(minVal, v)
    maxVal = math.max(maxVal, v)
    sumVal += v
    countVal += 1
  }

  override def merge(other: AccumulatorV2[Double, (Double, Double, Double, Long)]): Unit = {
    other match {
      case o: StatsAccumulator =>
        minVal = math.min(minVal, o.minVal)
        maxVal = math.max(maxVal, o.maxVal)
        sumVal += o.sumVal
        countVal += o.countVal
      case _ => throw new UnsupportedOperationException("Cannot merge different type")
    }
  }

  override def value: (Double, Double, Double, Long) = (minVal, maxVal, sumVal, countVal)
}

// 注册和使用
val statsAcc = sc.register(new StatsAccumulator, "stats")
rdd.foreach(record => statsAcc.add(record.value))
println(statsAcc.value)  // (min, max, sum, count)

3.5 内置累加器一览

累加器 类型 用途
LongAccumulator 长整型 计数器、求和
DoubleAccumulator 双精度浮点 精度统计
CollectionAccumulator[T] 集合 收集异常记录

四、广播变量 vs 累加器:核心对比

scss 复制代码
                广播变量 (Broadcast)          累加器 (Accumulator)
                ──────────────────────        ─────────────────────
方向             Driver → Executor             Executor → Driver
读写权限          Driver 写,Executor 只读       Driver 读,Executor 只写
数据流           序列化 → 分块 → P2P 分发        copyAndReset → add → merge
传输时机         首次 value() 调用(惰性)       每次 Task 完成回传
存储策略         MEMORY_AND_DISK_SER            不持久化(仅内存)
典型场景         字典表、模型广播                 计数、统计
一致性          强一致(只读)                    最终一致(Action 后)
网络开销         大(但Executor级别)             极小(仅 Task 回传)

五、常见陷阱与避坑指南 🔥

🔥 陷阱 1:Transform 算子中读取累加器

scala 复制代码
val counter = sc.longAccumulator("badCounter")

val result = rdd.map { x =>
  counter.add(1)            // ❌ map 中累加
  if (counter.value > 100)  // ❌ map 中读取累加器值
    x * 2
  else x
}

// 问题:map 内 counter.value 读取的是 Executor 端本地副本
// 不会是 Driver 端的全局聚合值
// 且 Task 重试会导致重复累加

正确做法:只在 Action 算子中使用累加器,只在 Action 完成后读取累加器值。

🔥 陷阱 2:Task 重试导致累加器重复计数

scala 复制代码
val counter = sc.longAccumulator("retryCounter")
rdd.mapPartitions { iter =>
  counter.add(iter.size)  // 累加分区记录数
  iter
}.count()

// ❌ 如果某个 Task 失败重试,该分区的计数会累加两次!
// Spark 不会自动去重累加器更新

Spark 的 Task 重试机制 不保证 累加器的 exactly-once 语义。如果重试发生,累加器值可能大于实际值。

解决方案:尽量在 Action 中做累加,或设计幂等累加逻辑。

🔥 陷阱 3:忘记销毁广播变量

scala 复制代码
for (i <- 1 to 100) {
  val broadcast = sc.broadcast(loadDataForIteration(i))
  processWithBroadcast(broadcast)
  // ❌ 忘了 broadcast.destroy()!
}
// 100 轮后 Executor 内存被占满

正确做法 :使用后立即 broadcast.destroy() 或开启自动清理 spark.cleaner.referenceTracking=true

🔥 陷阱 4:广播超大变量

scala 复制代码
val hugeDF = spark.table("big_table")  // 10GB
// ❌ 广播 10GB 数据
val broadcastHuge = sc.broadcast(hugeDF.collect())

// 应该:如果表较小用 broadcast hint
spark.sql("SELECT /*+ BROADCAST(small) */ * FROM big JOIN small ...")

广播变量的阈值经验:超过 2GB 的变量不要广播 ,检查 spark.sql.autoBroadcastJoinThreshold 配置。


六、实战:数据质量统计

scala 复制代码
object DataQualityMonitor {
  def main(args: Array[String]): Unit = {
    val spark = SparkSession.builder()
      .appName("DataQualityMonitor")
      .getOrCreate()
    val sc = spark.sparkContext

    // ① 广播数据字典(如合法值列表)
    val validRegions = Set("US", "CN", "JP", "KR", "EU")
    val broadcastRegions = sc.broadcast(validRegions)

    // ② 创建多个累加器统计
    val totalRecords = sc.longAccumulator("totalRecords")
    val nullFieldCount = sc.longAccumulator("nullFieldCount")
    val invalidRegionCount = sc.longAccumulator("invalidRegionCount")
    val duplicateCount = sc.longAccumulator("duplicateCount")

    // ③ 处理数据
    val df = spark.read.parquet("hdfs://data/events")
    val regions = broadcastRegions.value

    df.repartition(200).foreachPartition { iter =>
      // 每个 Partition 用 Set 去重(local)然后累加
      iter.foreach { row =>
        totalRecords.add(1)
        if (row.getAs[String]("region") == null) nullFieldCount.add(1)
        else if (!regions.contains(row.getAs[String]("region")))
          invalidRegionCount.add(1)
      }
    }

    // ④ 读取统计结果(Action 后)
    println(s"总记录数: ${totalRecords.value}")
    println(s"空字段数: ${nullFieldCount.value}")
    println(s"非法区域: ${invalidRegionCount.value}")
    println(s"重复记录: ${duplicateCount.value}")
  }
}

关键配置

properties 复制代码
# spark-defaults.conf
spark.broadcast.blockSize=8m           # 广播分块大小(默认4MB)
spark.broadcast.compress=true          # 启用压缩
spark.sql.autoBroadcastJoinThreshold=10485760  # 自动广播阈值 10MB
spark.cleaner.referenceTracking=true   # 自动清理广播变量
spark.cleaner.referenceTracking.blocking=false

七、总结

本文从源码级别深度剖析了 Spark 两大分布式共享变量的架构原理:

  1. 广播变量(Broadcast):基于 TorrentBroadcast + BitTorrent P2P 协议的块交换机制,通过序列化、压缩、分块、Peer-to-Peer 并行拉取,实现大对象的 Executor 级高效分发。每个 Executor 只序列化和传输一次,后续 Task 直接复用 BlockManager 缓存。

  2. 累加器(Accumulator):基于 AccumulatorV2 接口的 copyAndReset → add → merge 回传模型。Executor 端独立副本、本地累加、零网络开销,Task 完成后由 DAGScheduler 合并回 Driver 元对象。需注意 Task 重试带来的非 exactly-once 语义。

  3. 核心差异 :广播变量是 Driver → Executor 单向分发(读共享),累加器是 Executor → Driver 单向聚合(写计数)。二者互为补充,解决分布式计算中"数据分发"与"状态统计"两大难题。


作者 :starzy

博客blog.starzy.cn

GitHubstarzy1990.github.io

专注 AI Agent · LangGraph · RAG · 大数据架构 · 数据工程实践

相关推荐
A15362552 小时前
WMS 仓储系统的软件有哪些?2026 主流产品分类与选型参考
大数据·人工智能
hopsky2 小时前
《HBase 权威指南》 Lars George
大数据·数据库·hbase
Elastic 中国社区官方博客2 小时前
Elasticsearch:列式索引模式 - Columnar index mode
大数据·数据库·elasticsearch·搜索引擎·全文检索
仍然.3 小时前
服务端高并发分布式结构演进之路
大数据·数据库·redis
企查查数据服务4 小时前
从席位订阅到用量计费:Agent 正在重写企业软件商业模式
大数据·人工智能
科莱特SAP4 小时前
精准匹配双向赋能:科莱特数智人才猎场重塑数字化人才服务模式
大数据·人工智能·物联网·人力资源·科莱特·科莱特人才服务
飞飞传输4 小时前
海关行业FTP传输服务器评测:传统FTP与现代化替代方案对比
大数据·运维·安全
渣渣盟5 小时前
当 Checkpoint 稳定运行后,如何进一步优化 Flink 作业的启动和恢复速度,让大状态作业的扩缩容从“小时级”降到“分钟级”?
大数据·flink
互联网中的一颗神经元14 小时前
04 — 安全撤销:改错了怎么退回去
大数据·安全·elasticsearch