IT 论文精读 · PAPER 68
Zaharia 等 · UC Berkeley AMPLab · NSDI 2012
2012 年,加州大学伯克利分校的一群人提出了 RDD(弹性分布式数据集),并拿它做出了 Spark——今天处理海量数据几乎绕不开的那个引擎。在它之前这类活儿要交给 Hadoop MapReduce;RDD 让同样的活儿快一个数量级,还顺手让「在上百台机器上敲一句话、几秒出结果」成了日常。
一百台机器凑成集群算大数据,最怕的不是慢,是中途有机器坏掉——总有一两台掉线,它手上那份数据和算了一半的结果就没了。老办法的对策很实在:每做完一步就把中间结果完整写到硬盘、还存三份,谁坏了就捞回来重来。
问题出在要反复算很多遍的活儿——训练模型、算网页排名,同一批数据得过几十遍,而每遍都要写一次硬盘再读一次。数据明明没变却被搬进搬出几十趟,时间几乎全花在搬运上。
那就把数据留在内存里别搬走啊——可机器一坏,内存里的东西是真没了。于是回到老问题:不存三份、不落硬盘,坏了怎么办?
RDD 的答案很妙:别存结果,存做法。
打个比方。后厨备了二十盘半成品,怕丢,常规做法是每盘复制两份进冷库——又占地方又费时间。RDD 说不用:给你一张菜谱,写清这盘由哪盘经哪一步做出来、那盘又来自更早的哪盘,一路追到最开始那袋原料。丢了哪盘就照菜谱重做哪盘,别的不用动;菜谱几行字,几乎不花钱。论文把它叫血统(lineage)。
凭一条自我约束:只允许「整批」的做法——这盘每样都切一刀、把不辣的都挑出去、这两盘按名字配对;不允许「单独给第 37 号土豆加半勺盐」这种零碎改动。
这条约束才是命门:若允许零碎改动,要记的就不是几行菜谱而是几百万条流水账,那还不如直接存结果;只记整批动作,菜谱才短到能抄一份放每台机器上。而大数据的活儿本就绝大多数是「对全部数据做同一件事」,这条约束基本不疼。
还有一条:一盘做好就不许再改。正因为不许改,照菜谱重做的那盘和丢掉的那盘一定一模一样——重算才敢算数。
数据能安心待在内存里反复用,迭代的活儿快出十几倍;机器坏了也不用整个任务重来,只并行重算丢掉的那一小块。它还改变了人和数据的关系:过去在几 TB 数据上问个问题要等几十分钟,现在像用计算器一样一句一问、几秒一答。
诚实一句代价:这套办法吃内存,也只对「整批处理」的活儿有效——要「来一条改一条」的零碎实时更新,它帮不上忙。
把数据切块摊在集群内存里、只许整批变换、做好就不许改;容错不靠复印三份,而靠记住「它是怎么一步步做出来的」,丢了哪块就重算哪块。于是数据能留在内存里反复用——迭代的活儿快一个数量级,也能几秒钟回答一个 TB 级的问题。
想看血统图、窄依赖/宽依赖的结构和实验数字? → 切到精读版
RDD 是一种只读、分区、只能由「粗粒度变换」生成的分布式内存数据集:正因为写入被限制成「对整个数据集做同一个变换」,系统只需记下这些变换构成的血统(lineage)图,就能在节点故障时重算丢失的分区,而不必像传统内存系统那样跨机器复制数据或记细粒度更新日志。这让数据第一次能安全地常驻集群内存反复使用,使迭代式机器学习、图计算与交互式查询比 Hadoop MapReduce 快一个数量级。
作者是 Matei Zaharia、Mosharaf Chowdhury、Michael Franklin、Scott Shenker、Ion Stoica 等,来自 UC Berkeley AMPLab,论文发在 NSDI 2012 并拿下最佳论文。它上承 MapReduce(2004)与 Dryad 的集群批处理范式、以及 Pregel、HaLoop 这类单一负载专用系统,向下长成 Apache Spark 整个生态:Spark SQL、MLlib、GraphX、Structured Streaming 全建在这个抽象上。
MapReduce 把「在上千台会坏的机器上跑作业」变简单了,但有一个硬性设计:阶段之间必须经稳定存储交接——每轮输出写进 HDFS(还带三副本),下一轮再读回来。对「扫一遍出份报表」没问题,可两类负载被它卡死:
那为什么不干脆做个「集群内存」抽象?因为已有的这类系统——分布式共享内存、内存键值存储(如 Piccolo、RAMCloud)——都允许细粒度更新:任意进程可改任意一个格子。灵活是灵活,但容错只剩两条路:跨机器复制数据,或把每次更新记进日志再跨机器同步,两条都要在网络上搬大量数据,代价能吃掉内存带来的全部好处。难题从来不是「把数据放内存」,而是「放进去之后怎么便宜地容错」。
RDD 的定义里藏着全部答案:它是只读的、被切成若干分区的记录集合,且只能通过对另一个 RDD(或稳定存储里的数据)施加一个确定性变换来创建——map、filter、join、groupByKey 这类作用于整个数据集的算子。你不能随手改其中某条记录。
这看似能力缩水,实际是一次精准的交换:因为每个 RDD 都只能由「(父 RDD, 变换)」生成,记住这条来历就等于掌握了重造它的完整配方。这条来历的链就是血统(lineage)——一张「谁由谁、经哪个变换算出来」的 DAG。于是容错的账变成:平时什么都不用多存(血统只是几十字节的元数据),出故障时只重算丢掉的那些分区,不碰别的分区、不回滚整个作业,多个丢失分区还可在不同节点上并行重算。
这里有个容易被跳过、却决定成败的前提:RDD 不可变、变换确定。正因为父 RDD 不会被偷偷改掉、变换又确定,重算出的分区与丢掉的那个逐字节等价——重算才算「恢复」而非「换了个结果」。可变状态 + 细粒度更新做不到这点:它既记不住、也重现不了那条路径。
光说「重算」还不够——重算的代价可能天差地别。论文给了一个至今仍是 Spark 心智模型核心的区分:
map、filter、已同分区的 join)。父子分区一一对应。groupByKey、未同分区的 join),必须做一次跨机器的 shuffle。这条线一画,两件事同时有了答案。执行上,连续的窄依赖可在同一台机器上流水线执行——读一条记录,map 完接着 filter,不落地、不进网络;调度器正是在宽依赖处切开「阶段(stage)」,阶段内部全是窄依赖、尽情流水线,阶段之间才 shuffle 一次。恢复上,窄依赖只需重算对应的那几个父分区;宽依赖丢一个分区,却可能要把祖先 RDD 的所有分区重算一遍,代价高到值得单独存档。
只有两类操作。变换(transformation)——map、filter、join——是惰性的:写下去只是往血统图上加一个节点,什么都不算;动作(action)——count、collect、save——才真正触发计算。惰性的好处很实在:系统拿到的是整张图而非一句句孤立指令,于是能把连续的窄依赖并成流水线、按数据所在位置派发任务。
用户还可对 RDD 调 persist()(即 cache)说「这个我要反复用」,并选存储级别:内存里的 Java 对象(最快)、内存里的序列化字节(省空间)、或溢写硬盘。内存不够时按 LRU 以分区为单位淘汰——淘汰了也不会错,下次照血统重算即可。论文的交互式例子正是如此:把过滤出的错误日志 persist 进内存,之后几十个查询都在这份数据上跑。
血统不是万能的。当血统链又长又含宽依赖(如 PageRank 迭代几十轮、轮轮 shuffle),一次故障可能触发近乎全量的重算。这时该退回老办法:做检查点,把某一代 RDD 落到 HDFS 存档、在这里截断血统。而 RDD 让检查点格外省心:因为只读,检查点可以在后台慢慢写,不必停机做一致性快照、也不怕写到一半数据被改。论文把「何时自动打检查点」留作后续工作,交给用户显式调用。
论文的 Spark 实现约 1.4 万行 Scala,跑在 Mesos 上,并改造 Scala 解释器以支持交互式使用。主要实验多在 100 台 Amazon EC2 机器上:
直接一层:Apache Spark 成了 MapReduce 的事实继任者、过去十年大数据处理的主力引擎,而 Spark SQL、MLlib、GraphX、Structured Streaming 全是 RDD 之上的库——恰好兑现了论文「一个通用抽象装下多个专用系统」的主张。
思想一层:它把「用血统重算代替数据复制」确立为分布式容错的一条正统路线。这远超出 Spark——任何「输入不可变 + 计算确定」的系统都能用它,如数据流水线重跑、事件溯源(event sourcing)、不少现代编排工具的重算语义。
方法论一层:它演示了系统设计里最值钱的一类动作——主动放弃能力以换取性质:砍掉细粒度写,换来容错几乎免费,与 Dynamo 舍强一致换可用同源。
① 一句话:RDD = 只读 + 分区 + 只能由粗粒度确定性变换生成的内存数据集;容错靠记「血统」并重算,而非复制数据。
② 痛点:MapReduce 每阶段必经 HDFS,迭代算法与交互式查询被反复落盘拖死;而放进内存又撞上「细粒度更新只能靠复制或日志容错」的老墙。
③ 关键交换:主动放弃细粒度写,于是「怎么算出来的」几十字节就记得下——容错从「存数据」变成「存配方」。前提是不可变 + 变换确定,重算的分区才与丢失的逐字节等价。
④ 窄依赖(一对一)可本机流水线、重算便宜;宽依赖要 shuffle、重算可能波及上游全部——调度器就在宽依赖处切「阶段」。
⑤ 接口:变换惰性、动作触发,引擎因此拿到整张 DAG 做优化;persist() 指定缓存级别,内存不足按分区 LRU 淘汰、淘汰了照血统重算;血统太长则打检查点截断,且因只读可后台异步写、无需停机快照。
⑥ 结果:100 台机器上迭代式 ML 比 Hadoop 快最多约 20 倍;1 TB 缓存后交互查询 5–7 秒;故障只拖慢出事那一轮;内存不足时平滑退化。
⑦ 影响与局限:Spark 全家桶的地基,「重算代替复制」成为分布式容错的正统路线之一;但不适合细粒度更新,长血统 + 宽依赖恢复昂贵、自动检查点未解,吃内存、JVM 开销大,算子对优化器是黑箱——后被 DataFrame / Catalyst 取代为推荐接口。