第 27 章 MapReduce——2004年,Google 如何让上千台普通计算机共同处理海量数据?标记本章已读大字阅读打印本章回到上次阅读位置交互模块加载失败。正文仍可阅读,请刷新重试。正在加载交互实验…第一季 · 人类如何驯服数据来源历史主线:2003—2008年 · Jeffrey Dean、Sanjay Ghemawat、Map、Shuffle、Reduce、GFS 与分布式容错2004年12月,美国旧金山。在第六届 OSDI 操作系统设计与实现研讨会上,Google 的两位工程师发表了一篇论文:MapReduce: Simplified Data Processing on Large Clusters《MapReduce:大型集群上的简化数据处理》。作者是 Jeffrey Dean 和 Sanjay Ghemawat。这篇论文只有14页。但它提出了一个足以改变大规模数据处理方式的思想:程序员不应该为了处理海量数据,亲自编写所有机器之间的任务分配、数据交换、故障恢复与负载均衡代码。程序员只需要描述数据怎样变换。而分布式运行时负责把计算分配给成百上千台机器。论文发表于2004年 OSDI,页码137—150。[Google Research][USENIX]它最吸引人的地方不是某个复杂数学公式。而是:把一个极其复杂的分布式计算系统,隐藏在两个看起来非常简单的函数背后。这两个函数就是:Map\boxed{Map}Map和Reduce\boxed{Reduce}Reduce我们今天要研究的问题是:如果 Google 需要统计数十亿份网页中,每个单词出现过多少次,它怎样让上千台服务器分工合作?如果其中一台服务器突然停止工作,计算结果又为什么不必全部作废?第一幕:2003年,Google 面对的真正困难不是计算本身来源Jeffrey Dean 与 Sanjay GhemawatGoogle 早期大规模基础设施的重要设计者,两人共同参与了 MapReduce、Google File System 等系统研究。假设我们要统计一本书里的单词频率。用 Python 写,可能只需要十几行代码。读入文本。把文本切分为单词。为每个单词计数。最后输出结果。但2003年前后的 Google 需要处理的不是一本书。而是大规模网页抓取数据、搜索请求日志、网页之间的链接关系以及构建搜索索引所需的各种中间结果。论文作者回顾,他们已经开发过大量专用程序来处理这些数据。其计算逻辑往往不难,真正麻烦的却是把任务可靠地分布到几百甚至几千台机器上。每次都需要考虑:输入文件放在哪些服务器上?怎样把任务切成足够小的部分?哪台机器负责处理哪部分数据?机器执行速度不一样怎么办?网络传输占用太多带宽怎么办?某台机器运行到一半宕机怎么办?这些问题逐渐淹没了原本简单的业务计算。[CMU School of Computer Science]一个重要的历史细节MapReduce 并不是直到2004年会议召开才突然诞生。原始论文明确记录:Google 在2003年2月开发了第一版 MapReduce 库,并在2003年8月加入了数据局部性、动态任务分配等重要优化。2004年12月的 OSDI 论文则系统介绍了它的编程模型、运行机制和实测结果。[Technical Paper]这是一段典型的计算机工程史。先有真实的生产需求。再形成运行中的系统。最后把设计方法与实验结果整理为论文。Map 和 Reduce 的名字从哪里来?它们并不是 Google 从零发明的两个概念。论文作者明确说明,MapReduce 的抽象受 Lisp 和其他函数式编程语言中的 map 与 reduce 原语启发。这与我们上一章关于 Lisp 和垃圾回收的故事形成了连接。但 Google 的重要贡献不是简单调用两个已有函数。而是构建了一套能够自动处理数据划分、跨机器调度、网络通信和故障恢复的分布式运行系统。函数式操作本身很简单;让它们在上千台可能故障的机器上可靠执行,才是最困难的部分。第二幕:先从三份文档开始假设我们有三份文档。待统计的文本文档 1cat dog cat文档 2dog bird文档 3cat bird dog我们想知道:cat 出现几次?dog 出现几次?bird 出现几次?直接数一下,正确答案是:单词次数bird2cat3dog3但现在,假设三份文档分别由三台服务器处理。服务器 A 只看到文档1。服务器 B 只看到文档2。服务器 C 只看到文档3。它们没有一台机器可以直接访问另外两台服务器的全部文本。怎样完成统计?MapReduce 的办法是:不要要求每台服务器都知道完整数据,而是让每台服务器先生成统一格式的中间结果。Map:把单词转换成键值对Map 函数的任务非常简单。每看见一个单词,就输出:text复制代码(单词, 1)例如,服务器 A 处理文档1:text复制代码cat dog cat得到:text复制代码(cat, 1) (dog, 1) (cat, 1)服务器 B 得到:text复制代码(dog, 1) (bird, 1)服务器 C 得到:text复制代码(cat, 1) (bird, 1) (dog, 1)Map 阶段结束后,我们仍然没有最终答案。但已经把不同来源的数据变成一种统一的结构:(key,value)(key,value)(key,value)其中:key 表示单词。value 表示一次出现。Reduce:把相同单词的计数加起来接下来,如果我们能让所有 cat 的记录出现在同一组:text复制代码cat → [1,1,1]所有 dog 的记录出现在另一组:text复制代码dog → [1,1,1]那么 Reduce 函数只需要:python复制代码sum(values)就能得到正确结果。但是,这里出现了一个非常重要的技术问题:各台服务器生成的中间记录,究竟怎样找到拥有相同 key 的其他记录?这便是 MapReduce 中真正的分布式关键阶段:Shuffle。第三幕:MapReduce 实际上有三个关键阶段很多入门教程说:MapReduce 只有 Map 和 Reduce 两步。这在用户自定义函数接口的意义上没有问题。但如果你要理解真实的分布式执行,就必须把中间的数据重新分配过程单独拿出来。MapReduce 数据处理流水线Input:输入数据分片文档1 · 文档2 · 文档3↓Map AMap BMap C↓Shuffle & Sort:跨机器归组把相同 key 的记录送往同一个 Reduce 分区并排序、分组↓Reduce birdReduce catReduce dog↓Output:bird=2 · cat=3 · dog=3教学示意中,每个单词对应一个归组。实际执行时,一个 Reduce 任务可以处理很多不同的 key,并不要求为每个单词启动一台服务器。Map 阶段各个 Worker 独立读取自己的输入分片。产生中间键值对。Map(k1,v1)→[(k2,v2),...]Map(k_1,v_1)\rightarrow[(k_2,v_2),...]Map(k1,v1)→[(k2,v2),...]Shuffle & Sort 阶段系统按照中间 key 进行分区。每个 key 的全部相关值,必须被路由到同一个 Reduce 分区。接着根据 key 排序或分组。Reduce 阶段Reduce 函数接收:text复制代码key values_for_this_key然后进行汇总。Reduce(k2,[v2,...])→outputReduce(k_2,[v_2,...])\rightarrow outputReduce(k2,[v2,...])→output这就是原始论文描述的核心接口与执行流程。[Technical Paper]Map 负责让计算可以独立展开;Shuffle 负责重新建立需要汇总的数据关系;Reduce 负责生成最终结果。第四幕:亲手运行一个 MapReduce 作业下面的实验不需要真正启动三台计算机。我们把每份文档视为一个独立的 Map 任务,并模拟它们产生中间结果、进行 Shuffle、最后执行 Reduce。你可以直接修改原始文本,也可以打开 Combiner,观察网络中间数据量发生什么变化。MapReduce 词频实验室自定义文本 · Map · Shuffle · Reduce8 个单词修改输入文档(用空格分隔英文单词)文档 1文档 2文档 3启用本地 CombinerReduce 数量123① Map② Shuffle③ Reduce各 Map 任务独立产生中间键值对Map 任务 1(cat, 1)(dog, 1)(cat, 1)Map 任务 2(dog, 1)(bird, 1)Map 任务 3(cat, 1)(bird, 1)(dog, 1)Map 原始键值对8Combiner 后键值对8重置实验下一阶段 →分区函数使用教学用的字符编码求和取模,便于重现结果。真实框架采用可配置的分区器;Combiner 在本例中可用于整数求和,网络传输的实际字节数还取决于序列化等开销。为什么要特别观察 Shuffle?假设你有1000台 Map Worker。每台 Worker 都发现一个 cat。现在负责统计 cat 的 Reduce Worker 必须得到全部1000份计数。这意味着网络上需要发生数据的重新分配。MapReduce 的主要开销,并不总是 Map 和 Reduce 函数里面那几行计算。真正昂贵的可能是:读取海量输入。写入中间文件。将中间记录经网络发送到其他机器。对大量中间 key 进行排序和分组。以及把结果写入可靠的存储系统。原论文特别指出:在当时的 Google 集群中,网络带宽是一项相对稀缺的资源,因此系统非常重视减少数据在机器之间的搬运。[Technical Paper]第五幕:Shuffle 为什么要按照 key 分区?这是 MapReduce 最关键的正确性条件之一。假设有三个 Reduce Worker:R0、R1、R2。那么中间记录应该送到哪个 Worker?一种经典规则是:partition(key)=hash(key) mod R\boxed{partition(key)=hash(key)\bmod R}partition(key)=hash(key)modR其中 RRR 为 Reduce 分区数量。如果:text复制代码hash("cat") % 3 = 1那么所有 cat 中间记录,都必须进入 R1。如果:text复制代码hash("dog") % 3 = 2所有 dog 记录就进入 R2。关键不是具体使用哪种哈希函数。而是:相同的 key 必须被确定性地映射到同一个 Reduce 分区。否则:A 机器上的 cat 被送到 R0。B 机器上的 cat 被送到 R1。两个 Reduce Worker 分别产生部分结果。就无法得到完整计数。分区和排序解决两个不同问题分区负责回答:哪个 Reduce 任务接收这个 key?排序与分组负责回答:属于这个 key 的全部值在哪里?例如,R1 接收:text复制代码(cat,1) (bird,1) (cat,1) (bird,1) (cat,1)排序后:text复制代码(bird,1) (bird,1) (cat,1) (cat,1) (cat,1)于是可以逐组传给 Reduce:text复制代码reduce("bird", [1,1]) reduce("cat", [1,1,1])这里还有一个容易忽略的事实。原始 MapReduce 并不是必须让所有键值对一次性装入 Reduce Worker 内存。论文的 Reduce 接口可以使用迭代器提供同一个 key 的值,以支持很大的分组。运行时还可以通过外部排序、磁盘文件与归并过程处理超出内存容量的数据。[Technical Paper]一个工程陷阱:数据倾斜假设:the 占所有英文单词中的很大比例。它对应的全部计数都需要进入同一个 Reduce 分区。即使你有1000台 Worker,也可能出现:999台 Reduce Worker 已经完成。只有处理 the 的那台机器仍然很忙。这种现象称为:Data Skew,数据倾斜。所以 MapReduce 不是“机器越多就一定越快”。如果少数热门 key 集中了绝大多数中间数据,负载可能非常不均衡。这也是为什么分区策略、预聚合和特殊的倾斜处理,在实际系统中如此重要。第六幕:Combiner——为什么相同的单词不应该每次都经过网络?再看一个例子。假设 Map Worker A 处理一百万个单词。其中 cat 出现了十万次。最朴素的 Map 会产生:text复制代码(cat, 1) (cat, 1) (cat, 1) ...总共十万条 cat 记录。这些数据可能都需要通过网络发送给某个 Reduce Worker。但有没有必要真的发送十万条?没有。因为我们只是求和。可以在 Map Worker 本地提前计算:text复制代码(cat, 100000)然后只发送一条聚合记录。这个本地预聚合阶段就叫:Combiner。原论文明确把 Combiner 作为一种可选优化,用于减少重复键产生的中间数据。[Technical Paper]为什么不是所有 Reduce 都能直接拿来当 Combiner?对于整数求和:(a+b)+c=a+(b+c)(a+b)+c=a+(b+c)(a+b)+c=a+(b+c)满足结合律。整数加法也满足交换律。因此,可以先局部求和,再汇总各机器的部分和。但对于算术平均数,如果直接把每台机器的平均值再取平均,就可能算错。例如:机器 A 有100个数字,平均值为10。机器 B 只有1个数字,数值为100。正确总体平均数是:100×10+100101\frac{100\times10+100}{101}101100×10+100而不是:10+1002\frac{10+100}{2}210+100所以,如果想使用 Combiner 计算平均数,可以让每台 Worker 输出:text复制代码(sum, count)然后把两者分别相加。最终再计算:average=sumcountaverage=\frac{sum}{count}average=countsum这是一种很经典的代数结构设计方法。把不能直接安全分组的计算,改造成能够组合的中间统计量。它与第二十四章 CRDT 的交换律、结合律思想有联系,但两者并不相同:Combiner 用于同一个批处理作业中的局部预聚合。CRDT 则还需要考虑分布式副本状态收敛、重复传播和因果关系等问题。第七幕:MapReduce 真正革命性的地方——把机器故障变成一次普通重试2004年的 Google 面临一个现实问题:当你使用上千台普通服务器完成一次计算时,不能把“所有机器从头到尾都正常工作”当成基本假设。磁盘可能坏。操作系统可能崩溃。网络可能暂时不可达。某台机器可能运行得特别慢。如果一个作业包含两万个 Map 任务,只要任何一个 Worker 出错就必须从头重算,那么整个系统会很脆弱。MapReduce 的解决方法相当大胆:不要求每个 Worker 绝对可靠,而是让任务可以被其他 Worker 重新执行。Master 与 Worker 的职责Google 在2004年论文中描述的实现采用一个 Master 负责协调作业。Master 维护任务状态:idle:还未分配。in-progress:正在执行。completed:已经完成。Worker 负责真正运行 Map 或 Reduce 任务。Master 会定期与 Worker 通信,判断它们是否仍然可用。如果一个 Worker 长时间没有回应,就可能被标记为故障节点。随后把需要重新执行的任务安排给其他机器。[Technical Paper]一个很有意思的区别:Map 完成了,为什么还可能重新执行?因为2004年 Google 的实现通常把 Map 中间结果写到执行该任务的 Worker 本地磁盘。假设:Worker W1 完成了 Map 任务 M1。中间文件在 W1 本地。这时 W1 整台机器不可访问。Master 虽然知道 M1 曾经完成,但 Reduce Worker 现在可能已经无法取得 M1 的中间文件。因此,需要重新执行 M1。与之不同,已完成的 Reduce 结果写入全局文件系统,通常不必因为原 Reduce Worker 故障而重新计算。这不是 MapReduce 抽象必须永远采用的唯一存储方案,而是论文描述的 Google 2004年实现细节。[Technical Paper]实验:一个 Worker 宕机之后会发生什么?MapReduce Worker 故障实验室两份 Map 分片 · 三台 Worker步骤 0/4W1◇等待分配W2◇等待分配W3◇空闲Map 任务 M1未执行Map 任务 M2未执行Reduce 输出尚未计算Master 准备把 M1 交给 W1,把 M2 交给 W2。运行 Map→↶这里最值得记住的是:同一个逻辑任务,可以执行不止一次,但正确的最终输出不能因为重试而被重复计算。Google 的原始实现使用临时文件、任务完成确认和原子文件重命名等机制,控制哪一次执行的结果成为有效输出。在 Map 和 Reduce 函数是确定性函数的前提下,论文给出了与非故障顺序执行等价的输出语义。[Technical Paper]第八幕:为什么重试一万次,也不意味着能够安全调用一万次外部 API?现在,我们必须讨论 MapReduce 最容易被误解的地方。假设 Map 函数是:python复制代码def map_function(document): return count_words(document)它只读取输入,计算结果。如果因为 Worker 故障重新执行一次,通常没有本质问题。但如果有人这样写:python复制代码def map_function(order): payment_api.charge( order.user_id, order.amount ) return ("charged", 1)情况就完全不同了。假设任务已经成功向支付系统扣款。但还没有来得及向 Master 报告完成。Worker 突然宕机。Master 认为该任务没有完成。于是安排另一个 Worker 重新执行。结果:用户可能被扣款两次。即使最终的 Reduce 输出只保留一次成功结果,也无法自动撤销支付接口已经发生的副作用。所以:任务输出的准确提交语义,不等于任意外部副作用的恰好一次执行语义。这一区别在2004年的论文中已经被明确讨论。作者指出,Map 或 Reduce 中产生的额外文件等副作用,需要应用开发者自己保证适当的原子性和幂等性;当时实现并不提供针对一项任务多个输出文件的通用原子两阶段提交。[Technical Paper]三种经常混淆的语义语义含义At-most-once最多执行一次,可能丢失操作At-least-once至少执行一次,可能重复Exactly-once effect最终业务效果恰好发生一次,需要额外机制保证这里应当区分“函数执行次数”和“最终可观察效果”。MapReduce 的容错可以通过重新执行任务实现。但如果函数会调用外部支付、发送消息或修改其他数据库,就需要额外的幂等请求编号、事务或其他协调机制。这与我们第二十章的数据库事务,以及第十六章讨论的分布式故障问题完全一致。第九幕:2004年,Google 还要解决另一类问题——机器没坏,只是特别慢现在假设1000个任务已经完成了999个。剩下一个任务。它恰好被安排在一台磁盘特别慢的 Worker 上。其他999台 Worker 都在等待。这台慢机器可能需要再花十分钟才能完成。那么,整个作业就被拖慢了十分钟。这种机器或任务被称作:Straggler,掉队者。Google 的研究人员并没有只研究机器彻底宕机的情况。他们还研究:机器仍然在线,但执行速度远低于正常水平,怎么办?一个大胆的办法:同时再跑一次当作业接近完成时,如果还有一些任务执行得特别慢,Master 可以把这些任务再交给其他空闲 Worker。原来的 Worker 继续执行。新的 Worker 也开始执行同一个逻辑任务。谁先完成,就采用满足提交规则的有效结果。这种策略称作:Backup Tasks,也常称为 Speculative Execution(推测执行)的一个经典实例。它有点像:“既然我们不知道这台机器什么时候能完成,不如再找一台机器试一次。”虽然增加了一部分计算成本,却可能显著降低整个作业的尾部延迟。2004年论文特别介绍了这种策略,还给出了有趣的实测数据。[Technical Paper]2004年论文中的真实排序实验研究人员使用约1800台机器组成的集群,执行规模约为1 TB的排序任务。12830正常执行 完成时间: 891891故意终止200个Worker进程 完成时间: 933933禁用Backup Tasks 完成时间: 12831283正常执行故意终止200个Worker进程禁用Backup Tasks查看图表数据case完成时间正常执行891故意终止200个Worker进程933禁用Backup Tasks1283来源:Dean 与 Ghemawat,OSDI 2004,论文第5.3—5.5节。[Technical Paper]几个数字十分值得注意。正常执行耗时:891秒。禁用 Backup Tasks 以后:1283秒。增加了约44%的时间。还有一次实验,研究人员故意终止了200个 Worker 进程。作业仍然完成了。耗时为933秒,比正常实验只增加约5%。[Technical Paper]当然,这不是说:任何系统杀掉200台机器都只会慢5%。实验是在特定硬件、负载、任务和故障方式下完成的。作者终止的是 Worker 进程,底层集群调度器能够在相关机器上重新启动执行进程,并不是200台物理服务器都永久损坏了。但这些实测数据说明了一个重要设计优势:当任务能够被细粒度地重新分配和重算时,系统就不必把单台 Worker 的稳定性当成整个作业的稳定性。第十幕:为什么 Google 要把计算搬到数据旁边?来源现在我们讨论另一个极其重要的优化:Data Locality,数据局部性。假设一份64 MB的数据已经存储在服务器 A 上。而服务器 B 空闲,可以执行这个 Map 任务。你有两种选择。第一种:把64 MB数据从 A 通过网络传输到 B,再让 B 处理。第二种:直接让 A 读取本机存储的数据并完成计算。如果 A 还有计算资源,第二种方式通常能够减少网络流量。这就是:与其把大量数据搬到计算所在的位置,不如尽可能把计算安排到数据所在的位置。2003年,Sanjay Ghemawat 与 Howard Gobioff、Shun-Tak Leung 发表了著名论文:The Google File System。简称:GFS。它是一种面向大型数据密集型应用的分布式文件系统。论文讨论了如何在普通硬件上实现容错存储和高吞吐访问。[Google Research]而2004年 MapReduce 的实现,正好能够利用 GFS 保存的文件位置信息。原论文中的具体设计当时的 GFS 使用典型64 MB的数据块,并把一个数据块保存为多个副本,常见配置是三个副本。MapReduce Master 知道输入数据块存储在哪些机器上。调度任务时,优先安排到拥有对应数据副本的 Worker。如果做不到,也尽量选择网络距离较近的 Worker,例如位于同一交换机下的机器。这些都是2004年原始论文明确描述的优化。[Technical Paper]为什么不能简单地让每台服务器只处理自己的全部文件?因为不同服务器拥有的数据量和计算负载并不一定相同。某些服务器可能提前完成。另一些服务器可能积压大量工作。因此,MapReduce 把任务划分得比 Worker 数量更多。例如:1000台 Worker。分配10000个 Map 任务。Worker 完成一个任务后,可以继续领取其他未完成的任务。这样快机器通常能够处理更多任务。慢机器少处理一些。当一台 Worker 失败时,尚未完成或丢失结果的任务也可以由其他机器接管。这是一种细粒度任务 + 动态调度的设计。它牺牲少量调度元数据与管理开销,换取更灵活的负载均衡和故障恢复。第十一幕:如果任务切得越小越好,为什么不每个单词一个任务?你可能又想到一个问题:既然细粒度任务便于并行与重试,那我们为什么不把一份1 TB文件,拆成几千亿个小任务?因为任务也有管理成本。每个任务需要:分配 Worker。跟踪状态。保存中间结果信息。处理完成通知。维护失败重试信息。如果任务太小,系统可能把大量时间浪费在调度、进程通信和文件管理上,而不是实际计算。另一方面,如果任务过大:负载均衡就可能很差。某台机器失败后的重算代价也会非常高。因此需要折中。MapReduce 的任务规模2004年的 Google 实现通常把输入文件切分成约16—64 MB的 Map 分片。论文还给出一个规模非常大的运行实例:M=200,000M=200,000M=200,000个 Map 任务。R=5,000R=5,000R=5,000个 Reduce 任务。使用约2000台 Worker。这里的 MMM 与 RRR 分别表示任务数量,不是服务器数量。[Technical Paper]时间复杂度该怎么算?如果整个输入包含 NNN 个数据记录,每个 Map 记录的处理成本为常数,那么全部 Map 的总计算工作可以是:O(N)O(N)O(N)但实际运行时间还涉及并行度。假设有 PPP 台可有效并行的 Worker。在任务均衡、没有重大 I/O 瓶颈的理想模型中,Map 阶段可能接近:O(NP)O\left(\frac NP\right)O(PN)的计算时间。但不能仅凭这个式子就认为 MapReduce 的整个作业都是 O(N/P)O(N/P)O(N/P)。因为:Shuffle 需要搬运中间数据。中间记录可能需要排序。某些 key 可能造成严重倾斜。最后一个慢任务还可能拖延全局完成。因此,MapReduce 的性能分析通常不能只用一个时间复杂度公式解释。它还需要考察:计算量、磁盘 I/O、网络传输、并行任务数量、数据倾斜和尾部延迟。第十二幕:用 Python 实现自己的 MapReduce 引擎我们先写一个单进程教学版。它不负责真正的集群调度。但会明确分离:Map。Combiner。Shuffle。Reduce。这样可以理解哪些部分是用户自定义计算,哪些部分是框架负责的数据处理。python复制代码import re from collections import defaultdict documents = { "doc1": "cat dog cat", "doc2": "dog bird", "doc3": "cat bird dog", } def map_words(document_id, text): """把文档变成单词计数键值对""" for word in re.findall(r"[a-z]+", text.lower()): yield word, 1 def local_combine(pairs): """在一个 Map 任务内部提前合并""" counts = defaultdict(int) for key, value in pairs: counts[key] += value return list(counts.items()) def shuffle(map_outputs): """把所有 Map 输出按照 key 归组""" grouped = defaultdict(list) for task_output in map_outputs: for key, value in task_output: grouped[key].append(value) return grouped def reduce_sum(key, values): """将相同 key 的值汇总""" return key, sum(values) def run_mapreduce(documents, use_combiner=True): map_outputs = [] # Map for doc_id, text in documents.items(): pairs = list(map_words(doc_id, text)) if use_combiner: pairs = local_combine(pairs) map_outputs.append(pairs) # Shuffle grouped = shuffle(map_outputs) # Reduce result = {} for key in sorted(grouped): word, count = reduce_sum( key, grouped[key] ) result[word] = count return result print(run_mapreduce(documents))输出:text复制代码{'bird': 2, 'cat': 3, 'dog': 3}这是一段真正能够运行的 Python 代码。但不要误以为:“我写了一个 MapReduce 函数,就已经拥有 Google 2004年的分布式框架。”这段代码还缺少:并行 Worker。分布式输入存储。Shuffle 网络传输。外部排序。任务调度。失败检测。任务重试。原子输出提交。数据局部性优化。Backup Tasks。它只实现了 MapReduce 的核心数据变换语义。这已经非常有价值。因为你可以先在单机上确认 Map、Combiner、Reduce 的计算逻辑正确,再考虑如何分布式执行。给这个程序增加“任务重试但不重复统计”假设任务 doc1 运行了两次。第一次由 W1 执行。第二次由 W3 重试。最简单但错误的方式,是把这两次输出都加进总结果:python复制代码outputs = [ list(map_words("doc1", documents["doc1"])), list(map_words("doc1", documents["doc1"])), ]这会把 doc1 的词频重复计算一遍。所以,我们应该明确区分:逻辑任务 ID。以及:任务执行尝试 ID。例如:python复制代码# 同一个逻辑任务可以被执行多次 attempts = [ ("doc1", "W1", list(map_words( "doc1", documents["doc1"] ))), ("doc1", "W3", list(map_words( "doc1", documents["doc1"] ))), ("doc2", "W2", list(map_words( "doc2", documents["doc2"] ))), ("doc3", "W4", list(map_words( "doc3", documents["doc3"] ))), ] # 教学模拟:Master 只接受每个逻辑任务 # 的一个有效完成结果 accepted = {} for task_id, worker, output in attempts: if task_id not in accepted: accepted[task_id] = output grouped = shuffle(accepted.values()) result = { key: sum(values) for key, values in grouped.items() } print(result) # {'cat': 3, 'dog': 3, 'bird': 2}这里用字典演示去重的思想。它没有实现真正的故障检测、原子提交或并发竞争处理。实际系统还需要保证:一个执行结果只有经过有效提交,才能被下游当作该逻辑任务的结果使用。如果 Worker 崩溃后输出文件不可访问,就必须使旧结果失效,再接受新的有效执行。但我们已经理解了最重要的一点:重复执行是容错手段;正确选择并提交有效输出,是避免重复计算的保证。第十三幕:Google 当年到底取得了怎样的实际效果?我们已经了解机制,现在看看2004年论文里的真实生产数字。第一项:约1 TB数据中的模式搜索论文介绍了一次分布式 Grep 实验。输入包含:101010^{10}1010条记录。每条记录约100字节。总数据量约为1 TB。使用最多约1764台 Worker 执行时,扫描吞吐量峰值超过30 GB/s。整个计算包含启动开销,约150秒完成。[Technical Paper]这说明 MapReduce 不只是能进行复杂的键值聚合。它同样可以用于大量独立数据的并行扫描。第二项:约1 TB数据排序在同一篇论文中,研究者还展示了处理约1 TB排序数据的结果。任务包括大量中间文件写入、Shuffle、局部排序与最终输出。完整作业约891秒完成。也就是大约15分钟。注意这不是现代机器性能基准,而是 Google 在2004年发表的真实实验环境与结果。[Technical Paper]第三项:简化搜索索引代码Google 当时使用 MapReduce 重写了部分生产搜索索引流程。论文给出一个非常有代表性的例子。某个计算阶段原先需要约3800行 C++ 代码。采用 MapReduce 后,缩减到约700行。为什么能够减少这么多?并不是搜索索引本身的算法突然变得不复杂。而是过去每个业务程序都需要处理的大量分布式基础设施逻辑,被统一的 MapReduce 库承担了。同时,故障恢复、机器负载分配和输入读取也由运行时统一处理。[Technical Paper]这个结果比单纯强调“处理了多少 TB”更加重要。一项基础设施技术的成功,不仅在于机器运行得更快,也在于它让开发者不必重复解决同一种复杂问题。但 MapReduce 当时仍然存在边界2004年 Google 实现中,Master 仍然是相对脆弱的集中协调点。论文明确说明:虽然从设计上可以让 Master 保存检查点并重新启动,但当时的具体实现如果 Master 失败,会中止当前 MapReduce 计算,由客户端决定是否重试整个作业。[Technical Paper]所以不能简单说:“MapReduce 即使任何节点坏掉,整个系统都绝对不会中断。”更准确的说法是:它通过任务分片、数据复制、动态调度和重算机制,降低了大量普通 Worker 故障对作业的影响,但其具体容错边界仍然取决于实现。第十四幕:MapReduce 与普通多线程、数据库 SQL、Spark 有什么区别?到了这里,你可能有一个疑问。既然 MapReduce 的最终本质还是并行计算,为什么不用多线程?因为多线程与 MapReduce 解决的层次不同。技术典型计算环境主要优势多线程一台机器内部共享内存,适合低延迟任务并行多进程一台或少量机器进程隔离与并行执行MapReduce分布式集群大规模批处理、数据划分和失败重试关系数据库 SQL数据库执行引擎声明式查询、优化器和事务语义Spark通用分布式数据处理多阶段计算、数据集抽象和多种执行模式它们之间并不是完全互斥的关系。例如:数据库查询执行引擎可以使用多线程。Spark 可以执行涉及 Shuffle 的 MapReduce 风格计算。而 MapReduce 的 Map、Reduce 任务内部,也完全可以使用多个线程进行计算。真正的区别,在于它们提供什么抽象、管理什么资源、承担哪些运行时责任。为什么后来还需要 Spark?因为传统 MapReduce 对每一个作业阶段的中间数据持久化和任务边界有较强的设计约束。对于大规模批处理、搜索索引和离线分析,这些约束有利于故障恢复。但在一些需要反复迭代的机器学习、图算法或交互式分析工作负载中,多次作业之间频繁写入和重新读取中间数据,会产生可观成本。后来的 Spark 等系统,在数据复用、多阶段执行和内存计算等方面引入了不同设计。这不代表 MapReduce 在算法史上已经没有价值。恰恰相反。MapReduce 奠定了一种重要的分布式数据计算思维:将计算表达为可划分的数据变换,再交给运行时系统处理调度、通信与故障。此后的很多系统都继承、扩展或者有意识地突破了这个设计。终章挑战:为什么 Worker 重试之后,词频不能增加两次?假设 Worker W1 已经处理了文档:text复制代码cat cat dog产生:text复制代码(cat, 1) (cat, 1) (dog, 1)但它尚未完成有效提交,就发生故障。Master 将同一个 Map 任务重新分配给 W2。W2 成功执行相同输入,并生成相同结果。现在应该怎么办?请选择最合理的处理方式把 W1 与 W2 的输出都加入 Reduce,避免遗漏只让同一个逻辑 Map 任务的一份有效提交输出参与最终计算整个作业必须从第一份文档开始全部重做MapReduce 可以自动保证任何外部 API 只被调用一次尾声:Google 并没有让每台机器变得绝对可靠,而是让不可靠变得可以管理2003年,Google 开发了第一版 MapReduce 库。同年8月,系统加入数据局部性与动态任务分配等重要优化。2004年,Jeffrey Dean 与 Sanjay Ghemawat 在 OSDI 发表经典论文,系统介绍了编程模型、任务调度、故障恢复和性能实验。论文展示了当时普通硬件集群在大规模扫描、排序与搜索索引构建中的能力。2008年,两位作者又在《Communications of the ACM》发表后续回顾。当时 Google 已经内部开发了超过一万个不同的 MapReduce 程序,并在集群上每天平均执行约十万个 MapReduce 作业,处理的数据量达到每天二十多 PB。[数字对象标识符]但这个故事真正值得记住的,不是机器数量和数据规模。而是一种系统设计观念:与其要求每个执行单元永远不会出错,不如让计算具有明确的输入、输出和任务边界,使错误发生后能够安全地重新执行。Map 让工作可以独立分开。Shuffle 让相关数据重新汇聚。Reduce 负责生成聚合结果。Master 负责协调任务与有效输出。输入副本、局部性调度和任务重试帮助系统应对故障。Backup Tasks 则把机器运行速度不均匀造成的尾部延迟转化为可以通过额外计算资源缓解的问题。把这些设计放在一起,才构成了2004年 Google MapReduce 真正的工程贡献。而且,它与我们此前学习的算法形成了紧密联系。第四章的哈希函数决定数据分区。第七章的排序参与 Shuffle 与分组。第十五章的分布式存储思想帮助我们理解数据位置和副本。第十六章的故障模型提醒我们,不同节点故障需要不同的恢复策略。第二十章的事务与幂等性则告诉我们,重新执行计算不能无条件等同于安全重复执行任意业务操作。MapReduce 是算法、数据结构、存储系统和故障恢复共同组成的一套分布式计算模型。第二十七章知识总结概念核心思想Map独立处理输入记录,生成中间键值对Shuffle按 key 分区,把相关中间结果送到同一 Reduce 分区Sort / Group对中间 key 排序或归组Reduce汇总同一个 key 的值Combiner在 Map 端局部预聚合,降低中间数据量Partitioner决定某个 key 归属哪个 Reduce 任务Master维护任务状态、调度与故障恢复信息Worker执行被分配的 Map 或 Reduce 任务Data Locality尽量将计算放到数据所在或附近的位置Task Re-execution重新执行失败或丢失输出的逻辑任务Atomic Output Commit只接受有效完成的输出,避免重复提交Backup Tasks对落后的任务进行冗余执行,降低尾部延迟Data Skew少数 key 或分片导致负载不均衡GFSGoogle 早期大规模分布式文件系统本章真实历史资料Google Research:Dean、Ghemawat(2004)MapReduce 原论文介绍2004年 MapReduce 原始论文 PDF,包含架构图和实验数据USENIX OSDI 2004:MapReduce 论文完整 HTML 版本Ghemawat、Gobioff、Leung(2003):Google File System 原始研究Dean、Ghemawat(2008):MapReduce 后续研究回顾Apache Hadoop:MapReduce 编程与 Shuffle/Sort 教程下一章预告第二十八章:PageRank——1998年,为什么网页的重要性取决于谁链接了它?1990年代,互联网网页数量快速增长。搜索引擎面临一个困难:如果一千个网页都包含同一个关键词,究竟应该先展示哪一个?1998年,斯坦福大学的 Lawrence Page、Sergey Brin、Rajeev Motwani 和 Terry Winograd 共同署名了一份研究报告:The PageRank Citation Ranking: Bringing Order to the Web《PageRank 引文排序:为互联网建立秩序》。[BibBase]它提出了一个大胆的思路:一个网页的重要性,不应该仅取决于有多少其他网页链接了它。还应该取决于:那些给它提供链接的网页,本身有多重要。一个普通网页的链接,和一个权威网页的链接,不能简单当成同样价值的投票。于是,研究者把整个互联网看作一张巨大的有向图。网页是节点。超链接是有向边。再通过反复迭代,把重要性在整个网页网络中传播。这就是:PageRank。下一章,我们将研究 PageRank 的真实发明历史、随机冲浪者模型、阻尼系数、幂迭代,以及如何使用 MapReduce 计算超大规模网页图的排名。最精彩的问题是:如果一个网页的重要性来自其他网页,而那些网页的重要性又来自更多网页,这种互相依赖的定义,为什么最终竟然能够计算出稳定的排名?上一章第二十六章:分布式快照——1985年,为什么不暂停整个网络,也能拍下一张全局一致的照片?下一章第二十八章:PageRank——1998年,为什么一个网页的重要性,取决于哪些网页指向了它?