容错机制:Lineage、Checkpoint 与推测执行
上一篇解决了 BlockManager 的存储管理。这一篇进入容错机制——Spark 如何在节点故障时恢复计算。
分布式系统的容错通常有两条路线:数据副本(每份数据存多份,一份丢了从副本恢复)和计算重放(不存副本,丢了从头重算)。Spark 选择了第二条路线——通过 RDD 的 Lineage(血统)记录计算路径,丢失的分区沿着 Lineage 重算。
本文只抓一个问题:Lineage 容错的优势和代价,以及 Checkpoint 和推测执行如何补充 Lineage 的不足。
Lineage 容错原理
每个 RDD 记录了自己的 dependencies——从哪些父 RDD 经过什么变换得来。当一个分区丢失时(Executor 故障、磁盘损坏),Spark 不需要从副本恢复,只需要找到该分区的父 RDD 分区,重新执行 compute 函数。
1 | |
Lineage 容错相对数据副本的优势:不需要额外的存储空间来保存副本。对于窄依赖链条,恢复一个分区只需要重算该分区对应的父分区,不影响其他分区的计算。
但 Lineage 容错有一个结构性弱点:宽依赖。如果丢失的分区经过了 Shuffle(宽依赖),恢复该分区需要重算上游 Stage 的所有分区——因为宽依赖意味着该分区的数据来自上游所有分区。如果上游 Stage 的 Shuffle 文件也丢失了(比如对应的 Executor 也挂了),就需要从更上游重算,形成级联重算。
1 | |
Checkpoint:截断过长的 Lineage
当 Lineage 链条很长时(比如迭代算法的每轮迭代都产生新 RDD),一个分区的丢失可能触发从数据源开始的全量重算。Checkpoint 通过把 RDD 的数据写入可靠存储(通常是 HDFS)来截断 Lineage。
1 | |
Checkpoint 的工作流程:
1 | |
注意步骤 3:checkpoint 默认会触发一次额外的计算(重新执行 RDD 的计算逻辑把结果写入 HDFS)。为了避免重复计算,通常在 checkpoint 之前先 persist 到内存:
1 | |
Checkpoint 和 persist 的本质区别:persist 把数据存在 Executor 本地(内存或磁盘),Executor 故障时数据丢失;checkpoint 把数据写入 HDFS 等分布式文件系统,节点故障后仍然可以恢复。
推测执行:应对慢节点
在大规模集群中,总有一些节点因为硬件退化、网络拥塞或资源争抢而运行缓慢。一个 Stage 中如果有一个 Task 特别慢(straggler),整个 Stage 的完成时间由这个最慢的 Task 决定。
推测执行(Speculative Execution)的策略是:如果检测到某个 Task 的运行时间显著超过同 Stage 其他 Task 的中位数,在另一个 Executor 上启动该 Task 的备份副本。两个副本中先完成的被采用,后完成的被杀掉。
1 | |
推测执行的配置:
1 | |
推测执行不是没有代价。备份 Task 消耗额外的 CPU 和内存资源。如果 straggler 的原因是数据倾斜(该分区数据量本身就大),备份 Task 大概率也同样慢。推测执行适合硬件异构或临时负载不均的场景,不适合数据倾斜场景。
Shuffle 文件的容错
Shuffle 文件存储在 Executor 的本地磁盘上。如果 Executor 故障,它的 Shuffle 文件也随之丢失。下游 Stage 的 Task 在读取 Shuffle 数据时会发现 FetchFailedException,DAGScheduler 会重新提交丢失了 Shuffle 输出的上游 Stage。
External Shuffle Service(ESS)可以缓解这个问题:把 Shuffle 文件的管理从 Executor 进程分离到一个独立的服务进程中。即使 Executor 被 kill(比如动态资源分配回收空闲 Executor),Shuffle 文件仍然可以通过 ESS 读取。
1 | |
ESS 在启用动态资源分配(Dynamic Resource Allocation)时几乎是必需的——否则每次释放 Executor 都会丢失 Shuffle 数据。
实验:观察容错行为
1 | |
checkpoint 前,toDebugString 显示从 parallelize 开始的完整变换链。checkpoint 后,Lineage 被截断为 ReliableCheckpointRDD → 当前 RDD,之前的所有父 RDD 信息都被丢弃。
模式提炼
Spark 的 Lineage 容错体现了计算重放 vs 数据副本的设计选择。
计算重放的优势是不需要额外的存储开销来维护副本,在窄依赖场景下恢复效率高。代价是宽依赖场景下可能触发级联重算。Checkpoint 通过在关键节点物化数据来平衡这个代价。
这个选择和数据库的 WAL(Write-Ahead Log)恢复机制形成对比:数据库先写日志,再更新数据页。崩溃后从日志重放(redo)恢复数据。Spark 的 Lineage 相当于一种"声明式的日志"——不记录具体操作的序列,而是记录数据之间的变换关系。恢复时不是"重放操作",而是"重算数据"。
推测执行体现了冗余计算换确定性延迟的通用策略。这个思路在分布式系统中广泛使用:Google 的 “The Tail at Scale” 论文详细讨论了如何用冗余请求对抗长尾延迟。
工程迁移表
| Spark 概念 | MapReduce 对应 | Flink 对应 | 数据库对应 |
|---|---|---|---|
| Lineage 容错 | Task 重试(从 HDFS 输入重算) | Checkpoint + 状态恢复 | WAL Redo 日志 |
| Checkpoint | 无(每轮结果写 HDFS) | 周期性 Checkpoint(Chandy-Lamport) | 检查点(数据库 Checkpoint) |
| 推测执行 | Speculative Execution(同名机制) | 无内置推测执行 | 查询超时重试 |
| External Shuffle Service | 无(MapReduce 不分离 Shuffle) | Remote Shuffle Service | 独立的中间结果存储 |
| 级联重算 | Job 间通过 HDFS 隔离,不级联 | 从最近 Checkpoint 恢复,不重算 | 从最近检查点开始 Redo |
常见误解
误解一:“Lineage 等价于操作日志”。Lineage 记录的是 RDD 之间的变换关系(声明式),不是具体的操作序列(命令式)。它不记录"读了哪些字节、写了哪些字节",只记录"从哪个 RDD 经过什么函数得到当前 RDD"。
误解二:“Checkpoint 和 persist(DISK_ONLY) 效果相同”。persist(DISK_ONLY) 把数据写入 Executor 本地磁盘,Executor 故障时数据丢失。Checkpoint 写入 HDFS,节点故障后仍然可用。更关键的区别是 checkpoint 会截断 Lineage,而 persist 不会。
误解三:“开启推测执行总是有益的”。在数据倾斜场景下,推测执行的备份 Task 处理的数据量和原始 Task 相同,不会更快。反而浪费了一份 Executor 资源。推测执行适合硬件性能不均匀导致的 straggler,不适合数据分布不均匀导致的 straggler。
练习
-
构造一条包含 5 次 map 的长 Lineage 链,用 toDebugString 观察。然后在中间某个 RDD 上 checkpoint,再次观察 toDebugString 的变化。验证 Lineage 确实被截断。
-
启动一个多 Executor 的 Spark 应用,缓存一个 RDD 后执行 action。在 Spark UI 的 Executors 页面手动 kill 一个 Executor(如果允许),观察后续 action 是否触发了 Task 重算。在 Stages 页面查看是否出现了 “skipped” 的 Task(表示使用了缓存)和重新执行的 Task。
-
思考:如果一个 iterative 算法执行 100 轮迭代,每轮产生一个新 RDD。不做 checkpoint 时,第 100 轮丢失一个分区需要从第 1 轮开始重算。如果每 10 轮 checkpoint 一次,最坏情况下需要从哪一轮开始重算?
系列导航
| 序号 | 主题 | 状态 |
|---|---|---|
| 00 | 导读:为什么 Spark 的核心是一张 DAG | 已完成 |
| 01 | RDD:弹性分布式数据集的五大属性 | 已完成 |
| 02 | 宽依赖与窄依赖:Stage 是怎样划分出来的 | 已完成 |
| 03 | DAG Scheduler 与 Task Scheduler:从逻辑计划到物理执行 | 已完成 |
| 04 | Shuffle 机制:数据跨分区交换的代价与优化 | 已完成 |
| 05 | 内存管理与 Tungsten:堆外内存、序列化与代码生成 | 已完成 |
| 06 | 存储体系:Block Manager、广播变量与累加器 | 已完成 |
| 07 | 容错机制:Lineage、Checkpoint 与推测执行 | 本篇 |
| 08 | Spark SQL 与 Catalyst:从 SQL 文本到物理算子树 | 下一篇 |
| 09 | DataFrame 与 Dataset:类型安全与性能的折中 | |
| 10 | Adaptive Query Execution:运行时改写执行计划 | |
| 11 | 数据源 API 与谓词下推:让存储层帮忙过滤 | |
| 12 | Structured Streaming:把流当成无界表 | |
| 13 | 水位线与状态管理:流处理的时间语义 | |
| 14 | Spark on YARN / K8s:资源管理与部署模式 | |
| 15 | 动态资源分配与作业调度:多租户集群的资源博弈 | |
| 16 | 性能诊断:Spark UI、Event Log 与调优思路 | |
| 17 | Spark 与 Flink:两种计算引擎的设计选择 |
参考资料
- Matei Zaharia et al. Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing. NSDI 2012. Section 3: Fault Recovery.
- Apache Spark 官方文档:RDD Checkpointing. https://spark.apache.org/docs/latest/rdd-programming-guide.html#rdd-persistence
- Apache Spark 源码:
core/src/main/scala/org/apache/spark/rdd/ReliableCheckpointRDD.scalahttps://github.com/apache/spark - Jeff Dean, Luiz André Barroso. The Tail at Scale. Communications of the ACM, 2013.
