Spark 核心之自定义累加器以及版本对比变化深度剖析

摘要 :Spark 1.x 的 Accumulator 接口存在严重设计缺陷:类型单调(IN=OUT)、无法自定义扩展、内置实现仅 4 种。Spark 2.0 引入了 AccumulatorV2[IN,OUT] 重构了整个累加器体系,通过 7 个核心抽象方法(add / merge / copy / copyAndReset / reset / isZero / value)实现了完全可扩展的累加器框架。本文从 Accumulator → AccumulatorV2 接口演化、自定义累加器实现模式、Executor 端 Task 隔离机制、Spark 1.x → 2.x → 3.x 版本对比变化四个维度,配合 2 张原创架构图 + 代码实例,带你彻底掌握自定义累加器设计与版本迁移。

关键词:AccumulatorV2, 自定义累加器, Spark 版本演进, copyAndReset, Task 隔离, Spark 1.x vs 2.x vs 3.x, merge 合并律


一、开篇:从 Spark 1.x 的局限说起

如果你是从 Spark 1.x 时代走过来的工程师,下面这段代码一定不陌生:

scala 复制代码
// Spark 1.x:老式累加器
val count = sc.accumulator(0, "recordCount")
rdd.foreach(_ => count += 1)

// 仅此而已------你想统计更多维度?做不到。

Spark 1.x 累加器的三大硬伤

痛点 说明 后果
① 类型单调 Accumulator[T] --- IN 和 OUT 必须是同一类型 无法做 Double→Stats 的类型转换
② 无法扩展 没有 merge/copy/reset 等抽象方法 自定义累加器几乎不可能
③ 内置稀少 仅 Int/Long/Double/Float 集合类、统计类均无法原生支持
scala 复制代码
// Spark 1.x 源码:Accumulator 简陋接口
class Accumulator[T] private[spark] (
    @transient private[spark] val initialValue: T,
    param: AccumulatorParam[T],
    name: Option[String],
    ...
) extends Accumulable[T, T](initialValue, param, name, ...) {
  // 没有 merge / copyAndReset / isZero 等扩展点!
}

Spark 2.0 彻底重构了这个局面------AccumulatorV2IN, OUT 横空出世。


二、架构全景:自定义累加器实现全流程

2.1 AccumulatorV2 七核心方法

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

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

  // ---- 查询与重置 ----
  def reset(): Unit             // ⑤ 重置为零值
  def isZero: Boolean           // ⑥ 是否为零值(用于验证 reset 后状态)
  def value: OUT                // ⑦ 读取累加值(单向:仅 Driver 有意义)
}

方法之间的不变量

scala 复制代码
// 等式 1:copyAndReset 等价于 copy + reset
accum.copyAndReset() ≡ { val cp = accum.copy(); cp.reset(); cp }

// 等式 2:reset 后必须为 isZero
accum.reset(); assert(accum.isZero)

// 等式 3:merge 必须满足合并律(幂等)
a.merge(b); b.merge(a); assert(a.value == b.value)

2.2 自定义累加器实战:StatsAccumulator

scala 复制代码
import org.apache.spark.util.AccumulatorV2

class StatsAccumulator extends AccumulatorV2[Double, (Double, Double, Double, Long)] {

  // 内部状态:线程安全
  private var _min: Double = Double.PositiveInfinity
  private var _max: Double = Double.NegativeInfinity
  private var _sum: Double = 0.0
  private var _count: Long = 0L

  // ① 零值判断
  override def isZero: Boolean = _count == 0L

  // ② 深拷贝(保值)
  override def copy(): StatsAccumulator = {
    val cp = new StatsAccumulator
    cp._min = this._min
    cp._max = this._max
    cp._sum = this._sum
    cp._count = this._count
    cp
  }

  // ③ 重置为零
  override def reset(): Unit = {
    _min = Double.PositiveInfinity
    _max = Double.NegativeInfinity
    _sum = 0.0
    _count = 0L
  }

  // ④ Task 端累加
  override def add(v: Double): Unit = {
    _min = math.min(_min, v)
    _max = math.max(_max, v)
    _sum += v
    _count += 1
  }

  // ⑤ merge:Driver 端合并 Task 副本(幂等!)
  override def merge(other: AccumulatorV2[Double, _]): Unit = {
    other match {
      case o: StatsAccumulator =>
        _min = math.min(_min, o._min)
        _max = math.max(_max, o._max)
        _sum += o._sum
        _count += o._count
      case _ =>
        throw new UnsupportedOperationException(
          s"Cannot merge ${other.getClass.getName} with StatsAccumulator")
    }
  }

  // ⑥ 返回最终统计
  override def value: (Double, Double, Double, Long) = (_min, _max, _sum, _count)
}

注册与使用

scala 复制代码
val statsAcc = new StatsAccumulator
sc.register(statsAcc, "dataStats")  // ⚡ register → Spark UI 可追踪!

rdd.foreach { record =>
  statsAcc.add(record.value)
}

// Action 完成后读取
val (minVal, maxVal, sumVal, cnt) = statsAcc.value
println(s"[Stats] Min=$minVal Max=$maxVal Avg=${sumVal/cnt} Count=$cnt")

三、核心机制:Task 隔离与副本回传

3.1 copyAndReset 的意义

copyAndReset() 是整个累加器框架中最巧妙的设计。它解决了"如何在 Executor 端给每个 Task 一个独立的零值副本"这个经典问题。

scala 复制代码
// 时序流程
Driver: val acc = new StatsAccumulator          // ① 元对象(id=5, 零值)
      : sc.register(acc, "myStats")              // ② 注册到 AccumulatorContext

DAGScheduler.submitMissingTasks():
  → 序列化 Task
    → accMerged = acc.copyAndReset()             // ③ 生成独立零值副本
    → Task 携带 accMerged(id=5, 零值)

Executor: 反序列化 Task → acc.add(3.14)         // ④ 本地累加
       : acc.add(2.72)

       : Task 完成 → TaskResult(accUpdates)      // ⑤ 回传累加结果
         → accumulatorUpdates = Map(5 → (3.14+2.72))

Driver: acc.merge(execCopy)                     // ⑥ Driver 元对象合并
      : println(acc.value)                        // ⑦ 读取最终值
scala 复制代码
// 源码验证:Task 序列化时调用 copyAndReset
// DAGScheduler.scala
val taskBinary = sc.broadcast(taskBinaryBytes)
new ShuffleMapTask(
  stageId, stageAttemptId, taskBinary,
  partition, locs, properties,
  // 关键:每个 Task 获取累加器独立副本
  serializedTaskMetrics,
  Option(jobId), Option(sc.applicationId), sc.applicationAttemptId,
  // ↓ 累加器通过 copyAndReset 隔离
  stage.latestInfo.accumulables.values.map(_.copyAndReset()).toSeq
)

3.2 merge 的合并律要求

merge() 是累加器正确性的基石。由于 Task 回传是无序且可能重试的,merge 必须满足

scala 复制代码
// 规则 1:交换律
a.merge(b) ≡ b.merge(a)

// 规则 2:结合律
(a merge b) merge c ≡ a merge (b merge c)

// 规则 3:零值单位元
a merge zero == a
scala 复制代码
// ❌ 错误示范:违反了交换律!
class BadAccumulator extends AccumulatorV2[String, String] {
  private var _list = List.empty[String]
  override def add(v: String): Unit = _list = _list :+ v         // 顺序追加
  override def merge(o: AccumulatorV2[String,String]): Unit = {
    _list = _list ++ o.asInstanceOf[BadAccumulator]._list         // 顺序依赖!
  }
  // ❌ merge 结果依赖于调用顺序,破坏了交换律
}

// ✅ 正确做法:使用 Set 或排序后合并
class GoodAccumulator extends AccumulatorV2[String, Set[String]] {
  private var _set = Set.empty[String]
  override def add(v: String): Unit = _set += v
  override def merge(o: AccumulatorV2[String, Set[String]]): Unit = {
    _set = _set ++ o.asInstanceOf[GoodAccumulator]._set           // Set 并集 → 交换律成立
  }
}

四、版本演进全景:Accumulator → AccumulatorV2

4.1 Spark 1.x Accumulator(已弃用)

scala 复制代码
// Spark 1.x API --- @deprecated since 2.0.0
val counter = sc.accumulator(0, "myCounter")      // Accumulator[Int]
val sum     = sc.accumulator(0.0, "mySum")         // Accumulator[Double]

rdd.foreachPartition { iter =>
  iter.foreach { record =>
    counter += 1                                    // += 操作符
    sum += record.value
  }
}

// 限制:
// - counter 只能是 Int → Int(无法改为 Int → Long)
// - 无法自定义 AccumulatorParam(接口不开放)
// - 无法实现 CollectionAccumulator / 多维度统计

4.2 Spark 2.x AccumulatorV2(当前主版本)

scala 复制代码
// Spark 2.x/3.x API --- AccumulatorV2[IN, OUT]
val counter      = sc.longAccumulator("counter")     // LongAccumulator extends AccuV2
val doubleAcc    = sc.doubleAccumulator("sum")       // DoubleAccumulator
val collectAcc   = sc.collectionAccumulator[String]("errors") // CollectionAccumulator
val customAcc    = new StatsAccumulator               // 自定义!
sc.register(customAcc, "stats")                     // register → UI 可见

// IN ≠ OUT 支持!
// LongAccumulator: IN=Long, OUT=Long
// StatsAccumulator: IN=Double, OUT=(min,max,sum,count)
// CollectionAccumulator: IN=String, OUT=java.util.List[String]

4.3 Spark 3.x 增强

版本 新增特性 说明
3.0 register() API 稳定 自定义累加器注册到 AccumulatorContext
3.1 countFailedValues 标志 Task 失败/重试的累加器值追踪
3.2 Spark UI Accumulators Tab 实时可视化累加器值
3.3 AverageAccumulator 内置 (sum, count) 双维度累加器

4.4 完整版本对比表

scss 复制代码
                    Spark 1.x             Spark 2.x              Spark 3.x
                   ────────────          ────────────           ────────────
抽象基类            Accumulator[T]        AccumulatorV2[IN,OUT]   AccumulatorV2[IN,OUT]
类型系统            T → T 单调             IN/OUT 独立泛型         IN/OUT 独立泛型
内置实现            4 种                   4 种 + CollectionAcc    4 种 + CollectionAcc + AvgAcc
自定义扩展          ❌ 不支持               ✅ extends AccuV2       ✅ 完全支持
核心方法            2 个 (add/value)       7 个 (含merge/copy)     7 个 + countFailedValues
创建方式            sc.accumulator(0)      sc.longAccumulator()   sc.register(custom,"name")
Spark UI            有限支持               Accumulators Tab       增强可视化 + 实时追踪
状态                @deprecated            活跃主版本              持续增强

五、自定义累加器实战:三种常见模式

5.1 模式一:SetAccumulator(去重计数)

scala 复制代码
import scala.collection.mutable
import org.apache.spark.util.AccumulatorV2

class SetAccumulator[T] extends AccumulatorV2[T, mutable.Set[T]] {
  private val _set: mutable.Set[T] = mutable.Set.empty

  override def isZero: Boolean = _set.isEmpty
  override def copy(): SetAccumulator[T] = {
    val cp = new SetAccumulator[T]
    cp._set ++= this._set
    cp
  }
  override def reset(): Unit = _set.clear()
  override def add(v: T): Unit = _set += v
  override def merge(other: AccumulatorV2[T, mutable.Set[T]]): Unit = {
    _set ++= other.asInstanceOf[SetAccumulator[T]]._set
  }
  override def value: mutable.Set[T] = _set
}

// 使用:统计所有访问过的用户 ID
val userSet = new SetAccumulator[String]
sc.register(userSet, "uniqueUsers")

logsRDD.foreach { log =>
  userSet.add(log.userId)
}
println(s"独立用户数: ${userSet.value.size}")

5.2 模式二:HistogramAccumulator(分布统计)

scala 复制代码
class HistogramAccumulator(buckets: Array[Double])
    extends AccumulatorV2[Double, Map[String, Long]] {

  private val _histogram = mutable.Map.empty[String, Long]

  override def add(v: Double): Unit = {
    val bucket = buckets.zipWithIndex
      .find { case (bound, _) => v <= bound }
      .map { case (_, i) => s"≤ ${buckets(i)}" }
      .getOrElse("> ${buckets.last}")
    _histogram(bucket) = _histogram.getOrElse(bucket, 0L) + 1
  }

  override def merge(other: AccumulatorV2[Double, Map[String, Long]]): Unit = {
    other.value.foreach { case (k, v) =>
      _histogram(k) = _histogram.getOrElse(k, 0L) + v
    }
  }
  // ... isZero/copy/reset/value 实现略
}

5.3 模式三:BloomFilterAccumulator(布隆过滤器)

scala 复制代码
class BloomFilterAccumulator(expectedInsertions: Long, fpp: Double)
    extends AccumulatorV2[String, BloomFilter[String]] {

  private var _filter: BloomFilter[String] =
    BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8), expectedInsertions, fpp)

  override def add(v: String): Unit = _filter.put(v)

  override def merge(other: AccumulatorV2[String, BloomFilter[String]]): Unit = {
    _filter.putAll(other.value)
  }
  // ... copy/reset/isZero/value 实现

  // 使用
  def mightContain(v: String): Boolean = _filter.mightContain(v)
}

六、迁移指南:1.x → 2.x/3.x

6.1 直接映射

scala 复制代码
// ❌ Spark 1.x
val counter = sc.accumulator(0, "counter")

// ✅ Spark 2.x/3.x
val counter = sc.longAccumulator("counter")
scala 复制代码
// ❌ Spark 1.x
val sum = sc.accumulator(0.0, "sum")

// ✅ Spark 2.x/3.x
val sum = sc.doubleAccumulator("sum")

6.2 自定义累加器迁移

scala 复制代码
// ❌ Spark 1.x: 通过 AccumulableParam 变通(复杂且受限)
class StatsParam extends AccumulableParam[Stats, Double] {
  def addAccumulator(stats: Stats, v: Double): Stats = { stats.add(v); stats }
  def addInPlace(s1: Stats, s2: Stats): Stats = { s1.merge(s2); s1 }
  def zero(initial: Stats): Stats = new Stats()
}

// ✅ Spark 2.x/3.x: 直接 extends AccumulatorV2
class StatsAccumulator extends AccumulatorV2[Double, (Double, Double, Double, Long)] {
  // 清晰、直观、类型安全
}

6.3 register vs 便捷 API

scala 复制代码
// 便捷 API(内置类型)
sc.longAccumulator("name")       // → 自动注册到 AccumulatorContext
sc.doubleAccumulator("name")     // → 同上
sc.collectionAccumulator[T]("name")

// 自定义累加器 → 必须手动 register
val custom = new MyCustomAccumulator
sc.register(custom, "myCustom")  // → Spark UI 可见
// vs.
// val custom = new MyCustomAccumulator  // ❌ 不 register → UI 不可见

七、总结

  1. Spark 1.x Accumulator 设计简陋(IN=OUT、无 merge、内置仅 4 种),自 2.0 起弃用。迁移成本极低:sc.accumulator(0)sc.longAccumulator()

  2. AccumulatorV2IN,OUT 通过 7 个核心抽象方法实现了完全可扩展的累加器体系。关键设计:copyAndReset 实现 Task 隔离 + merge 满足合并律实现 Driver 聚合。

  3. 自定义模式 覆盖了去重计数(SetAccumulator)、分布统计(HistogramAccumulator)、布隆过滤器(BloomFilterAccumulator)等多种场景,通过 sc.register() 即可接入 Spark UI 追踪。

  4. Spark 3.x 引入了 countFailedValuesAverageAccumulator、UI 增强等改进,持续完善累加器生态。


作者 :starzy

博客blog.starzy.cn

GitHubstarzy1990.github.io

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

相关推荐
观远数据1 小时前
当ChatBI遇上数据合规:AI+BI规模化落地的安全边界如何划定
大数据·人工智能·安全
具身智能进化论2 小时前
协作机器人产业进入规模化部署期,未来几年的增长从何而来
大数据·运维·人工智能·机器人·自动化·工厂方法模式
hzcj8882 小时前
汇正财经:储能装机回暖,估值有待提升
大数据·人工智能
2601_954971132 小时前
大数据时代职场突围
大数据
我要用代码向我喜欢的女孩表白3 小时前
hdfs获取所有路径大小的脚本
大数据·hadoop·hdfs
品牌测评3 小时前
Token Plan平台分享|七条算力订阅路径拆解
大数据·人工智能·架构
渣渣盟3 小时前
当反压问题解决后,如何进一步优化 Flink 作业的 Checkpoint 性能,让大状态作业也能稳定运行?
大数据·flink
迪康Defender3 小时前
终端邮件安全全覆盖:规则、关键词、附件白名单配置大全
大数据·人工智能·安全
中电金信4 小时前
中电金信“金融信息技术应用中试平台”入选《智能研发生产力工具选型手册》首批推荐工具
大数据·人工智能