摘要 :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() 被调用:
- 先查本地 BlockManager 缓存 ------ 命中直接返回
- 未命中 → 向 Driver 或已持有该块的 Peer Executor 请求
- 获取后立即缓存到本地 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 两大分布式共享变量的架构原理:
-
广播变量(Broadcast):基于 TorrentBroadcast + BitTorrent P2P 协议的块交换机制,通过序列化、压缩、分块、Peer-to-Peer 并行拉取,实现大对象的 Executor 级高效分发。每个 Executor 只序列化和传输一次,后续 Task 直接复用 BlockManager 缓存。
-
累加器(Accumulator):基于 AccumulatorV2 接口的 copyAndReset → add → merge 回传模型。Executor 端独立副本、本地累加、零网络开销,Task 完成后由 DAGScheduler 合并回 Driver 元对象。需注意 Task 重试带来的非 exactly-once 语义。
-
核心差异 :广播变量是 Driver → Executor 单向分发(读共享),累加器是 Executor → Driver 单向聚合(写计数)。二者互为补充,解决分布式计算中"数据分发"与"状态统计"两大难题。
作者 :starzy
博客 :blog.starzy.cn
GitHub :starzy1990.github.io
专注 AI Agent · LangGraph · RAG · 大数据架构 · 数据工程实践