Spark 任务在分布式环境中运行,天然面临节点故障、网络超时、任务重试等不可靠因素。幂等性保障的核心目标是:无论 Spark 作业因为何种原因执行多少次,最终产生的输出状态与只执行一次完全一致。结合原文的确定性回放思路以及其他技术实践,我将 Spark 任务幂等性拆解为四个层次来讨论。
一、确定性回放:基于数据分治的隐式幂等
这是原文方案的核心思想,也是批处理场景下最优雅的幂等保障方式。
核心机制:通过纯函数变换(Pure Functional Transformation)让相同输入必然产生相同输出。一条数据经过以下三步后,其写入路径就完全确定了:
- 哈希重分区 :按业务主键(如
id)进行repartition,相同主键的数据落入同一个分区。哈希函数的选择决定了分布的均匀性 - 分区内排序:每个分区内按主键升序或降序排列,消除分区内数据顺序的不确定性
- 固定大小切分请求 :按预设的
Limit切分连续的请求批次,每个批次拥有唯一且稳定的标识(前缀_分区号_批次号)
为什么能保证幂等 :假设有一个数据集 D,经过复合函数 g ∘ f(f = 分区+排序,g = 切分),得到的请求序列 R 是 D 的纯函数。作业重试时,D 不变,则 R 也不变。目标存储层收到完全相同的请求序列,由于写入是覆盖语义(而非追加),多次回刷不改变最终状态。
适用条件:
- 输入数据集在重试期间保持不变
- 目标存储支持覆盖写(
Put、Upsert、Overwrite),而非仅支持追加写(Append) - 分区数固定,不会因机器数变化而改变
常见问题与解法:
| 问题 | 原因 | 解决 |
|---|---|---|
| 分区倾斜 | 哈希分布不均,某些分区数据量远超其他 | 加盐(Salted Key)或使用范围分区(Range Partitioning) |
| 输入数据增删 | 上游数据源非幂等,重试时输入变了 | 冻结输入快照(如快照 Hive 分区或读取固定 Offset 的 Kafka) |
| 分区数不稳定 | Spark 动态分配导致 spark.sql.shuffle.partitions 变化 |
显式设置固定分区数,或使用 repartition() 指定具体分区数 |
二、输出端幂等:针对目标存储的差异化策略
确定性回放不能覆盖所有场景------当写入语义不是覆盖写,或者输出端是外部 API 时,需要针对目标存储选择不同的幂等方案。
Delta Lake / Lakehouse 表------MERGE 方案
Delta Lake 的 MERGE(也称为 Upsert)是 Spark 批处理中最常用的幂等写入方式。核心思路是将"是否有重复"的判断下推到存储层:
sql
MERGE INTO target t
USING source s
ON t.pk = s.pk
WHEN MATCHED THEN UPDATE SET *
WHEN NOT MATCHED THEN INSERT *
配合 Spark Structured Streaming 的 foreachBatch,可以实现流批一体的 Exactly-Once 写入。Databricks 的推荐做法是为每次微批次分配一个唯一的 txnVersion,借助 Delta 的事务日志来去重 编辑Azure Databricks - foreachBatch。
HBase / Cassandra / KV 存储------行级覆盖写
对于以 RowKey 为主键的存储,直接使用 Put(覆盖语义)即可保证幂等。关键在于业务主键必须能唯一定位到一行------如果同一个业务实体对应多行,则需要保证"相同主键覆盖写"这一前提成立。
HDFS / 对象存储------分区覆盖写
对于 HDFS 或 S3 上的分区表,使用 INSERT OVERWRITE 按分区覆盖写入。Spark 支持静态和动态两种覆盖模式:
- 静态覆盖:写入前删除整个表或指定分区,再写入新数据
- 动态覆盖:
spark.sql.sources.partitionOverwriteMode=dynamic,仅覆盖查询结果涉及的分区 - 动态覆盖适合增量回刷场景,但需要确保每个分区内的数据是完整且正确的,否则会引入部分更新问题 编辑Amoro - Spark Writes
外部 API------幂等 Key 方案
当 Spark 作业通过 foreach 或 foreachBatch 调用外部 API 时(如推送通知、写入消息队列),在请求体中携带幂等 Key(Idempotency Key),由下游系统去重。幂等 Key 的生成规则需要是确定的,例如 作业名_分区号_批次内序号,和原文的请求命名思路一致。
三、作业级的 Exactly-Once:Checkpoint 与状态管理
对于需要持续运行的流式作业,短暂的幂等写入还不够------还需要保证作业重启后能从正确的位点恢复,不丢数据也不重复处理。
Spark Structured Streaming 的 Exactly-Once 三支柱 编辑DataDriven - Exactly-Once:
- 可重放的源(Replayable Source):Kafka 在可配置的保留期内保留日志,Spark 可以读取任何 Offset 范围的数据重播
- 确定性的进度记录(Deterministic Checkpoint):Checkpoint 记录每个微批次覆盖的 Offset 范围和对应的状态快照,重启时从最新位点恢复
- 幂等的或事务性的写入(Idempotent/Transactional Sink):Delta Lake 的事务性提交将每个微批次绑定到确定的表版本,重放的批次要么发现提交已存在,要么重新写入恰好一次
批处理作业的重试安全
对于离线批处理作业,推荐做法是:
- 使用增量表的事务写入 :如 Delta Lake 的
txnAppId + txnVersion机制,每次作业绑定一个全局唯一的 AppId,配合自增的批次号,确保同一批次不会重复提交 Azure Databricks - Processing guarantees - 记录作业执行的元数据:在调度系统中记录每个作业分区的执行状态和输出快照标识,调度时先检查是否已执行过,跳过已完成的分区
四、异常场景的幂等容错
幂等设计不仅要在正常情况下工作,更要覆盖以下典型异常场景:
部分成功(Partial Failure)
Spark 作业的几个分区写入成功、部分失败。重新运行时,已写入的数据再次被覆盖,未写入的数据得到补充。这是确定性回放方案对部分失败的天然容错------关键在于输入数据集在重试期间不变。
数据漂移(Data Drift)
与原文讨论的输入保持不变的假设不同,实际生产环境中,反复重试可能面临输入数据发生变化的场景。防范手段:
- 快照读:批处理开始时将输入数据表快照到临时表或固定路径,作业全程使用此快照
- 版本依赖:在请求中携带输入数据的版本号或哈希值,存储层在校验版本一致后才处理
并发写入冲突
多个 Spark 作业同时写同一张表时,需要防止互相覆盖。常用的解决方案:
- 乐观锁:Paimetad 等存储支持基于版本号的乐观锁写入,写入时检查版本号
- 分区隔离:不同作业写入不同的物理分区,互不干扰
- 写前检查:写入前检查目标位点是否已被其他作业修改
方案选型决策树
面对一个 Spark 任务,可以按以下逻辑快速选择幂等方案:
bash
批处理 + 输入确定 + 存储支持覆盖写
├─ 是 → 确定性回放(分区+排序+切分)★ 最简洁
└─ 否 → 输出端有事务机制
├── Delta / Iceberg → MERGE / INSERT OVERWRITE
├── HBase / KV 存 → Put(按行覆盖)
└── HDFS / S → 分区覆盖写
└── 外部 API → Idempotency Key
流处理
└─ Structurd Streaming → Checkpoint + 幂等 Sink
总结
Spark 任务的幂等性不是"一个方案打天下",而是一个分层的设计问题 。原文的确定性回放方案在批处理回库场景中提供了一条极其简洁且优雅的路径------不依赖额外的状态存储,不改目标系统,仅通过数据组织方式就天然保证了幂等。但在更广泛的 Spark 任务场景中,我们需要根据作业类型(批/流)、目标存储(数据库/文件系统/API)、数据特征(是否稳定/倾斜程度)灵活组合多种方案。核心原则始终不变:让相同输入必然产生相同输出,或让重复操作可以被安全地忽略。