36-大规模数据处理实战

开篇词 | 从这里开始，带你走上硅谷一线系统架构师之路

你好，我是蔡元楠。目前是 Google Brain 的软件工程师。

在接下来的 4 个月时间里，我会与你一起探索大规模数据处理的世界。

在开始我们的系统性学习之前，我想先和你分享两个我亲历的故事，借此告诉你，我为什么要开这个专栏。

2014 年，我刚开始在美国找工作，在一次面试中，面试官让我解释一下 C++ 的 smart pointer 和 string view。我完全回答不上来。

当时我面露难色怀疑人生，难道我的前半生学的是“假的”C++ 吗？

回想学习经历，虽然我在一个还可以的高校里学习了 C++ 课程，考试成绩也是 90 分以上。但我的学习资料只有按 C++ 98 标准编写的教材，和当时流行的 CSDN 论坛文章。

而当时的教材里根本没有提到过 smart pointer 和 string view。

没想到工业界早就进入了 C++ 0x/11 标准甚至是 C++ 17（当时是试验标准）。我后悔没有学习紧跟时代的最新技术，而信息的不对称就会造成巨大的认知偏差。

那学习“最新技术”一定就是好事情吗？我想通过第二个故事来回答这个问题。

2017 年，我帮 Google Ventures（Google 的风险投资基金，会寻找并帮助优秀初创公司）在投的初创公司做导师，那时候经常参加一些对方公司的技术架构评审。

一次评审中，对方的技术 VP 眉飞色舞地介绍他们的技术框架和××大厂一样，罗列了 Kylin 和 Tornado 等一些时髦的技术名词。

因为我并不了解提到的几个技术，就好奇地问为什么 Kylin 适合他们团队，Kylin 为他们解决了哪些独特的问题？

他当时的回答并没能说服我和别的同事：“因为××大厂也在用，这就是未来的技术方向”。

看得出来，这位技术 VP 还没有真的搞清楚使用一个技术的原因。

学会用一个技术只是第一步，最重要的是要追问自己：

这个技术解决了哪些痛点？

别的技术为什么不能解决？

这个技术用怎样的方法解决问题？

采用这个技术真的是最好的方法吗？

如果不用这个技术，你会怎样独立解决这类问题？

如果没有这些深层次的思考，你就永远只是在赶技术的时髦而已，不会拥有影响他人的技术领导力。

事实上在 Google，类似这样的“灵魂追问”每天都在发生。

这里敢于打碎任何权威，所有的技术设计都是从问题出发。每一个工程师都会独立思考究竟什么是最佳方案，而不是照搬现有结论。

正如乔布斯所说，过去的点最终会连成线。这两个经历让我深感有使命去帮助更多技术同行，比如第一个故事里的我，或者是第二个故事里的初创公司 VP。我想设计一个专栏，去解决故事里的问题。

第一，我想要介绍硅谷最前沿技术和真实的案例。

比如，在大规模数据处理领域，MapReduce 或者 Apache Storm 的不少设计理念已经无法胜任最新的挑战。

所以，我会介绍最新的知识，例如框架层面的前后端分离理念，和批处理流处理统一的思想。

第二，我不想只停留在照本宣科的层面。

正如上文所说，学会用一个或者两个技术是不够的。

更重要的是，我会剖析技术框架产生的原因和它们解决的问题。这样当下一次你再碰到相似的问题时，就不用照搬别人的方法。

为什么写大规模数据处理？

为什么会选择大规模数据处理这个主题呢？并不是因为我觉得这个主题受众多、销量好，相反，我认为大部分人都还没有正确理解数据处理技术，常常见到的误区有如下几种。

第一，低估了数据处理的重要性。

因为我在 Google Brain 的 AI 应用领域工作，切身感受到过，没有高质量的数据处理的话，人工智能是只有人工没有智能的。

Google 也曾在很长的一段时间里低估过数据处理。

例如，在语义理解上，Google 认为自己有最多的搜索文本数据，最好的算法，那就一定能把语义理解做的最好。

可是到 2016 年左右，一个名不见经传的德国小公司却一举超过了 Google，大家都很惊讶。后来发现原来他们凭借的是高质量的数据标注和处理。

第二，低估了数据处理工程师在组织架构上的重要性。

许多工程师都喜欢自嘲自己的工作是“搬砖”，事实也正是如此。

包括我在内，很多人的工作内容都避不开数据的搬运和处理。把数据从这个格式处理成那个格式，把数据从这个数据库搬到那个数据库，这个服务器搬到那个服务器，这个客户端搬到那个客户端。

可能连你自己都还没有意识到，即使是一个写前端的工程师，他的很多工作还是数据处理。

大数据领域泰斗级人物 Jesse Anderson 曾做过一项研究，一个人工智能团队的合理组织架构，需要 4/5 的数据处理工程师。很不幸，很多团队没有认识到这一点。

第三，低估了数据处理规模变大带来的复杂度。

我把这个专栏定位在“大规模”数据处理，因为我想着重在数据规模变大时需要的技术思想。

很多人可能还没有遇到过“大规模”数据的问题，容易把问题想简单了。

我在 Google 面试过很多优秀的候选人，应对普通的编程问题，他们能够用算法和数据结构解决得很好。可是当我追问数据规模变大时怎么设计系统，他们的回答却并不让人满意。

当你的产品从 1 万用户到 1 亿用户，技术团队从 10 个人到 1000 个人，之前的方法还能奏效吗？

第四，高估了上手数据处理的难度。

一方面我们需要认识到大规模的数据处理是有复杂的因素的。但另一方面，我想在这个专栏里教会你，有了正确的工具和技术理念，现在上手数据处理并不困难。

在 Google，我见到很多应届生来了半年后也能轻松应对上亿的数据量。

我给开篇词起名为《从这里开始，带你走上硅谷一线系统架构师之路》，就是为了给你设计切实可操作的学习路径，让你比别人更准确深入地掌握实用的大规模数据处理技术，最终通往硅谷一线系统架构师的水平。

因此，我们的学习路径会是这样的。

第一部分，先会用原汁原味最实际的硅谷一线大厂的案例，向你解释 MapReduce 为什么不能应对最新的技术挑战。然后我会从实际的问题出发，从头开始引导你怎样从顶层设计一个数据处理框架。

第二部分，同样是结合实战案例，来讲解在数据处理框架的使用和设计中必需的一些基础知识。这些案例紧贴应用，可能就是你的团队明天会碰到的问题。

第三、第四部分深入拆解了 Apache Spark 和 Apache Beam。不仅会用实际的案例教会你如何使用，还要教会你为什么它们这么设计。你会发现它们的设计其实大致和第一部分的顶层设计是一致的。下一次，即使这个世界一无所有，你也能构建类似的框架解决一系列问题。

第五部分按 Google T6 级别设计，是带着代码的真枪实弹的架构设计。毫不夸张地说，能完整掌握第五部分的思想精髓，你就能比肩硅谷一线大规模数据处理架构师。

第六部分着重培养你的技术远见。因为是否能现在就开始准备应对 10 年后人类社会的技术挑战，是你拉开与别人差距的重要一站。

在刚开始动笔写专栏的时候，我就在设想，什么样的人会是这个专栏的目标读者呢？

直到专栏快上线，我写下这篇开篇词，我才真正定义读者的标签——应该就是跟我一样渴望成长的人。是的，我和你一样，都渴望成长——渴望知识的成长，渴望经验的成长，渴望财富的成长。

所以我想把这个专栏设计成一份共同的成长规划，而不是一本死板的教材。

正如我在开头的小故事里所说的，这个世界没有谁是绝对的权威。

我希望你每一期都能在留言栏里质疑、提问和讨论。这些互动能帮助我和别的同学一起提高。

最后，我期待和你一起开始学习，共同成长！

01 | 为什么MapReduce会被硅谷一线公司淘汰？

你好，我是蔡元楠。

今天我要与你分享的主题是“为什么 MapReduce 会被硅谷一线公司淘汰”。

我有幸几次与来 Google 参观的同行进行交流，当谈起数据处理技术时，他们总是试图打探 MapReduce 方面的经验。

这一点让我颇感惊讶，因为在硅谷，早已没有人去谈论 MapReduce 了。

今天这一讲，我们就来聊聊为什么 MapReduce 会被硅谷一线公司淘汰。

我们先来沿着时间线看一下超大规模数据处理的重要技术以及它们产生的年代。

我认为可以把超大规模数据处理的技术发展分为三个阶段：石器时代，青铜时代，蒸汽机时代。

石器时代

我用“石器时代”来比喻 MapReduce 诞生之前的时期。

数据的大规模处理问题早已存在。早在 2003 年的时候，Google 就已经面对大于 600 亿的搜索量。

但是数据的大规模处理技术还处在彷徨阶段。当时每个公司或者个人可能都有自己的一套工具处理数据。却没有提炼抽象出一个系统的方法。

青铜时代

2003 年，MapReduce 的诞生标志了超大规模数据处理的第一次革命，而开创这段青铜时代的就是下面这篇论文《MapReduce: Simplified Data Processing on Large Clusters》。

杰夫（Jeff Dean）和桑杰（Sanjay Ghemawat）从纷繁复杂的业务逻辑中，为我们抽象出了 Map 和 Reduce 这样足够通用的编程模型。后面的 Hadoop 仅仅是对于 GFS、BigTable、MapReduce 的依葫芦画瓢，我这里不再赘述。

蒸汽机时代

到了 2014 年左右，Google 内部已经几乎没人写新的 MapReduce 了。

2016 年开始，Google 在新员工的培训中把 MapReduce 替换成了内部称为 FlumeJava（不要和 Apache Flume 混淆，是两个技术）的数据处理技术。

这标志着青铜时代的终结，同时也标志着蒸汽机时代的开始。

我跳过“铁器时代”之类的描述，是因为只有工业革命的概念才能解释从 MapReduce 进化到 FlumeJava 的划时代意义。

Google 内部的 FlumeJava 和它后来的开源版本 Apache Beam 所引进的统一的编程模式，将在后面的章节中为你深入解析。

现在你可能有一个疑问 ：为什么 MapReduce 会被取代？今天我将重点为你解答。

高昂的维护成本

使用 MapReduce，你需要严格地遵循分步的 Map 和 Reduce 步骤。当你构造更为复杂的处理架构时，往往需要协调多个 Map 和多个 Reduce 任务。

然而，每一步的 MapReduce 都有可能出错。

为了这些异常处理，很多人开始设计自己的协调系统（orchestration）。例如，做一个状态机（state machine）协调多个 MapReduce，这大大增加了整个系统的复杂度。

如果你搜 “MapReduce orchestration” 这样的关键词，就会发现有很多书，整整一本都在写怎样协调 MapReduce。

你可能会惊讶于 MapReduce 的复杂度。我也经常会看到一些把 MapReduce 说得过度简单的误导性文章。

例如，“把海量的××数据通过 MapReduce 导入大数据系统学习，就能产生××人工智能”。似乎写文的“专家”动动嘴就能点石成金。

而现实的 MapReduce 系统的复杂度是超过了“伪专家”的认知范围的。下面我来举个例子，告诉你 MapReduce 有多复杂。

想象一下这个情景，你的公司要预测美团的股价，其中一个重要特征是活跃在街头的美团外卖电动车数量，而你负责处理所有美团外卖电动车的图片。

在真实的商用环境下，为了解决这个问题，你可能至少需要 10 个 MapReduce 任务：

首先，我们需要搜集每日的外卖电动车图片。

数据的搜集往往不全部是公司独自完成，许多公司会选择部分外包或者众包。所以在数据搜集（Data collection）部分，你至少需要 4 个 MapReduce 任务：

数据导入（data ingestion）：用来把散落的照片（比如众包公司上传到网盘的照片）下载到你的存储系统。

数据统一化（data normalization）：用来把不同外包公司提供过来的各式各样的照片进行格式统一。

数据压缩（compression）：你需要在质量可接受的范围内保持最小的存储资源消耗 。

数据备份（backup）：大规模的数据处理系统我们都需要一定的数据冗余来降低风险。

仅仅是做完数据搜集这一步，离真正的业务应用还差得远。

真实的世界是如此不完美，我们需要一部分数据质量控制（quality control）流程，比如：

数据时间有效性验证 （date validation）：检测上传的图片是否是你想要的日期的。

照片对焦检测（focus detection）：你需要筛选掉那些因对焦不准而无法使用的照片。

最后才到你负责的重头戏——找到这些图片里的外卖电动车。而这一步因为人工的介入是最难控制时间的。你需要做 4 步：

数据标注问题上传（question uploading）：上传你的标注工具，让你的标注者开始工作。

标注结果下载（answer downloading）：抓取标注完的数据。

标注异议整合（adjudication）：标注异议经常发生，比如一个标注者认为是美团外卖电动车，另一个标注者认为是京东快递电动车。

标注结果结构化（structuralization）: 要让标注结果可用，你需要把可能非结构化的标注结果转化成你的存储系统接受的结构。

这里我不再深入每个 MapReduce 任务的技术细节，因为本章的重点仅仅是理解 MapReduce 的复杂度。

通过这个案例，我想要阐述的观点是，因为真实的商业 MapReduce 场景极端复杂，像上面这样 10 个子任务的 MapReduce 系统在硅谷一线公司司空见惯。

在应用过程中，每一个 MapReduce 任务都有可能出错，都需要重试和异常处理的机制。所以，协调这些子 MapReduce 的任务往往需要和业务逻辑紧密耦合的状态机。

这样过于复杂的维护让系统开发者苦不堪言。

时间性能“达不到”用户的期待

除了高昂的维护成本，MapReduce 的时间性能也是个棘手的问题。

MapReduce 是一套如此精巧复杂的系统，如果使用得当，它是青龙偃月刀，如果使用不当，它就是一堆废铁。不幸的是并不是每个人都是关羽。

在实际的工作中，不是每个人都对 MapReduce 细微的配置细节了如指掌。

在现实中，业务往往需求一个刚毕业的新手在 3 个月内上线一套数据处理系统，而他很可能从来没有用过 MapReduce。这种情况下开发的系统是很难发挥好 MapReduce 的性能的。

你一定想问，MapReduce 的性能优化配置究竟复杂在哪里呢？

我想 Google500 多页的 MapReduce 性能优化手册足够说明它的复杂度了。这里我举例讲讲 MapReduce 的分片（sharding）难题，希望能窥斑见豹，引发大家的思考。

Google 曾经在 2007 年到 2012 年间做过一个对于 1PB 数据的大规模排序实验，来测试 MapReduce 的性能。

从 2007 年的排序时间 12 小时，到 2012 年的排序时间缩短至 0.5 小时。即使是 Google，也花了 5 年的时间才不断优化了一个 MapReduce 流程的效率。

2011 年，他们在 Google Research 的博客上公布了初步的成果。

其中有一个重要的发现，就是他们在 MapReduce 的性能配置上花了非常多的时间。包括了缓冲大小 (buffer size），分片多少（number of shards），预抓取策略（prefetch），缓存大小（cache size）等等。

所谓的分片，是指把大规模的的数据分配给不同的机器 / 工人，流程如下图所示。

选择一个好的分片函数（sharding function）为何格外重要？让我们来看一个例子。

假如你在处理 Facebook 的所有用户数据，你选择了按照用户的年龄作为分片函数（sharding function）。我们来看看这时候会发生什么。

因为用户的年龄分布不均衡（假如在 20~30 这个年龄段的 Facebook 用户最多），导致我们在下图中 worker C 上分配到的任务远大于别的机器上的任务量。

这时候就会发生掉队者问题（stragglers）。别的机器都完成了 Reduce 阶段，只有 worker C 还在工作。

当然它也有改进方法。掉队者问题可以通过 MapReduce 的性能剖析（profiling）发现。 如下图所示，箭头处就是掉队的机器。

图片引用：Chen, Qi, Cheng Liu, and Zhen Xiao. “Improving MapReduce performance using smart speculative execution strategy.” IEEE Transactions on Computers 63.4 (2014): 954-967.

回到刚刚的 Google 大规模排序实验。

因为 MapReduce 的分片配置异常复杂，在 2008 年以后，Google 改进了 MapReduce 的分片功能，引进了动态分片技术 (dynamic sharding），大大简化了使用者对于分片的手工调整。

在这之后，包括动态分片技术在内的各种崭新思想被逐渐引进，奠定了下一代大规模数据处理技术的雏型。

小结

这一讲中，我们分析了两个 MapReduce 之所以被硅谷一线公司淘汰的“致命伤”：高昂的维护成本和达不到用户期待的时间性能。

文中也提到了下一代数据处理技术雏型。这就是 2008 年左右在 Google 西雅图研发中心诞生的 FlumeJava，它一举解决了上面 MapReduce 的短板。

另外，它还带来了一些别的优点：更好的可测试性；更好的可监控性；从 1 条数据到 1 亿条数据无缝扩展，不需要修改一行代码，等等。

在后面的章节中，我们将具体展开这几点，通过深入解析 Apache Beam（FlumeJava 的开源版本），揭开 MapReduce 继任者的神秘面纱。

思考题

如果你在 Facebook 负责处理例子中的用户数据，你会选择什么分片函数，来保证均匀分布的数据分片?

欢迎你把答案写在留言区，与我和其他同学一起探讨。

如果你觉得有所收获，也欢迎把文章分享给你的朋友。

02 | MapReduce后谁主沉浮：怎样设计下一代数据处理技术？

你好，我是蔡元楠。

在上一讲中，我们介绍了 2014 年之前的大数据历史，也就是 MapReduce 作为数据处理的默认标准的时代。重点探讨了 MapReduce 面对日益复杂的业务逻辑时表现出的不足之处，那就是：1. 维护成本高；2. 时间性能不足。

同时，我们也提到了 2008 年诞生在 Google 西雅图研发中心的 FlumeJava，它成为了 Google 内部的数据处理新宠。

那么，为什么是它扛起了继任 MapReduce 的大旗呢？

要知道，在包括 Google 在内的硅谷一线大厂，对于内部技术选择是非常严格的，一个能成为默认方案的技术至少满足以下条件：

经受了众多产品线，超大规模数据量例如亿级用户的考验；

自发地被众多内部开发者采用，简单易用而受开发者欢迎；

能通过内部领域内专家的评审；

比上一代技术仅仅提高 10% 是不够的，必须要有显著的比如 70% 的提高，才能够说服整个公司付出技术迁移的高昂代价。就看看从 Python 2.7 到 Python 3 的升级花了多少年了，就知道在大厂迁移技术是异常艰难的。

今天这一讲，我不展开讲任何具体技术。

我想先和你一起设想一下，假如我和你站在 2008 年的春夏之交，在已经清楚了 MapReduce 的现有问题的情况下，我们会怎么设计下一代大规模数据处理技术，带领下一个十年的技术革新呢？

我们需要一种技术抽象让多步骤数据处理变得易于维护

上一讲中我提到过，维护协调多个步骤的数据处理在业务中非常常见。

像图片中这样复杂的数据处理在 MapReduce 中维护起来令人苦不堪言。

为了解决这个问题，作为架构师的我们或许可以用有向无环图（DAG）来抽象表达。因为有向图能为多个步骤的数据处理依赖关系，建立很好的模型。如果你对图论比较陌生的话，可能现在不知道我在说什么，你可以看下面一个例子，或者复习一下极客时间的《数据结构与算法之美》。

西红柿炒鸡蛋这样一个菜，就是一个有向无环图概念的典型案例。

比如看这里面番茄的处理，最后一步“炒”的步骤依赖于切好的番茄、打好的蛋、热好的油。而切好的番茄又依赖于洗好的番茄等等。如果用 MapReduce 来实现的话，在这个图里面，每一个箭头都会是一个独立的 Map 或 Reduce。

为了协调那么多 Map 和 Reduce，你又难以避免会去做很多检查，比如：番茄是不是洗好了，鸡蛋是不是打好了。

最后这个系统就不堪重负了。

但是，如果我们用有向图建模，图中的每一个节点都可以被抽象地表达成一种通用的数据集，每一条边都被表达成一种通用的数据变换。如此，你就可以用数据集和数据变换描述极为宏大复杂的数据处理流程，而不会迷失在依赖关系中无法自拔。

我们不想要复杂的配置，需要能自动进行性能优化

上一讲中提到，MapReduce 的另一个问题是，配置太复杂了。以至于错误的配置最终导致数据处理任务效率低下。

这种问题怎么解决呢？很自然的思路就是，如果人容易犯错，就让人少做一点，让机器多做一点呗。

我们已经知道了，得益于上一步中我们已经用有向图对数据处理进行了高度抽象。这可能就能成为我们进行自动性能优化的一个突破口。

回到刚才的番茄炒鸡蛋例子，哪些情况我们需要自动优化呢？

设想一下，如果我们的数据处理食谱上又增加了番茄牛腩的需求，用户的数据处理有向图就变成了这个样子了。

理想的情况下，我们的计算引擎要能够自动发现红框中的两条数据处理流程是重复的。它要能把两条数据处理过程进行合并。这样的话，番茄就不会被重复准备了。

同样的，如果需求突然不再需要番茄炒蛋了，只需要番茄牛腩，在数据流水线的预处理部分也应该把一些无关的数据操作优化掉，比如整个鸡蛋的处理过程就不应该在运行时出现。

另一种自动的优化是计算资源的自动弹性分配。

比如，还是在番茄炒蛋这样一个数据处理流水线中，如果你的规模上来了，今天需要生产 1 吨的番茄炒蛋，明天需要生产 10 吨的番茄炒蛋。你发现有时候是处理 1000 个番茄，有时候又是 10000 个番茄。如果手动地去做资源配置的话，你再也配置不过来了。

我们的优化系统也要有可以处理这种问题的弹性的劳动力分配机制。它要能自动分配，比如 100 台机器处理 1000 个番茄，如果是 10000 个番茄那就分配 1000 台机器，但是只给热油 1 台机器可能就够了。

这里的比喻其实是很粗糙也不精准的。我想用这样两个例子表达的观点是，在数据处理开始前，我们需要有一个自动优化的步骤和能力，而不是按部就班地就把每一个步骤就直接扔给机器去执行了。

我们要能把数据处理的描述语言，与背后的运行引擎解耦合开来

前面两个设计思路提到了很重要的一个设计就是有向图。

用有向图进行数据处理描述的话，实际上数据处理描述语言部分完全可以和后面的运算引擎分离了。有向图可以作为数据处理描述语言和运算引擎的前后端分离协议。

举两个你熟悉的例子可能能更好理解我这里所说的前后端分离（client-server design）是什么意思：

比如一个网站的架构中，服务器和网页通过 HTTP 协议通信。

比如在 TensorFlow 的设计中，客户端可以用任何语言（比如 Python 或者 C++）描述计算图，运行时引擎（runtime) 理论上却可以在任何地方具体运行，比如在本地，在 CPU，或者在 TPU。

那么我们设计的数据处理技术也是一样的，除了有向图表达需要数据处理描述语言和运算引擎协商一致，其他的实现都是灵活可拓展的。

比如，我的数据描述可以用 Python 描述，由业务团队使用；计算引擎用 C++ 实现，可以由数据底层架构团队维护并且高度优化；或者我的数据描述在本地写，计算引擎在云端执行。

我们要统一批处理和流处理的编程模型

关于什么是批处理和流处理概念会在后面的章节展开。这里先简单解释下，批处理处理的是有界离散的数据，比如处理一个文本文件；流处理处理的是无界连续的数据，比如每时每刻的支付宝交易数据。

MapReduce 的一个局限是它为了批处理而设计的，应对流处理的时候不再那么得心应手。即使后面的 Apache Storm、Apache Flink 也都有类似的问题，比如 Flink 里的批处理数据结构用 DataSet，但是流处理用 DataStream。

但是真正的业务系统，批处理和流处理是常常混合共生，或者频繁变换的。

比如，你有 A、B 两个数据提供商。其中数据提供商 A 与你签订的是一次性的数据协议，一次性给你一大波数据，你可以用批处理。而数据提供商 B 是实时地给你数据，你又得用流处理。更可怕的事情发生了，本来是批处理的数据提供商 A，突然把协议修改了，现在他们实时更新数据。这时候你要是用 Flink 就得爆炸了。业务需求天天改，还让不让人活了？！

因此，我们设计的数据处理框架里，就得有更高层级的数据抽象。

不论是批处理还是流处理的，都用统一的数据结构表示。编程的 API 也需要统一。这样不论业务需求什么样，开发者只需要学习一套 API。即使业务需求改变，开发者也不需要频繁修改代码。

我们要在架构层面提供异常处理和数据监控的能力

真正写过大规模数据处理系统的人都深有感触：在一个复杂的数据处理系统中，难的不是开发系统，而是异常处理。

事实正是如此。一个 Google 内部调研表明，在大规模的数据处理系统中，90% 的时间都花在了异常处理中。常常发生的问题的是，比如在之前的番茄炒鸡蛋处理问题中，你看着系统 log，明明买了 1000 个鸡蛋，炒出来的菜却看起来只有 999 个鸡蛋，你仰天长叹，少了一个蛋到底去哪里了！

这一点和普通的软件开发不同。比如，服务器开发中，偶尔一个 RPC 请求丢了就丢了，重试一下，重启一下能过就行了。可如果在数据处理系统中，数据就是钱啊，不能随便丢。比如我们的鸡蛋，都是真金白银买回来的。是超市买回来数错了？是打蛋时候打碎了？还是被谁偷吃了？你总得给老板一个合理的交代。

我们要设计一套基本的数据监控能力，对于数据处理的每一步提供自动的监控平台，比如一个监控网站。

在番茄炒蛋系统中，要能够自动的记录下来，超市买回来是多少个蛋，打蛋前是多少个蛋，打完蛋是多少个蛋，放进锅里前是多少个蛋等等。也需要把每一步的相关信息进行存储，比如是谁去买的蛋，哪些人打蛋。这样出错后可以帮助用户快速找到可能出错的环节。

小结

通过上面的分析，我们可以总结一下。如果是我们站在 2008 年春夏之交来设计下一代大规模数据处理框架，一个基本的模型会是图中这样子的：

但是这样粗糙的设计和思想实验离实现还是太远。你可能还是会感到无从下手。

后面的章节会给你补充一些设计和使用大规模数据处理架构的基础知识。同时，也会深入剖析两个与我们这里的设计理念最接近的大数据处理框架，Apache Spark 和 Apache Beam。

思考题

你现在在使用的数据处理技术有什么问题，你有怎样的改进设计？

欢迎你把自己的想法写在留言区，与我和其他同学一起讨论。

如果你觉得有所收获，也欢迎把文章分享给你的朋友。

03 | 大规模数据处理初体验：怎样实现大型电商热销榜？

你好，我是蔡元楠。

今天我要与你分享的主题是“怎样实现大型电商热销榜”。

我在 Google 面试过很多优秀的候选人，应对普通的编程问题 coding 能力很强，算法数据结构也应用得不错。

可是当我追问数据规模变大时该怎么设计系统，他们却说不出所以然来。这说明他们缺乏必备的规模增长的技术思维（mindset of scaling）。这会限制这些候选人的职业成长。

因为产品从 1 万用户到 1 亿用户，技术团队从 10 个人到 1000 个人，你的技术规模和数据规模都会完全不一样。

今天我们就以大型电商热销榜为例，来谈一谈从 1 万用户到 1 亿用户，从 GB 数据到 PB 数据系统，技术思维需要怎样的转型升级？

同样的问题举一反三，可以应用在淘宝热卖，App 排行榜，抖音热门，甚至是胡润百富榜，因为实际上他们背后都应用了相似的大规模数据处理技术。

真正的排序系统非常复杂，仅仅是用来排序的特征（features）就需要多年的迭代设计。

为了便于这一讲的讨论，我们来构想一个简化的玩具问题，来帮助你理解。

假设你的电商网站销售 10 亿件商品，已经跟踪了网站的销售记录：商品 id 和购买时间 {product_id, timestamp}，整个交易记录是 1000 亿行数据，TB 级。作为技术负责人，你会怎样设计一个系统，根据销售记录统计去年销量前 10 的商品呢？

举个例子，假设我们的数据是：

我们可以把热销榜按 product_id 排名为：1, 2, 3。

小规模的经典算法

如果上过极客时间的《数据结构与算法之美》，你可能一眼就看出来，这个问题的解法分为两步：

第一步，统计每个商品的销量。你可以用哈希表（hashtable）数据结构来解决，是一个 O(n) 的算法，这里 n 是 1000 亿。

第二步，找出销量前十，可以用经典的 Top K 算法，也是 O(n) 的算法。

如果你考虑到了这些，先恭喜你答对了。

在小规模系统中，我们确实完全可以用经典的算法简洁漂亮地解决。以 Python 编程的话可能是类似这样的：

def CountSales(sale_records):

  """Calculate number of sales for each product id.

  Args:

    sales_records: list of SaleRecord, SaleRecord is a named tuple,

      e.g. {product_id: “1”, timestamp: 1553721167}.

  Returns:

    dict of {product_id: num_of_sales}. E.g. {“1”: 1, “2”: 1}

  """

  sales_count = {}

  for record in sale_records:

    sales_count[record[product_id]] += 1

  return sales_count

def TopSellingItems(sale_records, k=10):

  """Calculate the best selling k products.

  Args:

    sales_records: list of SaleRecord, SaleRecord is a named tuple,

      e.g. {product_id: “1”, timestamp: 1553721167}.

    K: num of top products you want to output.

  Returns:

    List of k product_id, sorted by num of sales.

  """

  sales_count = CountSales(sale_records)

  return heapq.nlargest(k, sales_count, key=sales_count.get)

但在一切系统中，随着尺度的变大，很多方法就不再适用。

比如，在小尺度经典物理学中适用的牛顿力学公式是这样的：

这在高速强力的物理系统中就不再适用，在狭义相对论中有另外的表达。

在社会系统中也是一样，管理 10 人团队，和治理 14 亿人口的国家，复杂度也不可同日而语。

具体在我们这个问题中，同样的 Top K 算法当数据规模变大会遇到哪些问题呢？

第一，内存占用。

对于 TB 级的交易记录数据，很难找到单台计算机容纳那么大的哈希表了。你可能想到，那我不要用哈希表去统计商品销售量了，我把销量计数放在磁盘里完成好了。

比如，就用一个 1000 亿行的文件或者表，然后再把销量统计结果一行一行读进后面的堆树 / 优先级队列。理论上听起来不错，实际上是否真的可行呢，那我们看下一点。

第二，磁盘 I/O 等延时问题。

当数据规模变大，我们难以避免地需要把一些中间结果存进磁盘，以应对单步任务出错等问题。一次磁盘读取大概需要 10ms 的时间。

如果按照上一点提到的文件替代方法，因为我们是一个 O(n * log k) 的算法，就需要 10ms * 10^9 = 10 ^ 7 s = 115 天的时间。你可能需要贾跃亭附体，才能忽悠老板接受这样的设计方案了。

这些问题怎么解决呢？你可能已经想到，当单台机器已经无法适应我们数据或者问题的规模，我们需要横向扩展。

大规模分布式解决方案

之前的思路依然没错。但是，我们需要把每一步从简单的函数算法，升级为计算集群的分布式算法。

统计每个商品的销量

我们需要的第一个计算集群，就是统计商品销量的集群。

例如，1000 台机器，每台机器一次可以处理 1 万条销售记录。对于每台机器而言，它的单次处理又回归到了我们熟悉的传统算法，数据规模大大缩小。

下图就是一个例子，图中每台机器输入是 2 条销售记录，输出是对于他们的本地输入而言的产品销量计数。

找出销量前 K

我们需要的第二个计算集群，则是找出销量前十的集群。

这里我们不妨把问题抽象一下，抽象出是销量前 K 的产品。因为你的老板随时可能把产品需求改成前 20 销量，而不是前 10 了。

在上一个统计销量集群得到的数据输出，将会是我们这个处理流程的输入。所以这里需要把分布在各个机器分散的产品销量汇总出来。例如，把所有 product_id = 1 的销量全部叠加。

下图示例是 K = 1 的情况，每台机器先把所有 product_id = 1 的销量叠加在了一起，再找出自己机器上销量前 K = 1 的商品。可以看到对于每台机器而言，他们的输出就是最终排名前 K = 1 的商品候选者。

汇总最终结果

到了最后一步，你需要把在“销量前 K 集群”中的结果汇总出来。也就是说，从所有排名前 K=1 的商品候选者中找出真正的销量前 K=1 的商品。

这时候完全可以用单一机器解决了。因为实际上你汇总的就是这 1000 台机器的结果，规模足够小。

看到这里，你已经体会到处理超大规模数据的系统是很复杂的。

当你辛辛苦苦设计了应对 1 亿用户的数据处理系统时，可能你就要面临另一个维度的规模化（scaling）。那就是应用场景数量从 1 个变成 1000 个。每一次都为不同的应用场景单独设计分布式集群，招募新的工程师维护变得不再“可持续发展”。

这时，你需要一个数据处理的框架。

大规模数据处理框架的功能要求

在第二讲“MapReduce 后谁主沉浮：怎样设计现代大规模数据处理技术”中，我们对于数据处理框架已经有了基本的方案。

今天这个实际的例子其实为我们的设计增加了新的挑战。

很多人面对问题，第一个想法是找有没有开源技术可以用一下。

但我经常说服别人不要先去看什么开源技术可以用，而是从自己面对的问题出发独立思考，忘掉 MapReduce，忘掉 Apache Spark，忘掉 Apache Beam。

如果这个世界一无所有，你会设计怎样的大规模数据处理框架？你要经常做一些思维实验，试试带领一下技术的发展，而不是永远跟随别人的技术方向。

在我看来，两个最基本的需求是：

高度抽象的数据处理流程描述语言。作为小白用户，我肯定再也不想一一配置分布式系统的每台机器了。作为框架使用者，我希望框架是非常简单的，能够用几行代码把业务逻辑描述清楚。

根据描述的数据处理流程，自动化的任务分配优化。这个框架背后的引擎需要足够智能，简单地说，要把那些本来手动配置的系统，进行自动任务分配。

那么理想状况是什么？对于上面的应用场景，我作为用户只想写两行代码。

第一行代码：

sales_count = sale_records.Count()

这样简单的描述，在我们框架设计层面，就要能自动构建成上文描述的“销量统计计算集群”。

第二行代码

top_k_sales = sales_count.TopK(k)

这行代码需要自动构建成上文描述的“找出销量前 K 集群”。

看到这里，你能发现这并不复杂。我们到这里就已经基本上把现代大规模数据处理架构的顶层构造掌握了。而背后的具体实现，我会在后面的专栏章节中为你一一揭晓。

小结

这一讲中，我们粗浅地分析了一个电商排行榜的数据处理例子。

从 GB 数据到 TB 数据，我们从小规模算法升级到了分布式处理的设计方案；从单一 TB 数据场景到 1000 个应用场景，我们探索了大规模数据处理框架的设计。

这些都是为了帮助你更好地理解后面所要讲的所有知识。比如，为什么传统算法不再奏效？为什么要去借助抽象的数据处理描述语言？希望在后面的学习过程中，你能一直带着这些问题出发。

思考题

在你的工作中，有没有随着数据规模变大，系统出问题的情况，你又是怎么解决的？

欢迎你把自己的想法写在留言区，与我和其他同学一起讨论。

如果你觉得有所收获，也欢迎把文章分享给你的朋友。

04 | 分布式系统（上）：学会用服务等级协议SLA来评估你的系统

你好，我是蔡元楠。

从今天开始，我们进入专栏的第二模块。通过这一模块的学习，带你一起夯实大规模数据处理的基础。

首先，我将结合硅谷顶尖科技公司的最佳实践 (Best Practice) ，和你一起分享在设计分布式系统架构时，我们有可能会碰到哪些雷区？又有哪些必备的基础知识？

在硅谷一线大厂所维护的系统服务中，我们经常可以看见 SLA 这样的承诺。

例如，在谷歌的云计算服务平台 Google Cloud Platform 中，他们会写着“99.9% Availability”这样的承诺。那什么是“99.9% Availability”呢？

要理解这个承诺是什么意思，首先，你需要了解到底什么是 SLA？

SLA（Service-Level Agreement），也就是服务等级协议，指的是系统服务提供者（Provider）对客户（Customer）的一个服务承诺。这是衡量一个大型分布式系统是否“健康”的常见方法。

在开发设计系统服务的时候，无论面对的客户是公司外部的个人、商业用户，还是公司内的不同业务部门，我们都应该对自己所设计的系统服务有一个定义好的 SLA。

因为 SLA 是一种服务承诺，所以指标可以多种多样。根据我的实践经验，给你介绍最常见的四个 SLA 指标，可用性、准确性、系统容量和延迟。

1. 可用性（Availabilty）

可用性指的是系统服务能正常运行所占的时间百分比。

如果我们搭建了一个拥有“100％可用性”的系统服务，那就意味着这个系统在任何时候都能正常运行。是不是很完美？但真要实现这样的目标其实非常困难，并且成本也会很高。

我们知道，即便是大名鼎鼎的亚马逊 AWS 云计算服务这样大型的、对用户来说极为关键的系统，也不能承诺 100％的可用性，它的系统服务从推出到现在，也有过服务中断（Service Outage）的时候。

对于许多系统而言，四个 9 的可用性（99.99％ Availability，或每年约 50 分钟的系统中断时间）即可以被认为是高可用性（High availability）。

说到这里，我来为你揭开一开始所提到的“99.9% Availability”的真实含义。

“99.9% Availability”指的是一天当中系统服务将会有大约 86 秒的服务间断期。服务间断也许是因为系统维护，也有可能是因为系统在更新升级系统服务。

86 秒这个数字是怎么算出来的呢？

99.9% 意味着有 0.1% 的可能性系统服务会被中断，而一天中有 24 小时 × 60 分钟 × 60 秒，也就是有 (24 × 60 × 60 × 0.001) = 86.4 秒的可能系统服务被中断了。而上面所说的四个 9 的高可用性服务就是承诺可以将一天当中的服务中断时间缩短到只有 (24 × 60 × 60 × 0.0001) = 8.64 秒。

2. 准确性（Accuracy）

准确性指的是我们所设计的系统服务中，是否允许某些数据是不准确的或者是丢失了的。如果允许这样的情况发生，用户可以接受的概率（百分比）是多少？

这该怎么衡量呢？不同的系统平台可能会用不同的指标去定义准确性。很多时候，系统架构会以错误率（Error Rate）来定义这一项 SLA。

怎么计算错误率呢？可以用导致系统产生内部错误（Internal Error）的有效请求数，除以这期间的有效请求总数。

例如，我们在一分钟内发送 100 个有效请求到系统中，其中有 5 个请求导致系统返回内部错误，那我们可以说这一分钟系统的错误率是 5 / 100 = 5%。

下面，我想带你看看硅谷一线公司所搭建的架构平台的准确性 SLA。

Google Cloud Platform 的 SLA 中，有着这样的准确性定义：每个月系统的错误率超过 5% 的时间要少于 0.1%，以每分钟为单位来计算。

而亚马逊 AWS 云计算平台有着稍微不一样的准确性定义：以每 5 分钟为单位，错误率不会超过 0.1%。

你看，我们可以用错误率来定义准确性，但具体该如何评估系统的准确性呢？一般来说，我们可以采用性能测试（Performance Test）或者是查看系统日志（Log）两种方法来评估。

具体的做法我会在后面展开讲解，今天你先理解这项指标就可以了。

3. 系统容量（Capacity）

在数据处理中，系统容量通常指的是系统能够支持的预期负载量是多少，一般会以每秒的请求数为单位来表示。

我们常常可以看见，某个系统的架构可以处理的 QPS （Queries Per Second）是多少又或者 RPS（Requests Per Second）是多少。这里的 QPS 或者是 RPS 就是指系统每秒可以响应多少请求数。

我们来看看之前 Twitter 发布的一项数据，Twitter 系统可以响应 30 万的 QPS 来读取 Twitter Timelines。这里 Twitter 系统给出的就是他们对于系统容量（Capacity）的 SLA。

你可能会问，我要怎么给自己设计的系统架构定义出准确的 QPS 呢？以我的经验看，可以有下面这几种方式。

第一种，是使用限流（Throttling）的方式。

如果你是使用 Java 语言进行编程的，就可以使用 Google Guava 库中的 RateLimiter 类来定义每秒最多发送多少请求到后台处理。

假设我们在每台服务器都定义了一个每秒最多处理 1000 个请求的 RateLimiter，而我们有 N 台服务器，在最理想的情况下，我们的 QPS 可以达到 1000 * N。

这里要注意的雷区是，这个请求数并不是设置得越多越好。因为每台服务器的内存有限，过多的请求堆积在服务器中有可能会导致内存溢出（Out-Of-Memory）的异常发生，也就是所有请求所需要占用的内存超过了服务器能提供的内存，从而让整个服务器崩溃。

第二种，是在系统交付前进行性能测试（Performance Test）。

我们可以使用像 Apache JMeter 又或是 LoadRunner 这类型的工具对系统进行性能测试。这类工具可以测试出系统在峰值状态下可以应对的 QPS 是多少。

当然了，这里也是有雷区的。

有的开发者可能使用同一类型的请求参数，导致后台服务器在多数情况下命中缓存（Cache Hit）。这个时候得到的 QPS 可能并不是真实的 QPS。

打个比方，服务器处理请求的正常流程需要查询后台数据库，得到数据库结果后再返回给用户，这个过程平均需要 1 秒。在第一次拿到数据库结果后，这个数据就会被保存在缓存中，而如果后续的请求都使用同一类型的参数，导致结果不需要从数据库得到，而是直接从缓存中得到，这个过程我们假设只需要 0.1 秒。那这样，我们所计算出来的 QPS 就会比正常的高出 10 倍。所以在生成请求的时候，要格外注意这一点。

第三种，是分析系统在实际使用时产生的日志（Log）。

系统上线使用后，我们可以得到日志文件。一般的日志文件会记录每个时刻产生的请求。我们可以通过系统每天在最繁忙时刻所接收到的请求数，来计算出系统可以承载的 QPS。

不过，这种方法不一定可以得到系统可以承载的最大 QPS。

在这里打个比喻，一家可以容纳上百桌客人的餐馆刚开业，因为客流量还比较小，在每天最繁忙的时候只接待了 10 桌客人。那我们可以说这家餐馆最多只能接待 10 桌客人吗？不可以。

同样的，以分析系统日志的方法计算出来的 QPS 并不一定是服务器能够承载的最大 QPS。想要得到系统能承受的最大 QPS，更多的是性能测试和日志分析相结合的手段。

4. 延迟（Latency）

延迟指的是系统在收到用户的请求到响应这个请求之间的时间间隔。

在定义延迟的 SLA 时，我们常常看到系统的 SLA 会有 p95 或者是 p99 这样的延迟声明。这里的 p 指的是 percentile，也就是百分位的意思。如果说一个系统的 p95 延迟是 1 秒的话，那就表示在 100 个请求里面有 95 个请求的响应时间会少于 1 秒，而剩下的 5 个请求响应时间会大于 1 秒。

下面我们用一个具体的例子来说明延迟这项指标在 SLA 中的重要性。

假设，我们已经设计好了一个社交软件的系统架构。这个社交软件在接收到用户的请求之后，需要读取数据库中的内容返回给用户。

为了降低系统的延迟，我们会将数据库中内容放进缓存（Cache）中，以此来减少数据库的读取时间。在系统运行了一段时间后，我们得到了一些缓存命中率（Cache Hit Ratio）的信息。有 90% 的请求命中了缓存，而剩下的 10% 的请求则需要重新从数据库中读取内容。

这时服务器所给我们的 p95 或者 p99 延迟恰恰就衡量了系统的最长时间，也就是从数据库中读取内容的时间。作为一个优秀架构师，你可以通过改进缓存策略从而提高缓存命中率，也可以通过优化数据库的 Schema 或者索引（Index）来降低 p95 或 p99 延迟。

总而言之，当 p95 或者 p99 过高时，总会有 5% 或者 1% 的用户抱怨产品的用户体验太差，这都是我们要通过优化系统来避免的。

小结

通过今天的内容，你可以发现，定义好一个系统架构的 SLA 对于一个优秀的架构师来说是必不可少的一项技能，也是一种基本素养。

特别是当系统架构在不停迭代的时候，有了一个明确的 SLA，我们可以知道下一代系统架构的改进目标以及优化好的系统架构是否比上一代的系统 SLA 更加优秀。

我们通常会使用可用性、准确性、系统容量、延迟这四个指标来定义系统架构的 SLA。

思考题

你可以思考一下，在自己所在的开发项目中，系统的 SLA 是什么呢？又有什么方面可以优化的呢？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

05 | 分布式系统（下）：架构师不得不知的三大指标

你好，我是蔡元楠。

上一讲中，我们学习了如何用服务等级协议（SLA）来评估我们设计的分布式系统，并了解了几个常见的 SLA 指标。

今天我们继续来探索分布式系统的另外几个重要基础概念。

可扩展性

还是从我们为什么需要分布式系统讲起。原因是我们要面对的数据量越来越大，从 GB 到 TB 再到现在的 PB 级，单机无法胜任这样的工作。

工作中也常有这样的场景，随着业务变得越来越复杂，之前设计的系统无法处理日渐增长的负载。这时，我们就需要增加系统的容量。

分布式系统的核心就是可扩展性（Scalability）。

最基本而且最流行的增加系统容量的模型有两种: 水平扩展（Horizontal Scaling）和垂直扩展（Vertical Scaling）。

所谓水平扩展，就是指在现有的系统中增加新的机器节点。

垂直扩展就是在不改变系统中机器数量的情况下，“升级”现有机器的性能，比如增加机器的内存。

举个例子，假设你现在负责一批木材采伐的操作。你有 3 辆卡车，每辆车一次可以运 25 根木材。那么 1 小时最多可以运 3 辆卡车 * 25 根木材 * 1 小时 =75 根木材／小时。

如果要使这个系统的负荷量增加一倍，用水平扩展的办法，我们可以将卡车的数量增加到 6 辆；用垂直扩展的办法，我们可以使每辆卡车的运输量增加一倍，或者使每辆卡车的速度增加一倍。

你是不是已经发现了，水平扩展的适用范围更广，操作起来更简单，并且会提升系统的可用性（Availability）。

如果你的系统部署在 AWS 或者其他主流的云服务上，你只需要点几个按钮，就可以在现有的机器集群中增加一个新的节点。

但是，无节制地增加机器数量也会带来一些问题，比如机器的管理、调度、通信会变得更加复杂，出错的可能性会更高，更难保证数据的一致性等等。

与之相反，垂直扩展并没有让整个系统变得更加复杂，控制系统的代码也不需要做任何调整，但是它受到的限制比较多。多数情况下，单个机器的性能提升是有限的。而且受制于摩尔定律，提高机器的性能往往比购买新的机器更加昂贵。

所以在工作中，我们要对这两种模式进行取舍，要具体情况具体分析。

同样地，在大数据的时代，数据增长速度越来越快，数据规模越来越大，对数据存储系统的扩展性要求也越来越高。

传统的关系型数据库因为表与表之间的数据有关联，经常要进行 Join 操作，所有数据要存放在单机系统中，很难支持水平扩展。而 NoSQL 型的数据库天生支持水平扩展，所以这类存储系统的应用越来越广，如 BigTable、MongoDB 和 Redis 等。

一致性

可用性对于任何分布式系统都很重要。一般来说，构成分布式系统的机器节点的可用性要低于系统的可用性。

举个例子，如果我们想要构建一个可用性 99.999% 的分布式系统（每年约 5 分钟的宕机时间），但是我们使用的单台机器节点的可用性是 99.9%（每年约 8 个小时的宕机时间）。那么想要达到我们的目标，最简单的办法就是增加系统中机器节点的数量。这样即使有部分机器宕机了，其他的机器还在持续工作，所以整个系统的可用性就提高了。

这种情况下，我们要思考一个问题：如何保证系统中不同的机器节点在同一时间，接收到和输出的数据是一致的呢？这时就要引入一致性（Consistency）的概念。

回到之前的例子，要保证分布式系统内的机器节点有相同的信息，就需要机器之间定期同步。

然而，发送信息也会有失败的可能，比如信息丢失或者有的节点正好宕机而无法接收。因此，一致性在高可用性的系统里是非常核心的概念。

接下来，我会给你介绍几个在工程中常用的一致性模型，分别是：强一致性（Strong Consistency），弱一致性（Weak Consistency），最终一致性（Eventual Consistency）。

强一致性：系统中的某个数据被成功更新后，后续任何对该数据的读取操作都将得到更新后的值。所以在任意时刻，同一系统所有节点中的数据是一样的。

弱一致性：系统中的某个数据被更新后，后续对该数据的读取操作可能得到更新后的值，也可能是更改前的值。但经过“不一致时间窗口”这段时间后，后续对该数据的读取都是更新后的值。

最终一致性：是弱一致性的特殊形式。存储系统保证，在没有新的更新的条件下，最终所有的访问都是最后更新的值。

上面这三点我描述得比较正式，但其实都不难理解。这里，我进一步给你做个说明。

在强一致性系统中，只要某个数据的值有更新，这个数据的副本都要进行同步，以保证这个更新被传播到所有备份数据库中。在这个同步进程结束之后，才允许服务器来读取这个数据。

所以，强一致性一般会牺牲一部分延迟性，而且对于全局时钟的要求很高。举个例子，Google Cloud 的 Cloud Spanner 就是一款具备强一致性的全球分布式企业级数据库服务。

在最终一致性系统中，我们无需等到数据更新被所有节点同步就可以读取。尽管不同的进程读同一数据可能会读到不同的结果，但是最终所有的更新会被按时间顺序同步到所有节点。所以，最终一致性系统支持异步读取，它的延迟比较小。比如亚马逊云服务的 DynamoDB 就支持最终一致的数据读取。

除了以上三个，分布式系统理论中还有很多别的一致性模型，如顺序一致性（Sequential Consistency），因果一致性（Casual Consistency）等，如果你感兴趣的话，可以自己查资料了解一下。

在实际应用系统中，强一致性是很难实现的，应用最广的是最终一致性。我们一起来看两个例子。

很多人认为银行间转账应该是强一致的。但是你仔细分析一下就会发现，事实并非如此。

举个例子，小王给小张转账 1000 元，小王的账户扣除了 1000，此时小张并不一定立刻就收到 1000 元。这里可能会存在一个不一致的时间窗口：小王的钱扣除了 1000 元，小张还没收到 1000 元的时候。

另外一个例子，在 12306 网站买票的功能，也不是强一致的。

如果你在 12306 上发现一趟列车还剩余 10 张车票，你发起请求订了一张票，系统给你返回的可能是“正在排队，剩余 10 张票，现在有 15 人在购买”。

这时，你可能就需要去查询未完成订单，因为系统并没有给你及时返回订票成功或失败的结果。如果有人退了一张票，这张票也不会立即返回到票池中。这里明显也存在不一致的时间窗口。

但是，最终 10 张票只会卖给 10 个人，不可能卖给 11 个人，这就是最终一致性所谓的“最终所有数据都会同步”。

讲到这里，你对分布式系统的扩展性和一致性就很清楚了吧？接下来再给你介绍一个重要概念。

持久性

数据持久性（Data Durability）意味着数据一旦被成功存储就可以一直继续使用，即使系统中的节点下线、宕机或数据损坏也是如此。

不同的分布式数据库拥有不同级别的持久性。有些系统支持机器 / 节点级别的持久性，有些做到了集群级别，而有些系统压根没有持久性。

想要提高持久性，数据复制是较为通用的做法。因为把同一份数据存储在不同的节点上，即使有节点无法连接，数据仍然可以被访问。

在分布式数据处理系统中，还有一个持久性概念是消息持久性。什么意思呢？在分布式系统中，节点之间需要经常相互发送消息去同步以保证一致性。对于重要的系统而言，常常不允许任何消息的丢失。

分布式系统中的消息通讯通常由分布式消息服务完成，比如 RabbitMQ、Kafka。这些消息服务能支持（或配置后支持）不同级别的消息送达可靠性。消息持久性包含两个方面：

当消息服务的节点发生了错误，已经发送的消息仍然会在错误解决之后被处理；

如果一个消息队列声明了持久性，那么即使队列在消息发送之后掉线，仍然会在重新上线之后收到这条消息。

小结

在这一讲中，我们探讨了分布式处理系统的三个重要指标：扩展性，一致性和持久性。

结合前边提到的延迟性、可用性以及准确性，我们不难发现，这些设计分布式系统所要考虑的量化指标存在一定程度上的冲突。不可能有一个分布式处理系统在不牺牲某一指标的前提下，让每一个指标都达到最好。

作为优秀的系统架构师，我们一定要学会具体情况具体分析，找到最适合自己系统的指标，适当做出取舍。但是这一点说起来容易做起来难，到底该怎么取舍呢？你可以先思考一下这个问题，下一讲中我会结合 CAP 定理和你进一步讨论。

思考题

对于微信朋友圈的评论功能，你觉得哪种一致性模型更适用？为什么？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

06 | 如何区分批处理还是流处理？

你好，我是蔡元楠。

今天，我将会带领你一起学习在进行大规模数据处理时，无论如何也绕不开的两个处理模式：批处理（Batching Processing）和流处理（Streaming Processing）。

在我看来，大规模的视频流系统、大规模物联网（IoT）数据监控系统等各种现代大规模数据系统的出现，已经成为了一种必然的历史潮流。

无论你是在从事哪一种开发方向，都不可避免地要与这些海量数据打交道。如何能既满足实际应用场景的需求，又高效地处理好大规模数据，在整个项目开发架构中都是非常重要的一个环节。

在开始讲解批处理和流处理之前，我想先介绍一下几个必要的背景知识。

无边界数据和有边界数据

这个世界上的数据可以抽象成为两种，分别是无边界数据（Unbounded Data）和有边界数据（Bounded Data）。

顾名思义，无边界数据是一种不断增长，可以说是无限的数据集。

这种类型的数据，我们无法判定它们到底什么时候会停止发送。

例如，从手机或者从传感器发送出来的信号数据，又比如我们所熟知的移动支付领域中的交易数据。因为每时每刻都会有交易产生，所以我们不能判定在某一刻这类数据就会停止发送了。

在国外的一些技术文章上，有时候我们会看到“流数据（Streaming Data）”这一说法，其实它和无边界数据表达的是同一个概念。

与此相反，有边界数据是一种有限的数据集。

这种数据更常见于已经保存好了的数据中。例如，数据库中的数据，或者是我们常见的 CSV 格式文件中的数据。

当然了，你可能会问，那我们把无边界数据按照时间窗口提取一小份出来，那这样的数据是什么数据呢？

拿我们之前提到过的移动支付中的交易数据来说吧。移动支付中的交易数据可以看作是无边界数据。那我们按 2019 年 4 月 29 日这个时间窗口提取出来的数据呢？这个当日的交易数据就变成了有边界数据了。

所以，有边界数据其实可以看作是无边界数据的一个子集。

事件时间和处理时间

在处理大规模数据的时候，我们通常还会关心时域（Time Domain）的问题。

我们要处理的任意数据都会有两种时域，分别是事件时间（Event Time）和处理时间（Precessing Time）。

事件时间指的是一个数据实际产生的时间点，而处理时间指的是处理数据的系统架构实际接收到这个数据的时间点。

下面我来用一个实际的例子进一步说明这两个时间概念。

现在假设，你正在去往地下停车场的路上，并且打算用手机点一份外卖。选好了外卖后，你就用在线支付功能付款了，这个时候是 12 点 05 分。恰好这时，你走进了地下停车库，而这里并没有手机信号。因此外卖的在线支付并没有立刻成功，而支付系统一直在重试（Retry）“支付”这个操作。

当你找到自己的车并且开出地下停车场的时候，已经是 12 点 15 分了。这个时候手机重新有了信号，手机上的支付数据成功发到了外卖在线支付系统，支付完成。

在上面这个场景中你可以看到，支付数据的事件时间是 12 点 05 分，而支付数据的处理时间是 12 点 15 分。事件时间和处理时间的概念，你明白了吗？

在了解完上面的 4 个基本概念后，我将开始为你揭开批处理和流处理模式的面纱。

批处理

数据的批处理，可以理解为一系列相关联的任务按顺序（或并行）一个接一个地执行。批处理的输入是在一段时间内已经收集保存好的数据。每次批处理所产生的输出也可以作为下一次批处理的输入。

绝大部分情况下，批处理的输入数据都是有边界数据，同样的，输出结果也一样是有边界数据。所以在批处理中，我们所关心的更多会是数据的事件时间。

举个例子，你在每年年初所看到的“支付宝年账单”就是一个数据批处理的典型例子。

支付宝会将我们在过去一年中的消费数据存储起来，并作为批处理输入，提取出过去一年中产生交易的事件时间，然后经过一系列业务逻辑处理，得到各种有趣的信息作为输出。

在许多情况下，批处理任务会被安排，并以预先定义好的时间间隔来运行，例如一天，一个月或者是一年这样的特定时间。

在银行系统中，银行信用卡消费账单和最低还款额度也都是由批处理系统以预先定义好的一个月的时间间隔运行，所产生出来的。

批处理架构通常会被设计在以下这些应用场景中：

日志分析：日志系统是在一定时间段（日，周或年）内收集的，而日志的数据处理分析是在不同的时间内执行，以得出有关系统的一些关键性能指标。

计费应用程序：计费应用程序会计算出一段时间内一项服务的使用程度，并生成计费信息，例如银行在每个月末生成的信用卡还款单。

数据仓库：数据仓库的主要目标是根据收集好的数据事件时间，将数据信息合并为静态快照 （static snapshot），并将它们聚合为每周、每月、每季度的报告等。

由 Google MapReduce 衍生出来的开源项目 Apache Hadoop 或者是 Apache Spark 等开源架构都是支持这种大数据批处理架构的。

由于完成批处理任务具有高延迟性，一般可以需要花费几小时，几天甚至是几周的时间。要是在开发业务中有快速响应用户的时间需求，我们则需要考虑使用流处理 / 实时处理来处理大数据。

流处理

数据的流处理可以理解为系统需要接收并处理一系列连续不断变化的数据。例如，旅行预订系统，处理社交媒体更新信息的有关系统等等。

流处理的输入数据基本上都是无边界数据。而流处理系统中是关心数据的事件时间还是处理时间，将视具体的应用场景而定。

例如，像网页监控系统这样的流处理系统要计算网站的 QPS，它所关心的更多是处理时间，也就是网页请求数据被监控系统接收到的时间，从而计算 QPS。

而在一些医疗护理监控系统的流处理系统中，他们则更关心数据的事件时间，这种系统不会因为接收到的数据有网络延时，而忽略数据本来产生的时间。

流处理的特点应该是要足够快、低延时，以便能够处理来自各种数据源的大规模数据。流处理所需的响应时间更应该以毫秒（或微秒）来进行计算。像我们平时用到的搜索引擎，系统必须在用户输入关键字后以毫秒级的延时返回搜索结果给用户。

流处理速度如此之快的根本原因是因为它在数据到达磁盘之前就对其进行了分析。

当流处理架构拥有在一定时间间隔（毫秒）内产生逻辑上正确的结果时，这种架构可以被定义为实时处理（Real-time Processing）。

而如果一个系统架构可以接受以分钟为单位的数据处理时间延时，我们也可以把它定义为准实时处理（Near real-time Processing）。

还记得我们在介绍批处理架构中所说到的不足吗？没错，是高延迟。而流处理架构则恰恰拥有高吞度量和低延迟等特点。

流处理架构通常都会被设计在以下这些应用场景中：

实时监控：捕获和分析各种来源发布的数据，如传感器，新闻源，点击网页等。

实时商业智能：智能汽车，智能家居，智能病人护理等。

销售终端（POS）系统：像是股票价格的更新，允许用户实时完成付款的系统等。

在如今的开源架构生态圈中，如 Apache Kafka、Apache Flink、Apache Storm、Apache Samza 等，都是流行的流处理架构平台。

在介绍完这两种处理模式后，你会发现，无论是批处理模式还是流处理模式，在现实生活中都有着很广泛的应用。你应该根据自己所面临的实际场景来决定到底采用哪种数据处理模式。

小结

批处理模式在不需要实时分析结果的情况下是一种很好的选择。尤其当业务逻辑需要处理大量的数据以挖掘更为深层次数据信息的时候。

而在应用需求需要对数据进行实时分析处理时，或者说当有些数据是永无止境的事件流时（例如传感器发送回来的数据时），我们就可以选择用流处理模式。

思考题

相信在学习完这一讲后，你会对批处理模式和流处理模式有着清晰的认识。今天的思考题是，在你的日常开发中，所面临的数据处理模式又是哪一种模式呢？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

07 | Workflow设计模式：让你在大规模数据世界中君临天下

你好，我是蔡元楠。

今天我要与你分享的主题是“Workflow 设计模式”。

在上一讲中，我们一起学习了大规模数据处理的两种处理模式——批处理和流处理。

利用好这两种处理模式，作为架构师的你就可以运筹帷幄，根据实际需求搭建出一套符合自己应用的数据处理系统。

然而，光是掌握了这两种数据处理模式就足够自如应对大规模数据世界中的需求挑战吗？从我的实战经验中看来，其实未必。

我们每个人在最开始学习大规模数据处理的时候，可能都是以 WordCount 作为教学例子来进行学习的。

WordCount 这个例子，只需要一个单词集合作为输入，数据处理的结果是统计单词出现的次数，中间只需要经过一次数据处理的转换，就如同下图所示。

但在现实的应用场景种中，各式各样的应用需求决定了大规模数据处理中的应用场景会比 WordCount 复杂很多倍。

我还是以我在第一讲中所提到过的例子来说明吧。

在根据活跃在街头的美团外卖电动车的数量来预测美团的股价这个例子中，我们的输入数据集有可能不止一个。

例如，会有自己团队在街道上拍摄到的美团外卖电动车图片，会有第三方公司提供的美团外卖电动车数据集等等。

整个数据处理流程又会需要至少 10 个处理模块，每一个处理模块的输出结果都将会成为下一个处理模块的输入数据，就如同下图所示。

像上面的图示一样，我们将这种由多个不同的处理模块连接在一​​起，最后得出一个自己需要结果的有向无环图（Directed Acyclic Graph/DAG），称为一个工作流系统（Workflow System）。

在工作流系统的每个处理模块里，系统要执行的操作有可能不是单单一个数据转换的操作这么简单。像在上面例子的 Ingestion 这个模块中，我们需要将多个不同的数据集合并在一起，也需要将不合格的一些图片过滤掉。

如果你用过 Apache Spark 1.4 以上的版本的话，Spark 平台里面的 Execution DAG 就可以为你展示一个完整的工作流图。

今天，我为你解释四种工作流系统的设计模式，希望你能够很好地理解它们，并运用在自己的数据处理系统设计中。在遇到各种复杂的应用场景的时候能够从容面对。

复制模式（Copier Pattern）

复制模式通常是将单个数据处理模块中的数据，完整地复制到两个或更多的数据处理模块中，然后再由不同的数据处理模块进行处理。工作流系统图通常如下图所示。

当我们在处理大规模数据时，需要对同一个数据集采取多种不同的数据处理转换，我们就可以优先考虑采用复制模式。

我来举个在 YouTube 视频平台中，系统处理视频数据集的一个例子吧。

我们都知道，视频平台很多时候都会提供不同分辨率的视频。4K 或 1080P 的视频可以提供给网络带宽很高的用户。而在网络很慢的情况下，视频平台系统会自动转换成低分辨率格式的视频，像 360P 这样的视频给用户。

而在 YouTube 视频平台中，如果你将鼠标放在视频缩略图上，它会自动播放一段已经生成好的动画缩略图（Animated GIF Thumbnail ）。

不仅如此，在平台的背后，一个视频的数据集可能被自然语言理解（NLP）的数据处理模块分析，用以自动生成视频字幕；还有可能被视频分析的数据处理模块分析，用以产生更好的内容推荐系统。那么，它的整个工作流系统就会如下图所示一样。

我们可以看到，在这个工作流系统中，每个数据处理模块的输入是相同的，而下面的 5 个数据处理模块都可以单独并且同步地运行处理。

过滤模式（Filter Pattern）

过滤模式的作用是过滤掉不符合特定条件的数据。

在数据集通过了这个数据处理模块后，数据集会缩减到只剩下符合条件的数据。工作流系统图通常如下图所示。

当我们在处理大规模数据时，需要针对一个数据集中某些特定的数据采取数据处理时，我们就可以优先考虑采用过滤模式。

我举个商城会员系统的例子来解释吧。

在商城会员系统中，系统通常会根据用户的消费次数、用户消费金额还有用户的注册时间，将用户划分成不同的等级。

假设现在商城有五星会员（Five-stars Membership）、金牌会员（Golden Membership）和钻石会员（Diamond Membership）。

而系统现在打算通过邮件，只针对身份是钻石会员的用户发出钻石会员活动邀请。这个时候，我们就可以通过过滤模式，将钻石会员的用户从所有用户中筛选出来，如下图所示。

在这个工作流系统中，一个数据处理模块会将输入的数据集过滤成符合条件的数据，然后传输到下一个数据处理模块进行单独处理。

分离模式（Splitter Pattern）

如果你在处理数据集时并不想丢弃里面的任何数据，而是想把数据分类为不同的类别来进行处理时，你就需要用到分离模式来处理数据。它的工作流系统图通常如下图所示。

需要注意的是，分离模式并不会过滤任何数据，只是将原来的数据集分组了。

还是以刚刚商城会员系统为例。假设现在商城有五星会员、金牌会员和钻石会员。

系统现在打算通过邮件，针对全部的会员用户发出与他们身份相符的不同活动的邀请。

这个时候，我们就可以通过分离模式将用户按照会员等级分组，然后发送相应的活动内容，如下图所示。

需要注意的是，在分离模式下，同样的数据其实是可以被划分到不同的数据处理模块的。

数据 B 是可以同时划分到工作流 1 和工作流 2 中。其实这种情况挺常见的，我可以给你举个例子来解释。

在银行系统上，用户可以通过勾选以短信通知或者以邮件通知的方式来提醒用户一笔交易成功。如果用户同时勾选了短信和邮件两种方式，那么属于这个用户的交易信息既会通过短信通知的数据处理模块来处理，也会通过邮件通知数据处理模块来处理。

合并模式（Joiner Pattern）

合并模式会将多个不同的数据集转换集中到一起，成为一个总数据集，然后将这个总的数据集放在一个工作流中进行处理。

还是以根据活跃在街头的美团外卖电动车的数量来预测美团的股价这个例子来说吧。

数据接入这一处理模块里，我们的输入数据有自己团队在街道上拍摄到的美团外卖电动车图片和第三方公司提供的美团外卖电动车图片。

如果我们打算先整合所有数据，然后进行其它数据处理的话，工作流系统图通常如下图所示。

小结

今天我们一起学习了在大规模数据处理中用到的四种设计模式，分别是复制模式、过滤模式、分离模式和合并模式。

在设计大规模数据处理系统的时候，我们都希望能事先设计好一个工作流系统图出来作为参考。

有了这样一个大规模数据处理的整体蓝图之后，对于我们理解不同的处理模块是如何相互关联或者对未来优化系统设计是有很大帮助的。

思考题

在一个航空预定系统中，我们需要处理用户注册、购买机票和出行前 24 小时的提醒等功能。在这整个过程中，系统的数据处理运用了哪几个设计模式呢？

欢迎你把文章分享给你的朋友。

08 | 发布/订阅模式：流处理架构中的瑞士军刀

你好，我是蔡元楠。

今天我想要与你分享的是在处理大规模数据中十分流行的一种设计模式：发布 / 订阅模式（Publish/Subscribe Pattern），有些地方也称它为 Pub/Sub。

在了解发布 / 订阅模式之前，我想先简单介绍几个基础概念——消息（Message）和消息队列（Message Queue）。

消息

消息是什么呢？

在分布式架构里，架构中的各个组件（Component）需要相互联系沟通。组件可以是后台的数据库，可以是前端的浏览器，也可以是公司内部不同的服务终端（Service Endpoint）。

而各个组件间就是依靠通过发送消息互相通讯的。如下图所示。

消息可以是任意格式的。例如，我们可以利用 JSON 格式来传输一个消息，也能利用 XML 格式来传输一个消息，甚至可以使用一种自己定义的格式。

消息队列

知道了消息的含义后，你知道消息队列有什么作用吗？

消息队列在发布 / 订阅模式中起的是一个持久化缓冲（Durable Buffer）的作用。

消息的发送方可以发送任意消息至这个消息队列中，消息队列在接收到消息之后会将消息保存好，直到消息的接收方确认已经从这个队列拿到了这个消息，才会将这条消息从消息队列中删除。

有的消息系统平台如 Apache Kafka，它能够让用户自己定义消息队列对消息的保留时间，我将会在介绍 Apache Kafka 的时候讲到。

有了消息队列后，整个发送消息的流程就变成下图所示。

发布 / 订阅模式

在了解了消息和消息队列后，现在我想和你正式地向你介绍发布 / 订阅模式的概念。

发布 / 订阅模式指的是消息的发送方可以将消息异步地发送给一个系统中不同组件，而无需知道接收方是谁。在发布 / 订阅模式中，发送方被称为发布者（Publisher），接收方则被称作订阅者（Subscriber）。

发布者将消息发送到消息队列中，订阅者可以从消息队列里取出自己感兴趣的消息。

在发布 / 订阅模式里，可以有任意多个发布者发送消息，也可以有任意多个订阅者接收消息，如下图所示。

读到这里，你可能会有一个疑问，从概念上看，发布 / 订阅模式只是简单地在消息发送方和消息接收方中间多加了一个消息队列，为什么这种设计架构在实际应用会如此流行呢？我来给你举个例子说明一下。

现在假设，你是一个移动支付 App 公司里支付团队的开发人员，App 里所有的支付操作都是由你的团队来开发的。而公司最近新成立了一个欺诈预防团队，他们希望从你的支付团队里获取交易数据。

也就是说，每次有交易发生的时候，你都需要通知他们交易的金额、地点、时间这些数据，让他们能够实时分析这次的交易是否存在欺诈行为。

按照传统的做法，两个团队需要开会交流，以确定数据消息传输的 API 和传输数据的格式。确定后，两个团队系统的交流方式会如下图所示。

欺诈预防团队将自己需要的数据格式定义在 API 中告诉支付团队，每次有交易产生的时候，支付系统就会通过调用欺诈预防系统 API 的方式通知他们。

一段时间过后，公司希望和商家一起合作推动一项优惠活动，不同的商家会有不同的优惠。公司希望能够精准投放优惠活动的广告给感兴趣的用户，所以又成立了一个新部门，我们叫它广告推荐组吧。

广告推荐组的同事也希望从你的支付团队里获取交易数据。这个时候，你有两种选择，一种是选择第六讲中说到的批处理方式，另一种就是今天讲的发布 / 订阅模式。

批处理方式会从数据库中一次性读取全部用户的交易数据来进行推荐分析。

这种做法有几个不好的地方。

它需要你开放自己数据库的权限给广告推荐组，推荐组每次大量读取数据时，可能也会造成你自己的数据库性能下降。同时，还要考虑广告推荐组也想维护一份自己的数据库的需求。

如果还是按照之前欺诈预防团队的做法，让广告推荐组分享 API 给你，每次有交易产生的时候再通知他们的话，系统的运行模式会如文中图片所示。

看到这里你应该明白了。每一次有一个新的系统想从支付团队里读取数据的话，都要双方开会讨论，定义一个新的 API，然后修改支付团队现有的系统，将 API 加入系统中。

而且这些 API 通常都是同步调用的，过多的 API 调用会让系统的延迟越来越大。这样的设计模式被称作观察者模式（Observer Pattern），系统中的各个组件紧耦合（Tightly Coupled）在一起。

如果是采用发布 / 订阅模式来重新设计呢？整个系统就如下图所示：

采用这样的数据处理模式，作为消息发布者的支付团队无需过多考虑以后有多少其它的团队需要读取交易数据，只需要设计好自己提供的数据内容与格式，在每次交易发生时发送消息进消息队列中就可以了。任何对这些数据感兴趣的团队只需要从消息队列中自行读取便可。

发布 / 订阅模式的优缺点

说到这里，我们可以看到发布 / 订阅模式会有以下几个优点：

松耦合（Loose Coupling）：消息的发布者和消息的订阅者在开发的时候完全不需要事先知道对方的存在，可以独立地进行开发。

高伸缩性（High Scalability）：发布 / 订阅模式中的消息队列可以独立的作为一个数据存储中心存在。在分布式环境中，更是消息队列更是可以扩展至上千个服务器中。我们从 Linkedin 公司的技术博客中可以得知，光在 2016 年，Linkedin 公司就维护开发了将近 1400 个消息队列。

系统组件间通信更加简洁：因为不需要为每一个消息的订阅者准备专门的消息格式，只要知道了消息队列中保存消息的格式，发布者就可以按照这个格式发送消息，订阅者也只需要按照这个格式接收消息。

虽然发布 / 订阅模式的数据处理模式优点多多，但是还是存在着自身的缺点的。

例如，在整个数据模式中，我们不能保证发布者发送的数据一定会送达订阅者。如果要保证数据一定送达的话，需要开发者自己实现响应机制。

在硅谷，很多大型云平台都是运用这个发布 / 订阅数据处理模式。

例如，Google 的 Cloud Pub/Sub 平台，AWS 的 Amazon Simple Notification Service（SNS）。被 Linkedin、Uber 等硅谷大厂所广泛使用的开源平台 Apache Kafka 也是搭建在发布 / 订阅数据处理模式之上的。甚至是连 Redis 也支持原生的发布 / 订阅模式。

Apache Kafka 作为一个被在硅谷大厂与独角兽广泛使用的开源平台，如果你是在创业阶段的话，说不定可以用得上，所以在这里我想简单介绍一下 Apache Kafka。

在 Apache Kafka 中，消息的发送方被称为 Producer，消息的接收方被称为 Consumer，而消息队列被称为 Topic。

Apache Kafka 在判断消息是否被接收方接收是利用了 Log offset 机制。

什么是 Log offset 机制呢？我举个例子给你解释一下。

假设发送方连续发送了 5 条数据到消息队列 Topics 中，这 5 条消息被编号为 10000、10001、10002、10003 和 10004。

如果接收方读取数据之后回应消息队列它接收的 Log offset 是 10000、10001 和 10003，那么消息队列就会认为接收方最多只接收了消息 10000 和 10001，剩下的消息 10002、10003 和 10004 则会继续发送给接收方，直到接收方回应接收了消息 10002、10003 和 10004。

发布 / 订阅模式的适用场景

我们说回到发布 / 订阅模式来，看看它能用在哪些场景。

如果你在处理数据的时候碰到以下场景，那么就可以考虑使用发布 / 订阅的数据处理模式。

系统的发送方需要向大量的接收方广播消息。

系统中某一个组件需要与多个独立开发的组件或服务进行通信，而这些独立开发的组件或服务可以使用不同的编程语言和通信协议。

系统的发送方在向接收方发送消息之后无需接收方进行实时响应。

系统中对数据一致性的要求只需要支持数据的最终一致性（Eventual Consistency）模型。

要提醒你注意的一点是，如果系统的发送方在向接收方发送消息之后，需要接收方进行实时响应的话，那么绝大多数情况下，都不要考虑使用发布 / 订阅的数据处理模式。

小结

今天我们一起学习了大规模数据处理中一种十分流行的设计模式——发布 / 订阅模式。它能够很好地解耦（Decouple）系统中不同的组件，许多实时的流处理架构就是利用这个数据处理的设计模式搭建起来的。因为发布 / 订阅模式同时具有很好的伸缩性。

如果你在开发的场景适合我所讲到的适应场景，可以优先考虑使用发布 / 订阅模式。

思考题

你认为微信的朋友圈功能适合使用发布 / 订阅模式吗？为什么？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

09 | CAP定理：三选二，架构师必须学会的取舍

你好，我是蔡元楠。

今天我要与你分享的主题是 CAP 定理。

在分布式系统的两讲中，我们一起学习到了两个重要的概念：可用性和一致性。

而今天，我想和你讲解一个与这两个概念相关，并且在设计分布式系统架构时都会讨论到的一个定理——CAP 定理（CAP Theorem）。

CAP 定理

CAP 这个概念最初是由埃里克·布鲁尔博士（Dr. Eric Brewer）在 2000 年的 ACM 年度学术研讨会上提出的。

如果你对这次演讲感兴趣的话，可以翻阅他那次名为“Towards Robust Distributed Systems”的演讲 deck。

在两年之后，塞思·吉尔伯特（Seth Gilbert）和麻省理工学院的南希·林奇教授（Nancy Ann Lynch）在他们的论文“Brewer’s conjecture and the Feasibility of Consistent, Available, Partition-Tolerant Web Services”中证明了这一概念。

他们在这篇论文中证明了：在任意的分布式系统中，一致性（Consistency），可用性（Availability）和分区容错性（Partition-tolerance）这三种属性最多只能同时存在两个属性。

下面，我来为你解读一下这三种属性在这篇论文里的具体意思。

C 属性：一致性

一致性在这里指的是线性一致性（Linearizability Consistency）。在线性一致性的保证下，所有分布式环境下的操作都像是在单机上完成的一样，也就是说图中 Sever A、B、C 的状态一直是一致的。

打个比方，现在有两个操作（Operation），操作 A 和操作 B，都需要在同一个分布式系统上完成。

我们假设操作 A 作用在系统上的时候，所看见的所有系统状态（State）叫作状态 A。而操作 B 作用在系统上的时候，所看见的所有系统状态叫作状态 B。

如果操作 A 是在操作 B 之前发生的，并且操作 A 成功了。那么系统状态 B 必须要比系统状态 A 更加新。

可能光看理论的话你还是会觉得这个概念有点模糊，那下面我就以一个具体例子来说明吧。

假设我们设计了一个分布式的购物系统，在这个系统中，商品的存货状态分别保存在服务器 A 和服务器 B 中。我们把存货状态定义为“有货状态”或者“无货状态”。在最开始的时候，服务器 A 和服务器 B 都会显示商品为有货状态。

等一段时间过后，商品卖完了，后台就必须将这两台服务器上的商品状态更新为无货状态。

因为是在分布式的环境下，商品状态的更新在服务器 A 上完成了，显示为无货状态。而服务器 B 的状态因为网络延迟的原因更新还未完成，还是显示着有货状态。

这时，恰好有两个用户使用着这个购物系统，先后发送了一个查询操作（Query Operation）到后台服务器中查询商品状态。

我们假设是用户 A 先查询的，这个查询操作 A 被发送到了服务器 A 上面，并且成功返回了商品是无货状态的。用户 B 在随后也对同一商品进行查询，而这个查询操作 B 被发送到了服务器 B 上面，并且成功返回了商品是有货状态的。

我们知道，对于整个系统来说，商品的系统状态应该为无货状态。而操作 A 又是在操作 B 之前发送并且成功完成的，所以如果这个系统有线性一致性这个属性的话，操作 B 所看到的系统状态理论上应该是无货状态。

但在我们这个例子中，操作 B 却返回了有货状态。所以我们说，这个分布式的购物系统并不满足论文里所讲到的线性一致性。

聊完了一致性，我们一起来看看可用性的含义。

A 属性：可用性

可用性的概念比较简单，在这里指的是在分布式系统中，任意非故障的服务器都必须对客户的请求产生响应。

当系统满足可用性的时候，不管出现什么状况（除非所有的服务器全部崩溃），都能返回消息。

也就是说，当客户端向系统发送请求，只要系统背后的服务器有一台还未崩溃，那么这个未崩溃的服务器必须最终响应客户端。

P 属性：分区容错性

在了解了可用性之后，你还需要了解分区容错性。它分为两个部分，“分区”和“容错”。

在一个分布式系统里，如果出现一些故障，可能会使得部分节点之间无法连通。由于这些故障节点无法联通，造成整个网络就会被分成几块区域，从而使数据分散在这些无法连通的区域中的情况，你可以认为这就是发生了分区错误。

如图所示，如果你要的数据只在 Sever A 中保存，当系统出现分区错误，在不能直接连接 Sever A 时，你是无法获取数据的。我们要“分区容错”，意思是即使出现这样的“错误”，系统也需要能“容忍”。也就是说，就算错误出现，系统也必须能够返回消息。

分区容错性，在这里指的是我们的系统允许网络丢失从一个节点发送到另一个节点的任意多条消息。

我们知道，在现代网络通信中，节点出现故障或者网络出现丢包这样的情况是时常会发生的。

如果没有了分区容错性，也就是说系统不允许这些节点间的通讯出现任何错误的话，那我们日常所用到的很多系统就不能再继续工作了。

所以在大部分情况下，系统设计都会保留 P 属性，而在 C 和 A 中二选一。

论文中论证了在任意系统中，我们最多可以保留 CAP 属性中的两种，也就是 CP 或者 AP 或者 CA。关于具体的论证过程，如果你感兴趣的话，可以自行翻阅论文查看。

你可能会问，在我们平常所用到的开发架构中，有哪些系统是属于 CP 系统，有哪些是 AP 系统又有哪些是 CA 系统呢？我来给你介绍一下：

CP 系统：Google BigTable, Hbase, MongoDB, Redis, MemCacheDB，这些存储架构都是放弃了高可用性（High Availablity）而选择 CP 属性的。

AP 系统：Amazon Dynamo 系统以及它的衍生存储系统 Apache Cassandra 和 Voldemort 都是属于 AP 系统

CA 系统：Apache Kafka 是一个比较典型的 CA 系统。

我在上面说过，P 属性在现代网络时代中基本上是属于一个必选项，那为什么 Apache Kafka 会放弃 P 选择 CA 属性呢？我来给你解释一下它的架构思想。

放弃了 P 属性的 Kafka Replication

在 Kafka 发布了 0.8 版本之后，Kafka 系统引入了 Replication 的概念。Kafka Relocation 通过将数据复制到不同的节点上，从而增强了数据在系统中的持久性（Durability）和可用性（Availability）。在 Kafka Replication 的系统设计中，所有的数据日志存储是设计在同一个数据中心（Data Center）里面的，也就是说，在同一个数据中心里网络分区出现的可能性是十分之小的。

它的具体架构是这样的，在 Kafka 数据副本（Data Replication）的设计中，先通过 Zookeeper 选举出一个领导者节点（Leader）。这个领导者节点负责维护一组被称作同步数据副本（In-sync-replica）的节点，所有的数据写入都必须在这个领导者节点中记录。

我来举个例子，假设现在数据中心有三台服务器，一台被选为作为领导者节点，另外两台服务器用来保存数据副本，分别是 Replication1 和 Replication2，它们两个节点就是被领导者节点维护的同步数据副本了。领导者节点知道它维护着两个同步数据副本。

如果用户想写入一个数据，假设是“Geekbang”

用户会发请求到领导者节点中想写入“Geekbang”。

领导者节点收到请求后先在本地保存好，然后也同时发消息通知 Replication1 和 Replication2。

Replication1 和 Replication2 收到消息后也保存好这条消息并且回复领导者节点写入成功。

领导者节点记录副本 1 和副本 2 都是健康（Healthy）的，并且回复用户写入成功。

红色的部分是领导者节点本地日志，记录着有哪些同步数据副本是健康的。

往后用户如果想查询写入的数据，无论是领导者节点还是两个副本都可以返回正确同步的结果。

那假如分区出现了该怎么办呢？例如领导者节点和副本 1 无法通讯了，这个时候流程就变成这样了。

用户会发请求到领导者节点中想写入“Geekbang”。

领导者节点收到请求后先在本地保存好，然后也同时发消息通知 Replication1 和 Replication2。

只有 Replication2 收到消息后也保存好这条消息并且回复领导者节点写入成功。

领导者节点记录副本 2 是健康的，并且回复用户写入成功。

同样，红色的部分是领导者节点本地日志，记录着有哪些同步数据副本是健康的。

如果所有副本都无法通讯的时候，Apache Kafka 允许系统只有一个节点工作，也就是领导者节点。这个时候所有的写入都只保存在领导者节点了。过程如下，

用户会发请求到领导者节点中想写入“Geekbang”。

领导者节点收到请求后先在本地保存好，然后也同时发消息通知 Replication1 和 Replication2。

没有任何副本回复领导者节点写入成功，领导者节点记录无副本是健康的，并且回复用户写入成功。

当然，在最坏的情况下，连领导者节点也挂了，Zookeeper 会重新去寻找健康的服务器节点来当选新的领导者节点。

小结

通过今天的学习，我们知道在 CAP 定理中，一致性，可用性和分区容错性这三个属性最多只能选择两种属性保留。CAP 定理在经过了差不多 20 年的讨论与演化之后，大家对这三个属性可能会有着自己的一些定义。

例如在讨论一致性的时候，有的系统宣称自己是拥有 C 属性，也就拥有一致性的，但是这个一致性并不是论文里所讨论到的线性一致性。

在我看来，作为大规模数据处理的架构师，我们应该熟知自己的系统到底应该保留 CAP 中的哪两项属性，同时也需要熟知，自己所应用到的平台架构是保留着哪两项属性。

思考题

如果让你重新设计微博系统中的发微博功能，你会选择 CAP 的哪两个属性呢？为什么呢？

欢迎你把答案写在留言区，与我和其他同学一起讨论。

如果你觉得有所收获，也欢迎把文章分享给你的朋友。

10 | Lambda架构：Twitter亿级实时数据分析架构背后的倚天剑

你好，我是蔡元楠。

今天我要与你分享的主题是 Lambda 架构。

通过这一讲，你可以了解什么是 Lambda 架构，以及它为什么能够成为 Twitter 亿级实时数据分析架构背后的“倚天剑”。

在学习了架构师的必备技能后，你是否已经摩拳擦掌，跃跃欲试地想要上手一个实际项目了呢？没问题，我们一起来看一个我的架构经历里的真实项目。

情况是这样的，我们正运行着广告精准投放业务，并且拥有海量的用户网站访问行为。我们需要进行用户行为分析来建立一个模型，然后根据这个模型来投放用户喜好的广告。

你可能想到了批处理架构。没错，这个时候数据批处理架构无疑是一种很好的选择。

可是我们不要忘了，之前讲过批处理架构有着高延时性的不足，而互联网用户行为的数据往往可以达到 Pb 或 Eb，甚至是 Zb 的级别。做这种分析挖掘用户行为的任务，往往能耗时好几个小时甚至是几天。这样的话，我们根据模型精准投放给特定用户的广告就会有一定延时了。

那我们只用流处理架构行不行呢？

在广告精准投放的业务需求下，只用流处理架构会造成忽略了用户的历史网站访问行为，一些异常行为可能会让我们的服务投放错误的广告。

例如，用户 A 的电脑暂时借给用户 B 使用了一下，而用户 B 浏览了一些新的网站类型（与用户 A 不同）。这种情况下，我们无法判断用户 A 实际上是否对这类型的广告感兴趣，所以不能根据这些新的浏览记录给用户 A 推送广告。

这个时候应该怎么优化我们的架构呢？我们先把问题放一放，在介绍完了 Lambda 架构之后或许会对你有所启发。

Lambda 架构

Lambda 架构（Lambda Architecture）是由 Twitter 工程师南森·马茨（Nathan Marz）提出的大数据处理架构。这一架构的提出基于马茨在 BackType 和 Twitter 上的分布式数据处理系统的经验。

Lambda 架构使开发人员能够构建大规模分布式数据处理系统。它具有很好的灵活性和可扩展性，也对硬件故障和人为失误有很好的容错性。

Lambda 架构总共由三层系统组成：批处理层（Batch Layer），速度处理层（Speed Layer），以及用于响应查询的服务层（Serving Layer）。

在 Lambda 架构中，每层都有自己所肩负的任务。

批处理层存储管理主数据集（不可变的数据集）和预先批处理计算好的视图。

批处理层使用可处理大量数据的分布式处理系统预先计算结果。它通过处理所有的已有历史数据来实现数据的准确性。这意味着它是基于完整的数据集来重新计算的，能够修复任何错误，然后更新现有的数据视图。输出通常存储在只读数据库中，更新则完全取代现有的预先计算好的视图。

速度处理层会实时处理新来的大数据。

速度层通过提供最新数据的实时视图来最小化延迟。速度层所生成的数据视图可能不如批处理层最终生成的视图那样准确或完整，但它们几乎在收到数据后立即可用。而当同样的数据在批处理层处理完成后，在速度层的数据就可以被替代掉了。

本质上，速度层弥补了批处理层所导致的数据视图滞后。比如说，批处理层的每个任务都需要 1 个小时才能完成，而在这 1 个小时里，我们是无法获取批处理层中最新任务给出的数据视图的。而速度层因为能够实时处理数据给出结果，就弥补了这 1 个小时的滞后。

所有在批处理层和速度层处理完的结果都输出存储在服务层中，服务层通过返回预先计算的数据视图或从速度层处理构建好数据视图来响应查询。

好了，我们回到刚刚的问题中。我们如何做到既能实时分析用户新的网站浏览行为又能兼顾到用户的网站浏览行为历史呢？没错，就是利用 Lambda 架构。

所有的新用户行为数据都可以同时流入批处理层和速度层。批处理层会永久保存数据并且对数据进行预处理，得到我们想要的用户行为模型并写入服务层。而速度层也同时对新用户行为数据进行处理，得到实时的用户行为模型。

而当“应该对用户投放什么样的广告”作为一个查询（Query）来到时，我们从服务层既查询服务层中保存好的批处理输出模型，也对速度层中处理的实时行为进行查询，这样我们就可以得到一个完整的用户行为历史了。

一个查询就如下图所示，既通过批处理层兼顾了数据的完整性，也可以通过速度层弥补批处理层的高延时性，让整个查询具有实时性。

Lambda 架构在硅谷一线大公司的应用已经十分广泛，我来带你一起看看一些实际的应用场景。

Twitter 的数据分析案例

Twitter 在欧美十分受欢迎，而 Twitter 中人们所发 Tweet 里面的 Hashtag 也常常能引爆一些热搜词汇，也就是 Most Popular Hashtags。下面我来给你讲述一下如何利用 Lambda 架构来实时分析这些 Hashtags。

在这个实际案例里，我们先用 twitter4J 的流处理 API 抓取实时的 Twitter 推文，同时利用 Apache Kafka 将抓取到的数据保存并实时推送给批处理层和速度层。

因为 Apache Spark 平台中既有批处理架构也兼容了流处理架构，所以我们选择在批处理层和速度层都采用 Apache Spark 来读取来自 Apache Kafka 的数据。

批处理层和速度层在分析处理好数据后会将数据视图输出存储在服务层中，我们将使用 Apache Cassandra 平台来存储他们的数据视图。Apache Cassandra 将批处理层的视图数据和速度层的实时视图数据结合起来，就可以得到一系列有趣的数据。

例如，我们根据每一条 Tweet 中元数据（Metadata）里的 location field，可以得知发推文的人的所在地。而服务层中的逻辑可以根据这个地址信息进行分组，然后统计在不同地区的人所关心的 Hashtag 是什么。

时间长达几周或者的几个月的数据，我们可以结合批处理层和速度层的数据视图来得出，而快至几个小时的数据我们又可以根据速度层的数据视图来获知，怎么样？这个架构是不是十分灵活？

看到这里，你可能会问，我在上面所讲的例子都是来自些科技巨头公司，如果我在开发中面对的数据场景没有这么巨大，又或者说我的公司还在创业起步阶段，我是否可以用到 Lambda 架构呢？

答案是肯定的！我下面将和你一起分享一个在硅谷旧金山创业公司的 App 后台架构。

Smart Parking 案例分析

在硅谷旧金山地区上班生活的小伙伴肯定都知道，找停车位是一大难题。这里地少车多，每次出行，特别是周末，找停车位都要绕个好几十分钟才能找得到。

智能停车 App 就是在这样的背景下诞生的。这个 App 可以根据大规模数据所构建的视图推荐最近的车位给用户。

看到这里，我想先请你结合之前所讲到的广告精准投放案例，思考一下 Lambda 架构是如何应用在这个 App 里的，然后再听我娓娓道来。

好，我们来梳理一下各种可以利用到的大数据。

首先是可以拿到各类停车场的数据。这类数据的实时性虽然不一定高，但是数据的准确性高。那我们能不能只通过这类大数据来推荐停车位呢？

我来给你举个极端的例子。假设在一个区域有三个停车场，停车场 A 现在只剩下 1 个停车位了。

停车场 B 和 C 还有非常多的空位。而在这时候距离停车场比 A 较近的位置有 10 位车主在使用这个 App 寻求推荐停车位。如果只通过车主和停车场的距离和停车场剩余停车位来判断的话，App 很有可能会将这个只剩下一个停车位的停车场 A 同时推荐给这 10 位用户。

结果可想而知，只有一位幸运儿能找到停车位，剩下的 9 位车主需要重新寻找停车位。

如果附近又出现了只有一个停车位的停车场呢？同理，这个 App 又会推荐这个停车场给剩下的 9 位用户。这时又只能有一位幸运儿找到停车位。

如此反复循环，用户体验会非常差，甚至会导致用户放弃这个 App。

那我们有没有办法可以改进推荐的准确度呢？

你可能会想到我们可以利用这些停车场的历史数据，建立一个人工智能的预测模型，在推荐停车位的时候，不单单考虑到附近停车场的剩余停车位和用户与停车场的相邻距离，还能将预测模型应用在推荐里，看看未来的一段时间内这个停车场是否有可能会被停满了。

这时候我们的停车位推荐系统就变成了一个基于分数（Score）来推荐停车位的系统了。

好了，这个时候的系统架构是否已经达到最优了呢？你有想到应用 Lambda 架构吗？

没错，这些停车场的历史数据或者每隔半小时拿到的停车位数据，我们可以把它作为批处理层的数据。

那速度层的数据呢？我们可以将所有用户的 GPS 数据聚集起来，这些需要每秒收集的 GPS 数据刚好又是速度层所擅长的实时流处理数据。从这些用户的实时 GPS 数据中，我们可以再建立一套预测模型来预测附近停车场位置的拥挤程度。

服务层将从批处理层和速度层得到的分数结合后将得到最高分数的停车场推荐给用户。这样利用了历史数据（停车场数据）和实时数据（用户 GPS 数据）能大大提升推荐的准确率。

小结

在了解 Lambda 架构后，我们知道 Lambda 架构具有很好的灵活性和可扩展性。我们可以很方便地将现有的开源平台套用入这个架构中，如下图所示。

当开发者需要迁移平台时，整体的架构不需要改变，只需要将逻辑迁移到新平台中。

例如，可以将 Apache Spark 替换成 Apache Storm。而因为我们有批处理层这一概念，又有了很好的容错性。

假如某天开发者发现逻辑出现了错误，只需要调整算法对永久保存好的数据重新进行处理写入服务层，经过多次迭代后整体的逻辑便可以被纠正过来。

从我的开发经验来看，现在有很多的开发项目可能已经有了比较成熟的架构或者算法了。

但是如果我们平时能多思考一下现有架构的瓶颈，又或者想一想现在的架构能不能改善得更好，有了这样的思考，在学习到这些经典优秀架构之后，说不定真的能让现有的架构变得更好。

也就是说，作为一名优秀的架构师，“现有的架构能不能做得更好？”应该是一个需要经常思考的问题。

思考题

你所做的项目开发能否利用 Lambda 架构呢？在生活中有没有哪些大数据处理场景可以利用 Lambda 架构呢？

欢迎在评论中留言，与我和其他同学一起讨论。

如果你觉得今天有些不一样的收获，也欢迎你把这篇文章分享给你的好友。

11 | Kappa架构：利用Kafka锻造的屠龙刀

你好，我是蔡元楠。

今天我要分享的主题是 Kappa 架构。

同样身为大规模数据处理架构，Kappa 架构这把利用 Kafka 锻造的“屠龙刀”，它与 Lambda 架构的不同之处在哪里呢？

上一讲中，我讲述了在处理大规模数据时所用到经典架构，Lambda 架构。我先来带你简要回顾一下。

Lambda 架构结合了批处理和流处理的架构思想，将进入系统的大规模数据同时送入这两套架构层中，分别是批处理层（Batch Layer）和速度层（Speed Layer），同时产生两套数据结果并存入服务层。

批处理层有着很好的容错性，同时也因为保存着所有的历史记录，使产生的数据集具有很好的准确性。速度层可以及时地处理流入的数据，因此具有低延迟性。最终服务层将这两套数据结合，并生成一个完整的数据视图提供给用户。

Lambda 架构也具有很好的灵活性，你可以将现有开源生态圈中不同的平台套入这个架构，具体请参照上一讲内容。

Lambda 架构的不足

虽然 Lambda 架构使用起来十分灵活，并且可以适用于很多的应用场景，但在实际应用的时候，Lambda 架构也存在着一些不足，主要表现在它的维护很复杂。

使用 Lambda 架构时，架构师需要维护两个复杂的分布式系统，并且保证他们逻辑上产生相同的结果输出到服务层中。

举个例子吧，我们在部署 Lambda 架构的时候，可以部署 Apache Hadoop 到批处理层上，同时部署 Apache Flink 到速度层上。

我们都知道，在分布式框架中进行编程其实是十分复杂的，尤其是我们还会针对不同的框架进行专门的优化。所以几乎每一个架构师都认同，Lambda 架构在实战中维护起来具有一定的复杂性。

那要怎么解决这个问题呢？我们先来思考一下，造成这个架构维护起来如此复杂的根本原因是什么呢？

维护 Lambda 架构的复杂性在于我们要同时维护两套系统架构：批处理层和速度层。我们已经说过了，在架构中加入批处理层是因为从批处理层得到的结果具有高准确性，而加入速度层是因为它在处理大规模数据时具有低延时性。

那我们能不能改进其中某一层的架构，让它具有另外一层架构的特性呢？

例如，改进批处理层的系统让它具有更低的延时性，又或者是改进速度层的系统，让它产生的数据视图更具准确性和更加接近历史数据呢？

另外一种在大规模数据处理中常用的架构——Kappa 架构（Kappa Architecture），便是在这样的思考下诞生的。

Kappa 架构

Kappa 架构是由 LinkedIn 的前首席工程师杰伊·克雷普斯（Jay Kreps）提出的一种架构思想。克雷普斯是几个著名开源项目（包括 Apache Kafka 和 Apache Samza 这样的流处理系统）的作者之一，也是现在 Confluent 大数据公司的 CEO。

克雷普斯提出了一个改进 Lambda 架构的观点：

我们能不能改进 Lambda 架构中速度层的系统性能，使得它也可以处理好数据的完整性和准确性问题呢？我们能不能改进 Lambda 架构中的速度层，使它既能够进行实时数据处理，同时也有能力在业务逻辑更新的情况下重新处理以前处理过的历史数据呢？

他根据自身多年的架构经验发现，我们是可以做到这样的改进的。

在前面 Publish–Subscribe 模式那一讲中，我讲到过像 Apache Kafka 这样的流处理平台是具有永久保存数据日志的功能的。通过平台的这一特性，我们可以重新处理部署于速度层架构中的历史数据。

下面我就以 Apache Kafka 为例来讲述整个全新架构的过程。

第一步，部署 Apache Kafka，并设置数据日志的保留期（Retention Period）。这里的保留期指的是你希望能够重新处理的历史数据的时间区间。

例如，如果你希望重新处理最多一年的历史数据，那就可以把 Apache Kafka 中的保留期设置为 365 天。如果你希望能够处理所有的历史数据，那就可以把 Apache Kafka 中的保留期设置为“永久（Forever）”。

第二步，如果我们需要改进现有的逻辑算法，那就表示我们需要对历史数据进行重新处理。

我们需要做的就是重新启动一个 Apache Kafka 作业实例（Instance）。这个作业实例将重头开始，重新计算保留好的历史数据，并将结果输出到一个新的数据视图中。我们知道 Apache Kafka 的底层是使用 Log Offset 来判断现在已经处理到哪个数据块了，所以只需要将 Log Offset 设置为 0，新的作业实例就会重头开始处理历史数据。

第三步，当这个新的数据视图处理过的数据进度赶上了旧的数据视图时，我们的应用便可以切换到从新的数据视图中读取。

第四步，停止旧版本的作业实例，并删除旧的数据视图。

这个架构就如同下图所示。

与 Lambda 架构不同的是，Kappa 架构去掉了批处理层这一体系结构，而只保留了速度层。你只需要在业务逻辑改变又或者是代码更改的时候进行数据的重新处理。

当然了，你也可以在我上面讲到的步骤中做一些优化。

例如不执行第 4 步，也就是不删除旧的数据视图。这样的好处是当你发现代码逻辑出错时可以及时回滚（Roll Back）到上一个版本的数据视图中去。又或者是你想在服务层提供 A/B 测试，保留多个数据视图版本将有助于你进行 A/B 测试。

在介绍完 Kappa 架构的概念后，我想通过一个实战例子，来和你进一步学习 Kappa 架构是如何应用在现实场景中的。

《纽约时报》内容管理系统架构实例

《纽约时报》是一个在美国纽约出版，在整个美国乃至全世界都具有相当影响力的日报。

《纽约时报》的内容管理系统收集、保存着各种各样来源的文档。这些文档有从第三方收集来的资料，也有自己报社编辑部所撰写的故事。当你访问《纽约时报》网站主页时，甚至能够查到 162 年前的新闻报道。

可想而知，要处理这么大规模的内容，并将这些内容提供于在线搜索、订阅的个性化推荐以及前端应用程序等等的服务，是一个非常棘手的任务。

我们先来看看他们曾经使用过的一个老式系统架构。

我们可以看到，这种系统架构是一种相当典型的基于 API 的架构，无论是在系统调度上还是使用场景上都存在着自身的不足。我来给你举一些例子。

不同的内容 API 可能由不同的团队开发，从而造成 API 有不同的语义，也有可能需要不同的参数。

调用不同 API 所得到的内容结果可能有不同的格式，在应用端需要重新进行规范化（Standardization）。

如果客户端上会实时推送一些新的热点新闻或者突发新闻（Breaking News），那么在上述基于 API 的架构中，想要实时获知新闻的话，就需要让客户端不停地做轮询操作（Polling）。轮询操作在这里指的是客户端定期地重复调用系统 API 来查看是否有新的新闻内容，这无疑增加了系统的复杂性。

客户端很难访问以前发布过的内容。即便我们知道这些已发布过的新闻列表需要从哪里获取，进行 API 调用去检索每个单独的新闻列表还是需要花很长的时间。而过多的 API 调用又会给服务器产生很大的负荷。

那现在你再来看看当《纽约时报》采取了 Kappa 架构之后，新的系统架构是什么样的。

首先，Kappa 架构在系统调度这个层面上统一了开发接口。

你可以看到，中间的 Kappa 架构系统规范好了输入数据和输出数据的格式之后，任何需要传送到应用端的数据都必须按照这个接口输入给 Kappa 架构系统。而所有的应用端客户都只需要按照 Kappa 架构系统定义好的输出格式接收传输过来的数据。这样就解决了 API 规范化的问题。

我们再来看看增加了中间一层 Kappa 架构之后数据传输速度上的变化。

因为 Apache Kafka 是可以实时推送消息数据的，这样一来，任何传输进中间 Kappa 架构的数据都会被实时推送到接收消息的客户端中。这样就避免了在应用层面上做定期轮询，从而减少了延时。而对于重新访问或者处理发布过的新闻内容这一问题，还记得我之前和你讲述过的 Kafka 特性吗？只需要设置 Log Offset 为 0 就可以重新读取所有内容了。

在讲述完 Kappa 架构和它的应用实例之后，我想强调一下，Kappa 架构也是有着它自身的不足的。

因为 Kappa 架构只保留了速度层而缺少批处理层，在速度层上处理大规模数据可能会有数据更新出错的情况发生，这就需要我们花费更多的时间在处理这些错误异常上面。

还有一点，Kappa 架构的批处理和流处理都放在了速度层上，这导致了这种架构是使用同一套代码来处理算法逻辑的。所以 Kappa 架构并不适用于批处理和流处理代码逻辑不一致的场景。

小结

在最近两讲中，我们学习到了 Lambda 架构和 Kappa 架构这两种大规模数据处理架构，它们都各自有着自身的优缺点。我们需要按照实际情况来权衡利弊，看看我们在业务中到底需要使用到哪种架构。

如果你所面对的业务逻辑是设计一种稳健的机器学习模型来预测即将发生的事情，那么你应该优先考虑使用 Lambda 架构，因为它拥有批处理层和速度层来确保更少的错误。

如果你所面对的业务逻辑是希望实时性比较高，而且客户端又是根据运行时发生的实时事件来做出回应的，那么你就应该优先考虑使用 Kappa 架构。

思考题

在学习完 Lambda 架构和 Kappa 架构之后，你能说出 Kappa 架构相对 Lambda 架构的优势吗？

欢迎你把答案写在留言区，与我和其他同学一起讨论。

如果你觉得有所收获，也欢迎把文章分享给你的朋友。

12 | 我们为什么需要Spark？

你好，我是蔡元楠。

今天我要与你分享的主题是“我们为什么需要 Spark”。

也许你之前没有做过大规模数据处理的项目，但是 Spark 这个词我相信你一定有所耳闻。

Spark 是当今最流行的分布式大规模数据处理引擎，被广泛应用在各类大数据处理场景。

2009 年，美国加州大学伯克利分校的 AMP 实验室开发了 Spark。2013 年，Spark 成为 Apache 软件基金会旗下的孵化项目。

而现在，Spark 已经成为了该基金会管理的项目中最活跃的一个。Spark 社区也是成长迅速，不仅有数以千计的个人贡献者在不断地开发维护，还有很多大公司也加入了这个开源项目，如 Databricks、IBM 和华为。

在技术不断高速更迭的程序圈，一个新工具的出现与流行，必然是因为它满足了很大一部分人长期未被满足的需求，或是解决了一个长期让很多人难受的痛点。

所以，在学一个新技术之前，你有必要先了解这门技术出现的意义。这样，你才能更好地理解：它是应用到什么场景的？与同类工具相比，它的优缺点是什么？什么时候用它比其它工具好（或差）？……

至少理解了这些，你才好说自己是真正掌握了这个工具，否则只能说是浅尝辄止，半生不熟。

学习 Spark 同样是如此。

我们首先要问自己，既然已经有了看似很成熟的 Hadoop 和 MapReduce，为什么我们还需要 Spark？它能帮我们解决什么实际问题？相比于 MapReduce，它的优势又是什么？

MapReduce 的缺陷

MapReduce 通过简单的 Map 和 Reduce 的抽象提供了一个编程模型，可以在一个由上百台机器组成的集群上并发处理大量的数据集，而把计算细节隐藏起来。各种各样的复杂数据处理都可以分解为 Map 或 Reduce 的基本元素。

这样，复杂的数据处理可以分解为由多个 Job（包含一个 Mapper 和一个 Reducer）组成的有向无环图（DAG），然后每个 Mapper 和 Reducer 放到 Hadoop 集群上执行，就可以得出结果。

我们在第一讲中讲到过 MapReduce 被硅谷一线公司淘汰的两大主要原因：高昂的维护成本、时间性能“达不到”用户的期待。不过除此之外，MapReduce 还存在诸多局限。

第一，MapReduce 模型的抽象层次低，大量的底层逻辑都需要开发者手工完成。

打个比方，写 MapReduce 的应用就好比用汇编语言去编写一个复杂的游戏。如果你是开发者，你会习惯用汇编语言，还是使用各种高级语言如 Java、C++ 的现有框架呢？

第二，只提供 Map 和 Reduce 两个操作。

很多现实的数据处理场景并不适合用这个模型来描述。实现复杂的操作很有技巧性，也会让整个工程变得庞大以及难以维护。

举个例子，两个数据集的 Join 是很基本而且常用的功能，但是在 MapReduce 的世界中，需要对这两个数据集做一次 Map 和 Reduce 才能得到结果。这样框架对于开发者非常不友好。正如第一讲中提到的，维护一个多任务协调的状态机成本很高，而且可扩展性非常差。

第三，在 Hadoop 中，每一个 Job 的计算结果都会存储在 HDFS 文件存储系统中，所以每一步计算都要进行硬盘的读取和写入，大大增加了系统的延迟。

由于这一原因，MapReduce 对于迭代算法的处理性能很差，而且很耗资源。因为迭代的每一步都要对 HDFS 进行读写，所以每一步都需要差不多的等待时间。

第四，只支持批数据处理，欠缺对流数据处理的支持。

因此，在 Hadoop 推出后，有很多人想办法对 Hadoop 进行优化，其中发展到现在最成熟的就是 Spark。

接下来，就让我们看一下 Spark 是如何对上述问题进行优化的。

Spark 的优势

Spark 最基本的数据抽象叫作弹性分布式数据集（Resilient Distributed Dataset, RDD），它代表一个可以被分区（partition）的只读数据集，它内部可以有很多分区，每个分区又有大量的数据记录（record）。

RDD 是 Spark 最基本的数据结构。Spark 定义了很多对 RDD 的操作。对 RDD 的任何操作都可以像函数式编程中操作内存中的集合一样直观、简便，使得实现数据处理的代码非常简短高效。这些我们会在这一模块中的后续文章中仔细阐述。

Spark 提供了很多对 RDD 的操作，如 Map、Filter、flatMap、groupByKey 和 Union 等等，极大地提升了对各种复杂场景的支持。开发者既不用再绞尽脑汁挖掘 MapReduce 模型的潜力，也不用维护复杂的 MapReduce 状态机。

相对于 Hadoop 的 MapReduce 会将中间数据存放到硬盘中，Spark 会把中间数据缓存在内存中，从而减少了很多由于硬盘读写而导致的延迟，大大加快了处理速度。

Databricks 团队曾经做过一个实验，他们用 Spark 排序一个 100TB 的静态数据集仅仅用时 23 分钟。而之前用 Hadoop 做到的最快记录也用了高达 72 分钟。此外，Spark 还只用了 Hadoop 所用的计算资源的 1/10，耗时只有 Hadoop 的 1/3。

这个例子充分体现出 Spark 数据处理的最大优势——速度。

在某些需要交互式查询内存数据的场景中，Spark 的性能优势更加明显。

根据 Databricks 团队的结果显示，Spark 的处理速度是 Hadoop 的 100 倍。即使是对硬盘上的数据进行处理，Spark 的性能也达到了 Hadoop 的 10 倍。

由于 Spark 可以把迭代过程中每一步的计算结果都缓存在内存中，所以非常适用于各类迭代算法。

Spark 第一次启动时需要把数据载入到内存，之后的迭代可以直接在内存里利用中间结果做不落地的运算。所以，后期的迭代速度快到可以忽略不计。在当今机器学习和人工智能大热的环境下，Spark 无疑是更好的数据处理引擎。

下图是在 Spark 和 Hadoop 上运行逻辑回归算法的运行时间对比。

可以看出，Hadoop 做每一次迭代运算的时间基本相同，而 Spark 除了第一次载入数据到内存以外，别的迭代时间基本可以忽略。

在任务（task）级别上，Spark 的并行机制是多线程模型，而 MapReduce 是多进程模型。

多进程模型便于细粒度控制每个任务占用的资源，但会消耗较多的启动时间。

而 Spark 同一节点上的任务以多线程的方式运行在一个 JVM 进程中，可以带来更快的启动速度、更高的 CPU 利用率，以及更好的内存共享。

从前文中你可以看出，Spark 作为新的分布式数据处理引擎，对 MapReduce 进行了很多改进，使得性能大大提升，并且更加适用于新时代的数据处理场景。

但是，Spark 并不是一个完全替代 Hadoop 的全新工具。

因为 Hadoop 还包含了很多组件：

数据存储层：分布式文件存储系统 HDFS，分布式数据库存储的 HBase；

数据处理层：进行数据处理的 MapReduce，负责集群和资源管理的 YARN；

数据访问层：Hive、Pig、Mahout……

从狭义上来看，Spark 只是 MapReduce 的替代方案，大部分应用场景中，它还要依赖于 HDFS 和 HBase 来存储数据，依赖于 YARN 来管理集群和资源。

当然，Spark 并不是一定要依附于 Hadoop 才能生存，它还可以运行在 Apache Mesos、Kubernetes、standalone 等其他云平台上。

此外，作为通用的数据处理平台，Spark 有五个主要的扩展库，分别是支持结构化数据的 Spark SQL、处理实时数据的 Spark Streaming、用于机器学习的 MLlib、用于图计算的 GraphX、用于统计分析的 SparkR。

这些扩展库与 Spark 核心 API 高度整合在一起，使得 Spark 平台可以广泛地应用在不同数据处理场景中。

小结

通过今天的学习，我们了解了 Spark 相较于 MapReduce 的主要优势，那就是快、易于开发及维护，和更高的适用性。我们还初步掌握了 Spark 系统的架构。

MapReduce 作为分布式数据处理的开山鼻祖，虽然有很多缺陷，但是它的设计思想不仅没有过时，而且还影响了新的数据处理系统的设计，如 Spark、Storm、Presto、Impala 等。

Spark 并没有全新的理论基础，它是一点点地在工程和学术的结合基础上做出来的。可以说，它站在了 Hadoop 和 MapReduce 两个巨人的肩膀上。在这一模块中，我们会对 Spark 的架构、核心概念、API 以及各个扩展库进行深入的讨论，并且结合常见的应用例子进行实战演练，从而帮助你彻底掌握这一当今最流行的数据处理平台。

思考题

你认为有哪些 MapReduce 的缺点是在 Spark 框架中依然存在的？用什么思路可以解决？

欢迎你把答案写在留言区，与我和其他同学一起讨论。

如果你觉得有所收获，也欢迎把文章分享给你的朋友。

14 | 弹性分布式数据集：Spark大厦的地基（下）

你好，我是蔡元楠。

上一讲我们介绍了弹性分布式数据集（RDD）的定义、特性以及结构，并且深入讨论了依赖关系（Dependencies）。

今天让我们一起来继续学习 RDD 的其他特性。

RDD 的结构

首先，我来介绍一下 RDD 结构中其他的几个知识点：检查点（Checkpoint）、存储级别（ Storage Level）和迭代函数（Iterator）。

通过上一讲，你应该已经知道了，基于 RDD 的依赖关系，如果任意一个 RDD 在相应的节点丢失，你只需要从上一步的 RDD 出发再次计算，便可恢复该 RDD。

但是，如果一个 RDD 的依赖链比较长，而且中间又有多个 RDD 出现故障的话，进行恢复可能会非常耗费时间和计算资源。

而检查点（Checkpoint）的引入，就是为了优化这些情况下的数据恢复。

很多数据库系统都有检查点机制，在连续的 transaction 列表中记录某几个 transaction 后数据的内容，从而加快错误恢复。

RDD 中的检查点的思想与之类似。

在计算过程中，对于一些计算过程比较耗时的 RDD，我们可以将它缓存至硬盘或 HDFS 中，标记这个 RDD 有被检查点处理过，并且清空它的所有依赖关系。同时，给它新建一个依赖于 CheckpointRDD 的依赖关系，CheckpointRDD 可以用来从硬盘中读取 RDD 和生成新的分区信息。

这样，当某个子 RDD 需要错误恢复时，回溯至该 RDD，发现它被检查点记录过，就可以直接去硬盘中读取这个 RDD，而无需再向前回溯计算。

存储级别（Storage Level）是一个枚举类型，用来记录 RDD 持久化时的存储级别，常用的有以下几个：

MEMORY_ONLY：只缓存在内存中，如果内存空间不够则不缓存多出来的部分。这是 RDD 存储级别的默认值。

MEMORY_AND_DISK：缓存在内存中，如果空间不够则缓存在硬盘中。

DISK_ONLY：只缓存在硬盘中。

MEMORY_ONLY_2 和 MEMORY_AND_DISK_2 等：与上面的级别功能相同，只不过每个分区在集群中两个节点上建立副本。

这就是我们在前文提到过的，Spark 相比于 Hadoop 在性能上的提升。我们可以随时把计算好的 RDD 缓存在内存中，以便下次计算时使用，这大幅度减小了硬盘读写的开销。

迭代函数（Iterator）和计算函数（Compute）是用来表示 RDD 怎样通过父 RDD 计算得到的。

迭代函数会首先判断缓存中是否有想要计算的 RDD，如果有就直接读取，如果没有，就查找想要计算的 RDD 是否被检查点处理过。如果有，就直接读取，如果没有，就调用计算函数向上递归，查找父 RDD 进行计算。

到现在，相信你已经对弹性分布式数据集的基本结构有了初步了解。但是光理解 RDD 的结构是远远不够的，我们的终极目标是使用 RDD 进行数据处理。

要使用 RDD 进行数据处理，你需要先了解一些 RDD 的数据操作。

在第 12 讲中，我曾经提过，相比起 MapReduce 只支持两种数据操作，Spark 支持大量的基本操作，从而减轻了程序员的负担。

接下来，让我们进一步了解基于 RDD 的各种数据操作。

RDD 的转换操作

RDD 的数据操作分为两种：转换（Transformation）和动作（Action）。

顾名思义，转换是用来把一个 RDD 转换成另一个 RDD，而动作则是通过计算返回一个结果。

不难想到，之前举例的 map、filter、groupByKey 等都属于转换操作。

Map

map 是最基本的转换操作。

与 MapReduce 中的 map 一样，它把一个 RDD 中的所有数据通过一个函数，映射成一个新的 RDD，任何原 RDD 中的元素在新 RDD 中都有且只有一个元素与之对应。

在这一讲中提到的所有的操作，我都会使用代码举例，帮助你更好地理解。

rdd = sc.parallelize(["b", "a", "c"])

rdd2 = rdd.map(lambda x: (x, 1)) // [('b', 1), ('a', 1), ('c', 1)]

Filter

filter 这个操作，是选择原 RDD 里所有数据中满足某个特定条件的数据，去返回一个新的 RDD。如下例所示，通过 filter，只选出了所有的偶数。

rdd = sc.parallelize([1, 2, 3, 4, 5])

rdd2 = rdd.filter(lambda x: x % 2 == 0) // [2, 4]

mapPartitions

mapPartitions 是 map 的变种。不同于 map 的输入函数是应用于 RDD 中每个元素，mapPartitions 的输入函数是应用于 RDD 的每个分区，也就是把每个分区中的内容作为整体来处理的，所以输入函数的类型是 Iterator[T] => Iterator[U]。

rdd = sc.parallelize([1, 2, 3, 4], 2)

def f(iterator): yield sum(iterator)

rdd2 = rdd.mapPartitions(f) // [3, 7]

在 mapPartitions 的例子中，我们首先创建了一个有两个分区的 RDD。mapPartitions 的输入函数是对每个分区内的元素求和，所以返回的 RDD 包含两个元素：1+2=3 和 3+4=7。

groupByKey

groupByKey 和 SQL 中的 groupBy 类似，是把对象的集合按某个 Key 来归类，返回的 RDD 中每个 Key 对应一个序列。

rdd = sc.parallelize([("a", 1), ("b", 1), ("a", 2)])

rdd.groupByKey().collect()

//"a" [1, 2]

//"b" [1]

在此，我们只列举这几个常用的、有代表性的操作，对其他转换操作感兴趣的同学可以去自行查阅官方的 API 文档。

RDD 的动作操作

让我们再来看几个常用的动作操作。

Collect

RDD 中的动作操作 collect 与函数式编程中的 collect 类似，它会以数组的形式，返回 RDD 的所有元素。需要注意的是，collect 操作只有在输出数组所含的数据数量较小时使用，因为所有的数据都会载入到程序的内存中，如果数据量较大，会占用大量 JVM 内存，导致内存溢出。

rdd = sc.parallelize(["b", "a", "c"])

rdd.map(lambda x: (x, 1)).collect() // [('b', 1), ('a', 1), ('c', 1)]

实际上，上述转换操作中所有的例子，最后都需要将 RDD 的元素 collect 成数组才能得到标记好的输出。

Reduce

与 MapReduce 中的 reduce 类似，它会把 RDD 中的元素根据一个输入函数聚合起来。

from operator import add

sc.parallelize([1, 2, 3, 4, 5]).reduce(add)  // 15

Count

Count 会返回 RDD 中元素的个数。

sc.parallelize([2, 3, 4]).count() // 3

CountByKey

仅适用于 Key-Value pair 类型的 RDD，返回具有每个 key 的计数的 <Key, Count> 的 map。

rdd = sc.parallelize([("a", 1), ("b", 1), ("a", 1)])

sorted(rdd.countByKey().items()) // [('a', 2), ('b', 1)]

讲到这，你可能会问了，为什么要区分转换和动作呢？虽然转换是生成新的 RDD，动作是把 RDD 进行计算生成一个结果，它们本质上不都是计算吗？

这是因为，所有转换操作都很懒，它只是生成新的 RDD，并且记录依赖关系。

但是 Spark 并不会立刻计算出新 RDD 中各个分区的数值。直到遇到一个动作时，数据才会被计算，并且输出结果给 Driver。

比如，在之前的例子中，你先对 RDD 进行 map 转换，再进行 collect 动作，这时 map 后生成的 RDD 不会立即被计算。只有当执行到 collect 操作时，map 才会被计算。而且，map 之后得到的较大的数据量并不会传给 Driver，只有 collect 动作的结果才会传递给 Driver。

这种惰性求值的设计优势是什么呢？让我们来看这样一个例子。

假设，你要从一个很大的文本文件中筛选出包含某个词语的行，然后返回第一个这样的文本行。你需要先读取文件 textFile() 生成 rdd1，然后使用 filter() 方法生成 rdd2，最后是行动操作 first()，返回第一个元素。

读取文件的时候会把所有的行都存储起来，但我们马上就要筛选出只具有特定词组的行了，等筛选出来之后又要求只输出第一个。这样是不是太浪费存储空间了呢？确实。

所以实际上，Spark 是在行动操作 first() 的时候开始真正的运算：只扫描第一个匹配的行，不需要读取整个文件。所以，惰性求值的设计可以让 Spark 的运算更加高效和快速。

让我们总结一下 Spark 执行操作的流程吧。

Spark 在每次转换操作的时候，使用了新产生的 RDD 来记录计算逻辑，这样就把作用在 RDD 上的所有计算逻辑串起来，形成了一个链条。当对 RDD 进行动作时，Spark 会从计算链的最后一个 RDD 开始，依次从上一个 RDD 获取数据并执行计算逻辑，最后输出结果。

RDD 的持久化（缓存）

每当我们对 RDD 调用一个新的 action 操作时，整个 RDD 都会从头开始运算。因此，如果某个 RDD 会被反复重用的话，每次都从头计算非常低效，我们应该对多次使用的 RDD 进行一个持久化操作。

Spark 的 persist() 和 cache() 方法支持将 RDD 的数据缓存至内存或硬盘中，这样当下次对同一 RDD 进行 Action 操作时，可以直接读取 RDD 的结果，大幅提高了 Spark 的计算效率。

rdd = sc.parallelize([1, 2, 3, 4, 5])

rdd1 = rdd.map(lambda x: x+5)

rdd2 = rdd1.filter(lambda x: x % 2 == 0)

rdd2.persist()

count = rdd2.count() // 3

first = rdd2.first() // 6

rdd2.unpersist()

在文中的代码例子中你可以看到，我们对 RDD2 进行了多个不同的 action 操作。由于在第四行我把 RDD2 的结果缓存在内存中，所以无论是 count 还是 first，Spark 都无需从一开始的 rdd 开始算起了。

在缓存 RDD 的时候，它所有的依赖关系也会被一并存下来。所以持久化的 RDD 有自动的容错机制。如果 RDD 的任一分区丢失了，通过使用原先创建它的转换操作，它将会被自动重算。

持久化可以选择不同的存储级别。正如我们讲 RDD 的结构时提到的一样，有 MEMORY_ONLY，

MEMORY_AND_DISK，DISK_ONLY 等。cache() 方法会默认取 MEMORY_ONLY 这一级别。

小结

Spark 在每次转换操作的时候使用了新产生的 RDD 来记录计算逻辑，这样就把作用在 RDD 上的所有计算逻辑串起来形成了一个链条，但是并不会真的去计算结果。当对 RDD 进行动作 Action 时，Spark 会从计算链的最后一个 RDD 开始，利用迭代函数（Iterator）和计算函数（Compute），依次从上一个 RDD 获取数据并执行计算逻辑，最后输出结果。

此外，我们可以通过将一些需要复杂计算和经常调用的 RDD 进行持久化处理，从而提升计算效率。

思考题

对 RDD 进行持久化操作和记录 Checkpoint，有什么区别呢？

欢迎你把对弹性分布式数据集的疑问写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

13 | 弹性分布式数据集：Spark大厦的地基（上）

你好，我是蔡元楠。

今天我要与你分享的主题是“弹性分布式数据集”。

上一讲中提到，Spark 最基本的数据抽象是弹性分布式数据集（Resilient Distributed Dataset, 下文用 RDD 代指）。

Spark 基于 RDD 定义了很多数据操作，从而使得数据处理的代码十分简洁、高效。所以，要想深入学习 Spark，我们必须首先理解 RDD 的设计思想和特性。

为什么需要新的数据抽象模型？

传统的 MapReduce 框架之所以运行速度缓慢，很重要的原因就是有向无环图的中间计算结果需要写入硬盘这样的稳定存储介质中来防止运行结果丢失。

而每次调用中间计算结果都需要要进行一次硬盘的读取，反复对硬盘进行读写操作以及潜在的数据复制和序列化操作大大提高了计算的延迟。

因此，很多研究人员试图提出一个新的分布式存储方案，不仅保持之前系统的稳定性、错误恢复和可扩展性，还要尽可能地减少硬盘 I/O 操作。

一个可行的设想就是在分布式内存中，存储中间计算的结果，因为对内存的读写操作速度远快于硬盘。而 RDD 就是一个基于分布式内存的数据抽象，它不仅支持基于工作集的应用，同时具有数据流模型的特点。

RDD 的定义

弹性分布式数据集是英文直译的名字，乍一看这个名字相信你会不知所云。如果你去 Google 或者百度搜索它的定义，你会得到如下结果：

RDD 表示已被分区、不可变的，并能够被并行操作的数据集合。

这个定义很不直观，我认识的很多 Spark 初学者在查阅了很多资料后还是对 RDD 一头雾水，很难理解这个抽象的概念。接下来，让我们一起来对这个晦涩的概念抽丝剥茧，见其真义。

在上述定义以及 RDD 的中文译名中，我们不难发现，RDD 有以下基本特性：分区、不可变和并行操作。接下来让我分别讲解这些特点。

分区

顾名思义，分区代表同一个 RDD 包含的数据被存储在系统的不同节点中，这也是它可以被并行处理的前提。

逻辑上，我们可以认为 RDD 是一个大的数组。数组中的每个元素代表一个分区（Partition）。

在物理存储中，每个分区指向一个存放在内存或者硬盘中的数据块（Block），而这些数据块是独立的，它们可以被存放在系统中的不同节点。

所以，RDD 只是抽象意义的数据集合，分区内部并不会存储具体的数据。下图很好地展示了 RDD 的分区逻辑结构：

RDD 中的每个分区存有它在该 RDD 中的 index。通过 RDD 的 ID 和分区的 index 可以唯一确定对应数据块的编号，从而通过底层存储层的接口中提取到数据进行处理。

在集群中，各个节点上的数据块会尽可能地存放在内存中，只有当内存没有空间时才会存入硬盘。这样可以最大化地减少硬盘读写的开销。

虽然 RDD 内部存储的数据是只读的，但是，我们可以去修改（例如通过 repartition 转换操作）并行计算单元的划分结构，也就是分区的数量。

不可变性

不可变性代表每一个 RDD 都是只读的，它所包含的分区信息不可以被改变。既然已有的 RDD 不可以被改变，我们只可以对现有的 RDD 进行转换（Transformation）操作，得到新的 RDD 作为中间计算的结果。从某种程度上讲，RDD 与函数式编程的 Collection 很相似。

lines = sc.textFile("data.txt")

lineLengths = lines.map(lambda s: len(s))

totalLength = lineLengths.reduce(lambda a, b: a + b)

在上述的简单例子中，我们首先读入文本文件 data.txt，创建了第一个 RDD lines，它的每一个元素是一行文本。然后调用 map 函数去映射产生第二个 RDD lineLengths，每个元素代表每一行简单文本的字数。最后调用 reduce 函数去得到第三个 RDD totalLength，它只有一个元素，代表整个文本的总字数。

那么这样会带来什么好处呢？显然，对于代表中间结果的 RDD，我们需要记录它是通过哪个 RDD 进行哪些转换操作得来，即依赖关系，而不用立刻去具体存储计算出的数据本身。

这样做有助于提升 Spark 的计算效率，并且使错误恢复更加容易。

试想，在一个有 N 步的计算模型中，如果记载第 N 步输出 RDD 的节点发生故障，数据丢失，我们可以从第 N-1 步的 RDD 出发，再次计算，而无需重复整个 N 步计算过程。这样的容错特性也是 RDD 为什么是一个“弹性”的数据集的原因之一。后边我们会提到 RDD 如何存储这样的依赖关系。

并行操作

由于单个 RDD 的分区特性，使得它天然支持并行操作，即不同节点上的数据可以被分别处理，然后产生一个新的 RDD。

RDD 的结构

通过上述讲解，我们了解了 RDD 的基本特性——分区、不可变和并行计算。而且，我们还提到每一个 RDD 里都会包括分区信息、所依赖的父 RDD 以及通过怎样的转换操作才能由父 RDD 得来等信息。

实际上 RDD 的结构远比你想象的要复杂，让我们来看一个 RDD 的简易结构示意图：

SparkContext 是所有 Spark 功能的入口，它代表了与 Spark 节点的连接，可以用来创建 RDD 对象以及在节点中的广播变量等。一个线程只有一个 SparkContext。SparkConf 则是一些参数配置信息。感兴趣的同学可以去阅读官方的技术文档，一些相对不重要的概念我就不再赘述了。

Partitions 前文中我已经提到过，它代表 RDD 中数据的逻辑结构，每个 Partition 会映射到某个节点内存或硬盘的一个数据块。

Partitioner 决定了 RDD 的分区方式，目前有两种主流的分区方式：Hash partitioner 和 Range partitioner。Hash，顾名思义就是对数据的 Key 进行散列分区，Range 则是按照 Key 的排序进行均匀分区。此外我们还可以创建自定义的 Partitioner。

依赖关系

Dependencies 是 RDD 中最重要的组件之一。如前文所说，Spark 不需要将每个中间计算结果进行数据复制以防数据丢失，因为每一步产生的 RDD 里都会存储它的依赖关系，即它是通过哪个 RDD 经过哪个转换操作得到的。

细心的读者会问这样一个问题，父 RDD 的分区和子 RDD 的分区之间是否是一对一的对应关系呢？Spark 支持两种依赖关系：窄依赖（Narrow Dependency）和宽依赖（Wide Dependency）。

窄依赖就是父 RDD 的分区可以一一对应到子 RDD 的分区，宽依赖就是父 RDD 的每个分区可以被多个子 RDD 的分区使用。

显然，窄依赖允许子 RDD 的每个分区可以被并行处理产生，而宽依赖则必须等父 RDD 的所有分区都被计算好之后才能开始处理。

如上图所示，一些转换操作如 map、filter 会产生窄依赖关系，而 Join、groupBy 则会生成宽依赖关系。

这很容易理解，因为 map 是将分区里的每一个元素通过计算转化为另一个元素，一个分区里的数据不会跑到两个不同的分区。而 groupBy 则要将拥有所有分区里有相同 Key 的元素放到同一个目标分区，而每一个父分区都可能包含各种 Key 的元素，所以它可能被任意一个子分区所依赖。

Spark 之所以要区分宽依赖和窄依赖是出于以下两点考虑：

窄依赖可以支持在同一个节点上链式执行多条命令，例如在执行了 map 后，紧接着执行 filter。相反，宽依赖需要所有的父分区都是可用的，可能还需要调用类似 MapReduce 之类的操作进行跨节点传递。

从失败恢复的角度考虑，窄依赖的失败恢复更有效，因为它只需要重新计算丢失的父分区即可，而宽依赖牵涉到 RDD 各级的多个父分区。

小结

弹性分布式数据集作为 Spark 的基本数据抽象，相较于 Hadoop/MapReduce 的数据模型而言，各方面都有很大的提升。

首先，它的数据可以尽可能地存在内存中，从而大大提高的数据处理的效率；其次它是分区存储，所以天然支持并行处理；而且它还存储了每一步骤计算结果之间的依赖关系，从而大大提升了数据容错性和错误恢复的正确率，使 Spark 更加可靠。

下一讲，我们会继续深入研究 RDD 的容错机制、任务执行机制，以及 Spark 定义在 RDD 上的各种转换与动作操作。

思考题

窄依赖是指父 RDD 的每一个分区都可以唯一对应子 RDD 中的分区，那么是否意味着子 RDD 中的一个分区只可以对应父 RDD 中的一个分区呢？如果子 RDD 的一个分区需要由父 RDD 中若干个分区计算得来，是否还算窄依赖？

最后，欢迎你把对弹性分布式数据集的疑问写在留言区，与我和其他同学一起讨论。

如果你觉得有所收获，也欢迎把文章分享给你的朋友。

15 | Spark SQL：Spark数据查询的利器

你好，我是蔡元楠。

上一讲中，我介绍了弹性分布式数据集的特性和它支持的各种数据操作。

不过在实际的开发过程中，我们并不是总需要在 RDD 的层次进行编程。

就好比编程刚发明的年代，工程师只能用汇编语言，到后来才慢慢发展出高级语言，如 Basic、C、Java 等。使用高级语言大大提升了开发者的效率。

同样的，Spark 生态系统也提供很多库，让我们在不同的场景中使用。

今天，让我们来一起探讨 Spark 最常用的数据查询模块——Spark SQL。

Spark SQL 发展历史

几年前，Hadoop/MapReduce 在企业生产中的大量使用，HDFS 上积累了大量数据。

由于 MapReduce 对于开发者而言使用难度较大，大部分开发人员最熟悉的还是传统的关系型数据库。

为了方便大多数开发人员使用 Hadoop，Hive 应运而生。

Hive 提供类似 SQL 的编程接口，HQL 语句经过语法解析、逻辑计划、物理计划转化成 MapReduce 程序执行，使得开发人员很容易对 HDFS 上存储的数据进行查询和分析。

在 Spark 刚问世的时候，Spark 团队也开发了一个 Shark 来支持用 SQL 语言来查询 Spark 的数据。

Shark 的本质就是 Hive，它修改了 Hive 的内存管理模块，大幅优化了运行速度，是 Hive 的 10 倍到 100 倍之多。

但是，Shark 对于 Hive 的依赖严重影响了 Spark 的发展。Spark 想要定义的是一个统一的技术栈和完整的生态，不可能允许有这样的外在依赖。

试想，如果 Spark 想发布新的功能还需要等 Hive 的更新，那么势必会很难执行。此外，依赖于 Hive 还制约了 Spark 各个组件的相互集成，Shark 也无法利用 Spark 的特性进行深度优化。

所以，2014 年 7 月 1 日，Spark 团队就将 Shark 交给 Hive 进行管理，转而开发了 SparkSQL。

SparkSQL 摒弃了 Shark 的（将 SQL 语句转化为 Spark RDD 的）执行引擎，换成自己团队重新开发的执行引擎。

Spark SQL 不仅将关系型数据库的处理模式和 Spark 的函数式编程相结合，还兼容多种数据格式，包括 Hive、RDD、JSON 文件、CSV 文件等。

可以说，Spark SQL 的问世大大加快了 Spark 生态的发展。

Spark SQL 的架构

Spark SQL 本质上是一个库。它运行在 Spark 的核心执行引擎之上。

如上图所示，它提供类似于 SQL 的操作接口，允许数据仓库应用程序直接获取数据，允许使用者通过命令行操作来交互地查询数据，还提供两个 API：DataFrame API 和 DataSet API。

Java、Python 和 Scala 的应用程序可以通过这两个 API 来读取和写入 RDD。

此外，正如我们在上一讲介绍的，应用程序还可以直接操作 RDD。

使用 Spark SQL 会让开发者觉得好像是在操作一个关系型数据库一样，而不是在操作 RDD。这是它优于原生的 RDD API 的地方。

与基本的 Spark RDD API 不同，Spark SQL 提供的接口为 Spark 提供了关于数据结构和正在执行的计算的更多信息。

在内部，Spark SQL 使用这些额外的信息来执行额外的优化。虽然 Spark SQL 支持多种交互方式，但是在计算结果时均使用相同的执行引擎。

这种统一意味着开发人员可以轻松地在不同的 API 之间来回切换，基于这些 API 提供了表达给定转换的最自然的方式。

接下来让我们进一步了解 DataSet 和 DataFrame。

DataSet

DataSet，顾名思义，就是数据集的意思，它是 Spark 1.6 新引入的接口。

同弹性分布式数据集类似，DataSet 也是不可变分布式的数据单元，它既有与 RDD 类似的各种转换和动作函数定义，而且还享受 Spark SQL 优化过的执行引擎，使得数据搜索效率更高。

DataSet 支持的转换和动作也和 RDD 类似，比如 map、filter、select、count、show 及把数据写入文件系统中。

同样地，DataSet 上的转换操作也不会被立刻执行，只是先生成新的 DataSet，只有当遇到动作操作，才会把之前的转换操作一并执行，生成结果。

所以，DataSet 的内部结构包含了逻辑计划，即生成该数据集所需要的运算。

当动作操作执行时，Spark SQL 的查询优化器会优化这个逻辑计划，并生成一个可以分布式执行的、包含分区信息的物理计划。

那么，DataSet 和 RDD 的区别是什么呢？

通过之前的叙述，我们知道 DataSet API 是 Spark SQL 的一个组件。那么，你应该能很容易地联想到，DataSet 也具有关系型数据库中表的特性。

是的，DataSet 所描述的数据都被组织到有名字的列中，就像关系型数据库中的表一样。

如上图所示，左侧的 RDD 虽然以 People 为类型参数，但 Spark 框架本身不了解 People 类的内部结构。所有的操作都以 People 为单位执行。

而右侧的 DataSet 却提供了详细的结构信息与每列的数据类型。

这让 Spark SQL 可以清楚地知道该数据集中包含哪些列，每列的名称和类型各是什么。也就是说，DataSet 提供数据表的 schema 信息。这样的结构使得 DataSet API 的执行效率更高。

试想，如果我们要查询 People 的年龄信息，Spark SQL 执行的时候可以依靠查询优化器仅仅把需要的那一列取出来，其他列的信息根本就不需要去读取了。所以，有了这些信息以后在编译的时候能够做更多的优化。

其次，由于 DataSet 存储了每列的数据类型。所以，在程序编译时可以执行类型检测。

DataFrame

DataFrame 可以被看作是一种特殊的 DataSet。它也是关系型数据库中表一样的结构化存储机制，也是分布式不可变的数据结构。

但是，它的每一列并不存储类型信息，所以在编译时并不能发现类型错误。DataFrame 每一行的类型固定为 Row，他可以被当作 DataSet[Row] 来处理，我们必须要通过解析才能获取各列的值。

所以，对于 DataSet 我们可以用类似 people.name 来访问一个人的名字，而对于 DataFrame 我们一定要用类似 people.get As [String] (“name”) 来访问。

RDD、DataFrame、DataSet 对比

学习 Spark 到现在，我们已经接触了三种基本的数据结构：RDD、DataFrame 和 DataSet。接下来你的表格中，你可以看到它们的异同点，思考一下怎样在实际工程中选择。

发展历史

从发展历史上来看，RDD API 在第一代 Spark 中就存在，是整个 Spark 框架的基石。

接下来，为了方便熟悉关系型数据库和 SQL 的开发人员使用，在 RDD 的基础上，Spark 创建了 DataFrame API。依靠它，我们可以方便地对数据的列进行操作。

DataSet 最早被加入 Spark SQL 是在 Spark 1.6，它在 DataFrame 的基础上添加了对数据的每一列的类型的限制。

在 Spark 2.0 中，DataFrame 和 DataSet 被统一。DataFrame 作为 DataSet[Row] 存在。在弱类型的语言，如 Python 中，DataFrame API 依然存在，但是在 Java 中，DataFrame API 已经不复存在了。

不变性与分区

由于 DataSet 和 DataFrame 都是基于 RDD 的，所以它们都拥有 RDD 的基本特性，在此不做赘述。而且我们可以通过简单的 API 在 DataFrame 或 Dataset 与 RDD 之间进行无缝切换。

性能

DataFrame 和 DataSet 的性能要比 RDD 更好。

Spark 程序运行时，Spark SQL 中的查询优化器会对语句进行分析，并生成优化过的 RDD 在底层执行。

举个例子，如果我们想先对一堆数据进行 GroupBy 再进行 Filter 操作，这无疑是低效的，因为我们并不需要对所有数据都 GroupBy。

如果用 RDD API 实现这一语句，在执行时它只会机械地按顺序执行。而如果用 DataFrame/DataSet API，Spark SQL 的 Catalyst 优化器会将 Filter 操作和 GroupBy 操作调换顺序，从而提高执行效率。

下图反映了这一优化过程。

错误检测

RDD 和 DataSet 都是类型安全的，而 DataFrame 并不是类型安全的。这是因为它不存储每一列的信息如名字和类型。

使用 DataFrame API 时，我们可以选择一个并不存在的列，这个错误只有在代码被执行时才会抛出。如果使用 DataSet API，在编译时就会检测到这个错误。

小结

DataFrame 和 DataSet 是 Spark SQL 提供的基于 RDD 的结构化数据抽象。

它既有 RDD 不可变、分区、存储依赖关系等特性，又拥有类似于关系型数据库的结构化信息。

所以，基于 DataFrame 和 DataSet API 开发出的程序会被自动优化，使得开发人员不需要操作底层的 RDD API 来进行手动优化，大大提升开发效率。

但是 RDD API 对于非结构化的数据处理有独特的优势，比如文本流数据，而且更方便我们做底层的操作。所以在开发中，我们还是需要根据实际情况来选择使用哪种 API。

思考题

什么场景适合使用 DataFrame API，什么场景适合使用 DataSet API？

欢迎你把答案写在留言区，与我和其他同学一起讨论。

如果你觉得有所收获，也欢迎把文章分享给你的朋友。

16 | Spark Streaming：Spark的实时流计算API

你好，我是蔡元楠。

今天我要与你分享的内容是“Spark Streaming”。

通过上一讲的内容，我们深入了解了 Spark SQL API。通过它，我们可以像查询关系型数据库一样查询 Spark 的数据，并且对原生数据做相应的转换和动作。

但是，无论是 DataFrame API 还是 DataSet API，都是基于批处理模式对静态数据进行处理的。比如，在每天某个特定的时间对一天的日志进行处理分析。

在第二章中你已经知道了，批处理和流处理是大数据处理最常见的两个场景。那么作为当下最流行的大数据处理平台之一，Spark 是否支持流处理呢？

答案是肯定的。

早在 2013 年，Spark 的流处理组件 Spark Streaming 就发布了。之后经过好几年的迭代与改进，现在的 Spark Streaming 已经非常成熟，在业界应用十分广泛。

今天就让我们一起揭开 Spark Streaming 的神秘面纱，让它成为我们手中的利器。

Spark Streaming 的原理

Spark Streaming 的原理与微积分的思想很类似。

在大学的微积分课上，你的老师一定说过，微分就是无限细分，积分就是对无限细分的每一段进行求和。它本质上把一个连续的问题转换成了无限个离散的问题。

比如，用微积分思想求下图中阴影部分 S 的面积。

我们可以把 S 无限细分成无数个小矩形，因为矩形的宽足够短，所以它顶端的边近似是一个直线。这样，把容易计算的矩形面积相加，就得到不容易直接计算的不规则图形面积。

你知道，流处理的数据是一系列连续不断变化，且无边界的。我们永远无法预测下一秒的数据是什么样。Spark Streaming 用时间片拆分了无限的数据流，然后对每一个数据片用类似于批处理的方法进行处理，输出的数据也是一块一块的。如下图所示。

Spark Streaming 提供一个对于流数据的抽象 DStream。DStream 可以由来自 Apache Kafka、Flume 或者 HDFS 的流数据生成，也可以由别的 DStream 经过各种转换操作得来。讲到这里，你是不是觉得内容似曾相识？

没错，底层 DStream 也是由很多个序列化的 RDD 构成，按时间片（比如一秒）切分成的每个数据单位都是一个 RDD。然后，Spark 核心引擎将对 DStream 的 Transformation 操作变为针对 Spark 中对 RDD 的 Transformation 操作，将 RDD 经过操作变成中间结果保存在内存中。

之前的 DataFrame 和 DataSet 也是同样基于 RDD，所以说 RDD 是 Spark 最基本的数据抽象。就像 Java 里的基本数据类型（Primitive Type）一样，所有的数据都可以用基本数据类型描述。

也正是因为这样，无论是 DataFrame，还是 DStream，都具有 RDD 的不可变性、分区性和容错性等特质。

所以，Spark 是一个高度统一的平台，所有的高级 API 都有相同的性质，它们之间可以很容易地相互转化。Spark 的野心就是用这一套工具统一所有数据处理的场景。

由于 Spark Streaming 将底层的细节封装起来了，所以对于开发者来说，只需要操作 DStream 就行。接下来，让我们一起学习 DStream 的结构以及它支持的转换操作。

DStream

下图就是 DStream 的内部形式，即一个连续的 RDD 序列，每一个 RDD 代表一个时间窗口的输入数据流。

对 DStream 的转换操作，意味着对它包含的每一个 RDD 进行同样的转换操作。比如下边的例子。

sc = SparkContext(master, appName)

ssc = StreamingContext(sc, 1)

lines = sc.socketTextStream("localhost", 9999)

words = lines.flatMap(lambda line: line.split(" "))

首先，我们创建了一个 lines 的 DStream，去监听来自本机 9999 端口的数据流，每一个数据代表一行文本。然后，对 lines 进行 flatMap 的转换操作，把每一个文本行拆分成词语。

本质上，对一个 DStream 进行 flatMap 操作，就是对它里边的每一个 RDD 进行 flatMap 操作，生成了一系列新的 RDD，构成了一个新的代表词语的 DStream。

正因为 DStream 和 RDD 的关系，RDD 支持的所有转换操作，DStream 都支持，比如 map、flatMap、filter、union 等。这些操作我们在前边学习 RDD 时都详细介绍过，在此不做赘述。

此外，DStream 还有一些特有操作，如滑动窗口操作，我们可以一起探讨。

滑动窗口操作

任何 Spark Streaming 的程序都要首先创建一个StreamingContext的对象，它是所有 Streaming 操作的入口。

比如，我们可以通过 StreamingContext 来创建 DStream。前边提到的例子中，lines 这个 DStream 就是由名为 sc 的 StreamingContext 创建的。

StreamingContext 中最重要的参数是批处理的时间间隔，即把流数据细分成数据块的粒度。

这个时间间隔决定了流处理的延迟性，所以，需要我们根据需求和资源来权衡间隔的长度。上边的例子中，我们把输入的数据流以秒为单位划分，每一秒的数据会生成一个 RDD 进行运算。

有些场景中，我们需要每隔一段时间，统计过去某个时间段内的数据。比如，对热点搜索词语进行统计，每隔 10 秒钟输出过去 60 秒内排名前十位的热点词。这是流处理的一个基本应用场景，很多流处理框架如 Apache Flink 都有原生的支持。所以，Spark 也同样支持滑动窗口操作。

从统计热点词这个例子，你可以看出滑动窗口操作有两个基本参数：

窗口长度（window length）：每次统计的数据的时间跨度，在例子中是 60 秒；

滑动间隔（sliding interval）：每次统计的时间间隔，在例子中是 10 秒。

显然，由于 Spark Streaming 流处理的最小时间单位就是 StreamingContext 的时间间隔，所以这两个参数一定是它的整数倍。

最基本的滑动窗口操作是 window，它可以返回一个新的 DStream，这个 DStream 中每个 RDD 代表一段时间窗口内的数据，如下例所示。

windowed_words = words.window(60, 10)

windowed_words 代表的就是热词统计例子中我们所需的 DStream，即它里边每一个数据块都包含过去 60 秒内的词语，而且这样的块每 10 秒钟就会生成一个。

此外，Spark Streaming 还支持一些“进阶”窗口操作。如 countByWindow、reduceByWindow、reduceByKeyAndWindow 和 countByValueAndWindow，在此不做深入讨论。

Spark Streaming 的优缺点

讲了这么多 Spark Streaming，不管内部实现也好，支持的 API 也好，我们还并不明白它的优势是什么，相比起其他流处理框架的缺点是什么。只有明白了这些，才能帮助我们在实际工作中决定是否使用 Spark Streaming。

首先，Spark Streaming 的优点很明显，由于它的底层是基于 RDD 实现的，所以 RDD 的优良特性在它这里都有体现。

比如，数据容错性，如果 RDD 的某些分区丢失了，可以通过依赖信息重新计算恢复。

再比如运行速度，DStream 同样也能通过 persist() 方法将数据流存放在内存中。这样做的好处是遇到需要多次迭代计算的程序时，速度优势十分明显。

而且，Spark Streaming 是 Spark 生态的一部分。所以，它可以和 Spark 的核心引擎、Spark SQL、MLlib 等无缝衔接。换句话说，对实时处理出来的中间数据，我们可以立即在程序中无缝进行批处理、交互式查询等操作。这个特点大大增强了 Spark Streaming 的优势和功能，使得基于 Spark Streaming 的应用程序很容易扩展。

而 Spark Streaming 的主要缺点是实时计算延迟较高，一般在秒的级别。这是由于 Spark Streaming 不支持太小的批处理的时间间隔。

在第二章中，我们讲过准实时和实时系统，无疑 Spark Streaming 是一个准实时系统。别的流处理框架，如 Storm 的延迟性就好很多，可以做到毫秒级。

小结

Spark Streaming，作为 Spark 中的流处理组件，把连续的流数据按时间间隔划分为一个个数据块，然后对每个数据块分别进行批处理。

在内部，每个数据块就是一个 RDD，所以 Spark Streaming 有 RDD 的所有优点，处理速度快，数据容错性好，支持高度并行计算。

但是，它的实时延迟相比起别的流处理框架比较高。在实际工作中，我们还是要具体情况具体分析，选择正确的处理框架。

思考题

如果想要优化一个 Spark Streaming 程序，你会从哪些角度入手？

欢迎你把答案写在留言区，与我和其他同学一起讨论。

如果你觉得有所收获，也欢迎把文章分享给你的朋友。

17 | Structured Streaming：如何用DataFrame API进行实时数据分析?

你好，我是蔡元楠。

上一讲中，我们介绍了 Spark 中的流处理库 Spark Streaming。它将无边界的流数据抽象成 DStream，按特定的时间间隔，把数据流分割成一个个 RDD 进行批处理。所以，DStream API 与 RDD API 高度相似，也拥有 RDD 的各种性质。

在第 15 讲中，我们比较过 RDD 和 DataSet/DataFrame。你还记得 DataSet/DataFrame 的优点吗？你有没有想过，既然已经有了 RDD API，我们为什么还要引入 DataSet/DataFrame 呢？

让我们来回顾一下 DataSet/DataFrame 的优点（为了方便描述，下文中我们统一用 DataFrame 来代指 DataSet 和 DataFrame）：

DataFrame 是高级 API，提供类似于SQL的 query 接口，方便熟悉关系型数据库的开发人员使用；

Spark SQL 执行引擎会自动优化 DataFrame 程序，而用 RDD API 开发的程序本质上需要工程师自己构造 RDD 的 DAG 执行图，所以依赖于工程师自己去优化。

那么我们自然会想到，如果可以拥有一个基于 DataFrame API 的流处理模块，作为工程师的我们就不需要去用相对 low level 的 DStream API 去处理无边界数据，这样会大大提升我们的开发效率。

基于这个思想，2016 年，Spark 在其 2.0 版本中推出了结构化流数据处理的模块 Structured Streaming。

Structured Streaming 是基于 Spark SQL 引擎实现的，依靠 Structured Streaming，在开发者眼里，流数据和静态数据没有区别。我们完全可以像批处理静态数据那样去处理流数据。随着流数据的持续输入，Spark SQL 引擎会帮助我们持续地处理新数据，并且更新计算结果。

今天，就让我们来一起学习 Structured Streaming 的原理以及应用。

Structured Streaming 模型

流数据处理最基本的问题就是如何对不断更新的无边界数据建模。

之前讲的 Spark Streaming 就是把流数据按一定的时间间隔分割成许多个小的数据块进行批处理。在 Structured Streaming 的模型中，我们要把数据看成一个无边界的关系型的数据表。每一个数据都是表中的一行，不断会有新的数据行被添加到表里来。我们可以对这个表做任何类似批处理的查询，Spark 会帮我们不断对新加入的数据进行处理，并更新计算结果。

与 Spark Streaming 类似，Structured Streaming 也是将输入的数据流按照时间间隔（以一秒为例）划分成数据段。每一秒都会把新输入的数据添加到表中，Spark 也会每秒更新输出结果。输出结果也是表的形式，输出表可以写入硬盘或者 HDFS。

这里我要介绍一下 Structured Streaming 的三种输出模式。

完全模式（Complete Mode）：整个更新过的输出表都被写入外部存储；

附加模式（Append Mode）：上一次触发之后新增加的行才会被写入外部存储。如果老数据有改动则不适合这个模式；

更新模式（Update Mode）：上一次触发之后被更新的行才会被写入外部存储。

需要注意的是，Structured Streaming 并不会完全存储输入数据。每个时间间隔它都会读取最新的输入，进行处理，更新输出表，然后把这次的输入删除。Structured Streaming 只会存储更新输出表所需要的信息。

Structured Streaming 的模型在根据事件时间（Event Time）处理数据时十分方便。

我们在第六讲中曾经讲过事件时间和处理时间（Processing Time）的区别。这里我再简单说一下。事件时间指的是事件发生的时间，是数据本身的属性；而处理时间指的是 Spark 接收到数据的时间。

很多情况下，我们需要基于事件时间来处理数据。比如说，统计每个小时接到的订单数量，一个订单很有可能在 12:59 被创建，但是到了 13:01 才被处理。

在 Structured Streaming 的模型中，由于每个数据都是输入数据表中的一行，那么事件时间就是行中的一列。依靠 DataFrame API 提供的类似于 SQL 的接口，我们可以很方便地执行基于时间窗口的查询。

Streaming DataFrame API

在 Structured Streaming 发布以后，DataFrame 既可以代表静态的有边界数据，也可以代表无边界数据。之前对静态 DataFrame 的各种操作同样也适用于流式 DataFrame。接下来，让我们看几个例子。

创建 DataFrame

SparkSession.readStream() 返回的 DataStreamReader 可以用于创建流 DataFrame。它支持多种类型的数据流作为输入，比如文件、Kafka、socket 等。

socketDataFrame = spark

   .readStream

   .format("socket"）

   .option("host", "localhost")

   .option("port", 9999)

   .load()

上边的代码例子创建了一个 DataFrame，用来监听来自 localhost:9999 的数据流。

基本的查询操作

流 DataFrame 同静态 DataFrame 一样，不仅支持类似 SQL 的查询操作（如 select 和 where 等），还支持 RDD 的转换操作（如 map 和 filter）。 让我们一起来看下面的例子。

假设我们已经有一个 DataFrame 代表一个学生的数据流，即每个数据是一个学生，每个学生有名字（name）、年龄（age）、身高（height）和年级（grade）四个属性，我们可以用 DataFrame API 去做类似于 SQL 的 Query。

df = … // 这个 DataFrame 代表学校学生的数据流，schema 是{name: string, age: number, height: number, grade: string}

df.select("name").where("age > 10") // 返回年龄大于 10 岁的学生名字列表

df.groupBy("grade").count() // 返回每个年级学生的人数

df.sort_values([‘age’], ascending=False).head(100) // 返回 100 个年龄最大的学生

在这个例子中，通过第二行我们可以得到所有年龄在 10 岁以上的学生名字，第三行可以得到每个年级学生的人数，第四行得到 100 个年龄最大的学生信息。此外，DataFrame 还支持很多基本的查询操作，在此不做赘述。

我们还可以通过 isStreaming 函数来判断一个 DataFrame 是否代表流数据。

df.isStreaming()

基于事件时间的时间窗口操作

在学习 Spark Streaming 的时间窗口操作时，我们举过一个例子，是每隔 10 秒钟输出过去 60 秒的前十热点词。这个例子是基于处理时间而非事件时间的。

现在让我们设想一下，如果数据流中的每个词语都有一个时间戳代表词语产生的时间，那么要怎样实现，每隔 10 秒钟输出过去 60 秒内产生的前十热点词呢？你可以看看下边的代码。

words = ...  # 这个 DataFrame 代表词语的数据流，schema 是 { timestamp: Timestamp, word: String}

windowedCounts = words.groupBy(

   window(words.timestamp, "1 minute", "10 seconds"),

   words.word

).count()

.sort(desc("count"))

.limit(10)

基于词语的生成时间，我们创建了一个窗口长度为 1 分钟，滑动间隔为 10 秒的 window。然后，把输入的词语表根据 window 和词语本身聚合起来，并统计每个 window 内每个词语的数量。之后，再根据词语的数量进行排序，只返回前 10 的词语。

在 Structured Streaming 基于时间窗口的聚合操作中，groupBy 是非常常用的。

输出结果流

当经过各种 SQL 查询操作之后，我们创建好了代表最终结果的 DataFrame。下一步就是开始对输入数据流的处理，并且持续输出结果。

我们可以用 Dataset.writeStream() 返回的 DataStreamWriter 对象去输出结果。它支持多种写入位置，如硬盘文件、Kafka、console 和内存等。

query = wordCounts

   .writeStream

   .outputMode("complete")

   .format("csv")

   .option("path", "path/to/destination/dir")

   .start()

query.awaitTermination()

在上面这个代码例子中，我们选择了完全模式，把输出结果流写入了 CSV 文件。

Structured Streaming 与 Spark Streaming 对比

接下来，让我们对比一下 Structured Streaming 和上一讲学过的 Spark Streaming。看看同为流处理的组件的它们各有什么优缺点。

简易度和性能

Spark Streaming 提供的 DStream API 与 RDD API 很类似，相对比较低 level。

当我们编写 Spark Streaming 程序的时候，本质上就是要去构造 RDD 的 DAG 执行图，然后通过 Spark Engine 运行。这样开发者身上的担子就很重，很多时候要自己想办法去提高程序的处理效率。这不是 Spark 作为一个数据处理框架想看到的。对于好的框架来说，开发者只需要专注在业务逻辑上，而不用操心别的配置、优化等繁杂事项。

Structured Streaming 提供的 DataFrame API 就是这么一个相对高 level 的 API，大部分开发者都很熟悉关系型数据库和 SQL。这样的数据抽象可以让他们用一套统一的方案去处理批处理和流处理，不用去关心具体的执行细节。

而且，DataFrame API 是在 Spark SQL 的引擎上执行的，Spark SQL 有非常多的优化功能，比如执行计划优化和内存管理等，所以 Structured Streaming 的应用程序性能很好。

实时性

在上一讲中我们了解到，Spark Streaming 是准实时的，它能做到的最小延迟在一秒左右。

虽然 Structured Streaming 用的也是类似的微批处理思想，每过一个时间间隔就去拿来最新的数据加入到输入数据表中并更新结果，但是相比起 Spark Streaming 来说，它更像是实时处理，能做到用更小的时间间隔，最小延迟在 100 毫秒左右。

而且在 Spark 2.3 版本中，Structured Streaming 引入了连续处理的模式，可以做到真正的毫秒级延迟，这无疑大大拓展了 Structured Streaming 的应用广度。不过现在连续处理模式还有很多限制，让我们期待它的未来吧。

对事件时间的支持

就像我们在前边讲过的，Structured Streaming 对基于事件时间的处理有很好的支持。

由于 Spark Streaming 是把数据按接收到的时间切分成一个个 RDD 来进行批处理，所以它很难基于数据本身的产生时间来进行处理。如果某个数据的处理时间和事件时间不一致的话，就容易出问题。比如，统计每秒的词语数量，有的数据先产生，但是在下一个时间间隔才被处理，这样几乎不可能输出正确的结果。

Structured Streaming 还有很多其他优点。比如，它有更好的容错性，保证了端到端 exactly once 的语义等等。所以综合来说，Structured Streaming 是比 Spark Streaming 更好的流处理工具。

思考题

在基于事件时间的窗口操作中，Structured Streaming 是怎样处理晚到达的数据，并且返回正确结果的呢？

比如，在每十分钟统计词频的例子中，一个词语在 1:09 被生成，在 1:11 被处理，程序在 1:10 和 1:20 都输出了对应的结果，在 1:20 输出时为什么可以把这个词语统计在内？这样的机制有没有限制？

欢迎你把自己的答案写在留言区，与我和其他同学一起讨论。

如果你觉得有所收获，也欢迎把文章分享给你的朋友。

18 | Word Count：从零开始运行你的第一个Spark应用

你好，我是蔡元楠。

今天我们来从零开始运行你的第一个 Spark 应用。

我们先来回顾一下模块三的学习路径。

首先，我们由浅入深地学习了 Spark 的基本数据结构 RDD，了解了它这样设计的原因，以及它所支持的 API。

之后，我们又学习了 Spark SQL 的 DataSet/DataFrame API，了解到它不仅提供类似于 SQL query 的接口，大大提高了开发者的工作效率，还集成了 Catalyst 优化器，可以提升程序的性能。

这些 API 应对的都是批处理的场景。

再之后，我们学习了 Spark 的流处理模块：Spark Streaming 和 Structured Streaming。两者都是基于微批处理（Micro batch processing）的思想，将流数据按时间间隔分割成小的数据块进行批处理，实时更新计算结果。

其中 Structured Streaming 也是使用 DataSet/DataFrame API，这套 API 在某种程度上统一了批处理和流处理，是当前 Spark 最流行的工具，我们必需要好好掌握。

虽然学习了这么多 API 以及它们的应用，但是大部分同学还没有从零开始写一个完整的 Spark 程序，可能更没有运行 Spark 程序的经历。纸上谈兵并不能帮助我们在工作生活中用 Spark 解决实际问题。所以，今天我就和你一起做个小练习，从在本地安装 Spark、配置环境开始，为你示范怎样一步步解决之前提到数次的统计词频（Word Count）的问题。

通过今天的学习，你可以收获：

怎样安装 Spark 以及其他相关的模块；

知道什么是 SparkContext、SparkSession；

一个完整的 Spark 程序应该包含哪些东西；

用 RDD、DataFrame、Spark Streaming 如何实现统计词频。

这一讲中，我们使用的编程语言是 Python，操作系统是 Mac OS X。

在这一讲以及之前文章的例子中，我们都是用 Python 作为开发语言。虽然原生的 Spark 是用 Scala 实现，但是在大数据处理领域中，我个人最喜欢的语言是 Python。因为它非常简单易用，应用非常广泛，有很多的库可以方便我们开发。

当然 Scala 也很棒，作为一个函数式编程语言，它很容易用链式表达对数据集进行各种处理，而且它的运行速度是最快的，感兴趣的同学可以去学习一下。

虽然 Spark 还支持 Java 和 R，但是我个人不推荐你使用。用 Java 写程序实在有些冗长，而且速度上没有优势。

操作系统选 Mac OS X 是因为我个人喜欢使用 Macbook，当然 Linux/Ubuntu 也很棒。

安装 Spark

首先，我们来简单介绍一下如何在本地安装 Spark，以及用 Python 实现的 Spark 库——PySpark。

在前面的文章中，我们了解过，Spark 的 job 都是 JVM（Java Virtual Machine）的进程，所以在安装运行 Spark 之前，我们需要确保已经安装 Java Developer Kit（JDK）。在命令行终端中输入：

java -version

如果命令行输出了某个 Java 的版本，那么说明你已经有 JDK 或者 JRE 在本地。如果显示无法识别这个命令，那么说明你还没有安装 JDK。这时，你可以去Oracle 的官网去下载安装 JDK，然后配置好环境变量。

同样，我们需要确保 Python 也已经被安装在本地了。在命令行输入“Python”或者“Python3”，如果可以成功进入交互式的 Python Shell，就说明已经安装了 Python。否则，需要去Python 官网下载安装 Python。这里，我推荐你使用 Python3 而不是 Python2。

我们同样可以在本地预装好 Hadoop。Spark 可以脱离 Hadoop 运行，不过有时我们也需要依赖于 HDFS 和 YARN。所以，这一步并不是必须的，你可以自行选择。

接下来我们就可以安装 Spark。首先去Spark 官网的下载界面。在第一个下拉菜单里选择最新的发布，第二个菜单最好选择与 Hadoop 2.7 兼容的版本。因为有时我们的 Spark 程序会依赖于 HDFS 和 YARN，所以选择最新的 Hadoop 版本比较好。

下载好之后，解压缩 Spark 安装包，并且把它移动到 /usr/local 目录下，在终端中输入下面的代码。

$ tar -xzf ~/Dowmloads/spark-2.4.3-bin-hadoop2.7.tg

$ mv spark-2.4.3-bin-hadoop2.7.tgz /usr/local/spark

经过上述步骤，从官网下载并安装 Spark 的文件，这样我们便完成了 Spark 的安装。但是，Spark 也是要进行相应的环境变量配置的。你需要打开环境变量配置文件。

vim ~/.bash_profile

并在最后添加一段代码。

export SPARK_HOME=/usr/local/spark

export PATH=$PATH:$SPARK_HOME/bin

这样，所需的步骤都做完之后，我们在命令行控制台输入 PySpark，查看安装情况。如果出现下面的欢迎标志，就说明安装完毕了。

Welcome to

      ____              __

     / __/__  ___ _____/ /__

    _\ \/ _ \/ _ `/ __/  '_/

   /__ / .__/\_,_/_/ /_/\_\   version 2.4.3

      /_/

Using Python version 2.7.10 (default, Oct  6 2017 22:29:07)

SparkSession available as 'spark'.

>>>

基于 RDD API 的 Word Count 程序

配置好所需的开发环境之后，下一步就是写一个 Python 程序去统计词语频率。我们都知道这个程序的逻辑应该是如下图所示的。

对于中间的先 map 再 reduce 的处理，我相信通过前面的学习，所有同学都可以用 RDD 或者 DataFrame 实现。

但是，我们对于 Spark 程序的入口是什么、如何用它读取和写入文件，可能并没有了解太多。所以，接下来让我们先接触一下 Spark 程序的入口。

在 Spark 2.0 之前，SparkContext是所有 Spark 任务的入口，它包含了 Spark 程序的基本设置，比如程序的名字、内存大小、并行处理的粒度等，Spark 的驱动程序需要利用它来连接到集群。

无论 Spark 集群有多少个节点做并行处理，每个程序只可以有唯一的 SparkContext，它可以被 SparkConf 对象初始化。

conf = SparkConf().setAppName(appName).setMaster(master)

sc = SparkContext(conf=conf)

这个 appName 参数是一个在集群 UI 上展示应用程序的名称，master 参数是一个 Spark、Mesos 或 YARN 的集群 URL，对于本地运行，它可以被指定为“local”。

在统计词频的例子中，我们需要通过 SparkContext 对象来读取输入文件，创建一个 RDD，如下面的代码所示。

text_file = sc.textFile("file://…...") // 替换成实际的本地文件路径。

这里的 text_file 是一个 RDD，它里面的每一个数据代表原文本文件中的一行。

在这些版本中，如果要使用 Spark 提供的其他库，比如 SQL 或 Streaming，我们就需要为它们分别创建相应的 context 对象，才能调用相应的 API，比如的 DataFrame 和 DStream。

hc = HiveContext(sc)

ssc = StreamingContext(sc)

在 Spark 2.0 之后，随着新的 DataFrame/DataSet API 的普及化，Spark 引入了新的SparkSession对象作为所有 Spark 任务的入口。

SparkSession 不仅有 SparkContext 的所有功能，它还集成了所有 Spark 提供的 API，比如 DataFrame、Spark Streaming 和 Structured Streaming，我们再也不用为不同的功能分别定义 Context。

在统计词频的例子中，我们可以这样初始化 SparkSession 以及创建初始 RDD。

spark = SparkSession

       .builder

       .appName(appName)

       .getOrCreate()

text_file = spark.read.text("file://….").rdd.map(lambda r: r[0])

由于 SparkSession 的普适性，我推荐你尽量使用它作为你们 Spark 程序的入口。随后的学习中，我们会逐渐了解怎样通过它调用 DataFrame 和 Streaming API。

让我们回到统计词频的例子。在创建好代表每一行文本的 RDD 之后，接下来我们便需要两个步骤。

把每行的文本拆分成一个个词语；

统计每个词语的频率。

对于第一步，我们可以用 flatMap 去把行转换成词语。对于第二步，我们可以先把每个词语转换成（word, 1）的形式，然后用 reduceByKey 去把相同词语的次数相加起来。这样，就很容易写出下面的代码了。

counts = lines.flatMap(lambda x: x.split(' '))

                  .map(lambda x: (x, 1))

                  .reduceByKey(add)

这里 counts 就是一个包含每个词语的（word，count）pair 的 RDD。

相信你还记得，只有当碰到 action 操作后，这些转换动作才会被执行。所以，接下来我们可以用 collect 操作把结果按数组的形式返回并输出。

output = counts.collect()

for (word, count) in output:

    print("%s: %i" % (word, count))

spark.stop() // 停止 SparkSession

基于 DataSet API 的 Word Count 程序

讲完基于 RDD API 的 Word Count 程序，接下来让我们学习下怎样用 DataSet API 来实现相同的效果。

在 DataSet 的世界中，我们可以把所有的词语放入一张表，表中的每一行代表一个词语，当然这个表只有一列。我们可以对这个表用一个 groupBy() 操作把所有相同的词语聚合起来，然后用 count() 来统计出每个 group 的数量。

spark = SparkSession

        .builder

        .appName(appName)

        .getOrCreate()

ds_lines = spark.read.textFile("file://….")

ds = ds_lines.flatMap(lambda x: x.split(' '))

                    .groupBy("Value")

                    .count()

ds.show()

spark.stop()

从这个例子，你可以很容易看出使用 DataSet/DataFrame API 的便利性——我们不需要创建（word, count）的 pair 来作为中间值，可以直接对数据做类似 SQL 的查询。

小结

通过今天的学习，我们掌握了如何从零开始创建一个简单的 Spark 的应用程序，包括如何安装 Spark、如何配置环境、Spark 程序的基本结构等等。

实践题

希望你可以自己动手操作一下，这整个过程只需要跑通一次，以后就可以脱离纸上谈兵，真正去解决实际问题。

欢迎你在留言中反馈自己动手操作的效果。

如果你跑通了，可以在留言中打个卡。如果遇到了问题，也请你在文章中留言，与我和其他同学一起讨论。

19 | 综合案例实战：处理加州房屋信息，构建线性回归模型

你好，我是蔡元楠。

今天我要与你分享的主题是“综合案例实战：处理加州房屋信息，构建线性回归模型”。

通过之前的学习，我们对 Spark 各种 API 的基本用法有了一定的了解，还通过统计词频的实例掌握了如何从零开始写一个 Spark 程序。那么现在，让我们从一个真实的数据集出发，看看如何用 Spark 解决实际问题。

数据集介绍

为了完成今天的综合案例实战，我使用的是美国加州 1990 年房屋普查的数据集。

数据集中的每一个数据都代表着一块区域内房屋和人口的基本信息，总共包括 9 项：

该地区中心的纬度（latitude）

该地区中心的经度（longitude）

区域内所有房屋屋龄的中位数（housingMedianAge）

区域内总房间数（totalRooms）

区域内总卧室数（totalBedrooms）

区域内总人口数（population）

区域内总家庭数（households）

区域内人均收入中位数（medianIncome）

该区域房价的中位数（medianHouseValue）

也就是说，我们可以把每一个数据看作一个地区，它含有 9 项我们关心的信息，也就是上面提到的 9 个指标。比如下面这个数据：

-122.230000,37.880000,41.000000,880.000000,129.000000,322.000000,126.000000,8.325200,452600.000000'

这个数据代表该地区的经纬度是（-122.230000,37.880000），这个地区房屋历史的中位数是 41 年，所有房屋总共有 880 个房间，其中有 129 个卧室。这个地区内共有 126 个家庭和 322 位居民，人均收入中位数是 8.3252 万，房价中位数是 45.26 万。

这里的地域单位是美国做人口普查的最小地域单位，平均一个地域单位中有 1400 多人。在这个数据集中共有两万多个这样的数据。显然，这样小的数据量我们并“不需要”用 Spark 来处理，但是，它可以起到一个很好的示例作用。这个数据集可以从网上下载到。这个数据集是在 1997 年的一篇学术论文中创建的，感兴趣的同学可以去亲自下载，并加以实践。

那么我们今天的目标是什么呢？就是用已有的数据，构建一个线性回归模型，来预测房价。

我们可以看到，前 8 个属性都可能对房价有影响。这里，我们假设这种影响是线性的，我们就可以找到一个类似A=bB+cC+dD+…+iI的公式，A 代表房价，B 到 I 分别代表另外八个属性。这样，对于不在数据集中的房子，我们可以套用这个公式来计算出一个近似的房价。由于专栏的定位是大规模数据处理专栏，所以我们不会细讲统计学的知识。如果你对统计学知识感兴趣，或者还不理解什么是线性回归的话，可以去自行学习一下。

进一步了解数据集

每当我们需要对某个数据集进行处理时，不要急着写代码。你一定要先观察数据集，了解它的特性，并尝试对它做一些简单的预处理，让数据的可读性更好。这些工作我们最好在 Spark 的交互式 Shell 上完成，而不是创建 python 的源文件并执行。因为，在 Shell 上我们可以非常直观而简便地看到每一步的输出。

首先，让我们把数据集读入 Spark。

from pyspark.sql import SparkSession

# 初始化 SparkSession 和 SparkContext

spark = SparkSession.builder

  .master("local")

  .appName("California Housing ")

  .config("spark.executor.memory", "1gb")

  .getOrCreate()

sc = spark.sparkContext

# 读取数据并创建 RDD

rdd = sc.textFile('/Users/yourName/Downloads/CaliforniaHousing/cal_housing.data')

# 读取数据每个属性的定义并创建 RDD

header = sc.textFile('/Users/yourName/Downloads/CaliforniaHousing/cal_housing.domain')

这样，我们就把房屋信息数据和每个属性的定义读入了 Spark，并创建了两个相应的 RDD。你还记得吧？RDD 是有一个惰性求值的特性的，所以，我们可以用 collect() 函数来把数据输出在 Shell 上。

header.collect()

[u'longitude: continuous.', u'latitude: continuous.', u'housingMedianAge: continuous. ', u'totalRooms: continuous. ', u'totalBedrooms: continuous. ', u'population: continuous. ', u'households: continuous. ', u'medianIncome: continuous. ', u'medianHouseValue: continuous. ']

这样，我们就得到了每个数据所包含的信息，这和我们前面提到的 9 个属性的顺序是一致的，而且它们都是连续的值，而不是离散的。你需要注意的是，collect() 函数会把所有数据都加载到内存中，如果数据很大的话，有可能会造成内存泄漏，所以要小心使用。平时比较常见的方法是用 take() 函数去只读取 RDD 中的某几个元素。

由于 RDD 中的数据可能会比较大，所以接下来让我们读取它的前两个数据。

rdd.take(2)

[u'-122.230000,37.880000,41.000000,880.000000,129.000000,322.000000,126.000000,8.325200,452600.000000', u'-122.220000,37.860000,21.000000,7099.000000,1106.000000,2401.000000,1138.000000,8.301400,358500.000000']

由于我们是用 SparkContext 的 textFile 函数去创建 RDD，所以每个数据其实是一个大的字符串，各个属性之间用逗号分隔开来。这不利于我们之后的处理，因为我们可能会需要分别读取每个对象的各个属性。所以，让我们用 map 函数把大字符串分隔成数组，这会方便我们的后续操作。

rdd = rdd.map(lambda line: line.split(","))

rdd.take(2)

[[u'-122.230000', u'37.880000', u'41.000000', u'880.000000', u'129.000000', u'322.000000', u'126.000000', u'8.325200', u'452600.000000'], [u'-122.220000', u'37.860000', u'21.000000', u'7099.000000', u'1106.000000', u'2401.000000', u'1138.000000', u'8.301400', u'358500.000000']]

我们在前面学过，Spark SQL 的 DataFrame API 在查询结构化数据时更方便使用，而且性能更好。在这个例子中你可以看到，数据的 schema 是定义好的，我们需要去查询各个列，所以 DataFrame API 显然更加适用。所以，我们需要先把 RDD 转换为 DataFrame。

具体来说，就是需要把之前用数组代表的对象，转换成为 Row 对象，再用 toDF() 函数转换成 DataFrame。

from pyspark.sql import Row

df = rdd.map(lambda line: Row(longitude=line[0],

                             latitude=line[1],

                             housingMedianAge=line[2],

                             totalRooms=line[3],

                             totalBedRooms=line[4],

                             population=line[5],

                             households=line[6],

                             medianIncome=line[7],

                             medianHouseValue=line[8])).toDF()

现在我们可以用 show() 函数打印出这个 DataFrame 所含的数据表。

df.show()

这里每一列的数据格式都是 string，但是，它们其实都是数字，所以我们可以通过 cast() 函数把每一列的类型转换成 float。

def convertColumn(df, names, newType)

 for name in names:

    df = df.withColumn(name, df[name].cast(newType))

 return df

columns = ['households', 'housingMedianAge', 'latitude', 'longitude', 'medianHouseValue', 'medianIncome', 'population', 'totalBedRooms', 'totalRooms']

df = convertColumn(df, columns, FloatType())

转换成数字有很多优势。比如，我们可以按某一列，对所有对象进行排序，也可以计算平均值等。比如，下面这段代码就可以统计出所有建造年限各有多少个房子。

df.groupBy("housingMedianAge").count().sort("housingMedianAge",ascending=False).show()

预处理

通过上面的数据分析，你可能会发现这些数据还是不够直观。具体的问题有：

房价的值普遍都很大，我们可以把它调整成相对较小的数字；

有的属性没什么意义，比如所有房子的总房间数和总卧室数，我们更加关心的是平均房间数；

在我们想要构建的线性模型中，房价是结果，其他属性是输入参数。所以我们需要把它们分离处理；

有的属性最小值和最大值范围很大，我们可以把它们标准化处理。

对于第一点，我们观察到大多数房价都是十万起的，所以可以用 withColumn() 函数把所有房价都除以 100000。

df = df.withColumn("medianHouseValue", col("medianHouseValue")/100000)

对于第二点，我们可以添加如下三个新的列：

每个家庭的平均房间数：roomsPerHousehold

每个家庭的平均人数：populationPerHousehold

卧室在总房间的占比：bedroomsPerRoom

当然，你们可以自由添加你们觉得有意义的列，这里的三个是我觉得比较典型的。同样，用 withColumn() 函数可以容易地新建列。

df = df.withColumn("roomsPerHousehold", col("totalRooms")/col("households"))

  .withColumn("populationPerHousehold", col("population")/col("households"))

  .withColumn("bedroomsPerRoom", col("totalBedRooms")/col("totalRooms"))

同样，有的列是我们并不关心的，比如经纬度，这个数值很难有线性的意义。所以我们可以只留下重要的信息列。

df = df.select("medianHouseValue",

             "totalBedRooms",

             "population",

             "households",

             "medianIncome",

             "roomsPerHousehold",

             "populationPerHousehold",

             "bedroomsPerRoom")

对于第三点，最简单的办法就是把 DataFrame 转换成 RDD，然后用 map() 函数把每个对象分成两部分：房价和一个包含其余属性的列表，然后在转换回 DataFrame。

from pyspark.ml.linalg import DenseVector

input_data = df.rdd.map(lambda x: (x[0], DenseVector(x[1:])))

df = spark.createDataFrame(input_data, ["label", "features"])

我们重新把两部分重新标记为“label”和“features”，label 代表的是房价，features 代表包括其余参数的列表。

对于第四点，数据的标准化我们可以借助 Spark 的机器学习库 Spark ML 来完成。Spark ML 也是基于 DataFrame，它提供了大量机器学习的算法实现、数据流水线（pipeline）相关工具和很多常用功能。由于本专栏的重点是大数据处理，所以我们并没有介绍 Spark ML，但是我强烈推荐同学们有空去了解一下它。

在这个 AI 和机器学习的时代，我们不能落伍。

from pyspark.ml.feature import StandardScaler

standardScaler = StandardScaler(inputCol="features", outputCol="features_scaled")

scaler = standardScaler.fit(df)

scaled_df = scaler.transform(df)

在第二行，我们创建了一个 StandardScaler，它的输入是 features 列，输出被我们命名为 features_scaled。第三、第四行，我们把这个 scaler 对已有的 DataFrame 进行处理，让我们看下代码块里显示的输出结果。

scaled_df.take(1)

[Row(label=4.526, features=DenseVector([129.0, 322.0, 126.0, 8.3252, 6.9841, 2.5556, 0.1466]), features_scaled=DenseVector([0.3062, 0.2843, 0.3296, 4.3821, 2.8228, 0.2461, 2.5264]))]

我们可以清楚地看到，这一行新增了一个 features_scaled 的列，它里面每个数据都是标准化过的，我们应该用它，而非 features 来训练模型。

创建模型

上面的预处理都做完后，我们终于可以开始构建线性回归模型了。

首先，我们需要把数据集分为训练集和测试集，训练集用来训练模型，测试集用来评估模型的正确性。DataFrame 的 randomSplit() 函数可以很容易的随机分割数据，这里我们将 80% 的数据用于训练，剩下 20% 作为测试集。

train_data, test_data = scaled_df.randomSplit([.8,.2],seed=123)

用 Spark ML 提供的 LinearRegression 功能，我们可以很容易得构建一个线性回归模型，如下所示。

from pyspark.ml.regression import LinearRegression

lr = LinearRegression(featuresCol='features_scaled', labelCol="label", maxIter=10, regParam=0.3, elasticNetParam=0.8)

linearModel = lr.fit(train_data)

LinearRegression 可以调节的参数还有很多，你可以去官方 API 文档查阅，这里我们只是示范一下。

模型评估

现在有了模型，我们终于可以用 linearModel 的 transform() 函数来预测测试集中的房价，并与真实情况进行对比。代码如下所示。

predicted = linearModel.transform(test_data)

predictions = predicted.select("prediction").rdd.map(lambda x: x[0])

labels = predicted.select("label").rdd.map(lambda x: x[0])

predictionAndLabel = predictions.zip(labels).collect()

我们用 RDD 的 zip() 函数把预测值和真实值放在一起，这样可以方便地进行比较。比如让我们看一下前两个对比结果。

predictionAndLabel.take(2)

[(1.4491508524918457, 1.14999), (1.5831547768979277, 0.964)]

这里可以看出，我们的模型预测的结果有些偏小，这可能有多个因素造成。最直接的原因就是房价与我们挑选的列并没有强线性关系，而且我们使用的参数也可能不够准确。

这一讲我只是想带着你一起体验下处理真实数据集和解决实际问题的感觉，想要告诉你的是这种通用的思想，并帮助你继续熟悉 Spark 各种库的用法，并不是说房价一定就是由这些参数线性决定了。感兴趣的同学可以去继续优化，或者尝试别的模型比如逻辑回归。

小结

这一讲我们通过一个真实的数据集，通过以下步骤解决了一个实际的数据处理问题：

观察并了解数据集

数据清洗

数据的预处理

训练模型

评估模型

其实这里还可以有与“优化与改进”相关的内容，这里没有去阐述是因为我们的首要目的依然是熟悉与使用 Spark 各类 API。相信通过今天的学习，你初步了解了数据处理问题的一般思路，并强化了对 RDD、DataFrame 和机器学习 API 的使用。

实践与思考题

今天请你下载这个数据集，按文章的介绍去动手实践一次。如果有时间的话，还可以对这个过程的优化和改进提出问题并加以解决。

欢迎你在留言板贴出自己的 idea。如果你觉得有所收获，也欢迎你把文章分享给朋友。

20 | 流处理案例实战：分析纽约市出租车载客信息

你好，我是蔡元楠。

今天我要与你分享的主题是“流处理案例实战：分析纽约市出租车载客信息”。

在上一讲中，我们结合加州房屋信息的真实数据集，构建了一个基本的预测房价的线性回归模型。通过这个实例，我们不仅学习了处理大数据问题的基本流程，而且还进一步熟练了对 RDD 和 DataFrame API 的使用。

你应该已经发现，上一讲的实例是一个典型的批处理问题，因为处理的数据是静态而有边界的。今天让我们来一起通过实例，更加深入地学习用 Spark 去解决实际的流处理问题。

相信你还记得，在前面的章节中我们介绍过 Spark 两个用于流处理的组件——Spark Streaming 和 Structured Streaming。其中 Spark Streaming 是 Spark 2.0 版本前的的流处理库，在 Spark 2.0 之后，集成了 DataFrame/DataSet API 的 Structured Streaming 成为 Spark 流处理的主力。

今天就让我们一起用 Structured Streaming 对纽约市出租车的载客信息进行处理，建立一个实时流处理的 pipeline，实时输出各个区域内乘客小费的平均数来帮助司机决定要去哪里接单。

数据集介绍

今天的数据集是纽约市 2009～2015 年出租车载客的信息。每一次出行包含了两个事件，一个事件代表出发，另一个事件代表到达。每个事件都有 11 个属性，它的 schema 如下所示：

这部分数据有个不太直观的地方，那就是同一次出行会有两个记录，而且代表出发的事件没有任何意义，因为到达事件已经涵盖了所有必要的信息。现实世界中的数据都是这样复杂，不可能像学校的测试数据一样简单直观，所以处理之前，我们要先对数据进行清洗，只留下必要的信息。

这个数据还包含有另外一部分信息，就是所有出租车的付费信息，它有 8 个属性，schema 如下所示。

这个数据集可以从网上下载到，数据集的规模在 100MB 左右，它只是节选了一部分出租车的载客信息，所以在本机运行就可以了。详细的纽约出租车数据集超过了 500GB，同样在网上可以下载，感兴趣的同学可以下载来实践一下。

流数据输入

你可能要问，这个数据同样是静态、有边界的，为什么要用流处理？

因为我们手里没有实时更新的流数据源。我也没有权限去公开世界上任何一个上线产品的数据流。所以，这里只能将有限的数据经过 Kafka 处理，输出为一个伪流数据，作为我们要构建的 pipeline 的输入。

在模块二中，我们曾经初步了解过 Apache Kafka，知道它是基于 Pub/Sub 模式的流数据处理平台。由于我们的专栏并不涉及 Apache Kafka 的具体内容，所以我在这里就不讲如何把这个数据输入到 Kafka 并输出的细节了。你只要知道，在这个例子中，Consumer 是之后要写的 Spark 流处理程序，这个消息队列有两个 Topic，一个包含出行的地理位置信息，一个包含出行的收费信息。Kafka 会按照时间顺序，向这两个 Topic 中发布事件，从而模拟一个实时的流数据源。

相信你还记得，写 Spark 程序的第一步就是创建 SparkSession 对象，并根据输入数据创建对应的 RDD 或者 DataFrame。你可以看下面的代码。

from pyspark.sql import SparkSession

spark = SparkSession.builder

   .appName("Spark Structured Streaming for taxi ride info")

   .getOrCreate()

rides = spark

   .readStream

   .format("kafka")

   .option("kafka.bootstrap.servers", "localhost:xxxx") // 取决于 Kafka 的配置

   .option("subscribe", "taxirides")

   .option("startingOffsets", "latest")

   .load()

   .selectExpr("CAST(value AS STRING)")

fares = spark

   .readStream

   .format("kafka")

   .option("kafka.bootstrap.servers", "localhost:xxxx")

   .option("subscribe", "taxifares")

   .option("startingOffsets", "latest")

   .load()

   .selectExpr("CAST(value AS STRING)

在这段代码里，我们创建了两个 Streaming DataFrame，并订阅了对应的 Kafka topic，一个代表出行位置信息，另一个代表收费信息。Kafka 对数据没有做任何修改，所以流中的每一个数据都是一个长 String，属性之间是用逗号分割的。

417986,END,2013-01-02 00:43:52,2013-01-02  00:39:56,-73.984528,40.745377,-73.975967,40.765533,1,2013007646,2013007642

数据清洗

现在，我们要开始做数据清洗了。要想分离出我们需要的位置和付费信息，我们首先要把数据分割成一个个属性，并创建对应的 DataFrame 中的列。为此，我们首先要根据数据类型创建对应的 schema。

ridesSchema = StructType([

   StructField("rideId", LongType()), StructField("isStart", StringType()),

   StructField("endTime", TimestampType()), StructField("startTime", TimestampType()),

   StructField("startLon", FloatType()), StructField("startLat", FloatType()),

   StructField("endLon", FloatType()), StructField("endLat", FloatType()),

   StructField("passengerCnt", ShortType()), StructField("taxiId", LongType()),

   StructField("driverId", LongType())])

faresSchema = StructType([

   StructField("rideId", LongType()), StructField("taxiId", LongType()),

   StructField("driverId", LongType()), StructField("startTime", TimestampType()),

   StructField("paymentType", StringType()), StructField("tip", FloatType()),

   StructField("tolls", FloatType()), StructField("totalFare", FloatType())])

接下来，我们将每个数据都用逗号分割，并加入相应的列。

def parse_data_from_kafka_message(sdf, schema):

   from pyspark.sql.functions import split

   assert sdf.isStreaming == True, "DataFrame doesn't receive streaming data"

   col = split(sdf['value'], ',')

   for idx, field in enumerate(schema):

       sdf = sdf.withColumn(field.name, col.getItem(idx).cast(field.dataType))

   return sdf.select([field.name for field in schema])

rides = parse_data_from_kafka_message(rides, ridesSchema)

fares = parse_data_from_kafka_message(fares, faresSchema)

在上面的代码中，我们定义了函数 parse_data_from_kafka_message，用来把 Kafka 发来的 message 根据 schema 拆成对应的属性，转换类型，并加入到 DataFrame 的表中。

正如我们之前提到的，读入的数据包含了一些无用信息。

首先，所有代表出发的事件都已被删除，因为到达事件已经包含了出发事件的所有信息，而且只有到达之后才会付费。

其次，出发地点和目的地在纽约范围外的数据，也可以被删除。因为我们的目标是找出纽约市内小费较高的地点。DataFrame 的 filter 函数可以很容易地做到这些。

MIN_LON, MAX_LON, MIN_LAT, MAX_LAT = -73.7, -74.05, 41.0, 40.5

rides = rides.filter(

   rides["startLon"].between(MIN_LON, MAX_LON) &

   rides["startLat"].between(MIN_LAT, MAX_LAT) &

   rides["endLon"].between(MIN_LON, MAX_LON) &

   rides["endLat"].between(MIN_LAT, MAX_LAT))

rides = rides.filter(rides["isStart"] == "END")

上面的代码中首先定义了纽约市的经纬度范围，然后把所有起点和终点在这个范围之外的数据都过滤掉了。最后，把所有代表出发事件的数据也移除掉。

当然，除了前面提到的清洗方案，可能还会有别的可以改进的地方，比如把不重要的信息去掉，例如乘客数量、过路费等，你可以自己思考一下。

Stream-stream Join

我们的目标是找出小费较高的地理区域，而现在收费信息和地理位置信息还在两个 DataFrame 中，无法放在一起分析。那么要用怎样的方式把它们联合起来呢？

你应该还记得，DataFrame 本质上是把数据当成一张关系型的表。在我们这个例子中，rides 所对应的表的键值（Key）是 rideId，其他列里我们关心的就是起点和终点的位置；fares 所对应的表键值也是 rideId，其他列里我们关心的就是小费信息（tips）。

说到这里，你可能会自然而然地想到，如果可以像关系型数据表一样，根据共同的键值 rideId 把两个表 inner join 起来，就可以同时分析这两部分信息了。但是这里的 DataFrame 其实是两个数据流，Spark 可以把两个流 Join 起来吗？

答案是肯定的。在 Spark 2.3 中，流与流的 Join（Stream-stream join）被正式支持。这样的 Join 难点就在于，在任意一个时刻，流数据都不是完整的，流 A 中后面还没到的数据有可能要和流 B 中已经有的数据 Join 起来再输出。为了解决这个问题，我们就要引入数据水印（Watermark）的概念。

数据水印定义了我们可以对数据延迟的最大容忍限度。

比如说，如果定义水印是 10 分钟，数据 A 的事件时间是 1:00，数据 B 的事件时间是 1:10，由于数据传输发生了延迟，我们在 1:15 才收到了 A 和 B，那么我们将只处理数据 B 并更新结果，A 会被无视。在 Join 操作中，好好利用水印，我们就知道什么时候可以不用再考虑旧数据，什么时候必须把旧数据保留在内存中。不然，我们就必须把所有旧数据一直存在内存里，导致数据不断增大，最终可能会内存泄漏。

在这个例子中，为什么我们做这样的 Join 操作需要水印呢？

这是因为两个数据流并不保证会同时收到同一次出行的数据，因为收费系统需要额外的时间去处理，而且这两个数据流是独立的，每个都有可能产生数据延迟。所以要对时间加水印，以免出现内存中数据无限增长的情况。

那么下一个问题就是，究竟要对哪个时间加水印，出发时间还是到达时间？

前面说过了，我们其实只关心到达时间，所以对 rides 而言，我们只需要对到达时间加水印。但是，在 fares 这个 DataFrame 里并没有到达时间的任何信息，所以我们没法选择，只能对出发时间加水印。因此，我们还需要额外定义一个时间间隔的限制，出发时间和到达时间的间隔要在一定的范围内。具体内容你可以看下面的代码。

faresWithWatermark = fares

   .selectExpr("rideId AS rideId_fares", "startTime", "totalFare", "tip")

   .withWatermark("startTime", "30 minutes")

ridesWithWatermark = rides

 .selectExpr("rideId", "endTime", "driverId", "taxiId", "startLon", "startLat", "endLon", "endLat")

 .withWatermark("endTime", "30 minutes")

joinDF = faresWithWatermark

   .join(ridesWithWatermark,

     expr("""

      rideId_fares = rideId AND

       endTime > startTime AND

       endTime <= startTime + interval 2 hours

       """)

在这段代码中，我们对 fares 和 rides 分别加了半小时的水印，然后把两个 DataFrame 根据 rideId 和时间间隔的限制 Join 起来。这样，joinDF 就同时包含了地理位置和付费信息。

接下来，就让我们开始计算实时的小费最高区域。

计算结果并输出

到现在为止，我们还没有处理地点信息。原生的经纬度信息显然并没有很大用处。我们需要做的是把纽约市分割成几个区域，把数据中所有地点的经纬度信息转化成区域信息，这样司机们才可以知道大概哪个地区的乘客比较可能给高点的小费。

纽约市的区域信息以及坐标可以从网上找到，这部分处理比较容易。每个接收到的数据我们都可以判定它在哪个区域内，然后对 joinDF 增加一个列“area”来代表终点的区域。现在，让我们假设 area 已经加到现有的 DataFrame 里。接下来我们需要把得到的信息告诉司机了。

还记得第 16 讲和第 17 讲中提到的滑动窗口操作吗？这是流处理中常见的输出形式，即输出每隔一段时间内，特定时间窗口的特征值。在这个例子中，我们可以每隔 10 分钟，输出过去半小时内每个区域内的平均小费。这样的话，司机可以每隔 10 分钟查看一下数据，决定下一步去哪里接单。这个查询（Query）可以由以下代码产生。

tips = joinDF

   .groupBy(

       window("endTime", "30 minutes", "10 minutes"),

       "area")

   .agg(avg("tip"))

最后，我们把 tips 这个流式 DataFrame 输出。

query.writeStream

   .outputMode("append")

   .format("console")

   .option("truncate", False

   .start()

   .awaitTermination()

你可能会问，为什么我们不可以把输出结果按小费多少进行排序呢？

这是因为两个流的 inner-join 只支持附加输出模式（Append Mode），而现在 Structured Streaming 不支持在附加模式下进行排序操作。希望将来 Structured Streaming 可以提供这个功能，但是现在，司机们只能扫一眼所有的输出数据来大概判断哪个地方的小费最高了。

小结

流处理和批处理都是非常常见的应用场景，而且相较而言流处理更加复杂，对延迟性要求更高。今天我们再次通过一个实例帮助你了解要如何利用 Structured Streaming 对真实数据集进行流处理。Spark 最大的好处之一就是它拥有统一的批流处理框架和 API，希望你在课下要进一步加深对 DataSet/DataFrame 的熟练程度。

思考题

今天的主题是“案例实战”，不过我留的是思考题，而不是实践题。因为我不确定你是否会使用 Kafka。如果你的工作中会接触到流数据，那么你可以参考今天这个案例的思路和步骤来解决问题，多加练习以便熟悉 Spark 的使用。如果你还没有接触过流数据，但却想往这方面发展的话，我就真的建议你去学习一下 Kafka，这是个能帮助我们更好地做流处理应用开发和部署的利器。

现在，来说一下今天的思考题吧。

为什么流的 Inner-Join 不支持完全输出模式?

对于 Inner-Join 而言，加水印是否是必须的？ Outer-Join 呢？

欢迎你把答案写在留言区，与我和其他同学一起讨论。

如果你觉得有所收获，也欢迎把文章分享给你的朋友。

21 | 深入对比Spark与Flink：帮你系统设计两开花

你好，我是蔡元楠。

今天我要与你分享的主题是“深入对比 Spark 与 Flink”。

相信通过这一模块前 9 讲的学习，你对 Spark 已经有了基本的认识。现在，我们先来回顾整个模块，理清一下思路。

首先，从 MapReduce 框架存在的问题入手，我们知道了 Spark 的主要优点，比如用内存运算来提高性能；提供很多 High-level API；开发者无需用 map 和 reduce 两个操作实现复杂逻辑；支持流处理等等。

接下来，我们学习了 Spark 的数据抽象——RDD。RDD 是整个 Spark 的核心概念，所有的新 API 在底层都是基于 RDD 实现的。但是 RDD 是否就是完美无缺的呢？显然不是，它还是很底层，不方便开发者使用，而且用 RDD API 写的应用程序需要大量的人工调优来提高性能。

Spark SQL 提供的 DataFrame/DataSet API 就解决了这个问题，它提供类似 SQL 的查询接口，把数据看成关系型数据库的表，提升了熟悉关系型数据库的开发者的工作效率。这部分内容都是专注于数据的批处理，那么我们很自然地就过渡到下一个问题：Spark 是怎样支持流处理的呢？

那就讲到了 Spark Streaming 和新的 Structured Streaming，这是 Spark 的流处理组件，其中 Structured Streaming 也可以使用 DataSet/DataFrame API，这就实现了 Spark 批流处理的统一。

通过这个简单的回顾我们发现，Spark 的发布，和之后各个版本新功能的发布，并不是开发人员拍脑袋的决定，每个新版本发布的功能都是在解决旧功能的问题。在如此多的开源工作者的努力下，Spark 生态系统才有今天的规模，成为了当前最流行的大数据处理框架之一。

在开篇词中我就提到过，我希望你能通过这个专栏建立自己的批判性思维，遇到一个新的技术，多问为什么，而不是盲目的接受和学习。只有这样我们才能不随波逐流，成为这个百花齐放的技术时代的弄潮儿。

所以，这里我想问你一个问题，Spark 有什么缺点？

这个缺点我们之前已经提到过一个——无论是 Spark Streaming 还是 Structured Streaming，Spark 流处理的实时性还不够，所以无法用在一些对实时性要求很高的流处理场景中。

这是因为 Spark 的流处理是基于所谓微批处理（Micro-batch processing）的思想，即它把流处理看作是批处理的一种特殊形式，每次接收到一个时间间隔的数据才会去处理，所以天生很难在实时性上有所提升。

虽然在 Spark 2.3 中提出了连续处理模型（Continuous Processing Model），但是现在只支持很有限的功能，并不能在大的项目中使用。Spark 还需要做出很大的努力才能改进现有的流处理模型。

想要在流处理的实时性上提升，就不能继续用微批处理的模式，而要想办法实现真正的流处理，即每当有一条数据输入就立刻处理，不做等待。那么当今时代有没有这样的流处理框架呢？

Apache Flink 就是其中的翘楚。它采用了基于操作符（Operator）的连续流模型，可以做到微秒级别的延迟。今天我就带你一起了解一下这个流行的数据处理平台，并将 Flink 与 Spark 做深入对比，方便你在今后的实际项目中做出选择。

Flink 核心模型简介

Flink 中最核心的数据结构是 Stream，它代表一个运行在多个分区上的并行流。

在 Stream 上同样可以进行各种转换操作（Transformation）。与 Spark 的 RDD 不同的是，Stream 代表一个数据流而不是静态数据的集合。所以，它包含的数据是随着时间增长而变化的。而且 Stream 上的转换操作都是逐条进行的，即每当有新的数据进来，整个流程都会被执行并更新结果。这样的基本处理模式决定了 Flink 会比 Spark Streaming 有更低的流处理延迟性。

当一个 Flink 程序被执行的时候，它会被映射为 Streaming Dataflow，下图就是一个 Streaming Dataflow 的示意图。

在图中，你可以看出 Streaming Dataflow 包括 Stream 和 Operator（操作符）。转换操作符把一个或多个 Stream 转换成多个 Stream。每个 Dataflow 都有一个输入数据源（Source）和输出数据源（Sink）。与 Spark 的 RDD 转换图类似，Streaming Dataflow 也会被组合成一个有向无环图去执行。

在 Flink 中，程序天生是并行和分布式的。一个 Stream 可以包含多个分区（Stream Partitions），一个操作符可以被分成多个操作符子任务，每一个子任务是在不同的线程或者不同的机器节点中独立执行的。如下图所示：

从上图你可以看出，Stream 在操作符之间传输数据的形式有两种：一对一和重新分布。

一对一（One-to-one）：Stream 维护着分区以及元素的顺序，比如上图从输入数据源到 map 间。这意味着 map 操作符的子任务处理的数据和输入数据源的子任务生产的元素的数据相同。你有没有发现，它与 RDD 的窄依赖类似。

重新分布（Redistributing）：Stream 中数据的分区会发生改变，比如上图中 map 与 keyBy 之间。操作符的每一个子任务把数据发送到不同的目标子任务。

Flink 的架构

当前版本 Flink 的架构如下图所示。

我们可以看到，这个架构和第 12 讲中介绍的 Spark 架构比较类似，都分为四层：存储层、部署层、核心处理引擎、high-level 的 API 和库。

从存储层来看，Flink 同样兼容多种主流文件系统如 HDFS、Amazon S3，多种数据库如 HBase 和多种数据流如 Kafka 和 Flume。

从部署层来看，Flink 不仅支持本地运行，还能在独立集群或者在被 YARN 或 Mesos 管理的集群上运行，也能部署在云端。

核心处理引擎就是我们刚才提到的分布式 Streaming Dataflow，所有的高级 API 及应用库都会被翻译成包含 Stream 和 Operator 的 Dataflow 来执行。

Flink 提供的两个核心 API 就是 DataSet API 和 DataStream API。你没看错，名字和 Spark 的 DataSet、DataFrame 非常相似。顾名思义，DataSet 代表有界的数据集，而 DataStream 代表流数据。所以，DataSet API 是用来做批处理的，而 DataStream API 是做流处理的。

也许你会问，Flink 这样基于流的模型是怎样支持批处理的？在内部，DataSet 其实也用 Stream 表示，静态的有界数据也可以被看作是特殊的流数据，而且 DataSet 与 DataStream 可以无缝切换。所以，Flink 的核心是 DataStream。

DataSet 和 DataStream 都支持各种基本的转换操作如 map、filter、count、groupBy 等，让我们来看一个用 DataStream 实现的统计词频例子。

public class WindowWordCount {

 public static void main(String[] args) throws Exception {

   StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

   DataStream<Tuple2<String, Integer>> dataStream = env

　　.socketTextStream("localhost", 9999)

　　.flatMap(new Splitter())

　　.keyBy(0)

　　.timeWindow(Time.seconds(5))

　　.sum(1);

   dataStream.print();

   env.execute("Window WordCount");

}

 public static class Splitter implements FlatMapFunction<String, Tuple2<String, Integer>> {

   @Override

   public void flatMap(String sentence, Collector<Tuple2<String, Integer>> out) {

     for (String word: sentence.split(" ")) {

       out.collect(new Tuple2<String, Integer>(word, 1));

     }

   }

 }

这里我是用 Java 来示范的，因为 Flink 就是用 Java 开发的，所以它对 Java 有原生的支持。此外，也可以用 Scala 来开发 Flink 程序，在 1.0 版本后更是支持了 Python。

在这个例子中，我们首先创建了一个 Splitter 类，来把输入的句子拆分成（词语，1）的对。在主程序中用 StreamExecutionEnvironment 创建 DataStream，来接收本地 Web Socket 的文本流，并进行了 4 步操作。

用 flatMap 把输入文本拆分成（词语，1）的对；

用 keyBy 把相同的词语分配到相同的分区；

设好 5 秒的时间窗口；

对词语的出现频率用 sum 求和。

可以看出，DataStream 的使用方法和 RDD 比较相似，都是把程序拆分成一系列的转换操作并分布式地执行。

在 DataSet 和 DataStream 之上，有更高层次的 Table API。Table API 和 Spark SQL 的思想类似，是关系型的 API，用户可以像操作 SQL 数据库表一样的操作数据，而不需要通过写 Java 代码、操作 DataStream/DataSet 的方式进行数据处理，更不需要手动优化代码的执行逻辑。

此外，Table API 同样统一了 Flink 的批处理和流处理。

Flink 和 Spark 对比

通过前面的学习，我们了解到，Spark 和 Flink 都支持批处理和流处理，接下来让我们对这两种流行的数据处理框架在各方面进行对比。

首先，这两个数据处理框架有很多相同点。

都基于内存计算；

都有统一的批处理和流处理 API，都支持类似 SQL 的编程接口；

都支持很多相同的转换操作，编程都是用类似于 Scala Collection API 的函数式编程模式；

都有完善的错误恢复机制；

都支持 Exactly once 的语义一致性。

当然，它们的不同点也是相当明显，我们可以从 4 个不同的角度来看。

从流处理的角度来讲，Spark 基于微批量处理，把流数据看成是一个个小的批处理数据块分别处理，所以延迟性只能做到秒级。而 Flink 基于每个事件处理，每当有新的数据输入都会立刻处理，是真正的流式计算，支持毫秒级计算。由于相同的原因，Spark 只支持基于时间的窗口操作（处理时间或者事件时间），而 Flink 支持的窗口操作则非常灵活，不仅支持时间窗口，还支持基于数据本身的窗口，开发者可以自由定义想要的窗口操作。

从 SQL 功能的角度来讲，Spark 和 Flink 分别提供 SparkSQL 和 Table API 提供 SQL 交互支持。两者相比较，Spark 对 SQL 支持更好，相应的优化、扩展和性能更好，而 Flink 在 SQL 支持方面还有很大提升空间。

从迭代计算的角度来讲，Spark 对机器学习的支持很好，因为可以在内存中缓存中间计算结果来加速机器学习算法的运行。但是大部分机器学习算法其实是一个有环的数据流，在 Spark 中，却是用无环图来表示。而 Flink 支持在运行时间中的有环数据流，从而可以更有效的对机器学习算法进行运算。

从相应的生态系统角度来讲，Spark 的社区无疑更加活跃。Spark 可以说有着 Apache 旗下最多的开源贡献者，而且有很多不同的库来用在不同场景。而 Flink 由于较新，现阶段的开源社区不如 Spark 活跃，各种库的功能也不如 Spark 全面。但是 Flink 还在不断发展，各种功能也在逐渐完善。

小结

今天我们从 Spark 存在的一个缺点——无法高效应对低延迟的流处理场景入手，一起学习了另一个主流流数据处理框架 Flink，还对比了这两个框架的异同，相信现在你对两个框架肯定有了更多的认识。

我经常被问到的一个问题是：Spark 和 Flink 到底该选哪一个？对于这个问题，我们还是要分一下场景。

对于以下场景，你可以选择 Spark。

数据量非常大而且逻辑复杂的批数据处理，并且对计算效率有较高要求（比如用大数据分析来构建推荐系统进行个性化推荐、广告定点投放等）；

基于历史数据的交互式查询，要求响应较快；

基于实时数据流的数据处理，延迟性要求在在数百毫秒到数秒之间。

Spark 完美满足这些场景的需求， 而且它可以一站式解决这些问题，无需用别的数据处理平台。

由于 Flink 是为了提升流处理而创建的平台，所以它适用于各种需要非常低延迟（微秒到毫秒级）的实时数据处理场景，比如实时日志报表分析。

而且 Flink 用流处理去模拟批处理的思想，比 Spark 用批处理去模拟流处理的思想扩展性更好，所以我相信将来 Flink 会发展的越来越好，生态和社区各方面追上 Spark。比如，阿里巴巴就基于 Flink 构建了公司范围内全平台使用的数据处理平台 Blink，美团、饿了么等公司也都接受 Flink 作为数据处理解决方案。

可以说，Spark 和 Flink 都在某种程度上统一了批处理和流处理，但也都有一些不足。下一模块中，让我们来一起学习一个全新的、完全统一批流处理的数据处理平台——Apache Beam，到时候我们会对 Spark 的优缺点有更加深入的认识。

思考题

除了高延迟的流处理这一缺点外，你认为 Spark 还有什么不足？可以怎样改进？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

22 | Apache Beam的前世今生

你好，我是蔡元楠。

今天我要与你分享的主题是“ Apache Beam 的前世今生”。

从这一讲开始，我们将进入一个全新的篇章。在这一讲中，我将会带领你了解 Apache Beam 的完整诞生历程。

让我们一起来感受一下，Google 是如何从处理框架上的一无所有，一直发展到推动、制定批流统一的标准的。除此之外，我还会告诉你，在 2004 年发布了 MapReduce 论文之后，Google 在大规模数据处理实战中到底经历了哪些技术难题和技术变迁。我相信通过这一讲，你将会完整地认识到为什么 Google 会强力推崇 Apache Beam。

在 2003 年以前，Google 内部其实还没有一个成熟的处理框架来处理大规模数据。而当时 Google 的搜索业务又让工程师们不得不面临着处理大规模数据的应用场景，像计算网站 URL 访问量、计算网页的倒排索引（Inverted Index）等等。

那该怎么办呢？这个答案既简单又复杂：自己写一个。

没错，当时的工程师们需要自己写一个自定义的逻辑处理架构来处理这些数据。因为需要处理的数据量非常庞大，业务逻辑不太可能只放在一台机器上面运行。很多情况下，我们都必须把业务逻辑部署在分布式环境中。所以，这个自定义的逻辑处理架构还必须包括容错系统（Fault Tolerant System）的设计。

久而久之，Google 内部不同组之间都会开发出一套自己组内的逻辑处理架构。因为工程师们遇到的问题很多都是相似的，开发出来的这些逻辑处理架构很多时候也都是大同小异，只是有一些数据处理上的逻辑差别而已。这无疑就变成了大家一起重复造轮子的情况。

这时候，就有工程师想到，能不能改善这一种状况。MapReduce 的架构思想也就由此应运而生。

MapReduce

其实 MapReduce 的架构思想可以从两个方面来看。

一方面，它希望能提供一套简洁的 API 来表达工程师数据处理的逻辑。另一方面，要在这一套 API 底层嵌套一套扩展性很强的容错系统，使得工程师能够将心思放在逻辑处理上，而不用过于分心去设计分布式的容错系统。

这个架构思想的结果你早就已经知道了。MapReduce 这一套系统在 Google 获得了巨大成功。在 2004 年的时候，Google 发布的一篇名为“MapReduce: Simplified Data Processing on Large Clusters”的论文就是这份成果的总结。

在 MapReduce 的计算模型里，它将数据的处理抽象成了以下这样的计算步骤

Map：计算模型从输入源（Input Source）中读取数据集合，这些数据在经过了用户所写的逻辑后生成出一个临时的键值对数据集（Key/Value Set）。MapReduce 计算模型会将拥有相同键（Key）的数据集集中起来然后发送到下一阶段。这一步也被称为 Shuffle 阶段。

Reduce：接收从 Shuffle 阶段发送过来的数据集，在经过了用户所写的逻辑后生成出零个或多个结果。

很多人都说，这篇 MapReduce 论文是具有划时代意义的。可你知道为什么都这么说吗？

这是因为 Map 和 Reduce 这两种抽象其实可以适用于非常多的应用场景，而 MapReduce 论文里面所阐述的容错系统，可以让我们所写出来的数据处理逻辑在分布式环境下有着很好的可扩展性（Scalability）。

MapReduce 在内部的成功使得越来越多的工程师希望使用 MapReduce 来解决自己项目的难题。

但是，就如我在模块一中所说的那样，使用 MapReduce 来解决一个工程难题往往会涉及到非常多的步骤，而每次使用 MapReduce 的时候我们都需要在分布式环境中启动机器来完成 Map 和 Reduce 步骤，以及启动 Master 机器来协调这两个步骤的中间结果（Intermediate Result），消耗不少硬件上的资源。

这样就给工程师们带来了以下一些疑问：

我们的项目数据规模是否真的需要运用 MapReduce 来解决呢？是否可以在一台机器上的内存中解决呢？

我们所写的 MapReduce 项目是否已经是最优的呢？因为每一个 Map 和 Reduce 步骤这些中间结果都需要写在磁盘上，会十分耗时。是否有些步骤可以省略或者合并呢？我们是否需要让工程师投入时间去手动调试这些 MapReduce 项目的性能呢？

问题既然已经提出来了，Google 的工程师们便开始考虑是否能够解决上述这些问题。最好能够让工程师（无论是新手工程师亦或是经验老到的工程师）都能专注于数据逻辑上的处理，而不用花更多时间在测试调优上。

FlumeJava 就是在这样的背景下诞生的。

FlumeJava

这里，我先将 FlumeJava 的成果告诉你。因为 FlumeJava 的思想又在 Google 内容获得了巨大成功，Google 也希望将这个思想分享给业界。所以在 2010 年的时候，Google 公开了 FlumeJava 架构思想的论文。

FlumeJava 的思想是将所有的数据都抽象成名为 PCollection 的数据结构，无论是从内存中读取的数据，还是在分布式环境下所读取的文件。

这样的抽象对于测试代码中的逻辑是十分有好处的。要知道，想测试 MapReduce 的话，你可能需要读取测试数据集，然后在分布式环境下运行，来测试代码逻辑。但如果你有了 PCollection 这一层抽象的话，你的测试代码可以在内存中读取数据然后跑测试文件，也就是同样的逻辑既可以在分布式环境下运行也可以在单机内存中运行。

而 FlumeJava 在 MapReduce 框架中 Map 和 Reduce 思想上，抽象出 4 个了原始操作（Primitive Operation），分别是 parallelDo、groupByKey、 combineValues 和 flatten，让工程师可以利用这 4 种原始操作来表达任意 Map 或者 Reduce 的逻辑。

同时，FlumeJava 的架构运用了一种 Deferred Evaluation 的技术，来优化我们所写的代码。

对于 Deferred Evaluation，你可以理解为 FlumeJava 框架会首先会将我们所写的逻辑代码静态遍历一次，然后构造出一个执行计划的有向无环图。这在 FlumeJava 框架里被称为 Execution Plan Dataflow Graph。

有了这个图之后，FlumeJava 框架就会自动帮我们优化代码。例如，合并一些本来可以通过一个 Map 和 Reduce 来表达，却被新手工程师分成多个 Map 和 Reduce 的代码。

FlumeJava 框架还可以通过我们的输入数据集规模，来预测输出结果的规模，从而自行决定代码是放在内存中跑还是在分布式环境中跑。

总的来说，FlumeJava 是非常成功的。但是，FlumeJava 也有一个弊端，那就是 FlumeJava 基本上只支持批处理（Batch Execution）的任务，对于无边界数据（Unbounded Data）是不支持的。所以，Google 内部有着另外一个被称为 Millwheel 的项目来支持处理无边界数据，也就是流处理框架。

在 2013 年的时候，Google 也公开了 Millwheel 思想的论文。

这时 Google 的工程师们回过头看，感叹了一下成果，并觉得自己可以再优秀一些：既然我们已经创造出好几个优秀的大规模数据处理框架了，那我们能不能集合这几个框架的优点，推出一个统一的框架呢？

这也成为了 Dataflow Model 诞生的契机。

Dataflow Model

在 2015 年时候，Google 公布了 Dataflow Model 的论文，同时也推出了基于 Dataflow Model 思想的平台 Cloud Dataflow，让 Google 以外的工程师们也能够利用这些 SDK 来编写大规模数据处理的逻辑。

讲到这么多，你可能会有个疑问了，怎么 Apache Beam 还没有出场呢？别着急，Apache Beam 的登场契机马上就到了。

Apache Beam

前面我说了，Google 基于 Dataflow Model 的思想推出了 Cloud Dataflow 云平台，但那毕竟也需要工程师在 Google 的云平台上面运行程序才可以。如果有的工程师希望在别的平台上面跑该如何解决呢？

所以，为了解决这个问题，Google 在 2016 年的时候联合了 Talend、Data Artisans、Cloudera 这些大数据公司，基于 Dataflow Model 的思想开发出了一套 SDK，并贡献给了 Apache Software Foundation。而它 Apache Beam 的名字是怎么来的呢？就如下图所示，Beam 的含义就是统一了批处理和流处理的一个框架。

这就是 Apache Beam 的发展历史，从中你可以看到它拥有很多优点，而这也是我们需要 Beam 的原因。

在现实世界中，很多时候我们不可避免地需要对数据同时进行批处理和流处理。Beam 提供了一套统一的 API 来处理这两种数据处理模式，让我们只需要将注意力专注于在数据处理的算法上，而不用再花时间去对两种数据处理模式上的差异进行维护。

它能够将工程师写好的算法逻辑很好地与底层的运行环境分隔开。也就是说，当我们通过 Beam 提供的 API 写好数据处理逻辑后，这个逻辑可以不作任何修改，直接放到任何支持 Beam API 的底层系统上运行。

关于怎么理解这个优点，其实我们可以借鉴一下 SQL（Structure Query Language）的运行模式。

我们在学习 SQL 语言的时候，基本上都是独立于底层数据库系统来学习的。而在我们写完一个分析数据的 Query 之后，只要底层数据库的 Schema 不变，这个 Query 是可以放在任何数据库系统上运行的，例如放在 MySql 上或者 Oracle DB 上。

同样的，我们用 Beam API 写好的数据处理逻辑无需改变，可以根据自身的需求，将逻辑放在 Google Cloud Dataflow 上跑，也可以放在 Apache Flink 上跑。在 Beam 上，这些底层运行的系统被称为 Runner。现阶段 Apache Beam 支持的 Runner 有近十种，包括了我们很熟悉的 Apache Spark 和 Apache Flink。

当然最后 Apache Beam 也是希望对自身的 SDK 能够支持任意多的语言来编写。现阶段 Beam 支持 Java、Python 和 Golang。

也就是说，通过 Apache Beam，最终我们可以用自己喜欢的编程语言，通过一套 Beam Model 统一地数据处理 API，编写好符合自己应用场景的数据处理逻辑，放在自己喜欢的 Runner 上运行。

小结

今天，我与你一起回顾了 Apache Beam 的完整诞生历程。

通过这一讲，我希望你知道每一项技术都不会毫无理由地诞生，而每一项技术诞生的背后都是为了解决某些特定问题的。了解前人一步步解决问题的过程，有助于我们更有层次地理解一项技术产生的根本原因。在学习一项技术之前，先了解了它的历史源流，可以让我们做到知其然，并知其所以然。

思考题

你也能分享一些你所经历过的技术变迁或是技术诞生的故事吗？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

23 | 站在Google的肩膀上学习Beam编程模型

你好，我是蔡元楠。

今天我要与你分享的话题是“站在 Google 的肩膀上学习 Beam 编程模型”。

在上一讲中，我带你一起领略了 Apache Beam 的完整诞生历史。通过上一讲，你应该对于 Apache Beam 在大规模数据处理中能够带来的便利有了一定的了解。

而在这一讲中，让我们一起来学习 Apache Beam 的编程模型，帮助你打下良好的基础以便应对接下来的 Beam 实战篇。希望你在以后遇到不同的数据处理问题时，可以有着 Beam 所提倡的思考模式。

现在让我们一起进入 Beam 的世界吧。

为什么要先学习 Beam 的编程模型？

可能你会有疑问，很多人学习一项新技术的时候，都是从学习 SDK 的使用入手，为什么我们不同样的从 SDK 入手，而是要先学习 Beam 的编程模型呢？

我的答案有两点。

第一，Apache Beam 和其他开源项目不太一样，它并不是一个数据处理平台，本身也无法对数据进行处理。Beam 所提供的是一个统一的编程模型思想，而我们可以通过这个统一出来的接口来编写符合自己需求的处理逻辑，这个处理逻辑将会被转化成为底层运行引擎相应的 API 去运行。

第二，学习 Apache Beam 的时候，如果只学习 SDK 的使用，可能你不一定能明白这些统一出来的 SDK 设计背后的含义，而这些设计的思想又恰恰是涵盖了解决数据处理世界中我们所能遇见的问题。我认为将所有的 SDK 都介绍一遍是不现实的。SDK 会变，但它背后的原理却却不会改变，只有当我们深入了解了整个设计原理后，遇到各种应用场景时，才能处理得更加得心应手。

Beam 的编程模型

那事不宜迟，我们来看看 Beam 的编程模型到底指的是什么？

简单来说，Beam 的编程模型需要让我们根据“WWWH”这四个问题来进行数据处理逻辑的编写。“WWWH”是哪四个问题呢？这里我先卖个关子，在进入四个具体问题之前，我需要先介绍一下根据 Beam 编程模型所建立起来的 Beam 生态圈，帮助你理解 Beam 的编程模型会涉及到的几个概念。整个 Apache Beam 的生态圈构成就如下图所示。

为了帮助你理解，我为这几层加了编号，数字编号顺序是自下而上的，你可以对照查找。

第一层，是现在已有的各种大数据处理平台（例如 Apache Spark 或者 Apache Flink），在 Beam 中它们也被称为 Runner。

第二层，是可移植的统一模型层，各个 Runners 将会依据中间抽象出来的这个模型思想，提供一套符合这个模型的 APIs 出来，以供上层转换。

第三层，是 SDK 层。SDK 层将会给工程师提供不同语言版本的 API 来编写数据处理逻辑，这些逻辑就会被转化成 Runner 中相应的 API 来运行。

第四层，是可扩展库层。工程师可以根据已有的 Beam SDK，贡献分享出更多的新开发者 SDK、IO 连接器、转换操作库等等。

第五层，我们可以看作是应用层，各种应用将会通过下层的 Beam SDK 或工程师贡献的开发者 SDK 来实现。

最上面的第六层，也就是社区一层。在这里，全世界的工程师可以提出问题，解决问题，实现解决问题的思路。

通过第 6 讲的内容，我们已经知道，这个世界中的数据可以分成有边界数据和无边界数据，而有边界数据又是无边界数据的一种特例。所以，我们都可以将所有的数据抽象看作是无边界数据。

同时，每一个数据都是有两种时域的，分别是事件时间和处理时间。我们在处理无边界数据的时候，因为在现实世界中，数据会有延时、丢失等等的状况发生，我们无法保证现在到底是否接收完了所有发生在某一时刻之前的数据。所以现实中，流处理必须在数据的完整性和数据处理的延时性上作出取舍。Beam 编程模型就是在这样的基础上提出的。

Beam 编程模型会涉及到的 4 个概念，窗口、水印、触发器和累加模式，我来为你介绍一下。

窗口（Window）

窗口将无边界数据根据事件时间分成了一个个有限的数据集。我们可以看看批处理这个特例。在批处理中，我们其实是把一个无穷小到无穷大的时间窗口赋予了数据集。我会在第 32 讲中，对窗口这个概念进行详细地介绍。

水印（Watermark）

水印是用来表示与数据事件时间相关联的输入完整性的概念。对于事件时间为 X 的水印是指：数据处理逻辑已经得到了所有事件时间小于 X 的无边界数据。在数据处理中，水印是用来测量数据进度的。

触发器（Triggers）

触发器指的是表示在具体什么时候，数据处理逻辑会真正地触发窗口中的数据被计算。触发器能让我们可以在有需要时对数据进行多次运算，例如某时间窗口内的数据有更新，这一窗口内的数据结果需要重算。

累加模式（Accumulation）

累加模式指的是如果我们在同一窗口中得到多个运算结果，我们应该如何处理这些运算结果。这些结果之间可能完全不相关，例如与时间先后无关的结果，直接覆盖以前的运算结果即可。这些结果也可能会重叠在一起。

懂得了这几个概念之后，我来告诉你究竟 Beam 编程模型中的“WWWH”是什么。它们分别是：What、Where、When、How。

What results are being calculated?

我们要做什么计算？得到什么样的结果？Beam SDK 中各种 transform 操作就是用来回答这个问题的。这包括我们经常使用到批处理逻辑，训练机器学习模型的逻辑等等。

举个例子，我们每次学习大规模数据处理时所用到的经典例子 WordCount 里，我们想要得到在一篇文章里每个单词出现的次数，那我们所要做的计算就是通过 Transform 操作将一个单词集合转换成以单词为 Key，单词出现次数为 Value 的集合。

Where in event time they are being computed?

计算什么时间范围的数据？这里的“时间”指的是数据的事件时间。我们可以通过窗口这个概念来回答这个问题。

例如，我们有三个不同的数据，它们的事件时间分别是 12:01、12:59 和 14:00。如果我们的时间窗口设定为 [12:00 , 13:00)，那我们所需要处理的数据就是前两个数据了。

When in processing time they are materialized?

何时将计算结果输出？我们可以通过使用水印和触发器配合触发计算。

在之前的概念中，我们知道触发器指的就是何时触发一个窗口中的数据被计算出最终结果。在 Beam 中，我们可以有多种多样的触发器信号，例如根据处理时间的信号来触发，也就是说每隔了一段时间 Beam 就会重新计算一遍窗口中的数据；也可以根据元素的计数来触发，意思就是在一个窗口中的数据只要达到一定的数据，这个窗口的数据就会被拿来计算结果。

现在我来举一个以元素计数来触发的例子。我们现在定义好的固定窗口（Fixed Window）时间范围为 1 个小时，从每天的凌晨 00:00 开始计算，元素计数定为 2。我们需要处理的无边界数据是商品交易数据，我们需要计算在一个时间窗口中的交易总量。

为了方便说明，我们假设只接收到了 4 个数据点，它们按照以下顺序进入我们的处理逻辑。

于 6 月 11 号 23:59 产生的 10 元交易；

于 6 月 12 号 00:01 产生的 15 元交易；

于 6 月 11 号 23:57 产生的 20 元交易；

于 6 月 11 号 23:57 产生的 30 元交易。

接收到第三个数据的时候，6 月 11 号这个 24 小时窗口的数据已经达到了两个，所以触发了这个窗口内的数据计算，也就是 6 月 11 号的窗口内交易总量现在为 10+20=30 元。

当第四个数据（6 月 11 号 23:57 产生的 30 元交易）进入处理逻辑时，6 月 11 号这个 24 小时窗口的数据又超过了两个元素，这个窗口的计算再次被触发，交易总量被更新为 30+30=60 元。你可以看到，由于 6 月 12 号这个窗口的数据一直没有达到我们预先设定好的 2，所以就一直没有被触发计算。

How earlier results relate to later refinements?

后续数据的处理结果如何影响之前的处理结果呢？这个问题可以通过累加模式来解决，常见的累加模式有：丢弃（结果之间是独立且不同的）、累积（后来的结果建立在先前的结果上）等等。

还是以刚刚上面所讲述的 4 个交易数据点为例子，你可能会认为这里我们采取的累加模式是累积，其实我们采取的是丢弃。因为我们从始至终只保存着一个计算结果。这里要再引入一个概念，每一次通过计算一个窗口中的数据而得到的结果，我们可以称之为窗格（Pane）。

我们可以看到，当数据处理逻辑第一次产生 6 月 11 号这个窗口结果的时候，两次交易相加产生的 30 元成为了一个窗格。而第二次产生窗口结果是 60 元，这个结果又是一个窗格。因为我们只需要计算在一个窗口时间中的交易总量，所以第一个窗格随之被丢弃，只保留了最新的窗格。如果我们采用的是累积的累加模式呢，那这两个交易总量 30 元和 60 元都会被保存下来，成为历史记录。

Beam 的编程模型将所有的数据处理逻辑都分割成了这四个纬度，统一成了 Beam SDK。我们在基于 Beam SDK 构建数据处理业务逻辑时，只需要根据业务需求，按照这四个维度调用具体的 API，即可生成符合自己要求的数据处理逻辑。Beam 会自动转化数据处理逻辑，并提交到具体的 Runner 上去执行。我们可以看到，无论是 Runner 自身的 API 还是 Beam 的 SDK 设计，都需要有能力解决上述四个问题。Beam 的编程模型是贯穿了 Beam 生态圈中的每一层的。

在模块四的后续的内容中，我们会围绕着这四个问题展开具体的分析，看看在 Beam 的实战中，这每一步是如何被解答的。

小结

Google 如此地推崇 Apache Beam 开源项目，除了借此能够推广自己的云计算平台之外，更是借鉴了 Apache Hadoop 在开源社区中所取得的巨大成功。Google 希望为外界贡献一个容易使用而又功能强大的大数据处理模型，可以同时适用于流处理和批处理，并且还可以移植于各种不同数据处理平台上。

在 Beam 的生态圈中，我们可以看到，每一层的设计都是根据 Beam 的编程模型来搭建的。懂得了 Beam 编程模型之后，我们可以为生态圈中的任意一层做出贡献。

思考题

在现实应用中，你能否根据 Beam 的编程模型来分享你会怎么设计自己的数据处理逻辑呢？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

24 | PCollection：为什么Beam要如此抽象封装数据？

你好，我是蔡元楠。

今天我要与你分享的主题是“为什么 Beam 要如此抽象封装数据”。

很多人在刚开始接触 Apache Beam 的时候，都会觉得这里面的概念太抽象了。什么 PCollection、PValue、Transform……这都是些什么？尤其是 PCollection，完全和先前的技术知识找不到对应。

确实如此。同样作为数据的容器，PCollection 却并不像 Python/Java 的 List 或者 C++ 的 vector。PCollection 是无序的，Beam 对于 PCollection 中元素的处理顺序不作任何保证。所以，你不可能说“我想处理 PCollection 中的第二个元素”，因为它就没有“第几个”这种概念。

PCollection 也不像 Python/Java 的 Set，或者 C++ 的 unordered_set，PCollection 不一定有固定的边界。所以，你也不能指望去查找一个 PCollection 的大小。在 PCollection 的世界里，也没有“固定大小”这样的概念。

作为程序员，我很讨厌重复造轮子，尤其是新瓶装旧酒。的确，有很多开发者为了体现自己项目的复杂度，故意强行引进了很多概念，让大家都似懂非懂的。这就像是为了体现自己知道茴香豆的“茴”有几种写法一样，故意用另一种写法来体现自己“有文化”。

那么 Apache Beam 引进这么多概念，是不是也是故意强行造轮子呢？答案是否定的。这一讲，我们就要分析 PCollection 为什么会如此设计。

在之前的章节中，我们已经讲到了 Apache Beam 的“爷爷”——FlumeJava 产生的背景。

当时 Google 的工程师们发现 MapReduce 使用起来并没有很方便。如果计算过程都能够分解成 map、shuffle、reduce 步骤的话，MapReduce 是非常能胜任的。

但是，很多现实世界中的问题，往往需要一系列的 map 或 reduce 步骤。这样的数据流水线就需要额外的代码用来协调这些 map 或 reduce 步骤。也需要一些额外的代码去管理分步的 map/reduce 步骤产生的一些中间结果。以至于新的开发者很难理解复杂的流水线。

清楚这样的背景对于理解 Apache Beam 的 PCollection 起源很有帮助。因为，这个项目起源只是为了提供比 MapReduce 更好的开发体验，最终的运算引擎仍然是 MapReduce。

为什么需要 PCollection？

那么，为什么 Beam 需要 PCollection 这样一个全新的抽象数据结构呢？

我们知道，不同的技术系统有不同的数据结构。比如，C++ 里有 vector、unordered_map，安卓有 ListView。相比它们而言，其实 Beam 的数据结构体系是很单调的，几乎所有数据都能表达为 PCollection。

PCollection，就是 Parallel Collection，意思是可并行计算的数据集。如果你之前学习了 Spark 的章节，就会发现 PCollection 和 RDD 十分相似。

在一个分布式计算系统中，我们作为架构设计者需要为用户隐藏的实现细节有很多，其中就包括了数据是怎样表达和存储的。

这个数据可能是来自于内存的数据（内部可能只是由一个 C++ array 存储）；也有可能是来自外部文件（由几个文件存储）；或者是来自于 MySQL 数据库（由数据库的行来表达）。

如果没有一个统一的数据抽象的话，开发者就需要不停地更改代码。比如，在小规模测试的时候用 C++ vector，等到了真实的生产环境，我再换 MySQL row。沉溺于这样的实现细节会让开发者无法集中注意力在真正重要的事情上，那就是“你想怎样处理数据”。

清楚了这些，你就能明白我们需要一层抽象来表达数据，而这层抽象就是 PCollection。

PCollection 的创建完全取决于你的需求。比如，在测试中 PCollection 往往来自于代码生成的伪造数据，或者从文件中读取。

Python

lines = (p

           | beam.Create(['To be, or not to be: that is the question. ']))

lines = p | 'ReadMyFile' >> beam.io.ReadFromText('gs://some/inputData.txt'

Java

PCollection<String> lines = p.apply(

      "ReadMyFile", TextIO.read().from("protocol://path/to/some/inputData.txt"));

为什么 PCollection 需要 Coders？

与普通编程相比，PCollection 的另一个不同之处是，你需要为 PCollection 的元素编写 Coder。例如，你有一个自己的类 MyClass，那么 PCollection一定需要实现 Coder。

刚开始使用 Beam 时，你可能会感觉这很不方便。例如，你只是要去读取 MySQL 的一个表，也得为此实现 Coder。

Coder 的作用和 Beam 的本质紧密相关。因为你的计算流程最终会运行在一个分布式系统。所以，所有的数据都有可能在网络上的计算机之间相互传递。而 Coder 就是在告诉 Beam，怎样把你的数据类型序列化和逆序列化，以方便在网络上传输。

Coder 需要注册进全局的 CoderRegistry 中，简单来说，是为你自己的数据类型建立与 Coder 的对应关系。不然每次你都需要手动指定 Coder。

Python

apache_beam.coders.registry.register_coder(int, BigEndianIntegerCoder)

Java

PipelineOptions options = PipelineOptionsFactory.create();

Pipeline p = Pipeline.create(options);

CoderRegistry cr = p.getCoderRegistry();

cr.registerCoder(Integer.class, BigEndianIntegerCoder.class);

为什么 PCollection 是无序的？

讲完为什么 PCollection 需要 Coder 之后，我们再来看下，为什么 PCollection 是无序的。

PCollection 的无序特性其实也和它的分布式本质有关。一旦一个 PCollection 被分配到不同的机器上执行，那么为了保证最大的处理输出，不同机器都是独立运行的。所以，它的执行顺序就无从得知了。可能是第一个元素先被运行，也可能是第二个元素先被运行。所以，肯定不会有 PCollection[2] 这样的运算符。

为什么 PCollection 没有固定大小？

无序也就算了，为什么 PCollection 还没有固定大小呢？

前面的章节中讲到过，Beam 想要统一批处理和流处理，所以它要统一表达有界数据和无界数据。正因为如此，PCollection 并没有限制它的容量。如前面所说，它可能表达内存上的一个数组，也可能表达整个数据库的所有数据。

一个 PCollection 可以是有界的，也可以是无界的。一个有界的 PCollection 表达了一个已知大小的固定的数据集。一个无界的 PCollection 表达了一个无限大小的数据集。事实上一个 PCollection 是否有界，往往取决于它是如何产生的。

从批处理的数据源中读取，比如一个文件或者是一个数据库，就会产生有界的 PColleciton。如果从流式的或者是持续更新的数据库中读取，比如 pub/sub 或者 kafka，会产生一个无界的 PCollection。

但是，PCollection 的有界和无界特性会影响到 Beam 的数据处理方式。一个批处理作业往往处理有界数据。而无界的 PCollection 需要流式的作业来连续处理。

在实现中，Beam 也是用 window 来分割持续更新的无界数据。所以，一个流数据可以被持续地拆分成不同的小块。这样的处理方式我们会在实战部分展开。

如何理解 PCollection 的不可变性？

在普通编程语言中，大部分数据结构都是可变的。

Python

Alist = []

alist.append(1)

C++

Std::vector<int> list;

list.push_back(1);

但是 PCollection 不提供任何修改它所承载数据的方式。修改一个 PCollection 的唯一方式就是去转化 (Transform) 它，下一讲会展开讲 Transformation。

但是在这一讲，我们需要理解的是，Beam 的 PCollection 都是延迟执行（deferred execution）的模式。也就是说，当你下面这样的语句的时候，什么也不会发生。

Java

PCollection<T1> p1 = ...;

PCollection<T2> p2 = doSomeWork(p1);

这样的语句执行完，p2 这个 PCollection 仅仅会记录下自己是由 doSomeWork 这个操作计算而来的，和计算自己所需要的数据 p1。当你执行写完 100 行的 beam 的运算操作，最终的结果仅仅是生成了一个有向无环图（DAG），也就是执行计划（execution plan）。

为什么这么设计呢？如果你记得我们在专栏第一部分讲到的大规模数据框架设计，可能会有印象。这样的有向无环图是框架能够自动优化执行计划的核心。

类似图中这样的数据处理流程，在 Beam 获知了整个数据处理流程后，就会被优化成下图所示。

这样的优化，在 Beam 中被称为 sibling fusion。类似的操作优化我们后面会继续介绍。在这个小标题下，我想用这个优化的例子说明，PCollection 下的数据不可变是因为改变本身毫无意义。

例如，在刚才这个例子中，你会发现，优化后的执行计划里已经没有了数据 A0。因为，Beam 发现数据 A0 作为中间结果并不影响最终输出。另外，由于 Beam 的分布式本质，即使你想要去修改一个 PCollection 的底层表达数据，也需要在多个机器上查找，毫无实现的价值。

小结

这一讲我们介绍了整个 Beam 核心数据结构 PCollection 的设计思想。尤其是分析了 PCollection 的几大特性为什么是这么设计的。它的特性包括 Coders、无序性、没有固定大小、不可变性等。在每个特性下面我们也介绍了几个例子或者代码示例，希望能帮助你加深印象。

思考题

PCollection 的设计是否能表达你的大规模数据处理场景呢？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

25 | Transform：Beam数据转换操作的抽象方法

你好，我是蔡元楠。

今天我要与你分享的主题是“Beam 数据转换操作的抽象方法”。

在上一讲中，我们一起学习了 Beam 中数据的抽象表达——PCollection。但是仅仅有数据的表达肯定是无法构建一个数据处理框架的。那么今天，我们就来看看 Beam 中数据处理的最基本单元——Transform。

下图就是单个 Transform 的图示。

之前我们已经讲过，Beam 把数据转换抽象成了有向图。PCollection 是有向图中的边，而 Transform 是有向图里的节点。

不少人在理解 PCollection 的时候都觉得这不那么符合他们的直觉。许多人都会自然地觉得 PCollection 才应该是节点，而 Transform 是边。因为数据给人的感觉是一个实体，应该用一个方框表达；而边是有方向的，更像是一种转换操作。事实上，这种想法很容易让人走入误区。

其实，区分节点和边的关键是看一个 Transform 是不是会有一个多余的输入和输出。

每个 Transform 都可能有大于一个的输入 PCollection，它也可能输出大于一个的输出 PCollection。所以，我们只能把 Transform 放在节点的位置。因为一个节点可以连接多条边，而同一条边却只能有头和尾两端。

Transform 的基本使用方法

在了解了 Transform 和 PCollection 的关系之后，我们来看一下 Transform 的基本使用方法。

Beam 中的 PCollection 有一个抽象的成员函数 Apply。使用任何一个 Transform 时候，你都需要调用这个 apply 方法。

Java

pcollection1 = pcollection2.apply(Transform)

Python

Pcollection1 = pcollection2 | Transform

当然，你也可以把 Transform 级连起来。

final_collection = input_collection.apply(Transform1)

.apply(Transform2)

.apply(Transform3)

所以说，Transform 的调用方法是要通过 apply() 的，但是 Transform 有很多种。

常见的 Transform

Beam 也提供了常见的 Transform 接口，比如 ParDo、GroupByKey。最常使用的 Transform 就是 ParDo 了。

ParDo 就是 Parallel Do 的意思，顾名思义，表达的是很通用的并行处理数据操作。GroupByKey 的意思是把一个 Key/Value 的数据集按 Key 归并，就如下面这个例子。

cat, 1

dog, 5

and, 1

jump, 3

tree, 2

cat, 5

dog, 2

and, 2

cat, 9

and, 6

=>

cat, [1,5,9]

dog, [5,2]

and, [1,2,6]

jump, [3]

tree, [2]

当然，你也可以用 ParDo 来实现 GroupByKey，一种简单的实现方法就是放一个全局的哈希表，然后在 ParDo 里把一个一个元素插进这个哈希表里。但这样的实现方法并不能用，因为你的数据量可能完全无法放进一个内存哈希表。而且，你还要考虑到 PCollection 会把计算分发到不同机器上的情况。

当你在编写 ParDo 时，你的输入是一个 PCollection 中的单个元素，输出可以是 0 个、1 个，或者是多个元素。你只要考虑好怎样处理一个元素。剩下的事情，Beam 会在框架层面帮你做优化和并行。

使用 ParDo 时，你需要继承它提供的 DoFn 类，你可以把 DoFn 看作是 ParDo 的一部分。因为 ParDo 和 DoFn 单独拿出来都没有意义。

java

static class UpperCaseFn extends DoFn<String, String> {

  @ProcessElement

  public void processElement(@Element String word, OutputReceiver<String> out) {

    out.output(word.toUpperCase());

  }

}

PCollection<String> upperCaseWords = words.apply(

    ParDo

    .of(new UpperCaseFn()));

在上面的代码中你可以看出，每个 DoFn 的 @ProcessElement 标注的函数 processElement，就是这个 DoFn 真正的功能模块。在上面这个 DoFn 中，我们把输入的一个词转化成了它的大写形式。之后在调用 apply(ParDo.of(new UpperCaseFn())) 的时候，Beam 就会把输入的 PCollection 中的每个元素都使用刚才的 processElement 处理一遍。

看到这里，你可能会比较迷惑，transform、apply、DoFn、ParDo 之间到底是什么关系啊？怎么突然冒出来一堆名词？其实，Transform 是一种概念层面的说法。具体在编程上面，Transform 用代码来表达的话就是这样的：

pcollection.apply(ParDo.of(new DoFn()))

这里的 apply(ParDo) 就是一个 Transform。

我们在第 7 讲中讲过数据处理流程的常见设计模式。事实上很多应用场景都可以用 ParDo 来实现。比如过滤一个数据集、格式转化一个数据集、提取一个数据集的特定值等等。

1. 过滤一个数据集

当我们只想要挑出符合我们需求的元素的时候，我们需要做的，就是在 processElement 中实现。一般来说会有一个过滤函数，如果满足我们的过滤条件，我们就把这个输入元素输出。

Java

@ProcessElement

public void processElement(@Element T input, OutputReceiver<T> out) {

    if (IsNeeded(input)) {

      out.output(input);

    }

  }

2. 格式转化一个数据集

给数据集转化格式的场景非常常见。比如，我们想把一个来自 csv 文件的数据，转化成 TensorFlow 的输入数据 tf.Example 的时候，就可以用到 ParDo。

Java

@ProcessElement

  public void processElement(@Element String csvLine, OutputReceiver<tf.Example> out) {

    out.output(ConvertToTfExample(csvLine));

  }

3. 提取一个数据集的特定值

ParDo 还可以提取一个数据集中的特定值。比如，当我们想要从一个商品的数据集中提取它们的价格的时候，也可以使用 ParDo。

Java

@ProcessElement

  public void processElement(@Element Item item, OutputReceiver<Integer> out) {

    out.output(item.price());

  }

通过前面的几个例子你可以看到，ParDo 和 DoFn 这样的抽象已经能处理非常多的应用场景问题。事实正是如此，在实际应用中，80% 的数据处理流水线都是使用基本的 ParDo 和 DoFn。

Stateful Transform 和 side input/side output

当然，还有一些 Transform 其实也是很有用的，比如 GroupByKey，不过它远没有 ParDo 那么常见。所以，这一模块中暂时不会介绍别的数据转换操作，需要的话我们可以在后面用到的时候再介绍。我想先在这里介绍和 ParDo 同样是必用的，却在大部分教程中被人忽略的技术点——Statefullness 和 side input/side output。

上面我们所介绍的一些简单场景都是无状态的，也就是说，在每一个 DoFn 的 processElement 函数中，输出只依赖于输入。它们的 DoFn 类不需要维持一个成员变量。无状态的 DoFn 能保证最大的并行运算能力。因为 DoFn 的 processElement 可以分发到不同的机器，或者不同的进程也能有多个 DoFn 的实例。但假如我们的 processElement 的运行需要另外的信息，我们就不得不转而编写有状态的 DoFn 了。

试想这样一个场景，你的数据处理流水线需要从一个数据库中根据用户的 id 找到用户的名字。你可能会想到用“在 DoFn 中增加一个数据库的成员变量”的方法来解决。的确，实际的应用情况中我们就会写成下面这个代码的样子。

java

static class FindUserNameFn extends DoFn<String, String> {

  @ProcessElement

  public void processElement(@Element String userId, OutputReceiver<String> out) {

    out.output(database.FindUserName(userId));

  }

  Database database;

}

但是因为有了共享的状态，这里是一个共享的数据库连接。在使用有状态的 DoFn 时，我们需要格外注意 Beam 的并行特性。

如上面讲到的，Beam 不仅会把我们的处理函数分发到不同线程、进程，也会分发到不同的机器上执行。当你共享这样一个数据库的读取操作时，很可能引发服务器的 QPS 过高。

例如，你在处理一个 1 万个用户 id，如果 beam 很有效地将你的 DoFn 并行化了，你就可能观察到数据库的 QPS 增加了几千。如果你不仅是读取，还做了修改的话，就需要注意是不是有竞争风险了。这里你可以联想在操作系统中有关线程安全的相关知识。

除了这种简单的增加一个成员变量的方法。如果我们需要共享的状态来自于另外一些 Beam 的数据处理的中间结果呢？这时候为了实现有状态 DoFn 我们需要应用 Beam 的 Side input/side output 计数。

java

PCollectionView<Integer> mediumSpending = ...;

PCollection<String> usersBelowMediumSpending =

  userIds.apply(ParDo

      .of(new DoFn<String, String>() {

          @ProcessElement

          public void processElement(@Element String userId, OutputReceiver<String> out, ProcessContext c) {

            int medium = c.sideInput(mediumSpending);

            if (findSpending(userId) <= medium) {

              out.output(userId);

            }

          }

      }).withSideInputs(mediumSpending)

  );

比如，在这个处理流程中，我们需要根据之前处理得到的结果，也就是用户的中位数消费数据，找到消费低于这个中位数的用户。那么，我们可以通过 side input 把这个中位数传递进 DoFn 中。然后你可以在 ProcessElement 的参数 ProcessContext 中拿出来这个 side input。

Transform 的优化

之前我们也提到过，Beam 中的数据操作都是 lazy execution 的。这使得 Transform 和普通的函数运算很不一样。当你写下面这样一个代码的时候，真正的计算完全没有被执行。

Pcollection1 = pcollection2.apply(Transform)

这样的代码仅仅是让 Beam 知道了“你想对数据进行哪些操作”，需要让它来构建你的数据处理有向图。之后 Beam 的处理优化器会对你的处理操作进行优化。所以，千万不要觉得你写了 10 个 Transform 就会有 10 个 Transform 马上被执行了。

理解 Transform 的 lazy execution 非常重要。很多人会过度地优化自己的 DoFn 代码，想要在一个 DoFn 中把所有运算全都做了。其实完全没这个必要。

你可以用分步的 DoFn 把自己想要的操作表达出来，然后交给 Beam 的优化器去合并你的操作。比如，在 FlumeJava 论文中提到的 MSCR Fusion，它会把几个相关的 GroupByKey 的 Transform 合并。

小结

在这一讲中，我们学习了 Transform 的概念和基本的使用方法。通过文章中的几个简单的例子，你要做到的是了解怎样编写 Transform 的编程模型 DoFn 类。有状态 DoFn 在实际应用中尤其常见，你可以多加关注。

思考题

你可能会发现 Beam 的 ParDo 类似于 Spark 的 map() 或者是 MapReduce 的 map。它们确实有很多相似之处。那你认为它们有什么不一样之处呢？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

26 | Pipeline：Beam如何抽象多步骤的数据流水线？

你好，我是蔡元楠。

今天我要与你分享的主题是“Pipeline：Beam 如何抽象多步骤的数据流水线”。

在上两讲中，我们一起学习了 Beam 是如何抽象封装数据，以及如何抽象对于数据集的转换操作的。在掌握了这两个基本概念后，我们就可以很好地回答 Beam 编程模型里的 4 个维度 What、Where、When、How 中的第一个问题——What 了。也就是，我们要做什么计算？想得到什么样的结果？

这个时候你可能已经跃跃欲试，开始想用 PCollection 和 Transform 解决我们平常经常会使用到的批处理任务了。没有问题，那我们就先抛开 Where、When 和 How 这三个问题，由简至繁地讲起。

现在假设我们的数据处理逻辑只需要处理有边界数据集，在这个情况下，让我们一起来看看 Beam 是如何运行一套批处理任务的。

数据流水线

在 Beam 的世界里，所有的数据处理逻辑都会被抽象成数据流水线（Pipeline）来运行。那么什么是数据流水线呢？

Beam 的数据流水线是对于数据处理逻辑的一个封装，它包括了从读取数据集，将数据集转换成想要的结果和输出结果数据集这样的一整套流程。

所以，如果我们想要跑自己的数据处理逻辑，就必须在程序中创建一个 Beam 数据流水线出来，比较常见的做法是在 main() 函数中直接创建。

Java

PipelineOptions options = PipelineOptionsFactory.create();

Pipeline p = Pipeline.create(options);

在创建 Beam 数据流水线的同时，我们必须给这个流水线定义一个选项（Options）。这个选项会告诉 Beam，用户的 Pipeline 应该如何运行。例如，是在本地的内存上运行，还是在 Apache Flink 上运行？关于具体 Beam 选项的解释，我会在第 30 讲中展开讲解。

Beam 数据流水线的应用

有了数据流水线这个抽象概念之后，我们就可以将 PCollection 和 Transform 应用在这个流水线里面了。

上图就是一个 Beam 的数据流水线，整个数据流水线包括了从读取数据，到经过了 N 个 Transform 之后输出数据的整个过程。

在第 24 讲中我们学习过 PCollection 的不可变性。也就是说，一个 PCollection 一经生成，我们就不能够再增加或者删除它里面的元素了。所以，在 Beam 的数据流水线中，每次 PCollection 经过一个 Transform 之后，流水线都会新创建一个 PCollection 出来。而这个新的 PCollection 又将成为下一个 Transform 的新输入。

在上图的示例中，Beam 数据流水线在经过 Transform1 读取了输入数据集之后，会创建出一个新的 PCollection1，而经过了 Transform2 之后，数据流水线又会创建出新的 PCollection2 出来，同时 PCollection1 不会有任何改变。也就是说，在上面的例子中，除去最终的输出结果，数据流水线一共创建了 3 个不同的 PCollection 出来。

这种特性可以让我们在编写数据处理逻辑的时候，对同一个 PCollection 应用多种不同的 Transfrom。

例如下图所示，对于 PCollection1，我们可以使三个不同的 Transform 应用在它之上，从而再产生出三个不同的 PCollection2、PCollection3 和 PCollection4 出来。

Beam 数据流水线的处理模型

在了解完 Beam 数据流水线高度抽象的概念后，紧接着，我想和你介绍一下 Beam 数据流水线的处理模型，也就是数据流水线在运行起来之后，会发生些什么，它是如何处理我们定义好的 PCollection 和 Transform 的。

Beam 数据流水线的底层思想其实还是动用了 MapReduce 的原理，在分布式环境下，整个数据流水线会启动 N 个 Workers 来同时处理 PCollection。而在具体处理某一个特定 Transform 的时候，数据流水线会将这个 Transform 的输入数据集 PCollection 里面的元素分割成不同的 Bundle，将这些 Bundle 分发给不同的 Worker 来处理。

Beam 数据流水线具体会分配多少个 Worker，以及将一个 PCollection 分割成多少个 Bundle 都是随机的。但 Beam 数据流水线会尽可能地让整个处理流程达到完美并行（Embarrassingly Parallel）。

我想举个几个例子让你更好地来理解这个概念。

假设在数据流水线的一个 Transform 里面，它的输入数据集 PCollection 是 1、2、3、4、5、6 这个 6 个元素。数据流水线可能会将这个 PCollection 按下图的方式将它分割成两个 Bundles。

当然，PCollection 也有可能会被分割成三个 Bundles。

那数据流水线会启用多少个 Worker 来处理这些 Bundle 呢？这也是任意的。还是以刚刚的 PCollection 输入数据集作为例子，如果 PCollection 被分割成了两个 Bundles，数据流水线有可能会分配两个 Worker 来处理这两个 Bundles。

甚至有可能只分配一个 Worker 来处理这两个 Bundles。

在多步骤的 Transforms 中，一个 Bundle 通过一个 Transform 产生出来的结果会作为下一个 Transform 的输入。

之前刚刚讲过，在 Beam 数据流水线中，抽象出来的 PCollection 经过一个 Transform 之后，流水线都会新创建一个 PCollection 出来。同样的，Beam 在真正运行的时候，每一个 Bundle 在一个 Worker 机器里经过 Transform 逻辑后，也会产生出来一个新的 Bundle，它们也是具有不可变性的。像这种具有关联性的 Bundle，必须在同一个 Worker 上面处理。

我现在来举例说明一下上面的概念。现在假设输入数据集如下图所示，它被分成了两个 Bundles。

我们现在需要做两个 Transforms。第一个 Transform 会将元素的数值减一；第二个 Transform 会对元素的数值求平方。整个过程被分配到了两个 Workers 上完成。

过程就如上图所示，总共产生了 6 个不可变的 Bundle 出来，从 Bundle1 到 Bundle3 的整个过程都必须放在 Worker1 上完成，因为它们都具有关联性。同样的，从 Bundle4 到 Bundle6 的整个过程也都必须放在 Worker2 上完成。

Beam 数据流水线的错误处理

在学习完 Beam 数据流水线底层的处理模型之后，你可能会有个疑问：既然 Bundle 都是放在分布式环境下处理的，要是其中一个步骤出错了，那数据流水线会做什么样的处理？接下来我会给你讲解一下 Beam 数据流水线的错误处理机制。

单个 Transform 上的错误处理

我们还是以单个 Transform 开始讲解。在一个 Transform 里面，如果某一个 Bundle 里面的元素因为任意原因导致处理失败了，则这整个 Bundle 里的元素都必须重新处理。

还是假设输入数据集如下图所示，被分成了两个 Bundles。

Beam 数据流水线分配了两个 Worker 来处理这两个 Bundles。我们看到下图中，在 Worker2 处理 Bundle2 的时候，最后一个元素 6 处理失败了。

这个时候，即便 Bundle2 的元素 5 已经完成了处理，但是因为同一个 Bundle 里面的元素处理失败，所以整个 Bundle2 都必须拿来重新处理。

重新处理的 Bundle 也不一定要在原来的 Worker 里面被处理，有可能会被转移到另外的 Worker 里面处理。如上图所示，需要重新被处理的 Bundle2 就被转移到 Worker1 上面处理了。

多步骤 Transform 上的错误处理

学习完单个 Transform 上的错误处理机制，我们再来看看在多步骤的 Transform 上发生错误时是如何处理的。

在多步骤的 Transform 上，如果处理的一个 Bundle 元素发生错误了，则这个元素所在的整个 Bundle 以及与这个 Bundle 有关联的所有 Bundle 都必须重新处理。

我们还是用上面的多步骤 Transform 来讲解这个例子。

你可以看到，在 Worker2 中，处理 Transform2 逻辑的时候生成 Bundle6 里面的第一个元素失败了。因为 Bundle4、Bundle5 和 Bundle6 都是相关联的，所以这三个 Bundle 都会被重新处理。

小结

今天我们一起学习了 Beam 里对于数据处理逻辑的高度抽象数据流水线，以及它的底层处理模型。数据流水线是构建数据处理的基础，掌握了它，我们就可以根据自身的应用需求，构建出一套数据流水线来处理数据。

思考题

你能根据自己的理解重述一下在 Beam 的数据流水线中，当处理的元素发生错误时流水线的错误处理机制吗？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

27 | Pipeline I/O: Beam数据中转的设计模式

你好，我是蔡元楠。

今天我要与你分享的主题是“Pipeline I/O: Beam 数据中转的设计模式”。

在前面的章节中，我们一起学习了如何使用 PCollection 来抽象封装数据，如何使用 Transform 来封装我们的数据处理逻辑，以及 Beam 是如何将数据处理高度抽象成为 Pipeline 来表达的，就如下图所示。

讲到现在，你有没有发现我们还缺少了两样东西没有讲？没错，那就是最初的输入数据集和结果数据集。那么我们最初的输入数据集是如何得到的？在经过了多步骤的 Transforms 之后得到的结果数据集又是如何输出到目的地址的呢？

事实上在 Beam 里，我们可以用 Beam 的 Pipeline I/O 来实现这两个操作。今天我就来具体讲讲 Beam 的 Pipeline I/O。

读取数据集

一个输入数据集的读取通常是通过 Read Transform 来完成的。Read Transform 从外部源 (External Source) 中读取数据，这个外部源可以是本地机器上的文件，可以是数据库中的数据，也可以是云存储上面的文件对象，甚至可以是数据流上的消息数据。

Read Transform 的返回值是一个 PCollection，这个 PCollection 就可以作为输入数据集，应用在各种 Transform 上。Beam 数据流水线对于用户什么时候去调用 Read Transform 是没有限制的，我们可以在数据流水线的最开始调用它，当然也可以在经过了 N 个步骤的 Transforms 后再调用它来读取另外的输入数据集。

以下的代码实例就是从 filepath 中读取文本。

Java

PCollection<String> inputs = p.apply(TextIO.read().from(filepath));

当然了，Beam 还支持从多个文件路径中读取数据集的功能，它的文件名匹配规则和 Linux 系统底下的 glob 文件路径匹配模式是一样的，使用的是“*”和“?”这样的匹配符。

我来为你举个例子解释一下，假设我们正运行着一个商品交易平台，这个平台会将每天的交易数据保存在一个一个特定的文件路径下，文件的命名格式为 YYYY-MM-DD.csv。每一个 CSV 文件都存储着这一天的交易数据。

现在我们想要读取某一个月份的数据来做数据处理，那我们就可以按照下面的代码实例来读取文件数据了。

Java

PCollection<String> inputs = p.apply(TextIO.read().from("filepath/.../YYYY-MM-*.csv");

这样做后，所有满足 YYYY-MM- 前缀和.csv 后缀的文件都会被匹配上。

当然了，glob 操作符的匹配规则最终还是要和你所要使用的底层文件系统挂钩的。所以，在使用的时候，最好要先查询好你所使用的文件系统的通配符规则。

我来举个 Google Cloud Storage 的例子吧。我们保存的数据还是上面讲到的商品交易平台数据，我们的数据是保存在 Google Cloud Storage 上面，并且文件路径是按照“filepath/…/YYYY/MM/DD/HH.csv”这样的格式来存放的。如果是这种情况，下面这样的代码写法就无法读取到一整个月的数据了。

Java

PCollection<String> inputs = p.apply(TextIO.read().from("filepath/.../YYYY/MM/*.csv");

因为在 Google Cloud Storage 的通配符规则里面，“”只能匹配到“”自己所在的那一层子目录而已。所以"filepath/…/YYYY/MM/*.csv"这个文件路径并不能找到“filepath/…/YYYY/MM/DD/…”这一层目录了。如果要达到我们的目标，我们就需要用到“**”的通配符，也就是如以下的写法。

Java

PCollection<String> inputs = p.apply(TextIO.read().from("filepath/.../YYYY/MM/**.csv");

如果你想要从不同的外部源中读取同一类型的数据来统一作为输入数据集，那我们可以多次调用 Read Transform 来读取不同源的数据，然后利用 flatten 操作将数据集合并，示例如下。

Java

PCollection<String> input1 = p.apply(TextIO.read().from(filepath1);

PCollection<String> input2 = p.apply(TextIO.read().from(filepath2);

PCollection<String> input3 = p.apply(TextIO.read().from(filepath3);

PCollectionList<String> inputs = PCollectionList.of(input1).and(input2).and(input3);

PCollection<String> inputs = collections.apply(Flatten.<String>pCollections());

输出数据集

将结果数据集输出到目的地址的操作是通过 Write Transform 来完成的。Write Transform 会将结果数据集输出到外部源中。

与 Read Transform 相对应，只要 Read Transform 能够支持的外部源，Write Transform 都是支持的。在 Beam 数据流水线中，Write Transform 可以在任意的一个步骤上将结果数据集输出。所以，用户能够将多步骤的 Transforms 中产生的任何中间结果输出。示例代码如下。

Java

output.apply(TextIO.write().to(filepath));

需要注意的是，如果你的输出是写入到文件中的话，Beam 默认是会写入到多个文件路径中的，而用户所指定的文件名会作为实际输出文件名的前缀。

Java

output.apply(TextIO.write().to(filepath/output));

当输出结果超过一定大小的时候，Beam 会将输出的结果分块，并写入到以“output00”“output01”等等为文件名的文件当中。如果你想将结果数据集保存成为特定的一种文件格式的话，可以使用“withSuffix”这个 API 来指定这个文件格式。

例如，如果你想将结果数据集保存成 csv 格式的话，代码就可以这样写：

Java

output.apply(TextIO.write().to(filepath/output).withSuffix(".csv"));

在 Beam 里面，Read 和 Write 的 Transform 都是在名为 I/O 连接器（I/O connector）的类里面实现的。而 Beam 原生所支持的 I/O 连接器也是涵盖了大部分应用场景，例如有基于文件读取输出的 FileIO、TFRecordIO，基于流处理的 KafkaIO、PubsubIO，基于数据库的 JdbcIO、RedisIO 等等。

当然了，Beam 原生的 I/O 连接器并不可能支持所有的外部源。比如，如果我们想从 Memcached 中读取数据，那原生的 I/O 连接器就不支持了。说到这里你可能会有一个疑问，当我们想要从一些 Beam 不能原生支持的外部源中读取数据时，那该怎么办呢？答案很简单，可以自己实现一个自定义的 I/O 连接器出来。

自定义 I/O 连接器

自定义的 I/O 连接器并不是说一定要设计得非常通用，而是只要能够满足自身的应用需求就可以了。实现自定义的 I/O 连接器，通常指的就是实现 Read Transform 和 Write Transform 这两种操作，这两种操作都有各自的实现方法，下面我以 Java 为编程语言来一一为你解释。

自定义读取操作

我们知道 Beam 可以读取无界数据集也可以读取有界数据集，而读取这两种不同的数据集是有不同的实现方法的。

如果读取的是有界数据集，那我们可以有以下两种选项：

使用在第 25 讲中介绍的两个 Transform 接口，ParDo 和 GroupByKey 来模拟读取数据的逻辑。

继承 BoundedSource 抽象类来实现一个子类去实现读取逻辑。

如果读取的是无界数据集的话，那我们就必须继承 UnboundedSource 抽象类来实现一个子类去实现读取逻辑。

无论是 BoundedSource 抽象类还是 UnboundedSource 抽象类，其实它们都是继承了 Source 抽象类。为了能够在分布式环境下处理数据，这个 Source 抽象类也必须是可序列化的，也就是说 Source 抽象类必须实现 Serializable 这个接口。

如果我们是要读取有界数据集的话，Beam 官方推荐的是使用第一种方式来实现自定义读取操作，也就是将读取操作看作是 ParDo 和 GroupByKey 这种多步骤 Transforms。

好了，下面我来带你分别看看在不同的外部源中读取数据集是如何模拟成 ParDo 和 GroupByKey 操作的。

从多文件路径中读取数据集

从多文件路径中读取数据集相当于用户转入一个 glob 文件路径，我们从相应的存储系统中读取数据出来。比如说读取“filepath/**”中的所有文件数据，我们可以将这个读取转换成以下的 Transforms：

获取文件路径的 ParDo：从用户传入的 glob 文件路径中生成一个 PCollection的中间结果，里面每个字符串都保存着具体的一个文件路径。

读取数据集 ParDo：有了具体 PCollection的文件路径数据集，从每个路径中读取文件内容，生成一个总的 PCollection 保存所有数据。

从 NoSQL 数据库中读取数据集

NoSQL 这种外部源通常允许按照键值范围（Key Range）来并行读取数据集。我们可以将这个读取转换成以下的 Transforms：

确定键值范围 ParDo：从用户传入的要读取数据的键值生成一个 PCollection 保存可以有效并行读取的键值范围。

读取数据集 ParDo：从给定 PCollection 的键值范围，读取相应的数据，并生成一个总的 PCollection 保存所有数据。

从关系型数据库读取数据集

从传统的关系型数据库查询结果通常都是通过一个 SQL Query 来读取数据的。所以，这个时候只需要一个 ParDo，在 ParDo 里面建立与数据库的连接并执行 Query，将返回的结果保存在一个 PCollection 里。

自定义输出操作

相比于读取操作，输出操作会简单很多，只需要在一个 ParDo 里面调用相应文件系统的写操作 API 来完成数据集的输出。

如果我们的输出数据集是需要写入到文件去的话，Beam 也同时提供了基于文件操作的 FileBasedSink 抽象类给我们，来实现基于文件类型的输出操作。像很常见的 TextSink 类就是实现了 FileBasedSink 抽象类，并且运用在了 TextIO 中的。

如果我们要自己写一个自定义的类来实现 FileBasedSink 的话，也必须实现 Serializable 这个接口，从而保证输出操作可以在分布式环境下运行。

同时，自定义的类必须具有不可变性（Immutability）。怎么理解这个不可变性呢？其实它指的是在这个自定义类里面，如果有定义私有字段（Private Field）的话，那它必须被声明为 final。如果类里面有变量需要被修改的话，那每次做的修改操作都必须先复制一份完全一样的数据出来，然后再在这个新的变量上做修改。这和我们在第 27 讲中学习到的 Bundle 机制一样，每次的操作都需要产生一份新的数据，而原来的数据是不可变的。

小结

今天我们一起学习了在 Beam 中的一个重要概念 Pipeline I/O，它使得我们可以在 Beam 数据流水线上读取和输出数据集。同时，我们还学习到了如何自定义一个 I/O 连接器，当 Beam 自身提供的原生 I/O 连接器不能满足我们需要的特定存储系统时，我们就可以自定义 I/O 逻辑来完成数据集的读取和输出。

思考题

你觉得 Beam 的 Pipeline I/O 设计能够满足我们所有的应用需求了吗？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

28 | 如何设计创建好一个Beam Pipeline？

你好，我是蔡元楠。

今天我要与你分享的主题是“如何设计创建好一个 Beam Pipeline”。

这一讲我们会用到第 7 讲中介绍过的四种常见设计模式——复制模式、过滤模式、分离模式和合并模式。这些设计模式就像是武功的基本套路一样，在实战中无处不在。今天，我们就一起来看看我们怎么用 Beam 的 Pipeline 来实现这些设计模式。

设计 Pipeline 的基本考虑因素

在设计 Pipeline 时，你需要注意 4 条基本的考虑因素。

1. 输入数据存储在哪里？

输入数据是存储在云存储文件系统，还是存储在一个关系型数据库里？有多大的数据量？这些都会影响你的 pipeline 设计是如何读入数据的。上一讲已经讲到过，Pipeline 的数据读入是使用 Read 这个特殊的 Transform。而数据读入往往是一个 Pipeline 的第一个数据操作。

2. 输入数据是什么格式？

输入数据是纯文本文件？还是读取自关系型数据库的行？还是结构化好的特殊数据结构？这些都会影响你对于 PCollection 的选择。比如，如果输入数据是自带 key/value 的结构，那你用 Beam 的 key/value 为元素的 PCollection 能更好的表示数据。

3. 这个 pipeline 你打算对数据进行哪些操作？

提前想好要做哪些数据操作，可以帮助你设计好 Transform。可能你也会使用一些 Beam 提供的 Transform 或者是你的团队共用的一些 Transform。

4. 输出数据需要是什么样的格式，需要存储到哪里？

和输入数据相同，对于输出数据，我们也要提前判断好业务的需求。看看需要的数据格式是什么样的，是要存储到本地文本文件？还是存储到另一个数据库？

比如，你在跑一个本地批处理作业，就会需要先存到本地看一看。如果你在生成环境有永久性数据库，或者你有结构化的数据，可能更想存储到你的数据库里。

复制模式的 Pipeline 设计

现在，我们就来看看在第 7 讲中提到的复制模式（Copier Pattern）的例子是怎么用 Beam 实现的。这里需要用到第 7 讲的 YouTube 视频平台的复制模式案例。这里就简单介绍一下，以便唤醒你的记忆。如果你完全忘记了，我建议你先去做个回顾。

如今的视频平台会提供不同分辨率的视频给不同网络带宽的用户。在 YouTube 视频平台中，将鼠标放在视频缩略图上时，它会自动播放一段已经生成好的动画缩略图。平台的自然语言理解（NLP）的数据处理模块可以分析视频数据，自动生成视频字幕。视频分析的数据处理模块也可以通过分析视频数据产生更好的内容推荐系统。这使用的就是复制模式。

要想在在 Beam 中采用复制模式，我们可以用一个 PCollection 来表示输入的 Video data set。将每一种视频处理编写成 Transform。最后，多重输出各自为一个 PCollection。整个过程就如同下图所示。

你可以从图片中看到，在这个工作流系统中，每个数据处理模块的输入都是相同的，而下面的 5 个数据处理模块都可以单独并且同步地运行处理。

复制模式通常是将单个数据处理模块中的数据完整地复制到两个或更多的数据处理模块中，然后再由不同的数据处理模块进行处理。当我们在处理大规模数据时，需要对同一个数据集采取多种不同的数据处理转换，我们就可以优先考虑采用复制模式。

比如下面的代码，我们用 5 个不同的 pipeline 来表示，它们的作用分别是生成高画质视频、生成低画质视频、生成 GIF 动画、生成视频字幕、分析视频。

PCollection<Video> videoDataCollection = ...;

//  生成高画质视频

PCollection<Video> highResolutionVideoCollection = videoDataCollection.apply("highResolutionTransform", ParDo.of(new DoFn<Video, Video>(){

  @ProcessElement

  public void processElement(ProcessContext c) {

    c.output(generateHighResolution(c.element()));

  }

}));

//  生成低画质视频

PCollection<Video> lowResolutionVideoCollection = videoDataCollection.apply("lowResolutionTransform", ParDo.of(new DoFn<Video, Video>(){

  @ProcessElement

  public void processElement(ProcessContext c) {

    c.output(generateLowResolution(c.element()));

  }

}));

// 生成 GIF 动画

PCollection<Image> gifCollection = videoDataCollection.apply("gifTransform", ParDo.of(new DoFn<Video, Image>(){

  @ProcessElement

  public void processElement(ProcessContext c) {

    c.output(generateGIF(c.element()));

  }

}));

//  生成视频字幕

PCollection<Caption> captionCollection = videoDataCollection.apply("captionTransform", ParDo.of(new DoFn<Video, Caption>(){

  @ProcessElement

  public void processElement(ProcessContext c) {

    c.output(generateCaption(c.element()));

  }

}));

//   分析视频

PCollection<Report> videoAnalysisCollection = videoDataCollection.apply("videoAnalysisTransform", ParDo.of(new DoFn<Video, Report>(){

  @ProcessElement

  public void processElement(ProcessContext c) {

    c.output(analyzeVideo(c.element()));

  }

}));

过滤模式的 Pipeline 设计

过滤模式（Filter Pattern）也可以用 Beam 来实现。这里我们先简单回顾一下第 7 讲的例子。在商城会员系统中，系统根据用户的消费次数、消费金额、注册时间划分用户等级。假设现在商城有五星、金牌和钻石这三种会员。而系统现在打算通过邮件对钻石会员发出钻石会员活动的邀请。

在过滤模式中，一个数据处理模块会将输入的数据集过滤，留下符合条件的数据，然后传输到下一个数据处理模块进行单独处理。

在用 Beam 实现时，我们把输入的用户群组表达成一个 PCollection。输出的钻石会员用户群组也表示成一个 PCollection。那么中间的过滤步骤就能编写成一个 Transform。如下面代码所示，我们在一个 Beam Pipeline 里调用 isDiamondUser() 方法，从所有的用户中过滤出钻石会员。

PCollection<User> userCollection = ...;

PCollection<User> diamondUserCollection = userCollection.apply("filterDiamondUserTransform", ParDo.of(new DoFn<User, User>(){

  @ProcessElement

  public void processElement(ProcessContext c) {

    if (isDiamondUser(c.element()) {

      c.output(c.element());

    }

  }

}));

PCollection<User> notifiedUserCollection = userCollection.apply("notifyUserTransform", ParDo.of(new DoFn<User, User>(){

  @ProcessElement

  public void processElement(ProcessContext c) {

    if (notifyUser(c.element()) {

      c.output(c.element());

    }

  }

}));

分离模式的 Pipeline 设计

分离模式（Splitter Pattern）与过滤模式不同，并不会丢弃里面的任何数据，而是将数据分组处理。还是以商城会员系统为例。系统打算通过邮件对不同会员发出与他们身份相应的活动邀请。需要通过分离模式将用户按照会员等级分组，然后发送相应的活动内容。

用 Beam 应该怎么实现呢？我们可以应用第 25 讲中讲到的 side input/output 技术。同样的还是把用户群组都定义成不同的 PCollection。最终的输出会是三个 PCollection。

// 首先定义每一个 output 的 tag

final TupleTag<User> fiveStarMembershipTag = new TupleTag<User>(){};

final TupleTag<User> goldenMembershipTag = new TupleTag<User>(){};

final TupleTag<User> diamondMembershipTag = new TupleTag<User>(){};

PCollection<User> userCollection = ...;

PCollectionTuple mixedCollection =

    userCollection.apply(ParDo

        .of(new DoFn<User, User>() {

          @ProcessElement

          public void processElement(ProcessContext c) {

            if (isFiveStartMember(c.element())) {

              c.output(c.element());

            } else if (isGoldenMember(c.element())) {

              c.output(goldenMembershipTag, c.element());

            } else if (isDiamondMember(c.element())) {

	  c.output(diamondMembershipTag, c.element());

	}

          }

        })

        .withOutputTags(fiveStarMembershipTag,

                        TupleTagList.of(goldenMembershipTag).and(diamondMembershipTag)));

// 分离出不同的用户群组

mixedCollection.get(fiveStarMembershipTag).apply(...);

mixedCollection.get(goldenMembershipTag).apply(...);

mixedCollection.get(diamondMembershipTag).apply(...);

比如在上面的代码中，我们在 processElement() 方法中，根据过滤函数，分拆出五星会员，金牌会员和钻石会员。并且把不同的会员等级输出到不同的 side output tag 中。之后可以在返回的 PCollection 中用这个 side output tag 得到想要的输出。

合并模式的 Pipeline 设计

合并模式（Joiner Pattern）会将多个不同的数据集合成一个总数据集，一并进行处理。之前介绍的合并模式案例是用街头美团外卖电动车的数量来预测美团的股价。

数据接入这一处理模块里，我们的输入数据有自己团队在街道上拍摄到的美团外卖电动车图片和第三方公司提供的美团外卖电动车图片。我们需要先整合所有数据然后进行其它数据处理。

使用 Beam 合并多个 PCollection 时，需要用到 Beam 自带的 Flatten 这个 Transform 函数，它的作用是把来自多个 PCollection 类型一致的元素融合到一个 PCollection 中去。下面的代码用元素类型为 Image 的 PCollection 来表达输入数据和输出数据。

PCollectionList<Image> collectionList = PCollectionList.of(internalImages).and(thirdPartyImages);

PCollection<Image> mergedCollectionWithFlatten = collectionList

    .apply(Flatten.<Image>pCollections());

mergedCollectionWithFlatten.apply(...);

例如，在上面的代码示例中，我们把 internalImages 和 thirdPartyImages 两个 PCollection 融合到一起。使用 apply(Flatten) 这样一个 Transform 实现多个 PCollection 的平展。

小结

今天我们一起学习了怎样在 Beam 中设计实现第 7 讲介绍的经典数据处理模式，分别是 4 种设计模式，分别是复制模式、过滤模式、分离模式和合并模式。

在实现这四种数据处理模式的过程中，我们学到了两种 Beam Transform 的两个重要技术，分别是分离模式中用到的 side output，和在合并模式中用到的 Flatten。正如前文所说，第 7 讲的经典数据处理模式就像是武功的基本套路，实战项目中可能 80% 都是这些基本套路的组合。有了这些小型的模块实现，对我们未来实现大型系统是有很大帮助的。

思考题

在你的项目中有没有这四种设计模式的影子呢？如果有的话你觉得可以怎样用 Beam Pipeline 实现呢？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

29 | 如何测试Beam Pipeline？

你好，我是蔡元楠。

今天我要与你分享的主题是“如何测试 Beam Pipeline”。

在上一讲中，我们结合了第 7 讲的内容，一起学习了在 Beam 的世界中我们该怎么设计好对应的设计模式。而在今天这一讲中，我想要讲讲在日常开发中经常会被忽略的，但是又非常重要的一个开发环节——测试。

你知道，我们设计好的 Beam 数据流水线通常都会被放在分布式环境下执行，具体每一步的 Transform 都会被分配到任意的机器上面执行。如果我们在运行数据流水线时发现结果出错了，那么想要定位到具体的机器，再到上面去做调试是不现实的。

当然还有另一种方法，读取一些样本数据集，再运行整个数据流水线去验证哪一步逻辑出错了。但这是一项非常耗时耗力的工作。即便我们可以把样本数据集定义得非常小，从而缩短运行数据流水线运行所需的时间。但是万一我们所写的是多步骤数据流水线的话，就不知道到底在哪一步出错了，我们必须把每一步的中间结果输出出来进行调试。

基于以上种种的原因，在我们正式将数据流水线放在分布式环境上面运行之前，先完整地测试好整个数据流水线逻辑，就变得尤为重要了。

为了解决这些问题，Beam 提供了一套完整的测试 SDK。让我们可以在开发数据流水线的同时，能够实现对一个 Transform 逻辑的单元测试，也可以对整个数据流水线端到端（End-to-End）地测试。

在 Beam 所支持的各种 Runners 当中，有一个 Runner 叫作 DirectRunner。DirectRunner 其实就是我们的本地机器。也就是说，如果我们指定 Beam 的 Runner 为 DirectRunner 的话，整个 Beam 数据流水线都会放在本地机器上面运行。我们在运行测试程序的时候可以利用这个 DirectRunner 来跑测试逻辑。

在正式讲解之前，有一点是我需要提醒你的。如果你喜欢自行阅读 Beam 的相关技术文章或者是示例代码的话，可能你会看见一些测试代码使用了在 Beam SDK 中的一个测试类，叫作 DoFnTester 来进行单元测试。这个 DoFnTester 类可以让我们传入一个用户自定义的函数（User Defined Function/UDF）来进行测试。

通过第 25 讲的内容我们已经知道，一个最简单的 Transform 可以用一个 ParDo 来表示，在使用它的时候，我们需要继承 DoFn 这个抽象类。这个 DoFnTester 接收的对象就是我们继承实现的 DoFn。在这里，我们把一个 DoFn 看作是一个单元来进行测试了。但这并不是 Beam 所提倡的。

因为在 Beam 中，数据转换的逻辑都是被抽象成 Transform，而不是 Transform 里面的 ParDo 这些具体的实现。每个 Runner 具体怎么运行这些 ParDo，对于用户来说应该都是透明的。所以，在 Beam 的 2.4.0 版本之后，Beam SDK 将这个类标记成了 Deprecated，转而推荐使用 Beam SDK 中的 TestPipeline。

所以，我在这里也建议你，在写测试代码的时候，不要使用任何和 DoFnTester 有关的 SDK。

Beam 的 Transform 单元测试

说完了注意事项，那事不宜迟，我们就先从一个 Transform 的单元测试开始，看看在 Beam 是如何做测试的（以下所有的测试示例代码都是以 Java 为编程语言来讲解）。

一般来说，Transform 的单元测试可以通过以下五步来完成：

创建一个 Beam 测试 SDK 中所提供的 TestPipeline 实例。

创建一个静态（Static）的、用于测试的输入数据集。

使用 Create Transform 来创建一个 PCollection 作为输入数据集。

在测试数据集上调用我们需要测试的 Transform 上并将结果保存在一个 PCollection 上。

使用 PAssert 类的相关函数来验证输出的 PCollection 是否是我所期望的结果。

假设我们要处理的数据集是一个整数集合，处理逻辑是过滤掉数据集中的奇数，将输入数据集中的偶数输出。为此，我们通过继承 DoFn 类来实现一个产生偶数的 Transform，它的输入和输出数据类型都是 Integer。

Java

static class EvenNumberFn extends DoFn<Integer, Integer> {

 @ProcessElement

 public void processElement(@Element Integer in, OutputReceiver<Integer> out) {

   if (in % 2 == 0) {

     out.output(in);

   }

 }

}

那我们接下来就根据上面所讲的测试流程，测试这个 EvenNumerFn Transform，来一步步创建我们的单元测试。

创建 TestPipeline

第一步，创建 TestPipeline。创建一个 TestPipeline 实例的代码非常简单，示例如下：

Java

...

Pipeline p = TestPipeline.create();

...

如果你还记得在第 26 讲中如何创建数据流水线的话，可以发现，TestPipeline 实例的创建其实不用给这个 TestPipeline 定义选项（Options）。因为 TestPipeline 中 create 函数已经在内部帮我们创建好一个测试用的 Options 了。

创建静态输入数据集

Java

...

static final List<Integer> INPUTS = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

...

第二步，创建静态的输入数据集。创建静态的输入数据集的操作就和我们平时所写的普通 Java 代码一样，在示例中，我调用了 Arrays 类的 asList 接口来创建一个拥有 10 个整数的数据集。

使用 Create Transform 创建 PCollection

在创建完静态数据集后，我们进入第三步，创建一个 PCollection 作为输入数据集。在 Beam 原生支持的 Transform 里面，有一种叫作 Create Transform，我们可以利用这个 Create Transform 将 Java Collection 的数据转换成为 Beam 的数据抽象 PCollection，具体的做法如下：

Java

...

PCollection<Integer> input = p.apply(Create.of(INPUTS)).setCoder(VarIntCoder.of());

...

调用 Transform 处理逻辑

第四步，调用 Transform 处理逻辑。有了数据抽象 PCollection，我们就需要在测试数据集上调用我们需要测试的 Transform 处理逻辑，并将结果保存在一个 PCollection 上。

Java

...

PCollection<String> output = input.apply(ParDo.of(new EvenNumberFn()));

...

根据第 25 讲的内容，我们只需要在这个输入数据集上调用 apply 抽象函数，生成一个需要测试的 Transform，并且传入 apply 函数中就可以了。

验证输出结果

第五步，验证输出结果。在验证结果的阶段，我们需要调用 PAssert 类中的函数来验证输出结果是否和我们期望的一致，示例如下。

Java

...

PAssert.that(output).containsInAnyOrder(2, 4, 6, 8, 10);

...

完成了所有的步骤，我们就差运行这个测试的数据流水线了。很简单，就是调用 TestPipeline 的 run 函数，整个 Transform 的单元测试示例如下：

Java

final class TestClass {

    static final List<Integer> INPUTS = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

    public void testFn() {

      Pipeline p = TestPipeline.create();

      PCollection<Integer> input = p.apply(Create.of(INPUTS)).setCoder(VarIntCoder.of());

      PCollection<String> output = input.apply(ParDo.of(new EvenNumberFn()));

      PAssert.that(output).containsInAnyOrder(2, 4, 6, 8, 10);

      p.run();

    }

}

有一点需要注意的是，TestPipeline 的 run 函数是在单元测试的结尾处调用的，PAssert 的调用必须在 TestPipeliner 调用 run 函数之前调用。

Beam 的端到端测试

在一般的现实应用中，我们设计的都是多步骤数据流水线，就拿我在第一讲中举到的处理美团外卖电动车的图片为例子，其中就涉及到了多个输入数据集，而结果也有可能会根据实际情况有多个输出。

所以，我们在做测试的时候，往往希望能有一个端到端的测试。在 Beam 中，端到端的测试和 Transform 的单元测试非常相似。唯一的不同点在于，我们要为所有的输入数据集创建测试数据集，而不是只针对某一个 Transform 来创建。对于在数据流水线的每一个应用到 Write Transfrom 的地方，我们都需要用到 PAssert 类来验证输出数据集。

所以，端到端测试的步骤也分五步，具体内容如下：

创建一个 Beam 测试 SDK 中所提供的 TestPipeline 实例。

对于多步骤数据流水线中的每个输入数据源，创建相对应的静态（Static）测试数据集。

使用 Create Transform，将所有的这些静态测试数据集转换成 PCollection 作为输入数据集。

按照真实数据流水线逻辑，调用所有的 Transforms 操作。

在数据流水线中所有应用到 Write Transform 的地方，都使用 PAssert 来替换这个 Write Transform，并且验证输出的结果是否我们期望的结果相匹配。

为了方便说明，我们就在之前的例子中多加一步 Transform 和一个输出操作来解释如何写端到端测试。假设，我们要处理数据集是一个整数集合，处理逻辑是过滤掉奇数，将输入数据集中的偶数转换成字符串输出。同时，我们也希望对这些偶数求和并将结果输出，示例如下：

Java

final class Foo {

  static class EvenNumberFn extends DoFn<Integer, Integer> {

    @ProcessElement

    public void processElement(@Element Integer in, OutputReceiver<Integer> out) {

      if (in % 2 == 0) {

        out.output(in);

      }

    }

  }

  static class ParseIntFn extends DoFn<String, Integer> {

    @ProcessElement

    public void processElement(@Element String in, OutputReceiver<Integer> out) {

      out.output(Integer.parseInt(in));

    }

  }

  public static void main(String[] args) {

    PipelineOptions options = PipelineOptionsFactory.create();

    Pipeline p = Pipeline.create(options);

    PCollection<Integer> input = p.apply(TextIO.read().from("filepath/input")).apply(ParDo.of(new ParseIntFn()));

    PCollection<Integer> output1 = input.apply(ParDo.of(new EvenNumberFn()));

    output1.apply(ToString.elements()).apply(TextIO.write().to("filepath/evenNumbers"));

    PCollection<Integer> sum = output1.apply(Combine.globally(new SumInts()));

    sum.apply(ToString.elements()).apply(TextIO.write().to("filepath/sum"));

    p.run();

  }

}

从上面的示例代码中你可以看到，我们从一个外部源读取了一系列输入数据进来，将它转换成了整数集合。同时，将我们自己编写的 EvenNumberFn Transform 应用在了这个输入数据集上。得到了所有偶数集合之后，我们先将这个中间结果输出，然后再针对这个偶数集合求和，最后将这个结果输出。

整个数据流水线总共有一次对外部数据源的读取和两次的输出，我们按照端到端测试的步骤，为所有的输入数据集创建静态数据，然后将所有有输出的地方都使用 PAssert 类来进行验证。整个测试程序如下所示：

Java

final class TestClass {

  static final List<String> INPUTS =

      Arrays.asList("1", "2", "3", "4", "5", "6", "7", "8", "9", "10");

  static class EvenNumberFn extends DoFn<Integer, Integer> {

    @ProcessElement

    public void processElement(@Element Integer in, OutputReceiver<Integer> out) {

      if (in % 2 == 0) {

        out.output(in);

      }

    }

  }

  static class ParseIntFn extends DoFn<String, Integer> {

    @ProcessElement

    public void processElement(@Element String in, OutputReceiver<Integer> out) {

      out.output(Integer.parseInt(in));

    }

  }

  public void testFn() {

    Pipeline p = TestPipeline.create();

    PCollection<String> input = p.apply(Create.of(INPUTS)).setCoder(StringUtf8Coder.of());

    PCollection<Integer> output1 = input.apply(ParDo.of(new ParseIntFn())).apply(ParDo.of(new EvenNumberFn()));

    PAssert.that(output1).containsInAnyOrder(2, 4, 6, 8, 10);

    PCollection<Integer> sum = output1.apply(Combine.globally(new SumInts()));

    PAssert.that(sum).is(30);

    p.run();

  }

}

在上面的示例代码中，我们用 TestPipeline 替换了原来的 Pipeline，创建了一个静态输入数据集并用 Create Transform 转换成了 PCollection，最后将所有用到 Write Transform 的地方都用 PAssert 替换掉，来验证输出结果是否是我们期望的结果。

小结

今天我们一起学习了在 Beam 中写编写测试逻辑的两种方式，分别是针对一个 Transform 的单元测试和针对整个数据流水线的端到端测试。Beam 提供的 SDK 能够让我们不需要在分布式环境下运行程序而是本地机器上运行。测试在整个开发环节中是非常的一环，我强烈建议你在正式上线自己的业务逻辑之前先对此有一个完整的测试。

思考题

如果让你来利用 Beam SDK 来测试你日常处理的数据逻辑，你会如何编写测试呢？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

30 | Apache Beam实战冲刺：Beam如何run everywhere?

你好，我是蔡元楠。

今天我要与你分享的主题是“Apache Beam 实战冲刺：Beam 如何 run everywhere”。

你可能已经注意到，自第 26 讲到第 29 讲，从 Pipeline 的输入输出，到 Pipeline 的设计，再到 Pipeline 的测试，Beam Pipeline 的概念一直贯穿着文章脉络。那么这一讲，我们一起来看看一个完整的 Beam Pipeline 究竟是如何编写的。

Beam Pipeline

一个 Pipeline，或者说是一个数据处理任务，基本上都会包含以下三个步骤：

读取输入数据到 PCollection。

对读进来的 PCollection 做某些操作（也就是 Transform），得到另一个 PCollection。

输出你的结果 PCollection。

这么说，看起来很简单，但你可能会有些迷惑：这些步骤具体该怎么做呢？其实这些步骤具体到 Pipeline 的实际编程中，就会包含以下这些代码模块：

Java

// Start by defining the options for the pipeline.

PipelineOptions options = PipelineOptionsFactory.create();

// Then create the pipeline.

Pipeline pipeline = Pipeline.create(options);

PCollection<String> lines = pipeline.apply(

  "ReadLines", TextIO.read().from("gs://some/inputData.txt"));

PCollection<String> filteredLines = lines.apply(new FilterLines());

filteredLines.apply("WriteMyFile", TextIO.write().to("gs://some/outputData.txt"));

pipeline.run().waitUntilFinish();

从上面的代码例子中你可以看到，第一行和第二行代码是创建 Pipeline 实例。任何一个 Beam 程序都需要先创建一个 Pipeline 的实例。Pipeline 实例就是用来表达 Pipeline 类型的对象。这里你需要注意，一个二进制程序可以动态包含多个 Pipeline 实例。

还是以之前的美团外卖电动车处理的例子来做说明吧。

比如，我们的程序可以动态判断是否存在第三方的电动车图片，只有当有需要处理图片时，我们才去创建一个 Pipeline 实例处理。我们也可以动态判断是否存在需要转换图片格式，有需要时，我们再去创建第二个 Pipeline 实例。这时候你的二进制程序，可能包含 0 个、1 个，或者是 2 个 Pipeline 实例。每一个实例都是独立的，它封装了你要进行操作的数据，和你要进行的操作 Transform。

Pipeline 实例的创建是使用 Pipeline.create(options) 这个方法。其中 options 是传递进去的参数，options 是一个 PipelineOptions 这个类的实例。我们会在后半部分展开 PipelineOptions 的丰富变化。

第三行代码，我们用 TextIO.read() 这个 Transform 读取了来自外部文本文件的内容，把所有的行表示为一个 PCollection。

第四行代码，用 lines.apply(new FilterLines()) 对读进来的 PCollection 进行了过滤操作。

第五行代码 filteredLines.apply(“WriteMyFile”, TextIO.write().to(“gs://some/outputData.txt”))，表示把最终的 PCollection 结果输出到另一个文本文件。

程序运行到第五行的时候，是不是我们的数据处理任务就完成了呢？并不是。

记得我们在第 24 讲、第 25 讲中提过，Beam 是延迟运行的。程序跑到第五行的时候，只是构建了 Beam 所需要的数据处理 DAG 用来优化和分配计算资源，真正的运算完全没有发生。

所以，我们需要最后一行 pipeline.run().waitUntilFinish()，这才是数据真正开始被处理的语句。

这时候运行我们的代码，是不是就大功告成呢？别急，我们还没有处理好程序在哪里运行的问题。你一定会好奇，我们的程序究竟在哪里运行，不是说好了分布式数据处理吗？

在上一讲《如何测试 Beam Pipeline》中我们学会了在单元测试环境中运行 Beam Pipeline。就如同下面的代码。和上文的代码类似，我们把 Pipeline.create(options) 替换成了 TestPipeline.create()。

Java

Pipeline p = TestPipeline.create();

PCollection<String> input = p.apply(Create.of(WORDS)).setCoder(StringUtf8Coder.of());

PCollection<String> output = input.apply(new CountWords());

PAssert.that(output).containsInAnyOrder(COUNTS_ARRAY);

p.run();

TestPipeline 是 Beam Pipeline 中特殊的一种，让你能够在单机上运行小规模的数据集。之前我们在分析 Beam 的设计理念时提到过，Beam 想要把应用层的数据处理业务逻辑和底层的运算引擎分离开来。

现如今 Beam 可以做到让你的 Pipeline 代码无需修改，就可以在本地、Spark、Flink，或者在 Google Cloud DataFlow 上运行。这些都是通过 Pipeline.create(options) 这行代码中传递的 PipelineOptions 实现的。

在实战中，我们应用到的所有 option 其实都是实现了 PipelineOptions 这个接口。

举个例子，如果我们希望将数据流水线放在 Spark 这个底层数据引擎运行的时候，我们便可以使用 SparkPipelineOptions。如果我们想把数据流水线放在 Flink 上运行，就可以使用 FlinkPipelineOptions。而这些都是 extends 了 PipelineOptions 的接口，示例如下：

Java

options = PipelineOptionsFactory.as(SparkPipelineOptions.class);

Pipeline pipeline = Pipeline.create(options);

通常一个 PipelineOption 是用 PipelineOptionsFactory 这个工厂类来创建的，它提供了两个静态工厂方法给我们去创建，分别是 PipelineOptionsFactory.as(Class) 和 PipelineOptionsFactory.create()。像上面的示例代码就是用 PipelineOptionsFactory.as(Class) 这个静态工厂方法来创建的。

当然了，更加常见的创建方法是从命令行中读取参数来创建 PipelineOption，使用的是 PipelineOptionsFactory#fromArgs(String[]) 这个方法，例如：

Java

public static void main(String[] args) {

     PipelineOptions options = PipelineOptionsFactory.fromArgs(args).create();

     Pipeline p = Pipeline.create(options);

}

下面我们来看看不同的运行模式的具体使用方法。

直接运行模式

我们先从直接运行模式开始讲。这是我们在本地进行测试，或者调试时倾向使用的模式。在直接运行模式的时候，Beam 会在单机上用多线程来模拟分布式的并行处理。

使用 Java Beam SDK 时，我们要给程序添加 Direct Runner 的依赖关系。在下面这个 maven 依赖关系定义文件中，我们指定了 beam-runners-direct-java 这样一个依赖关系。

pom.xml

<dependency>

   <groupId>org.apache.beam</groupId>

   <artifactId>beam-runners-direct-java</artifactId>

   <version>2.13.0</version>

   <scope>runtime</scope>

</dependency>

一般我们会把 runner 通过命令行指令传递进程序。就需要使用 PipelineOptionsFactory.fromArgs(args) 来创建 PipelineOptions。PipelineOptionsFactory.fromArgs() 是一个工厂方法，能够根据命令行参数选择生成不同的 PipelineOptions 子类。

PipelineOptions options =

       PipelineOptionsFactory.fromArgs(args).create();

在实验程序中也可以强行使用 Direct Runner。比如：

PipelineOptions options = PipelineOptionsFactory.create();

options.setRunner(DirectRunner.class);

// 或者这样

options = PipelineOptionsFactory.as(DirectRunner.class);

Pipeline pipeline = Pipeline.create(options);

如果是在命令行中指定 Runner 的话，那么在调用这个程序时候，需要指定这样一个参数–runner=DirectRunner。比如：

mvn compile exec:java -Dexec.mainClass=YourMainClass \

     -Dexec.args="--runner=DirectRunner" -Pdirect-runner

Spark 运行模式

如果我们希望将数据流水线放在 Spark 这个底层数据引擎运行的时候，我们便可以使用 Spark Runner。Spark Runner 执行 Beam 程序时，能够像原生的 Spark 程序一样。比如，在 Spark 本地模式部署应用，跑在 Spark 的 RM 上，或者用 YARN。

Spark Runner 为在 Apache Spark 上运行 Beam Pipeline 提供了以下功能：

Batch 和 streaming 的数据流水线；

和原生 RDD 和 DStream 一样的容错保证；

和原生 Spark 同样的安全性能；

可以用 Spark 的数据回报系统；

使用 Spark Broadcast 实现的 Beam side-input。

目前使用 Spark Runner 必须使用 Spark 2.2 版本以上。

这里，我们先添加 beam-runners-spark 的依赖关系。

<dependency>

  <groupId>org.apache.beam</groupId>

  <artifactId>beam-runners-spark</artifactId>

  <version>2.13.0</version>

</dependency>

<dependency>

  <groupId>org.apache.spark</groupId>

  <artifactId>spark-core_2.10</artifactId>

  <version>${spark.version}</version>

</dependency>

<dependency>

  <groupId>org.apache.spark</groupId>

  <artifactId>spark-streaming_2.10</artifactId>

  <version>${spark.version}</version>

</dependency>

然后，要使用 SparkPipelineOptions 传递进 Pipeline.create() 方法。常见的创建方法是从命令行中读取参数来创建 PipelineOption，使用的是 PipelineOptionsFactory.fromArgs(String[]) 这个方法。在命令行中，你需要指定 runner=SparkRunner：

mvn exec:java -Dexec.mainClass=YourMainClass \

    -Pspark-runner \

    -Dexec.args="--runner=SparkRunner \

      --sparkMaster=<spark master url>"

也可以在 Spark 的独立集群上运行，这时候 spark 的提交命令，spark-submit。

spark-submit --class YourMainClass --master spark://HOST:PORT target/...jar --runner=SparkRunner

当 Beam 程序在 Spark 上运行时，你也可以同样用 Spark 的网页监控数据流水线进度。

Flink 运行模式

Flink Runner 是 Beam 提供的用来在 Flink 上运行 Beam Pipeline 的模式。你可以选择在计算集群上比如 Yarn/Kubernetes/Mesos 或者本地 Flink 上运行。Flink Runner 适合大规模，连续的数据处理任务，包含了以下功能：

以 Streaming 为中心，支持 streaming 处理和 batch 处理；

和 flink 一样的容错性，和 exactly-once 的处理语义；

可以自定义内存管理模型；

和其他（例如 YARN）的 Apache Hadoop 生态整合比较好。

其实看到这里，你可能已经掌握了这里面的诀窍。就是通过 PipelineOptions 来指定 runner，而你的数据处理代码不需要修改。PipelineOptions 可以通过命令行参数指定。那么类似 Spark Runner，你也可以使用 Flink 来运行 Beam 程序。

同样的，首先你需要在 pom.xml 中添加 Flink Runner 的依赖。

<dependency>

  <groupId>org.apache.beam</groupId>

  <artifactId>beam-runners-flink-1.6</artifactId>

  <version>2.13.0</version>

</dependency>

然后在命令行中指定 flink runner：

mvn exec:java -Dexec.mainClass=YourMainClass \

    -Pflink-runner \

    -Dexec.args="--runner=FlinkRunner \

      --flinkMaster=<flink master url>"

Google Dataflow 运行模式

Beam Pipeline 也能直接在云端运行。Google Cloud Dataflow 就是完全托管的 Beam Runner。当你使用 Google Cloud Dataflow 服务来运行 Beam Pipeline 时，它会先上传你的二进制程序到 Google Cloud，随后自动分配计算资源创建 Cloud Dataflow 任务。

同前面讲到的 Direct Runner 和 Spark Runner 类似，你还是需要为 Cloud Dataflow 添加 beam-runners-google-cloud-dataflow-java 依赖关系：

<dependency>

  <groupId>org.apache.beam</groupId>

  <artifactId>beam-runners-google-cloud-dataflow-java</artifactId>

  <version>2.13.0</version>

  <scope>runtime</scope>

</dependency>

我们假设你已经在 Google Cloud 上创建了 project，那么就可以用类似的命令行提交任务：

mvn -Pdataflow-runner compile exec:java \

      -Dexec.mainClass=<YourMainClass> \

      -Dexec.args="--project=<PROJECT_ID> \

      --stagingLocation=gs://<STORAGE_BUCKET>/staging/ \

      --output=gs://<STORAGE_BUCKET>/output \

      --runner=DataflowRunner"

小结

这一节我们先总结了前面几讲 Pipeline 的完整使用方法。之后一起探索了 Beam 的重要特性，就是 Pipeline 可以通过 PipelineOption 动态选择同样的数据处理流水线在哪里运行。并且，分别展开讲解了直接运行模式、Spark 运行模式、Flink 运行模式和 Google Cloud Dataflow 运行模式。在实践中，你可以根据自身需要，去选择不同的运行模式。

思考题

Beam 的设计模式是对计算引擎动态选择，它为什么要这么设计？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

31 | WordCount Beam Pipeline实战

你好，我是蔡元楠。

今天我要与你分享的主题是“WordCount Beam Pipeline 实战”。

前面我们已经学习了 Beam 的基础数据结构 PCollection，基本数据转换操作 Transform，还有 Pipeline 等技术。你一定跃跃欲试，想要在实际项目中使用了。这一讲我们就一起学习一下怎样用 Beam 解决数据处理领域的教科书级案例——WordCount。

WordCount 你一定不陌生，在第 18 讲中，我们就已经接触过了。WordCount 问题是起源于 MapReduce 时代就广泛使用的案例。顾名思义，WordCount 想要解决的问题是统计一个文本库中的词频。

比如，你可以用 WordCount 找出莎士比亚最喜欢使用的单词，那么你的输入是莎士比亚全集，输出就是每个单词出现的次数。举个例子，比如这一段：

HAMLET

ACT I

SCENE I	Elsinore. A platform before the castle.

	[FRANCISCO at his post. Enter to him BERNARDO]

BERNARDO	Who's there?

FRANCISCO	Nay, answer me: stand, and unfold yourself.

BERNARDO	Long live the king!

FRANCISCO	Bernardo?

BERNARDO	He.

FRANCISCO	You come most carefully upon your hour.

BERNARDO	'Tis now struck twelve; get thee to bed, Francisco.

FRANCISCO	For this relief much thanks: 'tis bitter cold,

	And I am sick at heart.

BERNARDO	Have you had quiet guard?

FRANCISCO	Not a mouse stirring.

BERNARDO	Well, good night.

	If you do meet Horatio and Marcellus,

	The rivals of my watch, bid them make haste.

FRANCISCO	I think I hear them. Stand, ho! Who's there?

在这个文本库中，我们用“the: 数字”表示 the 出现了几次，数字就是单词出现的次数。

The: 3

And: 3

Him: 1

...

那么我们怎样在 Beam 中处理这个问题呢？结合前面所学的知识，我们可以把 Pipeline 分为这样几步：

用 Pipeline IO 读取文本库（参考第 27 讲）；

用 Transform 对文本进行分词和词频统计操作（参考第 25 讲）；

用 Pipeline IO 输出结果（参考第 27 讲）；

所有的步骤会被打包进一个 Beam Pipeline（参考第 26 讲）。

整个过程就如同下图所示。

创建 Pipeline

首先，我们先用代码创建一个 PipelineOptions 的实例。PipelineOptions 能够让我们对 Pipeline 进行必要的配置，比如配置执行程序的 Runner，和 Runner 所需要的参数。我们在这里先采用默认配置。

记得第 30 讲中我们讲过，Beam Pipeline 可以配置在不同的 Runner 上跑，比如 SparkRunner，FlinkRunner。如果 PipelineOptions 不配置的情况下，默认的就是 DirectRunner，也就是说会在本机执行。

Java

PipelineOptions options = PipelineOptionsFactory.create();

接下来，我们就可以用这个 PipelineOptions 去创建一个 Pipeline 了。一个 Pipeline 实例会去构建一个数据处理流水线所需要的数据处理 DAG，以及这个 DAG 所需要进行的 Transform。

Java

Pipeline p = Pipeline.create(options);

应用 Transform

在上面的设计框图中，我们可以看到，我们需要进行好几种 Transform。比如 TextIO.Read、ParDo、Count 去读取数据，操纵数据，以及存储数据。

每一种 Transform 都需要一些参数，并且会输出特定的数据。输入和输出往往会用 PCollection 的数据结构表示。简单回顾一下，PCollection 是 Beam 对于数据集的抽象，表示任意大小、无序的数据，甚至可以是无边界的 Streaming 数据。

在我们这个 WordCount 例子中，我们的 Transform 依次是这样几个。

第一个 Transform，是先要用 TextIO.Read 来读取一个外部的莎士比亚文集，生成一个 PCollection，包含这个文集里的所有文本行。这个 PCollection 中的每个元素都是文本中的一行。

Java

PCollection<String> lines = p.apply(TextIO.read().from("gs://apache-beam-samples/shakespeare/*"));

第二个 Transform，我们要把文本行中的单词提取出来，也就是做分词（tokenization）。

这一步的输入 PCollection 中的每个元素都表示了一行。那么输出呢？输出还是一个 PCollection，但是每个元素变成了单词。

你可以留意一下，我们这里做分词时，用的正则表达式 [^\p{L}]+，意思是非 Unicode Letters 所以它会按空格或者标点符号等把词分开。

Java

PCollection<String> words = lines.apply("ExtractWords", FlatMapElements

        .into(TypeDescriptors.strings())

        .via((String word) -> Arrays.asList(word.split("[^\\p{L}]+"))));

第三个 Transform，我们就会使用 Beam SDK 提供的 Count Transform。Count Transform 会把任意一个 PCollection 转换成有 key/value 的组合，每一个 key 是原来 PCollection 中的非重复的元素，value 则是元素出现的次数。

Java

PCollection<KV<String, Long>> counts = words.apply(Count.<String>perElement());

第四个 Transform 会把刚才的 key/value 组成的 PCollection 转换成我们想要的输出格式，方便我们输出词频。因为大部分的时候，我们都是想要把输出存储到另一个文件里的。

Java

PCollection<String> formatted = counts.apply("FormatResults", MapElements

    .into(TypeDescriptors.strings())

    .via((KV<String, Long> wordCount) -> wordCount.getKey() + ": " + wordCount.getValue()));

最后一个 Transform 就是 TextIO.Write 用来把最终的 PCollection 写进文本文档。PCollection 中的每一个元素都会被写为文本文件中的独立一行。

运行 Pipeline

调用 Pipeline 的 run() 方法会把这个 Pipeline 所包含的 Transform 优化并放到你指定的 Runner 上执行。这里你需要注意，run() 方法是异步的，如果你想要同步等待 Pipeline 的执行结果，需要调用 waitUntilFinish() 方法。

Java

p.run().waitUntilFinish();

改进代码的建议

代码看起来都完成了，不过，我们还可以对代码再做些改进。

编写独立的 DoFn

在上面的示例代码中，我们把 Transform 都 inline 地写在了 apply() 方法里。

Java

lines.apply("ExtractWords", FlatMapElements

        .into(TypeDescriptors.strings())

        .via((String word) -> Arrays.asList(word.split("[^\\p{L}]+"))));

但是这样的写法在实际工作中很难维护。

一是因为真实的业务逻辑往往比较复杂，很难用一两行的代码写清楚，强行写成 inline 的话可读性非常糟糕。

二是因为这样 inline 的 Transform 几乎不可复用和测试。

所以，实际工作中，我们更多地会去继承 DoFn 来实现我们的数据操作。这样每个 DoFn 我们都可以单独复用和测试。

接下来，我们看看怎样用用 DoFn 来实现刚才的分词 Transform？

其实很简单，我们继承 DoFn 作为我们的子类 ExtracrtWordsFn，然后把单词的拆分放在 DoFn 的 processElement 成员函数里。

Java

static class ExtractWordsFn extends DoFn<String, String> {

    private final Counter emptyLines = Metrics.counter(ExtractWordsFn.class, "emptyLines");

    private final Distribution lineLenDist =

        Metrics.distribution(ExtractWordsFn.class, "lineLenDistro");

    @ProcessElement

    public void processElement(@Element String element, OutputReceiver<String> receiver) {

      lineLenDist.update(element.length());

      if (element.trim().isEmpty()) {

        emptyLines.inc();

      // Split the line into words.

      String[] words = element.split(“[^\\p{L}]+”, -1);

      // Output each word encountered into the output PCollection.

      for (String word : words) {

        if (!word.isEmpty()) {

          receiver.output(word);

        }

      }

   }

}

创建 PTransform 合并相关联的 Transform

PTransform 类可以用来整合一些相关联的 Transform。

比如你有一些数据处理的操作包含几个 Transform 或者 ParDo，你可以把他们封装在一个 PTransform 里。

我们这里试着把上面的 ExtractWordsFn 和 Count 两个 Transform 封装起来。这样可以对这样一整套数据处理操作复用和测试。当定义 PTransform 的子类时，它的输入输出类型就是一连串 Transform 的最初输入和最终输出。那么在这里，输入类型是 String，输出类型是 KV<String, Long>。就如同下面的代码一样。

Java

  /**

   * A PTransform that converts a PCollection containing lines of text into a PCollection of

   * formatted word counts.

   *

   * <p>This is a custom composite transform that bundles two transforms (ParDo and

   * Count) as a reusable PTransform subclass. Using composite transforms allows for easy reuse,

   * modular testing, and an improved monitoring experience.

   */

  public static class CountWords

      extends PTransform<PCollection<String>, PCollection<KV<String, Long>>> {

    @Override

    public PCollection<KV<String, Long>> expand(PCollection<String> lines) {

      // Convert lines of text into individual words.

      PCollection<String> words = lines.apply(ParDo.of(new ExtractWordsFn()));

      // Count the number of times each word occurs.

      PCollection<KV<String, Long>> wordCounts = words.apply(Count.perElement());

      return wordCounts;

    }

  }

参数化 PipelineOptions

刚才我们把输入文件的路径和输出文件的路径都写在了代码中。但实际工作中我们很少会这样做。

因为这些文件的路径往往是运行时才会决定，比如测试环境和生产环境会去操作不同的文件。在真正的实际工作中，我们往往把它们作为命令行参数放在 PipelineOptions 里面。这就需要去继承 PipelineOptions。

比如，我们创建一个 WordCountOptions，把输出文件作为参数 output。

Java

public static interface WordCountOptions extends PipelineOptions {

    @Description("Path of the file to write to")

    @Required

    String getOutput();

    void setOutput(String value);

}

完成上面两个方面的改进后，我们最终的数据处理代码会是这个样子：

Java

public static void main(String[] args) {

  WordCountOptions options =

        PipelineOptionsFactory.fromArgs(args).withValidation().as(WordCountOptions.class);

  Pipeline p = Pipeline.create(options);

  p.apply("ReadLines", TextIO.read().from(options.getInputFile()))

        .apply(new CountWords())

        .apply(ParDo.of(new FormatAsTextFn()))

        .apply("WriteCounts", TextIO.write().to(options.getOutput()));

    p.run().waitUntilFinish();

}

DoFn 和 PTransform 的单元测试

如同第 29 讲“如何测试 Pipeline”中所讲的那样，我们用 PAssert 测试 Beam Pipeline。具体在我们这个例子中，我一再强调要把数据处理操作封装成 DoFn 和 PTransform，因为它们可以独立地进行测试。

什么意思呢？比如，ExtractWordsFn 我们想要测试它能把一个句子分拆出单词，比如“" some input words "，我们期待的输出是 [“some”, “input”, “words”]。在测试中，我们可以这样表达：

/** Example test that tests a specific {@link DoFn}. */

  @Test

  public void testExtractWordsFn() throws Exception {

    DoFnTester<String, String> extractWordsFn = DoFnTester.of(new ExtractWordsFn());

    Assert.assertThat(

        extractWordsFn.processBundle(" some  input  words "),

        CoreMatchers.hasItems("some", "input", "words"));

    Assert.assertThat(extractWordsFn.processBundle(" "), CoreMatchers.hasItems());

    Assert.assertThat(

        extractWordsFn.processBundle(" some ", " input", " words"),

        CoreMatchers.hasItems("some", "input", "words"));

  }

小结

这一讲我们应用前面学习的 PCollection，Pipeline，Pipeline IO，Transform 知识去解决了一个数据处理领域经典的 WordCount 问题。并且学会了一些在实际工作中改进数据处理代码质量的贴士，比如写成单独可测试的 DoFn，和把程序参数封装进 PipelineOptions。

思考题

文中提供了分词的 DoFn——ExtractWordsFn，你能利用相似的思路把输出文本的格式化写成一个 DoFn 吗？也就是文中的 FormatAsTextFn，把 PCollection<KV<String, Long>> 转化成 PCollection，每一个元素都是 : 的格式。

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

32 | Beam Window：打通流处理的任督二脉

你好，我是蔡元楠。

今天我要与你分享的主题是“Beam Window：打通流处理的任督二脉”。

在上一讲中，我们一起用 Beam 编写了第一个完整的 WordCount 项目，我们所用的例子是统计莎士比亚的文集中最常使用到的一些单词。

这里我们所用到的“莎士比亚文集”这种类型的数据集是一个静态的数据集。也就是说，我们在生成输入数据集的时候，就已经知道了这个数据集是完整的，并不需要再等待新的数据进来。

根据前面的内容，我们可以把这种数据集归类为有界数据集（Bounded Dataset）。这里我们的数据流水线就是一个批处理的数据流水线。

这个时候你可能会有一个疑问，如果我们想要统计的内容是一个正在连载的小说，我们在编写数据流水线的时候，这个小说还并没有完结，也就是说，未来还会不断有新的内容作为输入数据流入我们的数据流水线，那我们需要怎么做呢？

这个时候我们就需要用到窗口（Window）这个概念了。

窗口

在 Beam 的世界中，窗口这个概念将 PCollection 里的每个元素根据时间戳（Timestamp）划分成为了不同的有限数据集合。

当我们要将一些聚合操作（Aggregation）应用在 PCollection 上面的时候，或者我们想要将不同的 PCollections 连接（Join）在一起的时候，其实 Beam 是将这些操作应用在了这些被窗口划分好的不同数据集合上的。

无论是有界数据集还是无界数据集，Beam 都会一视同仁，按照上面所说的规则进行处理。

你可能又会有一个疑问，我们在上一讲的例子当中，根本就没有提到过窗口这个概念，但是我刚刚又说，Beam 同样会将有界数据集根据窗口划分成不同的有限数据集合来处理，那这些窗口、PCollection 中每个元素的时间戳又是从哪里来的呢？

其实，我们在用 I/O 连接器读取有界数据集的过程中，Read Transform 会默认为每个元素分配一个相同的时间戳。在一般情况下，这个时间戳就是你运行数据流水线的时间，也就是处理时间（Processing Time）。而 Beam 也会为这个数据流水线默认地分配一个全局窗口（Global Window），你可以把它理解为是一个从无限小到无限大的时间窗口。

如果你想要显式地将一个全局窗口赋予一个有界数据集的话，可以使用如下代码来完成：

Java

PCollection<String> input = p.apply(TextIO.read().from(filepath));

PCollection<String> batchInputs = input.apply(Window.<String>into(new GlobalWindows()));

需要注意的是，你在处理有界数据集的时候，可以不用显式地将一个窗口分配给一个 PCollection 数据集。但是，在处理无边界数据集的时候，你必须要显式地分配一个窗口给这个无边界数据集。而这个窗口不可以是前面提到的全局窗口，否则在运行数据流水线的时候会直接抛出异常错误。

在了解过窗口的基本概念之后，接下来我来给你讲讲在 Beam 中存在的不同窗口类型。

固定窗口（Fixed Window）

固定窗口在有的数据处理框架中又被称为滚动窗口（Tumbling Window）。固定窗口通常是由一个静态的窗口大小定义而来的。

例如，要是我们定义一个每小时的窗口，那这个窗口大小就是固定的一个小时，如果我们按照 2019 年 7 月 8 号的 0 时作为时间的起始点，那么这个固定窗口就可以分为类似下面这样的形式：

[July 8, 2019 0:00:00 AM, July 8, 2019 1:00:00 AM)，

[July 8, 2019 1:00:00 AM, July 8, 2019 2:00:00 AM)

[July 8, 2019 2:00:00 AM, July 8, 2019 3:00:00 AM)

……

而一个 PCollection 中的所有元素，就会根据它们各自自身的时间戳被分配给相应的固定窗口中。

这里你需要注意一点，因为固定窗口本质上并不可能会重叠在一起，如果我们定义的窗口是固定窗口的话，PCollection 中的每一个元素只会落入一个，且是唯一一个窗口中。

在 Beam 中，如果要定义一个上述所说的按照每小时分割的窗口，我们可以使用一个 Window Transform 来完成，如下所示：

Java

PCollection<String> input = p.apply(KafkaIO.<Long, String>read()).apply(Values.<String>create());

PCollection<String> fixedWindowedInputs = input.apply(Window.<String>into(FixedWindows.of(Duration.standardHours(1))));

滑动窗口（Sliding Window）

滑动窗口通常是由一个静态的窗口大小和一个滑动周期（Sliding Period）定义而来的。

例如，我们可以定义一个窗口大小为一个小时，滑动周期为 30 分钟的一个滑动窗口。我们还是以 2019 年 7 月 8 号的 0 时作为时间的起始点，那这个滑动窗口可以分为下面这样的形式：

[July 8, 2019 0:00:00 AM, July 8, 2019 1:00:00 AM)

[July 8, 2019 0:30:00 AM, July 8, 2019 1:30:00 AM)

[July 8, 2019 1:00:00 AM, July 8, 2019 1:30:00 AM)

[July 8, 2019 1:30:00 AM, July 8, 2019 2:00:00 AM)

……

因为 Beam 对于滑动周期的大小并没有做任何限制，所以你可以看到，滑动窗口和固定窗口不同的是，当滑动周期小于窗口大小的时候，滑动窗口会有部分重叠。也就是说，在一个 PCollection 里面，同一个元素是可以被分配到不同的滑动窗口中的。

可能你也会发现到，当滑动窗口的窗口大小和滑动周期一样的时候，这个滑动窗口的性质其实就和固定窗口一样了。

在 Beam 中，如果要定义一个上述所说，窗口大小为一个小时而滑动周期为 30 分钟的一个滑动窗口，我们同样可以使用一个 Window Transform 来完成，如下所示：

Java

PCollection<String> input = p.apply(KafkaIO.<Long, String>read()).apply(Values.<String>create());

PCollection<String> slidingWindowedInputs = input.apply(Window.<String>into(SlidingWindows.of(Duration.standardHours(1)).every(Duration.standardMinutes(30))));

会话窗口（Sessions Window）

会话窗口和上面所说的两个窗口有点不一样，它并没有一个固定的窗口长度。

会话窗口主要是用于记录持续了一段时间的活动数据集。在一个会话窗口中的数据集，如果将它里面所有的元素按照时间戳来排序的话，那么任意相邻的两个元素它们的时间戳相差不会超过一个定义好的静态间隔时间段（Gap Duration）。

怎么理解这个定义呢？我想用一个例子来解释会比较清晰。

假设，我们现在正在一个视频流的平台中处理无界数据集，我们想要分析在这个平台中的一些用户行为习惯。

为了方便说明，我们想要分析的问题非常简单，就是一个用户在线看视频一般会在每天的什么时候开始看多长时间的视频。同时，我们假设只会有一个用户的数据流入我们的输入数据集中，这个数据会带有用户对视频平台发送视频流请求的时间戳。

我们希望定义一个会话窗口来处理这些数据，而这个会话窗口的间隔时间段为 5 分钟。

所有的数据假设都是发生在 2019 年 7 月 8 号中的，流入的数据集如下：

(key1, value1, [7:44:00 AM，7:44:00 AM))

(key1, value2, [7:45:00 AM，7:45:00 AM))

(key1, value3, [7:49:00 AM，7:49:00 AM))

(key1, value4, [8:01:00 AM，8:01:00 AM))

(key1, value5, [8:02:00 AM，8:02:00 AM))

那么，这 5 个数据会形成两个会话窗口，分别是：

(key1，[(value1, [7:44:00 AM，7:44:00 AM)), (value2, [7:45:00 AM，7:45:00 AM)), (value3, [7:49:00 AM，7:49:00 AM))])

(key1，[(value4, [8:01:00 AM，8:01:00 AM)), (value5, [8:02:00 AM，8:02:00 AM))])

你可以看到，在第一个会话窗口中，数据的时间戳分别是 7:44:00AM，7:45:00AM 和 7:49:00AM，这个窗口的总长度有 5 分钟。任意相邻的两个元素之间的时间间隔不会超过我们之前定义好的 5 分钟。

而在第二个会话窗口中，数据的时间戳分别是 8:01:00AM 和 8:02:00AM，这个窗口的总长度有 1 分钟，同样任意相邻的两个元素之间的时间间隔不会超过 5 分钟。每一个会话窗口都记录了一个用户的在线活跃点和时长。

在 Beam 中，如果要定义一个上述所说会话窗口的话，你可以使用以下代码来完成：

Java

PCollection<String> input = p.apply(KafkaIO.<Long, String>read()).apply(Values.<String>create());

PCollection<String> sessionWindowedInputs = input.apply(Window.<String>into(Sessions.withGapDuration(Duration.standardMinutes(5))));

小结

今天我们一起学习了在处理无界数据集的时候，我们需要显示定义到的一个概念——窗口。

窗口的定义是后面我们编写流处理数据流水线的一个基石。而窗口这个概念其实就是用来回答我们在第 23 讲中“WWWH”问题里“Where in event time they are being computed”这个问题的。除去全局窗口，Beam 里面总共让我们定义三种不同的窗口类型，分别是固定窗口，滑动窗口和会话窗口。

思考题

在今天介绍的三种时间窗口类型中，你觉得这三种时间窗口分别适合使用在什么样的应用场景中呢？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

33 | 横看成岭侧成峰：再战Streaming WordCount

你好，我是蔡元楠。

今天我要与你分享的主题是“横看成岭侧成峰：再战 Streaming WordCount”。

在上一讲中，我们学习了 Beam 窗口（Window）的概念。当时，我们提到窗口技术的产生是因为我们想要根据时间戳去分组处理一个 PCollection 中的元素。

我们也提到了在“统计莎士比亚文集词频”这个例子中，如果莎士比亚穿越到了现代，成了一名极客时间的专栏作家，我们就可能需要根据他文章的写作时间来统计词频了。

举个具体的例子的话，就是我们能不能灵活地得到莎士比亚在 2017 年 9 月使用的高频词汇？或者是他在 2018 年第 7 个周五偏爱使用的高频词汇呢？

时效性是数据处理很重要的一部分，类似上面这样的问题还有很多。

比如，能不能根据实时交通数据，得到最近 24 小时之内拥堵的道路？能不能根据所有微信分享文章的点击数据，得到过去一周最热门的文章？这些问题都是可以用窗口技术来解决。

所以今天这一讲，我们就来看看怎样在 WordCount 这个例子中使用窗口技术。我会介绍怎样在 Beam 中实现以下六个问题：

怎样区分有界数据还是无界数据？

怎样读取无边界数据？

怎样给 PCollection 数据添加时间戳？

怎样在 PCollection 应用窗口？

怎样复用之前的 DoFn 和 PTransform？

怎样存储无边界数据？

怎样区分有界数据还是无界数据？

我们知道，在 Beam 中你可以用同一个 Pipeline 处理有边界数据或者无边界数据。但我们在处理两者时的思考方式和操作方法还是有细微的不同的。

比如，有界数据之所以有界，是因为你在处理数据时，所有的数据就已经准备就绪了。

在第 31 讲的 WordCount 例子中，莎士比亚文集早已成为历史，不会有新的作品了。所以，你可以用有界数据的处理方式进行实现。当你的数据输入是有界的时候，下游的数据一般也是有界的。因为你的数据元素是有限的，在数据处理的过程中，不会凭空造出无限多的数据。

而无边界数据的到来是时刻不停的。在你处理处理流水线的任意时刻，数据都没有完全结束。

比如，在我们第 1 讲中提到的处理美团外卖电动车例子中，美团外卖电动车的图片就是一直在不停地更新。你不可能说“我已经有了所有的外卖电动车图片了”。在无界数据的处理流水线中，因为输入数据是无界的，所以下游的处理结果一般也是无界的。

相信你已经掌握了区分区分有界和无界数据方法。在接下来的内容中，我们会看到针对两种数据的不同处理方式。

但是，不论是有界数据还是无界数据，在 Beam 中我们都可以用窗口把数据按时间分割成一些有限大小的集合。只是对于无界数据，你必须使用窗口对数据进行分割，然后对每个窗口内的数据集进行处理。

怎样读取无边界数据？

在第 31 讲WordCount 的案例中，我们从一个外部文本文件读取了莎士比亚全集的文本内容。当时，我们使用的是 Beam 的 TextIO：

Java

Pipeline p = Pipeline.create(options);

p.apply("ReadLines", TextIO.read().from(options.getInputFile()))

这是因为我们当时面对的是有边界的数据，在我们的数据处理流水线运行之前，所有的莎士比亚全集文本早已准备就绪，所以我们可以一股脑儿全部读进来。但是当输入数据是无界的时候，我们就没法这样读取数据了。常见的无界数据读取自 logging 系统或者 Pub/Sub 系统。

由于 logging 系统一直在不断地运行，新的 log 在不停地产生，并且每条 log 都自带时间戳。比如，我们想要根据用户对于微信文章的点击 log 分析不同时刻的热门文章，我们就可以去读取微信文章的 log。而在 Pub/Sub 系统中，我们订阅的消息也会永无止境地到来，类似的一般 Pub/Sub 订阅的每条消息也会自带原生的时间戳。

这一讲中，我们已经假设莎士比亚穿越到现代在极客时间开了个专栏。我们不妨把他的专栏文章更新设计在一个 Kafka 消息系统中。

如下图所示，即使你并没有使用过 Kafka 也没有关系。你只需要知道在我们的数据处理系统中能够不定时地收到来自莎士比亚的文章更新，每一次的文章更新包含了更新的文章标题和更新内容。

这时，我们可以使用 Beam 的 Kafka IO 来读取来自 Kafka 的订阅消息。

在下面的示例代码中，我们指定了需要读取的 Kafka 消息主题“shakespeare”，以及 Kafka 消息的 key/value 类型都是 String。你需要注意这里的读取选项 withLogAppendTime()，它的意思是我们用 Kafka 的 log append time 作为我们 beam PCollection 数据集的时间戳。

Java

pipeline

    .apply(KafkaIO.<String, String>read()

       .withBootstrapServers("broker_1:9092,broker_2:9092")

       .withTopic("shakespeare")  // use withTopics(List<String>) to read from multiple topics.

       .withKeyDeserializer(StringDeserializer.class)

       .withValueDeserializer(StringDeserializer.class)

       .withLogAppendTime()

    )

怎样给 PCollection 数据添加时间戳？

一般情况下，窗口的使用场景中，时间戳都是原生的。就如同我们从 Kafka 中读取消息记录一样，时间戳是自带在每一条 Kafka 消息中的。

但 Beam 也允许我们手动给 PCollection 的元素添加时间戳。例如第 31 讲的 WordCount 例子本身就是一个有界数据集，你还记得吗？那么我们怎么给这些有界数据集添加时间戳呢？

第 31 讲的输入数据格式就是简单的文本文件：

	HAMLET

ACT I

SCENE I	Elsinore. A platform before the castle.

	[FRANCISCO at his post. Enter to him BERNARDO]

BERNARDO	Who's there?

FRANCISCO	Nay, answer me: stand, and unfold yourself.

为了方便阐述概念，我们不妨假设一下，现在我们的输入文件变成了如下的格式，每一行的开头都会带有一个时间戳，在冒号分隔符号之后才是我们需要处理的文本：

2019-07-05:	HAMLET

2019-07-06: ACT I

2019-07-06:  SCENE I	Elsinore. A platform before the castle.

2019-07-07: 	[FRANCISCO at his post. Enter to him BERNARDO]

2019-07-07: BERNARDO	Who's there?

2019-07-07: FRANCISCO	Nay, answer me: stand, and unfold yourself.

当时我们是直接对每一行的文本提取了所有的单词。但在现在这样的输入格式下，我们就可以先把每一行开头的时间戳提取出来。在 DoFn 的 processElement 实现中，我们用 outputWithTimestamp() 方法，可以对于每一个元素附上它所对应的时间戳。

Java

static class ExtractTimestampFn extends DoFn<String, String> {

  @ProcessElement

  public void processElement(ProcessContext c) {

    String extractedLine = extractLine(c.element());

    Instant timestamp =

      new Instant(extractTimestamp(c.element());

    c.outputWithTimestamp(extractedLine, timestamp);

  }

}

怎样在 PCollection 应用窗口？

通过前面的内容，我们已经解决了“PCollection 的时间戳来自于哪里”的问题。在无界数据的应用场景中，时间戳往往是数据记录自带的，比如来自 Kafka 消息。在有界数据的应用场景中，时间戳往往需要自己指定，比如我们读取的自定义的莎士比亚文集格式。

PCollection 元素有了时间戳，我们就能根据时间戳应用窗口对数据进行划分。第 32 讲中，我们已经介绍了常见的窗口种类，有固定窗口、滑动窗口和会话窗口。

要把特定的窗口应用到 PCollection 上，我们同样使用 PCollection 的 apply() 方法。如果是固定窗口，我们就用 FixedWindows 类型，如果是滑动窗口就用 SlidingWindows 类型，相应的如果是会话窗口我们就用 Sessions 窗口类型。下面的代码示例就是使用 FixedWindows 的情况：

Java

PCollection<String> windowedWords = input

  .apply(Window.<String>into(

    FixedWindows.of(Duration.standardMinutes(options.getWindowSize()))));

怎样复用之前的 DoFn 和 PTransform？

有了窗口，我们下一步就是把之前的 DoFn 和 PTransform 应用到数据集上。

这一步其实是最简单的。因为 Beam 的 Transform 不区分有界数据还是无界数据。我们可以一行代码不改，和第 31 讲用到的例子一样，直接使用之前的 CountWords 这个 PTransform 就可以了。

Java

PCollection<KV<String, Long>> wordCounts = windowedWords.apply(new WordCount.CountWords());

值得注意的是，在应用了窗口之后，Beam 的 transform 是在每一个窗口中间进行数据处理的。在我们的例子中，词频统计的是每一个窗口里的词频，而不再是全局的词频。

怎样输出无边界数据？

同数据读取对应，无边界数据的输出也是与有界数据大相径庭。在第 31 讲中，我们把数据处理结果写进了一个外部文件中，使用了 TextIO：

Java

pipeline.apply("WriteCounts", TextIO.write().to(options.getOutput()));

但是在无边界的应用场景中，数据在持续不断地进来。最常见的输出模式是把处理结果还是以 Pub/Sub 的模式发布出去。

假设我们用 Google Pub/Sub 输出我们的处理结果的话，我们可以用 PubsubIO.writeStrings() 方法。同样，这里的输出结果是针对每一个窗口的，每一个窗口都会输出自己的词频统计结果。

Java

pipeline.apply("Write to PubSub", PubsubIO.writeStrings().to(options.getOutputTopic()));

小结

今天我们深入探索了 Beam 窗口在流处理的场景中的应用。

我们巩固了区分有界数据还是无界数据的方法，掌握了在 Beam 中怎样读取无边界数据，怎样给 PCollection 数据添加时间戳，怎样在 PCollection 应用窗口，怎样复用之前的 DoFn 和 PTransform 和怎样输出无边界数据。

将这些方法融会贯通后，相信类似的时间性数据处理或者是流处理问题在你手中都能迎刃而解了。

思考题

你的工作中有哪些应用场景不适合一般的数据批处理呢？能否利用这里介绍窗口方式处理？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

34 | Amazon热销榜Beam Pipeline实战

你好，我是蔡元楠。

今天我要与你分享的主题是“Amazon 热销榜 Beam Pipeline 实战”。

两个月前，亚马逊（Amazon）宣布将关闭中国国内电商业务的消息你一定还记忆犹新。虽然亚马逊遗憾离场，但它依然是目前全球市值最高的电商公司。

作为美国最大的一家网络电子商务公司，亚马逊的总部位于华盛顿州的西雅图。类似于 BAT 在国内的地位，亚马逊也是北美互联网 FAANG 五大巨头之一，其他四个分别是 Facebook、Apple、Netflix 和 Google。

亚马逊的热销商品系统就如下图所示。

当我搜索“攀岩鞋”时，搜索结果的第三个被打上了“热销商品”的标签，这样能帮助消费者快速做出购买决策。

当我点击这个“Best Seller”的标签时，我可以浏览“攀岩鞋”这个商品分类中浏览销量最高的前 100 个商品。

这些贴心的功能都是由热销商品系统实现的。

这一讲我们就来看看在这样的热销商品系统中，怎样应用之前所学的 Beam 数据处理技术吧。今天，我们主要会解决一个热销商品系统数据处理架构中的这几个问题：

怎样用批处理计算基础的热销商品列表、热销商品的存储和 serving 设计？

怎样设计每小时更新的热销榜单？

怎样设计商品去重处理流水线和怎样根据商品在售状态过滤热销商品？

怎样按不同的商品门类生成榜单？

1. 怎样用批处理计算基础的热销商品列表、热销商品的存储和 serving 设计？

我们先来看最简单的问题设置，怎样用批处理计算基础的热销商品列表。

假设你的电商网站销售着 10 亿件商品，并且已经跟踪了网站的销售记录：商品 id 和购买时间 {product_id, timestamp}，整个交易记录是 1000 亿行数据，TB 级。举个例子，假设我们的数据是这样的：

我们可以把热销榜按 product_id 排名为：1，2，3。

你现在有没有觉得这个问题似曾相识呢？的确，我们在第 3 讲“大规模数据初体验”中用这个例子引出了数据处理框架设计的基本需求。

这一讲中，我们会从这个基本问题设置开始，逐步深入探索。

在第 3 讲中，我们把我们的数据处理流程分成了两个步骤，分别是：

统计每个商品的销量

找出销量前 K

我们先来看第一个步骤的统计商品销量应该如何在 Beam 中实现。我们在第 3 讲中是画了这样的计算集群的示意图：

如果你暂时没有思路的话，我们不妨试试换一个角度来思考这个问题。

统计商品的销量，换句话说，其实就是计算同样的商品 id 在我们的销售记录数据库中出现了多少次。这有没有让你联想到什么呢？没错，就是我们在第 31 讲中讲到的 WordCount 例子。WordCount 是统计同样的单词出现的次数，而商品销量就是统计同样的商品 id 出现的次数。

所以，我们完全可以用 WordCount 中的部分代码解决商品销量统计的这部分数据处理逻辑。

在 WordCount 中，我们用 words.apply(Count.perElement()) 把一个分词后的 PCollection 转换成了“单词为 key，count 为 value”的一个 key/value 组合。

在这里呢，我们同样使用 salesRecords.apply(Count.perElement()) 把一个商品 id 的 PCollection 转换成“key 为商品 id，value 为 count”的 key/value 组合。

Java

// WordCount 的统计词频步骤

wordCount = words.apply(Count.perElement())

// 我们这里的统计销量步骤

salesCount = salesRecords.apply(Count.perElement())

解决了统计每个商品的销量步骤，我们再来看看怎样统计销量前 K 的商品。在第 3 讲中，我们是画了一个计算集群来解决这个问题。

但是 Beam 提供了很好的 API 供我们使用。我们可以使用 Top() 这个 Transform。

Top 接受的第一个参数就是我们这里的 K，也就是我们最终要输出的前几个元素。我们需要实现的是一个 Java Comparator interface。

Java

PCollection<KV<String, Long>> topK =

      salesCount.apply(Top.of(K, new Comparator<KV<String, Long>>() {

          @Override

          public int compare(KV<String, Long> a, KV<String, Long> b) {

            return b.getValue.compareTo(a.getValue());

          }

      }));

到这里，销量前 K 的产品就已经被计算出来了。

和所有数据处理流水线一样，我们需要的是一个完整的系统。那么你就不能仅仅满足于计算出结果，必须要考虑你的数据处理结果将怎样被使用。在本文开头的截图中，你能看到，热销商品都被打上了“Best Seller”的标签，点击“Best Seller”标签我们还能看到完整的热销榜单。

那么你可以考虑两种 serving 的方案。

一种是把热销商品的结果存储在一个单独的数据库中。但是在 serving 时候，你需要把商品搜索结果和热销商品结果进行交叉查询。如果搜索结果存在于热销商品数据库中，你就在返回的搜索结果元素中把它标注成“Best Seller”。

另一个可能不太灵活，就是把热销商品的结果写回原来的商品数据库中。如果是热销商品，你就在“是热销商品”这一列做标记。这种方案的缺点是每次更新热销结果后，都要去原来的数据库进行大量更新，不仅要把新成为热销的商品进行标记，还要将落选商品的标记去除。

两种 serving 方案的选择影响了你对于数据处理输出的业务需求。相应的，你可以把输出的前 K 销量产品使用 Pipeline output 输出到一个单独数据库，也可以统一更新所有数据库中的商品。

2. 怎样设计每小时更新的热销榜单？

在设计完基础的热销商品处理系统后，我们注意到在 Amazon 的热销榜上有一行小字 “Updated hourly”，也就是“每小时更新”。的确，热销商品往往是有时效性的。去年热销的 iPhone X 今年就变成了 iPhone XS。Amazon 选择了以小时为单位更新热销榜单确实是合理的产品设计。

那么怎样用 Beam 实现这种定时更新的数据处理系统呢？

可能你在看到“时间”这个关键词的时候，就马上联想到了第 32 讲介绍的 Beam Window。确实，用基于 Window 的流处理模式是一个值得考虑的方案。我在这里故意把问题设置得比较模糊。其实是因为这需要取决于具体的业务需求，实际上你也可以选择批处理或者流处理的方式。

我们先从简单的批处理模式开始。

在处理工程问题时，我们都是先看最简单的方案能否支撑起来业务需求，避免为了体现工程难度而故意将系统复杂化。采用批处理的方式的话，我们可以每隔一个小时运行一遍上一个小标题中的基础版热销商品系统，也就是部署成 cron job 的模式。

但你要注意，如果我们不修改代码的话，每一次运行都会计算目前为止所有销售的商品。如果这不是你的业务需求，你可以在批处理的数据输入步骤中，根据你的销售记录表的时间戳来筛选想要计算的时间段。比如，你可以设置成每一次运行都只计算从运行时间前 6 个月到该运行时间为止。

其实，批处理的模式已经能解决我们目前为止的大部分业务需求。但有时候我们不得不去使用流处理。比如，如果存储销售记录的团队和你属于不同的部门，你没有权限去直接读取他们的数据库，他们部门只对外分享一个 Pub/Sub 的消息队列。这时候就是流处理应用的绝佳场景。

不知道你还记不记得第 33 讲中我提到过，在 Streaming 版本的 WordCount 中监听一个 Kafka 消息队列的例子。同样的，这时候你可以订阅来自这个部门的销售消息。

Java

pipeline

    .apply(KafkaIO.<String, Long>read()

       .withBootstrapServers("broker_1:9092,broker_2:9092")

       .withTopic("sales_record")  // use withTopics(List<String>) to read from multiple topics.

       .withKeyDeserializer(StringDeserializer.class)

       .withValueDeserializer(StringDeserializer.class)

       .withLogAppendTime()

    )

之后你可以为你的输入 PCollection 添加窗口，和 WordCount 一样。不过这时候你很有可能需要滑动窗口，因为你的窗口是每小时移动一次。

Java

PCollection<String> windowedProductIds = input

  .apply(Window.<String>into(

    SlidingWindows.of(Duration.standardMonths(options.getWindowSize()))));

3. 怎样设计商品去重处理流水线和怎样根据商品在售状态过滤热销商品？

通过前面的内容，我们已经设计出了能够每小时更新的热销榜单。但不知道你有没有注意到，其实我们之前的问题设置是过于简化了，忽略了很多现实而重要的问题，比如：

怎样处理退货的商品？

怎样处理店家因为收到差评故意把商品下架换个马甲重新上架？

怎样处理那些虽然曾经热销但是现在已经不再出售的商品？

这些问题都需要使用第 28 讲中介绍的 Pipeline 的基本设计模式：过滤模式。

我们这个案例中所需要的过滤条件是：

把退货的商品销量减去

把重复的商品销量进行叠加

将在售商品过滤出来

一起来想想这些过滤条件应该怎么实现吧。

对于退货商品，我们可以把所有退货的记录挑出来进行统计。同样对于每一个商品 id，如果我们把出售的计数减去退货的计数就可以得到成功销售的计数。

而事实上，实际交易系统对于商品状态的跟踪会详细得多，每一个订单最终落在退货状态还是成功销售状态都可以在交易数据库中查询得到。我们可以把这个封装在 isSuccessfulSale() 方法中。

重复的商品在一个成熟的交易系统中一般会有另外一个去重的数据处理流水线。它能根据商品描述、商品图片等推测重复的商品。我们假设在我们系统中已经有了 product_unique_id 这样一个记录，那么我们只要把之前进行统计的 product_id 替换成统计 product_unique_id 就行了。

过滤在售的商品可能有多种实现方式，取决于你的小组有没有权限读取所需的数据库。

假如你可以读取一个商品状态的数据库列，那你可以直接根据 [商品状态 = 在售] 这样的判断条件进行过滤。假如你不能读取商品状态，那你可能需要查询在售的商品中是否有你的这个商品 id 来进行判断。但在这一讲中，我们先忽略实现细节，把这个过滤逻辑封装在 isInStock() 方法中。

最终我们的过滤处理步骤会是类似下面这样的。只有同时满足 isSuccessfulSale() 和 isInStock() 的 product_unique_id，才会被我们后续的销量统计步骤所计算。

Java

PCollection<Product> productCollection = ...;

PCollection<Product> qualifiedProductCollection = productCollection

  .apply(“uniqueProductTransform”, Distinct.withRepresentativeValueFn(

      new SerializableFunction<Product, Long>() {

        @Override

        public Long apply(Product input) {

          return input.productUniqueId();

        }

      }).withRepresentativeType(TypeDescriptor.of(Long.class))

  )

    .apply("filterProductTransform", ParDo.of(new DoFn<Product, Product>(){

    @ProcessElement

    public void processElement(ProcessContext c) {

      if (isSuccessfulSale(c.element()) && isInStockc.element())) {

        c.output(c.element());

      }

    }

  }));

4. 怎样按不同的商品门类生成榜单？

我们还注意到亚马逊的热销榜是按照不同的商品种类的。也就说每一个商品分类都有自己的榜单。这是很合理的业务设计，因为你不可能会去把飞机的销量和手机的销量相比，手机可能人手一个，飞机无法人手一个。

这时候我们在第 28 讲所学的分离模式设计就能大显身手了。分离模式把一个 PCollection 按照类别分离成了几个小的子 PCollection。

在这个案例里，我们也需要对商品进行分离。

与经典的分离模式不同，我们这里每一个商品可能都属于多个类别。比如一双鞋子，它可能既归类为“户外”，也归类为“潮鞋”。还记得分离模式在 Beam 中怎么实现吗？没错就是使用 output tag。我们先要为每一种分类定义 tag，比如刚才说的 outdoorTag 和 fashionTag。再把相应的商品输出进对应的 tag 中。示例如下：

Java

// 首先定义每一个 output 的 tag

final TupleTag<Product> outdoorTag = new TupleTag<Product>(){};

final TupleTag<Product> fashionTag = new TupleTag<Product>(){};

PCollection<Product> salesCollection = ...;

PCollectionTuple mixedCollection =

    userCollection.apply(ParDo

        .of(new DoFn<Product, Product>() {

          @ProcessElement

          public void processElement(ProcessContext c) {

            if (isOutdoorProduct(c.element())) {

              c.output(c.element());

            } else if (isFashionProduct(c.element())) {

              c.output(fashionTag, c.element());

	        }

          }

        })

        .withOutputTags(outdoorTag, TupleTagList.of(fashionTag)));

// 分离出不同的商品分类

mixedCollection.get(outdoorTag).apply(...);

mixedCollection.get(fashionTag).apply(...);

小结

这一讲我们从基础商品排行榜系统出发，利用到了之前学的数据处理设计模式和 Beam 编程方法。

同时，探索了以批处理计算为基础的热销商品列表。我们设计了每小时更新的热销榜单、商品去重处理流水线，根据商品在售状态过滤出热销商品，并按不同的商品门类生成榜单。

思考题

一个商品排名系统中还有太多需要解决的工程问题，你觉得哪些也可以利用大规模数据处理技术设计解决？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

35 | Facebook游戏实时流处理Beam Pipeline实战（上）

你好，我是蔡元楠。

今天我要与你分享的主题是“Facebook 游戏实时流处理 Beam Pipeline 实战”。

Facebook 这个社交平台我相信你一定早有耳闻。它除了能够让用户发送消息给好友，分享自己的动态图片和视频之外，还通过自身的 App Center 管理着各式各样的小游戏。许多游戏开发商借助 Facebook 的好友邀请机制让自己的 App 火了一把。

曾经有一段时间，在 Facebook 上有一款名为糖果传奇（Candy Crush Saga）的游戏风靡了整个北美。各个年龄层的玩家都会在空闲的时间拿出手机，过五关斩六将，希望尽快突破更多的关卡，并且获得高分。

当然了，除了消除游戏本身带来的乐趣以外，可以在 Facebook 里和自己的好友进行积分排名比拼也是另外一个能吸引用户的地方。

想要一个类似 Facebook 这样的好友间积分排行榜，你可以有很多种实现方式以及各种优化方法。那么，如果我们要利用 Apache Beam 的话，该怎样实现一个类似的游戏积分排行榜呢？

今天我就来和你一起研究，要如何利用 Apache Beam 的数据流水线来实现一个我们自定义的简单游戏积分排行榜。

为了简化整个游戏积分排行榜案例的说明，我们先来做几个方面的假设：

面向的群体：游戏积分排行榜针对的是全局每位用户以及每一个关卡，我们不需要担心如何在 Beam 的数据流水线中优化每个用户自身的好友积分列表。

更新时间：为了保持用户的粘性，我们设定这个游戏积分排行榜的数据每隔一周就会更新一次。也就是说如果一位用户在 2019 年 7 月 15 日成功通关了一次游戏并且分数是这周内自身的最高分，那么这次的最高分数将一直留在 2019 年 7 月 15 日至 2019 年 7 月 21 日这周的排行榜中。但是到了 2019 年 7 月 22 日后，这个分数将不会再出现，需要用户重新通关这个关卡后分数才会重新出现在新的一周游戏积分排行榜中。

积分排位：游戏积分排行榜需要显示出这个关卡中得分最高的前 100 位用户。

输入数据：每次用户通关后，这个 App 都会将用户自身的 ID，通关游戏的时间（也就是事件时间）还有分数以 CSV 格式上传到服务器中，每个用户的游戏积分数据都可以从 Google Cloud Bigtable 中读取出来。

输出数据：最终这个游戏积分排行榜结果可以从一个文件中获得。也就是说，我们的 Beam 数据流水线需要将最终结果写入文件中。

有了这些假设，我们就一起来由浅入深地看看有哪些执行方案。

正如上一讲中所说，如果可以用简单的方法解决战斗，我们当然要避免将问题复杂化了。一种比较直观的做法就是使用 crontab 定时执行一个 Beam 数据流水线，将每周需要进行计算排名的开始时间点和结束时间点传入数据流水线中，过滤掉所有事件时间不在这个时间范围内的数据。

那么，具体要怎么做呢？

首先，我们先要定义一个类，来保存我们之前假设好用户上传的信息。

Java

class UserScoreInfo {

    String userId;

    Double score;

    Long eventTimestamp;

    public UserScoreInfo(String userId, Double score, Long eventTimestamp) {

      this.userId = userId;

      this.score = score;

      this.eventTimestamp = eventTimestamp;

    }

    public String getUserId() {

      return this.userId;

    }

    public Double getScore() {

      return this.score;

    }

    public Long getEventTimestamp() {

      return this.eventTimestamp;

    }

  }

这个类十分简单，构造函数需要传入的是用户 ID、游戏通关时的积分还有通关时间。

有了这个类之后，整个数据流水线的逻辑就可以围绕着这个类来处理，步骤大致如下：

从 Google Cloud Bigtable 中读取保存用户通关积分等信息的所有 Bigtable Row 出来，得到 PCollection。

将 PCollection转换成我们定义好的类，成为 PCollection。

根据我们传入的开始边界时间和结束边界时间过滤掉不属于这一周里数据，得到有效时间内的 PCollection。

将 PCollection 转换成 PCollection<KV<String, UserScoreInfo>>，KV 里面的 Key 就用户 ID。

自定义一个 Composite Transform，其中包括三个步骤：利用 Top Transform 将每一个用户的最高分率选出来，得到 PCollection<KV<String, List>>；将 PCollection<KV<String, List>> 转换成为 PCollection<KV<String, UserScoreInfo>>；再次利用 Top Transform 将 PCollection<KV<String, UserScoreInfo>> 中前 100 名高分用户筛选出来。

将结果写入 CSV 格式的文件中，格式为“用户 ID，分数”。

在上面所描述的步骤中，第 5 步出现了一个叫 Composite Transform 的概念。

那么，什么是 Composite Transform 呢？其实 Composite Transform 并不是指一个具体的 Transform，而是指我们可以将多个不同的 Transforms 嵌套进一个类中，使得数据流水线更加模块化。具体做法是继承 PTransform 这个类，并且实现 expand 抽象方法来实现的。

用我们实现过的 WordsCount 来举例，我们可以将整个 WordsCount 数据流水线模块化成一个 Composite Transform，示例如下：

Java

public static class WordsCount extends PTransform<PCollection<String>,

      PCollection<KV<String, Long>>> {

    @Override

    public PCollection<KV<String, Long>> expand(PCollection<String> lines) {

      PCollection<String> words = lines.apply(

          ParDo.of(new ExtractWordsFn()));

      PCollection<KV<String, Long>> wordsCount =

          words.apply(Count.<String>perElement());

      return wordsCount;

    }

  }

在上面这个例子中，输入的参数是每一行字符串 PCollection，输出结果是每一个单词对应出现的次数 PCollection<KV<String, Long>。在实现 expand 这个抽象方法的时候，里面总共嵌套了两个不同的 Transform，分别是一个 ParDo 用来提取每一行的单词，还有一个 Count Transform 统计单词出现的次数。

所以在第 5 步中，我们也可以自己定义一个 ExtractUserAndScore 的 Composite Transform 来实现上面所描述的多个不同的 Transforms。

好了，为了事先知道游戏积分排行榜中开始的边界时间和结束的边界时间，我们还需要自己实现一个 Options 接口。方法是继承 PipelineOptions 这个接口，具体如下所示：

Java

public interface Options extends PipelineOptions {

    @Default.String("1970-01-01-00-00")

    String getStartBoundary();

    void setStartBoundary(String value);

    @Default.String("2100-01-01-00-00")

    String getEndBoundary();

    void setEndBoundary(String value);

  }

这样开始的边界时间和结束的边界时间就都可以通过 Pipeline option 的参数传入。

例如，我们想要得到 2019 年 7 月 15 日至 2019 年 7 月 21 日这周的排行榜，那在运行数据流水线的时候，参数就可以按照“–startBoundary=2019-07-15-00-00 --etartBoundary=2019-07-21-00-00”传入了。

整个数据流水线的大致逻辑如下：

Java

final class LeaderBoard {

  static class UserScoreInfo {

    String userId;

    Double score;

    Long eventTimestamp;

    public UserScoreInfo(String userId, Double score, Long eventTimestamp) {

      this.userId = userId;

      this.score = score;

      this.eventTimestamp = eventTimestamp;

    }

    public String getUserId() {

      return this.userId;

    }

    public Double getScore() {

      return this.score;

    }

    public Long getEventTimestamp() {

      return this.eventTimestamp;

    }

  }

  private static DateTimeFormatter formatter =

      DateTimeFormat.forPattern("yyyy-MM-dd-HH-mm")

          .withZone(DateTimeZone.forTimeZone(TimeZone.getTimeZone("Asia/Shanghai")));

  public static void main(String[] args) throws Exception {

    Options options = PipelineOptionsFactory.fromArgs(args).withValidation().as(Options.class);

    Pipeline pipeline = Pipeline.create(options);

    final Instant startBoundary = new Instant(formatter.parseMillis(options.getStartBoundary()));

    final Instant endBoundary = new Instant(formatter.parseMillis(options.getEndBoundary()));

    pipeline

        .apply(

            BigtableIO.read()

                .withProjectId(projectId)

                .withInstanceId(instanceId)

                .withTableId("ScoreTable"))

        .apply("ConvertUserScoreInfo", ParDo.of(new ConvertUserScoreInfoFn()))

        .apply(

            "FilterStartTime",

            Filter.by((UserScoreInfo info) -> info.getTimestamp() > startBoundary.getMillis()))

        .apply(

            "FilterEndTime",

            Filter.by((UserScoreInfo info) -> info.getTimestamp() < endBoundary.getMillis()))

        .apply("RetrieveTop100Players", new ExtractUserAndScore())

        .apply(

            FileIO.<List<String>>write()

                .via(

                    new CSVSink(Arrays.asList("userId", "score"))

                        .to("filepath")

                        .withPrefix("scoreboard")

                        .withSuffix(".csv")));

    pipeline.run().waitUntilFinish();

  }

}

其中，ConvertUserScoreInfoFn 这个 Transform 代表着第 2 步转换操作，数据流水线中两个 Filter Transform 分别代表着第 3 和第 4 步。第 5 步“获得最高分的前 100 位用户”是由 ExtractUserAndScore 这个 Composite Transform 来完成的。

你可以看到，不算上各种具体 Transform 的实现，整个数据流水线的逻辑框架大概用 60 行代码就可以表示出来。

虽然这个批处理的方法可以用简单的逻辑得到最后我们想要的结果，不过其实它还存在着不少的不足之处。

因为我们的批处理数据流水线使用 crontab 来定时运行，所以“运行数据流水线的时间间隔”以及“完成数据流水线”这之间的时间之和会给最终结果带来延迟。

比如，我们定义 crontab 每隔 30 分钟来运行一次数据流水线，这个数据流水线大概需要 5 分钟完成，那在这 35 分钟期间用户上传到服务器的分数是无法反应到积分排行榜中的。

那么，有没有能够缩小延时的办法呢？

当然有，答案就是将输入数据作为无边界数据集读取进来，进行实时的数据处理。在这里面我们会运用的到第 23 讲所讲述到的窗口（Window）、触发器（Trigger）和累加模式（Accumulation）的概念。

我将在下一讲中，与你具体分析怎样运用 Beam 的数据流水线实现一个实时输出的游戏积分排行榜。

小结

今天我们一起展开讨论了自己实现一个简易游戏积分排行榜的过程。可以知道的是，我们可以使用 Beam 的数据流水线来完成这一任务。而在 Beam 数据流水线的实现方式中，我们又可以分成批处理的实现方式和即将在下一讲中展开讨论的实时流处理的方式。批处理虽然简单，但是存在着延时性高、无法快速更新积分排行榜的缺点。

思考题

在今天这一讲的最后，我提示了你在实时流处理中需要用到窗口、触发器和累加模式。那我们就先来做个预热，思考一下，在流处理中你会对这三种概念赋予什么值呢？

欢迎你把答案写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

36 | Facebook游戏实时流处理Beam Pipeline实战（下）

你好，我是蔡元楠。

在上一讲中，我们一起对怎样实现一个简易的游戏积分排行榜展开了讨论，也一起研究了如何使用批处理计算的方式在 Beam 中构建出一个数据流水线来得出排行榜结果。

我们知道，虽然批处理计算可以得到一个完整的结果，但是它也存在着自身的不足，比如会有一定的延时，需要额外的 crontab 来管理定时任务，增加了维护成本等等。

所以在上一讲的末尾，我们提出了使用实时流处理来改进这些不足，而其中就需要用到窗口、触发器和累加模式这几个概念。

相信学习了第 32 讲的内容后，你对于窗口在 Beam 中是如何运作的，已经比较了解了。对于有效时间为一周的积分排行榜来说，我们可以赋予一个“窗口时长为一周的固定窗口”给数据流水线。也就是说，我们最终的结果会按照每一周的时长来得出。

那接下来的问题就剩下我们怎么定义触发器和累加模式了。

首先，我想先讲讲触发器在 Beam 中是怎么运作的。在第 23 讲中，我们已经了解了触发器在 Beam 中的作用。它是用于告诉数据流水线，什么时候需要计算一遍落在窗口中的所有数据的。这在实时流处理中尤为重要。

在实时流处理当中，我们总是需要在数据结果的完整性和延迟性上做出一些取舍。

如果我们设置的触发器比较频繁，例如说每隔几分钟甚至是几秒钟，或者是在时间上很早发生的话，那就表示我们更倾向于数据流水线的延时比较小，但是不一定能够获得完整的数据。

如果我们设置的触发器是比较长时间的，像每隔一个小时才会触发一次窗口中的计算的话，那就表示我们更希望获得完整的数据集来得到最终结果。

为什么这么说呢？

因为在现实世界中，我们是没有办法保证数据流水线可以在某一刻能够得到在这一刻之前所产生的所有数据的。

就拿获得这个游戏积分排行榜的数据为例子来说明一下。

在现实生活中，可能很多用户是用手机来通关游戏，并且上传通关时间和积分的。有的地方可能因为信号差，上传数据会有很大的延迟。甚至可能有这样的情况：有一些用户是在坐飞机的时候玩的游戏（在飞行模式之下完成各种通关），等到飞机降落，手机重新有了信号之后，数据才被上传到服务器，这时候可能已经有了好几个小时的延时了。

如果提早触发窗口中的数据计算，可能会有很多“迟到”的数据未被纳入最终结果中，而这些“迟到”的数据有可能又会影响到游戏积分排行榜。

所以，在很多复杂的场景下，我们希望尽可能地将所有数据归入正确的时间窗口中，而且还要能够得到正确的结果。因此，除了要对触发器进行设置之外，我们还需要设置到底应不应该在一些“迟到”的数据来到的时候，重新计算一下整个结果。

在 Beam 中，我们可以为 PCollection 设置的触发器有 4 种模式：

1.基于事件时间的触发器（Event-Time Trigger）

如果设置了基于事件时间的触发器，那所有的计算都是基于 PCollection 中所有元素的事件时间的。

如果我们不显式地设置触发器的话，Beam 的默认触发器就是基于事件时间的，如果要显式地设置基于事件时间的触发器，可以使用 AfterWatermark 类进行设置。

2.基于处理时间触发器（Process-Time Trigger）

如果设置了基于处理时间的触发器，那一个 PCollection 中的所有元素都会在数据流水线中的某一个时刻被处理。如果要显式地设置基于处理时间的触发器，可以使 AfterProcessingTime 类进行设置。

3.数据驱动的触发器（Data-Driven Trigger）

数据驱动的触发器一般是在每个元素到达每个窗口时，通过检查这个元素是否满足某个属性来触发的。

就好像我在第 23 讲所举的例子一样，检查元素是否在窗口中到达一定的数量，然后触发计算就是数据驱动的触发器的一种，在 Beam 中，可以使用 AfterPane.elementCountAtLeast() 函数来配置。

4.复合触发器（Composite Trigger）

复合触发器其实就是由上面所说三种基本触发器组合而成的。在第 23 讲中，我举过一个触发器的例子，例子中至少要等到有两个交易数据到达后才能触发计算。

有同学在留言中问我，如果现实中只有一个数据到达窗口，那岂不是永远都触发不了计算了？其实，这个时候就可以定义一个复合触发器，可以定义成累积有超过两个元素落入窗口中或者是每隔一分钟触发一次计算的复合触发器。

而像我之前提到的，如果我们需要处理“迟到”的数据，那在 Beam 中又是怎么操作呢？我们可以使用 withAllowedLateness 这个在 Window 类里定义好的函数，方法签名如下：

Java

public Window<T> withAllowedLateness(Duration allowedLateness);

这个函数接收的参数就是我们希望允许多久的“迟到”数据可以被纳入计算中。

最后需要说明的是累加模式。

在 Beam 中，我们可以设置两种累加模式，分别是丢弃模式和累积模式。它们可以分别通过 Window 类里的函数 discardingFiredPanes() 和 accumulatingFiredPanes() 来设置。

好了，那现在回到我们的积分排行榜问题当中。

虽然我们对输入数据集设定的窗口是一个窗口时长为 1 周的固定窗口，但是我们也需要尽可能地在近乎实时的状态下更新排行榜。所以，我们可以设置数据流水线在每 5 分钟更新一次。

那我们接受“迟到”多久的数据呢？

我在网上查询了一下，现在飞机航班直飞耗时最长的是新加坡飞往纽约的航班，大概需要 19 个小时。如果玩游戏的用户恰好也在这趟航班上，那么可能数据的延时可能就会超过 19 个小时了。那我们就设定允许“迟到”20 个小时的数据也纳入我们的窗口计算当中。

一般情况下，我们可以从 Pub/Sub 数据流中读取实时流数据。为了简化数据流水线的逻辑，不在数据流水线中保存中间状态，我们现在假设在实际操作的时候，服务器已经判断好某一用户的分数是否是最高分，如果是最高分的话，再通过 Pub/Sub 将数据传入流水线。

这时，我们的累加模式可以定义为丢弃模式，也就是只保留最新的结果。

为此，我们可以写出一个 Transform 来设置所有上述描述的概念，分别是：

设置窗口时长为 1 周的固定窗口。

每隔 5 分钟就会计算一次窗口内数据的结果。

允许“迟到”了 20 个小时的数据被重新纳入窗口中计算。

采用丢弃模式来保存最新的用户积分。

Java

static class ConfigUserScores extends PTransform<PCollection<UserScoreInfo>, PCollection<UserScoreInfo>> {

    private final Duration FIXED_WINDOW_SIZE = Duration.standardDays(7);

    private final Duration FIVE_MINUTES = Duration.standardMinutes(5);

    private final Duration TWENTY_HOURS = Duration.standardHours(20);

    @Override

    public PCollection<UserScoreInfo> expand(PCollection<UserScoreInfo> infos) {

      return infos.apply(

          Window.<UserScoreInfo>into(FixedWindows.of(FIXED_WINDOW_SIZE))

              .triggering(

                  AfterWatermark.pastEndOfWindow()

                      .withEarlyFirings(

                          AfterProcessingTime.pastFirstElementInPane().plusDelayOf(FIVE_MINUTES))

                      .withLateFirings(

                          AfterProcessingTime.pastFirstElementInPane().plusDelayOf(FIVE_MINUTES)))

              .withAllowedLateness(TWENTY_HOURS)

              .discardingFiredPanes());

    }

  }

有了这个 Transform 去设定好我们在实时流处理中如何处理数据之后，我们其实只需要修改之前批处理数据流水线中很小的一部分，就可以达到我们想要的结果了。

Java

...

pipeline.apply(

            KafkaIO.<String>read()

                .withBootstrapServers("broker_1:9092,broker_2:9092")

                .withTopic("user_scores")

                .withKeyDeserializer(StringDeserializer.class)

                .withValueDeserializer(StringDeserializer.class)

                .withLogAppendTime())

        .apply("ConvertUserScoreInfo", ParDo.of(new ConvertUserScoreInfoFn()))

        .apply("ConfigUserScores", new ConfigUserScores())

        .apply("RetrieveTop100Players", new ExtractUserAndScore())

...

如代码所示，真正做出修改的地方就是将读取输入数据集的 BigTableIO 改成使用 KafkaIO 来读取。将批处理的两个 Filter Transform 替换成我们自定义的 ConfigUserScores Transform。

到此为止，我们就可以“一劳永逸”，运行一个实时流处理的数据流水线来得到游戏积分排行榜结果了。

小结

今天我们一起设计了一个实时流处理的数据流水线，来完成之前自定义的一个简单游戏积分排行榜。

这里面有不少经验是值得我们在处理现实的应用场景中借鉴的。比如，我们应该考虑数据结果的完整性有多重要、我们能接受多大的延迟、我们是否接受晚来的数据集等等。

这些问题其实又回到了第 23 讲中提到过的——我们在解决现实问题时，应该回答好的“WWWH”这四个问题。

思考题

今天我们一起探讨了如何利用实时流处理的方式来解决游戏积分排行榜的问题，里面涉及了配置窗口，触发器和累加模式。这些配置可能还不是最优的，你觉得我们还有什么地方可以进行优化的呢？

欢迎你把自己的学习体会写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

FAQ第一期 | 学习大规模数据处理需要什么基础？

你好，我是蔡元楠。

专栏上线已经一个月了，在这里我要先感谢大家的留言，留言的对答可以使我们互有补益。

这段时间，我发现留言中的很多问题都很有价值，希望你也可以看到。所以，我根据已发布的文章中的思考题，从留言中摘录了一些典型的、常见的问题做出答疑集锦，最终成为了今天你看到的“特别福利篇”。

“开篇词”问题精选

问题一：学习大规模数据处理需要有什么基础？

这是一个很好的问题，虽然专栏已经更新了一个月，我还是要把这个开篇词中的提问放进来。就像你看到的那样，有好几位读者都问了类似的问题。

其实在最开始做专栏的内容设计时，我并没有对读者的知识背景作任何假设。

所以，即使是一些基础的技术概念，我也会举例解释一下（如果你已经会了可能会觉得啰嗦，这时候就需要你照顾一下其他同学了）。如果你有一些语言的编程经验（任何语言都可以）的话，看文章的理解速度会快一点。文章中会有一些示例代码，是用 Python 编写的。

但是在设计类型的案例中，我不觉得它对读者有特别的技术要求。

希望你在后面的阅读中提出建议，告诉我有哪些地方我讲得不够清楚，或者解释的过多，我会适当调整内容。

问题二：小型公司程序员学习大规模数据处理的意义？

这个问题问得很好。以客观条件来看，韩程的说法没有问题。

大规模的互联网公司天生数据量是要大一些的。但是，这并不意味着大数据处理只在大公司才能发挥价值。你也要考虑其他方面。

第一，对于公司来讲，小型互联网公司或者传统企业，并不是不需要数据处理技能，而是他们还没有从数据中挖掘 business insight 的意识，没有数据驱动决策的意识，甚至没有收集数据的意识。

举个我工作中见到的例子。比如，有些饲养奶牛的农户，他们几十年来根本不知道什么是数据。但是，当我们帮他们细致地搜集奶牛每天的活动数据，比如饮食、运动、作息、产奶，他们就能从中找到最经济（最优）的饲料投放方式。

第二，对于个人来讲，你就一定要看长期的职业发展，公司会从小变大，职位会从低变高。当你需要影响决策的时候，当你面临的数据量变多的时候，当你准备跳槽的时候，数据的处理能力都是至关重要的。

“第一讲”问题精选

思考题：如果你在 Facebook 负责处理用户数据，你会选择什么样的分片函数来保证均匀分布的数据分片？

我发现有很多精彩的回答。比如下图中的 CountingStars 同学，他的思路非常有意思。是把年龄的数值前后颠倒进行分片。

还有这位 Mark Lee，他认为可以使用身份证后面的随机数来进行分片，纯技术上看起来似乎可行。但要使用用户的身份 ID 的话，你还需要考虑是否符合法律、道德、隐私方面的问题。

而 Freud 的想法是引用随机标记来保证数据分片的随机性。但这里要保证数据的均匀可重复才行。如果你在 shard2 上的任务失败，你需要能够还原出错的任务并进行重试。

榣山樵客把这几个回答可能出现的问题做了个总结。他的回复是一切有效降低十位数权重的哈希算法都是可行的。

倒置年龄可以明显改善分布不均的问题，但是也可能对某些单一热点无解，比如 25 岁的用户特别多的话还是会出问题。

随机分区可以做到均衡，但对 combine、io 等优化不够友好。还有一个缺点，是当分区任务失败，需要重新分区的时候，分区结果不再是 deterministic 的。如果某一台机器宕机了，你要如何重新分配原本属于这台机器上的用户数据？

先采样，再动态合并和拆分的实现过于复杂，效果可能不够稳定。

像他一样，在每个答案里都分别给出这个答案所存在的不足，这一点是我非常赞赏的。在开发设计中没有哪个答案是特别完美的，我们能做的是分析哪一个才是最符合自身应用需求，进而改善。

“第二讲”问题精选

第二讲中，我留下的思考题是“你现在正在使用的数据处理技术有什么问题？你有怎样的改进设计？”。

mjl 在回答中阐述了他比较了解的 Spark 和 Flink，总结得很好。

虽然原生 Spark Streaming Model 和 Dataflow Model 不一样，但是 Cloudera Labs 也有根据 Dataflow Model 的原理实现了 Spark Dataflow，使得 Beam 也可以跑 Spark runner。

而对于 Flink 来说的话，在 0.10 版本以后，它的 DataStream API 就已经是根据 Dataflow Model 的思想来重写了。

现在 Flink 也支持两套 API，分别是 DataStream 版本的和 Beam 版本的。其实 data Artisans 一直都有和 Google 保持交流，希望未来两套 Beam 和 Flink 的 API 能达到统一。

最后赞一点，批处理是流处理的子集，这个观点我在第一讲的留言中也提到过。

第三讲和第四讲中问题较为开放，与读者自身的工作内容强相关，很多都是大家在分享自己的经验，内容很丰富，这里篇幅不足，建议大家去原文的留言中看一看。

“第五讲”问题精选

第五讲中讲的主要是分布式处理系统的三个重要指标：扩展性，一致性和持久性。根据这个内容，3SKarl 同学提问弱一致性和最终一致性的区别是什么。

这是个很棒的问题。简而言之，弱一致性是个很宽泛的概念，它是区别于强一致性而定义的。广义上讲，任何不是强一致的，而又有某种同步性的分布式系统，我们都可以说它是弱一致的。

而最终一致性是弱一致性的一个特例，而且是最常被各种分布式系统用到的一个特例。

其他的比如因果一致性、FIFO 一致性等都可以看作是弱一致性的特例，不同弱一致性只是对数据不同步的容忍程度不同，但是经过一段时间，所有节点的数据都要求要一致。

学习专栏时，重要的是理解它们的区别。这部分知识是为了后边讲 CAP 理论服务的，实际的工作中也不会像考试考概念题一样，让你背写这些一致性的定义。

hua168 同学问的是强一致性的误差范围。这个问题非常有趣，强一致性并没有误差可言的，强一致性简单地说指的就是如果更新一条数据，那所有用户读取数据的时候必须都看到这条更新了的数据。

在这里我也想借着 FAQ 分享一个自己当年在面试 Bloomberg 的面试经历。

面试官给我出的题目是这样的：如果要设计 Bloomberg 的股票信息系统中的数据库系统，系统需要实时更新股票价格，而数据更新的写入量非常大，用户也需要读取最新的股票资讯，你会如何设计这套系统。

这个问题其实有很多的未知区域需要我们去和面试官去阐明，例如用户的 Use Cases 是什么？在此我就不一一展开了，在这里我只想分享一个和一致性相关的内容。

在和 Bloomberg 的 Tech Lead 讨论时我发现，原来他们的股票系统显示的股价并不是强一致性的，延迟范围是 1 分钟左右。

因为应用场景上，普通股民并不会需要实时关心每秒钟股票价格的动态，更多的是关心大盘走势。而金融巨头在操作股票的时候，更多只关心特定的几只股票，所以这些股票的价格通常对于他们来说会更新快一点。

所以说，很多现实生活上的实际应用和我们本来想象的并不太一样。

到这里，我们的第一期答疑就结束了。

就像我在专栏一开始的时候与你说的一样，我希望你能够积极与我互动。其实很多同样的问题会在不同的人身上重复出现，你不表达出来的话，可能永远也不知道，原来有那么多人曾经和你遇到过同样的困境。

如果你觉得有所收获，欢迎你把文章分享给你的朋友。

FAQ第二期 | Spark案例实战答疑

你好，我是蔡元楠。

这里是第二期答疑，上周我们结束了 Spark 部分的内容，时隔一周，我们的 Spark 案例实战答疑终于上线了。

通过 10 讲的 Spark 学习，相信你已经对 Spark 的基本特性有了深入的了解，也基本掌握了如何使用各类常用 API，如 RDD、DataSet/DataFrame、Spark Streaming 和 Structured Streaming。今天我将针对模块三中提出的一些共性留言做一个集中答疑。

我首先要为积极留言的同学们点个赞，感谢同学们亲自动手实践，有的同学还通过查阅官方 API 文档的形式找出了正确的实现方式，这非常值得鼓励。

第 18 讲

在第 18 讲中，kylin 同学留言问到，为什么用我们通篇用的是 DataFrame API 而不是 DataSet。这是因为 PySpark 的 SQL 库只有 DataFrame，并没有 DataSet。不过在 Scala 和 Java 中，DataSet 已经成为了统一的 SQL 入口。

斯盖丸同学问道，第 18 讲代码中 groupBy(‘value’) 中 value 是什么意思？

这里我说一下，SparkSession.read.text() 读取文件后生成的 DataFrame 只有一列，它的默认名字就是“value”。

在第 18 讲的代码中，我们用 lines.value 去读取这一列，是同样的道理。之后我们给新的列重命名为”word”，所以 groupBy 的参数变成了”word”。如果你印象不深了，可以返回去查看一下。

讲到这里，我要为 Jerry 同学点个赞。在开篇词中我就提到过，我希望你可以建立自己的批判性思维，不要盲目听从任何人的意见。除了认真实践，像 Jerry 一样通过查阅官方文档找到了正确的实现方式，做的真的很棒，希望可以在工作中也把这份批判性思维和独立学习的能力保持下去。

你可以发现，在第 18 讲中，我介绍的 explode 和 split 方法在官方文档中都有详细的讲解，这些内容并没有很大的难点，通过自己阅读官方文档都可以学会。官方文档中还有很多我没有提到的用法，在仅仅 10 讲的篇幅中我不能把 Spark 的每一个用法都教给你。我能做的，只是从源头出发，分析新的技术、新的 API 产生的原因，教会你思考的方式，并结合例子，让你体会一下如何在真实场景中利用这些技术，而不是照本宣科地把官方文档复述一遍。

学习新的技术跟上学时背单词不一样，我们要做的是在最短时间内掌握核心内容和设计的理念，至于具体的用法，完全可以在用到的时候查阅官方文档。

第 19 讲

第 19 讲中，gotojeff 提出的这个语言选择问题，其实我之前就提到过，PySpark 现在不支持 DataSet，只有 Scala 和 Java 支持。这是由语言特性决定的，Python 是动态类型的语言，而 DataSet 是强类型的，要求在编译时检测类型安全。所以，在所有用 Python 的代码例子中，我用的都是 DataFrame。

大部分人都同意在 Spark 中，Scala 和 Python 是优于 Java 和 R 的。至于在 Spark 生态中，Scala 和 Python 孰优孰劣，这是个很主观的问题，我们不能只因为不支持 DataSet 这一点就说 Python 比 Scala 差。

Scala 确实很优秀，Spark 原生的实现就是用 Scala 写的，所以任何新发布的功能肯定支持 Scala，官方文档也都是用 Scala 来举例子。而且 Scala 的性能要优于 Python。但是 Python 也有很多优点，比如容易学习、应用场景广。这两种语言在 Spark 的世界中都可以满足我们绝大多数的需求，选择任何一个都不是错误的。

第 20 讲

第 20 讲的思考题是，为什么流的 Inner-Join 不支持完全输出模式？对于 Inner-Join 而言，加水印是否是必须的？ Outer-Join 呢？

这里，我节选了 never leave、jon、Ming 的留言，里面是他们对这个思考题的回答，不知道你是不是也进行了深入的思考？那么现在，让我也来分享一下我的看法吧。

首先，现阶段不仅 Inner-join 不支持完全输出模式，任何类型的 Join 都不支持完全输出模式。

这是因为完全输出模式要求每当有新数据输入时，输出完整的结果表。而对于无边界数据，我们很难把所有历史数据存在内存中。所以，一般 Join 的都是在某个时间窗口内的流数据，这就是引入 watermarking 的原因。希望将来 Spark 可以引入新的机制来支持这一点。

其次，我们都知道 Outer join 是要在 Inner Join 的基础上，把没有匹配的行的对应列设为 NULL。但是由于流数据的无边界性，Spark 永远无法知道在未来会不会找到匹配的数据。所以，为了保证 Outer Join 的正确性，加水印是必须的。这样 Spark 的执行引擎只要在水印的有效期内没找到与之匹配的数据，就可以把对应的列设为 NULL 并输出。

那么 Inner Join 呢？由于 Inner Join 不需要连接两个表中所有的行，所以在 Spark 官网的叙述中，水印和事件时间的限制不是必须的。但是如果不加任何限制，流数据会不断被读入内存，这样无疑是不安全的。所以，我推荐你即便是 Inner Join 也要加水印和事件时间的限制。

Feng.X 同学不是很理解实例中两个 Streaming DataFrame Join 时，为什么要加上事件时间的限制“endTime > startTime AND endTime <= startTime + interval 2 hours”。

事实上，这个限制会抛弃任何长于 2 个小时的出租车出行数据。确实，对于这个例子来说，这样一个对事件时间的限制并不是必须的。加入它其实是为了告诉你，在基于事件时间来 join 两个流时，我们一般不考虑时间跨度过大的情况，因为它们没有普遍意义，还会影响数据分析的结果。

举个例子吧，对于一个网页广告来说，我们需要知道用户看到一个广告后要多长时间才会去点击它，从而评估广告的效果。

这里显然有两个流：一个代表用户看到广告的事件，另一个代表用户点击广告的事件。尽管我们可以通过用户的 ID 来 Join 这两个流，但是我们需要加一个限制，就是点击广告的时间不能比看到广告的时间晚太久，否则 Join 的结果很可能是不准确的。比如，用户可能在 1:00 和 2:00 都看到了广告，但是只在 2:01 点击了它，我们应该把 2:00 和 2:01Join 起来，而不应该 Join1:00 和 2:01，因为 1:00 看到的广告并没有促使他点击。

第 21 讲

第 21 讲的思考题是，除了高延迟的流处理这一缺点外，你认为 Spark 还有什么不足？可以怎样改进？

我们都知道，Spark 并不是一个完美的数据处理框架，它的优点明显，也同样有不少不足之处。

在数据规模很大的场景中，靠内存处理的开销很大。如果内存不够把中间结果写入硬盘的话，又会影响处理速度；

Spark 没有自己的文件管理系统，它对 HDFS 或者其他的文件系统依赖很强；

在流处理中，只支持基于时间的窗口，而不支持其他种类的窗口，比如基于数据个数的窗口。

正是由于 Spark 还有诸多不足，所以众多开源的贡献者才会持续对 Spark 做改进，Spark 也在不断发布新版本。此外，很多新的数据处理框架的发明也是为了从根本上解决 Spark 存在的问题，比如 Flink，还有我们正在学习的 Apache Beam。

这位飞哥 grapefruit 不太明白 Flink 支持基于数据本身窗口是什么意思，我来回答一下。

窗口是流数据处理中最重要的概念之一，窗口定义了如何把无边界数据划分为一个个有限的数据集。基于事件时间的窗口只是窗口的一种，它是按照事件时间的先后顺序来划分数据，比如说 1:00-1:10 是一个集合，1:10-1:20 又是一个集合。

但是窗口并不都是基于时间的。比如说我们可以按数据的个数来划分，每接受到 10 个数据就是一个集合，这就是 Count-based Window（基于数量的窗口）。Flink 对于窗口的支持远比 Spark 要好，这是它相比 Spark 最大的优点之一。它不仅支持基于时间的窗口（处理时间、事件时间和摄入时间），还支持基于数据数量的窗口。

此外，在窗口的形式上，Flink 支持滚动窗口（Tumbling Window）、滑动窗口（Sliding Window）、全局窗口（Global Window）和会话窗口（Session Windows）。

到这里，我们 Spark 案例实战篇的答疑就结束了。欢迎继续留言，与我分享你的问题与答案。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

加油站 | Practice makes perfect！

你好，我是蔡元楠。

端午节了，先祝你节日快乐。不知道正在读文章的你这几天是不是有什么娱乐活动。我最近去参加了湾区一年一度的“三俗”活动——摘樱桃。而且，还看了一个有意思的电影《疾速备战》（John Wick: Chapter 3-Parabellum），推荐给你。

说归正题，在专栏的更新过程中，对于“留言的处理”这一点我还是要先道个歉，前段时间因为一些个人原因比较忙，所以对于问题的回复没有做好。我会努力把答疑这件事重新做起来。既然有问题了，就是要解决掉才行。

专栏进程也已经过半了，我好像感觉到同学们的阅读热情都在降低，排除“养肥了再读”的情况，可能是因为很多人进入了学习的“倦怠期”。我想了下，正好又到了假期，也许是应该专门写一篇文章，来跟你“聊聊天”。毕竟对于绝大部分人来说，“学习”这件事情真的是“逆人性”的，总需要一些建议和鼓励。

学习的目的就是解决问题

说实话，我们都不是天生以学习为乐趣的“学神”，所以现在我们的学习目的往往就是集中在“解决问题”上。而事实上，“解决问题”这件事其实并不是我们从工作的时候才开始面对的。

还记得我们在校园时期受的最多的训练吗？那就是“做题”。枯燥无味的“做题”过程就是我们接受的最初的对于“解决问题”的训练。在这个“做题”过程中学会的解决问题的方法一定是你至今都在受用的。

只不过是不同阶段里，你需要解决的问题的主题会变化而已。小时候应付数学题，生活中要应对和男 / 女朋友吵架，工作中要应对定义设计技术系统，等等。

当然，我们要面对的这些问题的规模也一直在变化。就像我们小时候做的题：根据 a 和 b，求 c 是多少？已知条件都有，正确解法只有一个。而成人世界的问题：怎样改善北京的交通？已知条件你不是很了解，解法也不止一个，而且很难验证。

我十分同意 Amazon 创始人贝索斯的一句话，“与其看 20 年后什么会变，不如看什么不会变”。在我们的人生中，不会变的就是“人要解决问题”这件事。

工作需要你解决沟通问题

其实学习方法有很多，如果套用机器学习的术语，我们在学生时期常用的解决问题方法是 Reinforcement Learning 的 Imitation Learning，也就是模仿学习。孔子所说的举一反三也是在说这个道理，通过做一个题，学会做相似的其他题。

当然，不只是具体的技术问题，与他人的沟通交流中也可以用上这样简单的道理。

举个例子吧，之前有一段时间里，一直困扰我的问题是，我主持的会议议程总是被人打断。

Let’s take a step back, …
Before we talk about this, let’s talk about…
……

而解决这个问题的方法和学生时期一样，就是模仿。模仿那些把会议组织好的人，学习他们做了什么，看他们是如何沟通的，再套用到自己身上加以练习。这里我自己总结了很多细节，比如提前一天再把 agenda 发给参加会的人，会议开始前把 agenda 投影上去，被人打断了再强势打断回来，等等。

That’s an important topic, let’s have a separate meeting.
Let’s put it offline.
We only have 20min left, let’s…
……

前面说过，学习的目的是解决问题。知道学习的方法只是第一步，更重要的是你要把它用到自己的工作、生活中。

工作需要你解决技术问题

说到这儿，我不知道你是不是了解我的工作，我给你简单介绍一下吧。其实这方面挺简单的，你随意 Google 一下，都能找到有关 Google Brain 的介绍。

Google Brain 的雏形起源于一项斯坦福大学与 Google 的联合研究项目。

2011 年，Google 资深专家杰夫·迪恩（Jeff Dean）、研究员格雷·科拉多（Greg Corrado）和吴恩达（Andrew Ng）教授是这个小团队的最初三名成员。团队有多个主要研究方向，包括机器学习算法和技术、医疗健康、机器学习支撑计算机系统、机器人、自然语言理解、音乐艺术创作和知觉仿真等。

我现在的工作方向就是医疗健康，主要负责的是癌症相关的 AI 应用。

比如，能否把所有癌症相关的数据更有效地整理起来，让医生和研究者更方便地查询。这些数据非常多，包括诊断过程中的 CT、活检切片、基因序列、治疗过程中患者对于不同疗法的反应，等等。

再举个细点的例子吧。有研究者发现，免疫疗法对于癌症患者的有效程度更多的是取决于癌变组织周围的免疫细胞环境。在这一前提下，我们要解决的问题就是：能否用 AI 来分析癌变组织的免疫细胞环境？

看到这，你大概能理解了，拥有大规模数据的整理、搜集、存储、分析能力，是我们完成工作内容的先决条件。而我们日常要锻炼的、加强的也就是这些解决技术问题的能力。

当然，可能大公司和小团队侧重的方面并不相同。如果你在一个小团队里，这个小团队里更看重的是你是否对这个事业有兴趣。但是，大公司主要看重的还是员工解决沟通问题和解决技术问题的能力。

这一点如果体现在面试流程的情境中，你可以这样理解：站在面试流程制定者的角度，他们设计一些面试流程的目的只是为了判定这个人进公司后是不是能成为一个好的同事，帮助公司成功。

所以，在面试的过程中，你可以先给自己一个人设，就是把自己当作是“面试官的同事”。把面试问题的内容定义成一个需要解决的问题，一个需要你们去共同解决的问题。比如，面试题条件没给全的时候，完全不需要惊慌，他很有可能是故意的，你需要做的是去和面试官沟通我们这个项目需求。

这与真实的工作中一样，拿到一个项目后，你要马上开始死命地工作吗？不是的。你应该是要多去问为什么。为什么做这个项目？团队想要达到什么目标？想要短期的方案还是长期的方案？这样的心态摆好之后，剩下的就是你的技术问题了。

从看到学，是一件事；从学到用，是一件事；从用到会，又是另外一件事了。而解决技术问题方法只有一个：Practice makes perfect！

今天说了这么多，其实都是我自己的一些经验。那么对于“如何解决问题”这一点，你可以分享一下你的经历吗？

37 | 5G时代，如何处理超大规模物联网数据

你好，我是蔡元楠。

时间过的真快，转眼间我们已经结束了前五个模块的学习，来到了最后一个模块“大规模数据的挑战和未来”。

一门技术类课程的常见学习路线就是“过去→现在→未来”。这个专栏也是如此，我们首先研究了大数据处理技术的发展历程，从 MapReduce 出发，深入剖析了它的设计思路和优缺点。接下来结合具体的例子，一起学习了当下最流行的数据处理框架 Spark 和 Apache Beam。

在这个过程中，你不难发现，任何一门技术的出现都是为了解决实际问题，改进之前的技术所存在的缺陷，而贯穿整个课程的两大场景就是批处理和流处理。

Spark 在 MapReduce 的基础上不断改进，在批处理这方面有良好的性能，在流处理上也在不断提高。Apache Beam 更是一个统一批处理和流处理的框架。

正如我在开篇词中写到的，我理想中的专栏是一份与你一同成长的计划。虽然我们已经对当下流行的技术有了深入的了解，但是作为一名架构师，你的目光一定要放长远，要时刻对未来 5～10 年，乃至 20 年的新问题和技术发展方向保持了解，不能固步自封，只满足于现状。毕竟，我们的征途是星辰大海。

在模块六中，我将列举三个大数据处理技术未来的方向，带你了解这些问题的挑战和难度，并学习现有的解决方案。希望通过这一模块的学习，你可以对大数据处理的未来有一些初步的认识，并强化自己学习新知识的能力。

什么是物联网？

物联网（Internet of Things）应该是一个你经常听说的名词，不过，你真的了解它吗？让我先来简要介绍一下什么是物联网吧。

你可以将物联网的功能看作“使用嵌入在物理环境中的网络连接设备，来改进现有流程，或启用以前无法实现的新场景”。这些设备或事物连接到网络后，可以提供它们使用传感器从环境中收集的信息，或允许其他系统通过执行器连接，并作用于现实世界。

它们可以是我们个人拥有并随身携带的设备（比如手表、眼镜），或留在家中的设备（比如电视、空调、音响等智能硬件），也可能是联网的工厂设备或机器，还可能是城市中的公共设施（比如停车场、公交车）。

想象一下，未来我们身边的所有物体，都有可能连入互联网，我们的生活将变得无比便捷。每个设备都能将来自现实世界的有价值信息转换为数字数据，从而有效改善人类与各类产品的交互方式。

物联网可以被广泛应用在生活的各方各面，比如智能家居、智能交通、智能工厂、智能医院、智能物流等。

智能家居：这可能是你对物联网应用了解最多的方面。家里的各种电器，乃至防盗门窗，都可以连入物联网，我们可以通过手机或者电脑远程操控所有电器。如果有异常情况，比如着火或者小偷进入，都可以及时发现并采取措施。

智能交通：在公路和铁路的关键点设置传感器，可以监控交通基础设施的运作状况，以及监控特殊事件，比如交通流量的变化和道路拥堵的发生。这些物联网传感器发回到总部的信息，可以用来向每辆汽车通知拥堵点，并提供备用路线。在停车场设置传感器和摄像头，也可以向每个人提供车位信息。

智能工厂：工厂的所有机器设备都可以连入网络，我们可以通过各类传感器来获得实时的机器设备数据与性能，并把它传入控制中心。通过对这些数据进行实时处理，我们可以自动预测设备何时需要维护、实时优化设备性能、预测停机时间、检测异常、跟踪设备状况和位置。工厂的自动化程度将大大提高。

智能医院：病人可以在身上佩戴检测身体基本指标的手环，每时每刻把身体信息发回数据处理中心，医院就可以实时了解病人的身体情况。一旦有异常情况发生，还可以自动呼叫救护车。

智能物流：卡车配备传感器之后，可以追踪一路上的运送情况，选择最佳运送路线，追踪时间等。在有些情况下，传感器还用于追踪驾驶员的速度、刹车习惯等，数据处理终端可以选择最安全、最环保的驾驶路线。

物联网的世界充分体现了大规模数据的四个特点——多样性、大规模、高速率和真实性。

1. 多样性

说数据是具备多样性的，你很容易理解。这是因为物联网涉及的应用范围很广，就如我刚才提到的智能家居、智能交通、智能工厂、智能医院等。

从广义上讲，生活中的各方各面都可以应用物联网。而且，在不同的领域和行业，需要面对的应用数据的类型、格式也不尽相同，这些都是物联网多样性的体现。

2. 大规模

之所以说物联网数据规模庞大，是因为它的节点是海量的，它不像互联网，局限于手机或者电脑。

想象一下，你的眼镜、手表、音响、空调、冰箱、电视……这些全部都成为了物联网的节点。而且，这些设备是 24 小时不间断地提供数据的，数据的生成频率远高于互联网。所以，物联网的实时数据规模是非常大的。

3. 高速率

物联网中的数据速率比常见的大数据处理场景要更高。由于前面数据“大规模”的特点，物联网要求数据处理中心能处理更多的数据。同时，为了满足物联网的实时响应，数据的传输速率也要更高才行。

举个例子，如果速率不够高、不够实时，那么汽车的自动驾驶就会危险重重。因为它与真实物理世界直接相关，需要能实时访问、控制相应的节点和设备才能完成安全的驾驶。只有高数据传输速率才能支持它的实时性。

这也是为什么物联网是最近十年才发展起来的原因，十几年前的通信和网速很难达到这样的要求。

4. 真实性

我们都知道，物联网的数据来源于真实世界，而且要根据数据分析处理后的结果，对真实世界中的设备发送指令采取相应的操作，最终会作用于真实世界。所以，物联网对数据真实性要求很高。

由此可见，在物联网的世界中，构建一个可靠的、处理速度快的大规模数据处理方案尤其重要。

处理物联网数据的架构

一个基本的物联网数据处理 pipeline 就如下图所示：

你可以看到，在这个 pipeline 中，各个设备终端不断地向数据接收层发送数据。在这一层，数据被清洗，并且转换为统一的格式，然后发送到数据分析层进行分析。在分析过后，处理过的数据可以被存储下来。基于存储的数据，我们可以创建各种 dashboard 来展示，这也方便管理人员直观地观察数据。

如果分析之后发现需要某些设备采取特定的操作，这些信息可以从数据分析层传送回设备控制层，从而向终端设备发送相应的指令。

各大云服务厂商都提供物联网数据处理的解决方案。

对于数据接收层，市场上有 Google IoT Core、IoT Hub、Azure Event Hub 等产品，它们可以接收各类设备发送的数据，并对它们进行管理。数据分析层就是我们进行数据处理的地方，可以用 Spark、Hadoop、Azure DataBricks 或者 Google Cloud Dataflow 等平台进行分析。数据存储层则是各类分布式存储系统如 Google Cloud BigQuery、HBase、Amazon S3 等。如果要基于数据创建 dashboard，可以用 Google Cloud Datalab 等交互式分析工具。

以 Google Cloud Platform 为例，它提供的物联网数据处理基本架构如下图所示：

终端数据经过 Cloud IoT Core 的清洗并转换成统一的格式之后，被发送到 Cloud Pub/Sub 这个消息队列中，我们可以配置不同的数据分析工具来订阅 Pub/Sub 中的消息。

Cloud Functions 是一个事件驱动的无服务器计算平台，利用它可以对数据进行实时处理，并无需配置服务器。Cloud DataFlow 是 Google Cloud 提供的基于 Apache Beam 的批流数据统一处理平台，它可以将数据存入 Big Query，还可以配置 Google Cloud Machine Learning 来对物联网数据进行训练，得到相应的数据模型。数据分析的结果可以传回 Cloud IoT Core，通过它来对终端设备发送指令。

在实际应用中，物联网的数据处理场景分不同的类型。

有的场景数据量小、处理简单，但是对实时性要求高；有的场景数据量大，处理比较复杂，而且需要综合历史数据。

基于这两种分类，有人提出了“Device-Edge-Cloud”（设备 - 边缘 - 云）的架构，即把简单的、需要实时计算和分析的过程放到离终端设备更近的地方，如设备本身、网关或者服务器，以保证数据数据处理的实时性，同时也减少数据传输的风险，即我们常听说的边缘计算；把复杂的、需要存储的数据处理放在 Cloud 上。这样可以大大加快简单操作的分析和响应速度。

在上面的架构中，除了物联网设备以外的部分，都部署在 Google Cloud 上。结合边缘设备处理的特性之后，Google Cloud 的物联网数据处理架构就如下图所示：

小结

物联网是当今大规模数据处理的一大热点。今天我们初步了解了物联网的应用场景，产生数据的特性，以及基本的物联网数据处理架构，并以 Google Cloud Platform 为例，带你一起了解了一个成熟的物联网云服务平台都有怎样的特性。你可以去看看其他的云服务厂商所提供的物联网数据处理平台，比如微软的 Azure IoT Hub，比较一下它们的异同。

思考题

都说在 5G 时代，边缘计算是一个非常重要的技术。你能去了解一下边缘计算，然后告诉我为什么可以这么说吗？

欢迎你把自己的学习体会写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

38 | 大规模数据处理在深度学习中如何应用？

你好，我是蔡元楠。

今天我要与你分享的主题是“大规模数据处理在深度学习中如何应用？”。

“深度学习”这个词，既是一个人工智能的研究领域，也概括了构建人工神经网络的技术方法。2012 年的 AlexNet，2015 年的 Google Inception V3 级数式地打破 ImageNet 计算机视觉比赛的最高纪录，2017 年亮相的 AlphaGo 更是掀起了全球的深度学习风暴。

在 Google，深度学习系统被应用在预测广告的点击率、推荐用户可能喜爱的视频、生成更接近人类的机器发声、自动生成邮件回复等几乎所有产品线。

很多人并不理解深度学习技术，我经常见到这样几种误区：

觉得深度学习是最近几年才兴起的技术；

觉得深度学习只是一个技术时髦（就像今年流行 Python，明年流行 go 语言一样）；

觉得深度学习只是算法模型。

要打破这些误区，我们必须深刻地理解超大规模数据在深度学习的发展中到底扮演了一个怎样的角色。

大规模数据在深度学习发展中扮演的角色

事实上，类似于模拟神经网络的计算机方法早在 20 世纪 60 年代就被提出来了。

当时通信领域大神香农也在神经网络领域有所涉猎。但是在 60 年代到 90 年代的几十年前，深度学习虽然想法新颖、听起来很好，但是在实际上，人们发现以当时的计算能力根本没法训练神经网络。反而是像决策树，SVM 等非神经网络的方法大放异彩。

所以，从 20 世纪下半叶到 2010 年代究竟是什么让深度学习成了世界的焦点呢？一根火柴是点不着的，只有把一根火柴扔进汽油罐里里才会爆炸。想要知道这个答案，我们需要结合技术发展的背景来看最近的十年有哪些改变？

芯片技术处在摩尔定律的末期，几乎每两年翻一番，云计算服务的兴起使得强大的计算能力变得容易获得。互联网的快速发展和以 2007 年发布 iPhone 为标志的移动互联网时代的到来，使得互联网用户的数量和使用时长都翻了好几倍，科技公司因此积累了比以往多得多的数据。

讲到这里，你肯定明白了，正是因为强大的计算能力和大规模数据突然变得可获得，人们一下子发现曾经遥不可及的神经网络方法真的可以被计算了，才引发了深度学习的爆发性发展。

即使是现在也是如此，在数据量并不充足的人工智能任务上，人们会发现还是传统方法表现更好，然而一旦数据量上来了，深度学习就会碾压式地击败所有传统方法。

理解了大规模数据在深度学习发展中扮演的主要角色，我们再来看看为什么说，以大规模数据驱动的深度学习将是一次不可逆的影响深远的技术变革。

这就得从最近十年哪些“没有变”说起。

我们看到，计算机的发展从有一个房子那么大的巨型机，到个人电脑，再到智能手机。表面上看，计算机体积变得更小了，然而实际上是人们想要计算更个性化，更私密的需求没有改变。而只有深度学习才能满足更个性的计算需求，无论是给你推荐你喜欢的音乐，还是分析你的健康记录。

工业生产的发展从人力，到蒸汽机，再到电能和计算机。人们想要解放繁重的重复劳动的需求从来没有改变。而只有深度学习才能满足下一波劳动力的解放，也就是重复的脑力劳动。这些影响力都是别的技术时髦远远不能相比的（比如今年流行这个前端框架，明年流行那个前端框架，今年流行这个语言，明年流行那个语言）。我们看一个技术的影响力，就是看这个技术能够解决哪些曾经不能解决的问题。而深度学习技术所能解决的新问题，几乎涵盖了人类社会发展的各个方面。

在了解了深度学习的巨大影响力和大规模数据在深度学习技术中的重要角色后，我们结合案例来具体看看，在一个由深度学习驱动的技术产品或者技术系统周期中，大规模数据处理技术是怎样被应用的呢？

最后你会发现，大规模数据处理技术几乎无处不在。你甚至可能会感叹，深度学习系统实际上就是一个复杂的大规模数据处理系统。事不宜迟，我们现在就来看看把。

一个深度学习驱动的产品周期一般按时间顺序分为这样几个阶段：

数据搜集整理；

深度学习模型开发；

部署和测试深度学习模型；

形成数据闭环反馈不断优化深度学习模型。

数据搜集整理 (Data Curation)

数据搜集整理就是针对你需要训练的深度学习问题收集所需要的数据。

比如，你要研发在线广告点击率的预测模型，你可能需要搜集用户的网页点击行为历史，网页链接的属性等数据。

如果你要研发之前提到的美团股价预测模型，你可能需要去搜集街上的美团外卖电动车图片。这种数据搜集整理的任务时间跨度可能很大，也可能涉及很多非技术的因素，比如需要去和合作公司谈判数据授权等等。

数据的搜集整理是任何 AI 系统开发的第一步，可以说没有数据就没有 AI。

要注意，并不是只有监督学习需要高质量的数据，实际上无监督学习也需要高质量的数据。比如，在自然语言理解的无监督预训练步骤，你也需要根据训练任务选择高质量的文本库，比如中文文本库，或者医学文本库（如果你要针对医学病例训练模型）。

抛开这些非技术因素不谈，数据搜集整理的技术复杂度也是非常高。我们往往用 data massage——给数据按摩，来形容数据搜集整理技术工作是一份并不容易的，十分需要技巧和力量的工作。

因为你的数据来源会非常多，每个数据源的格式可能都不一样，不同数据源提供的数据种类也会有不同，数据源直接甚至可能会相互矛盾。在实际应用中，数据搜集整理的技术部分经常是由很多个大规模数据处理流水线组成。

在我们在第 1 讲中介绍的美团外卖电动车搜集案例中，我们就介绍了数据搜集系统的复杂度。的确，一般来讲，你至少需要多个数据处理流水线去完成以下几项任务：

消化外面的数据；

对数据进行各种转换，变成你想要的结构和格式；

清理数据，比如不准确的数据要找出来，送给人工单独审核和处理；

如果由人工审核，你还需要数据处理流水线能够处理人工审核结果。

这些数据处理的流水线可能用一整个专栏的篇幅都无法罗列完。你看在计算机视觉领域鼎鼎有名的 ImageNet，到现在已经花了 10 多年整理收集，才取得现在这样非常干净、丰富、准确的状态。你就能明白，数据搜集整理本质上就是大规模的训练样本数据处理。

深度学习模型开发 （Modeling）

看到这，你可能会觉得：深度学习的模型开发阶段是不是总算没有数据处理什么事了？看起来都是算法啊，数学啊？完全不是的。

当我们在实验深度学习模型时候，许多时间都花在了数据处理上。经常要做的事情是，先去分析一下拿到手上的样本数据。

比如，在使用皮肤的照片分类良性的痣还是黑色素瘤时，肯定会先去看一下样本中良性和恶性的分布比例。如果分布很不均匀，比如黑色素瘤的样本只有 1% 时，很容易影响我们模型的表现。那就需要考虑别的方法，比如使用不同的损耗函数，过采样较少的分类样本等等。

除了数据分析以外，许多深度学习模型的架构设计都需要大规模的数据处理能力。比如，在 hard negative mining 技术中，我们需要使用数据处理技术把检查点的深度模型在数据集上都跑一遍，来找出值得加入训练集的样本。

部署和测试深度学习模型 （Deployment）

部署和测试深度学习模型，其实就是把模型工程化为一个提供结果预测的服务。这样的服务，本质上就是一个数据处理的流水线，它可以是批处理流水线，也可以是流处理流水线。

比如，我们部署广告点击率预测模型时，最终我们肯定是根据用户的浏览场景选择点击率高的广告，这样才能去展示点击率高和用户的网页浏览最相关的广告。然而可供选择的候选广告可能有几千万个。如何快速地把所有这样几千万个候选广告的点击率全部预测一遍，这就是一个大规模数据的批处理问题。

具体提供深度学习模型推理服务的可能是 TensorFlow Servo。但是真正用在生产环境时，我们都需要把 Servo 封装成一个大规模数据处理系统。

形成数据闭环反馈不断优化深度学习模型（Feedback and improvement)

你的深度学习产品上线后，依然需要大规模数据处理技术。比如你提供自动聊天回复功能，能够根据一个微信对话的上下文自动推荐一些合适的回复。如果别人说“明天一起吃饭好不好？”，你可以推荐回复“好”。

那么，像这样的功能上线后，你怎样评估这个深度学习模型的效果呢？你需要去跟踪用户与这个功能的交互。

比如，有多少用户会去选择你推荐的回复，又有多少用户会不选择你推荐的回复而自己打字呢？通过这些追踪的用户行为，你就能利用大规模的数据处理技术，不断地为你深度学习模型提供更多现实的数据，去进一步训练改进模型。也能利用用户行为去评估当前模型的表现。

小结

这一讲，我们首先学习了大规模数据在深度学习几十年的发展中所扮演的至关重要的角色。然后我们一起看了在一个深度学习驱动的产品周期里的各个阶段，有哪些大规模数据处理的任务。相信通过这一讲，你一定能感受到大规模的数据处理技术就是深度学习系统的奠基石。

思考题

除了这一讲中提到的这些应用，你还能想到哪些大规模数据处理技术在深度学习系统中的应用呢？

欢迎你把自己的学习体会写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

39 | 从SQL到Streaming SQL：突破静态数据查询的次元

你好，我是蔡元楠。

今天我要与你分享的主题是“从 SQL 到 Streaming SQL：突破静态数据查询的次元”。

在前面的章节中，我们介绍了一些流数据处理相关的知识和技术，比如 Apache Spark 的流处理模块——Spark Streaming 和 Structured Streaming，以及 Apache Beam 中的窗口处理。相信你对流处理的重要性和一些基本手段都有所了解了。

流处理之所以重要，是因为现在是个数据爆炸的时代，大部分数据源是每时每刻都在更新的，数据处理系统对时效性的要求都很高。作为当代和未来的数据处理架构师，我们势必要深刻掌握流数据处理的技能。

“批”“流”两手抓，两手都要硬。

你还记得，我在第 15 讲中介绍过的 Spark SQL 吗？它最大的优点就是 DataFrame/DataSet 是高级 API，提供类似于 SQL 的 query 接口，方便熟悉关系型数据库的开发人员使用。

当说到批处理的时候，我们第一个想到的工具就是 SQL，因为基本上每个数据从业者都懂，而且它的语法简单易懂，方便使用。那么，你也能很自然地联想到，如果在流处理的世界中也可以用 SQL，或者相似的语言，那真是太棒了。

这样的思想在第 17 讲中我们曾经提到过。

Spark 的 Structured Streaming 就是用支持类 SQL 的 DataFrame API 去做流处理的。支持用类似于 SQL 处理流数据的平台还有很多，比如 Flink、Storm 等，但它们是把 SQL 做成 API 和其他的处理逻辑结合在一起，并没有把它单独分离成一种语言，为它定义语法。

那么，到底有没有类似 SQL 语法来对流数据进行查询的语言呢？答案是肯定的。我们把这种语言统称为 Streaming SQL。Siddhi Streaming SQL 和 Kafka KSQL 就是两个典型的 Streaming SQL 语言，下文的例子我们主要用这两种语言来描述。

不同于 SQL，Streaming SQL 并没有统一的语法规范，不同平台提供的 Streaming SQL 语法都有所不同。而且 Streaming SQL 作用的数据对象也不是有界的数据表，而是无边界的数据流，你可以把它设想为一个底部一直在增加新数据的表。

SQL 是一个强大的、对有结构数据进行查询的语言，它提供几个独立的操作，如数据映射（SELECT）、数据过滤（WHERE）、数据聚合（GROUP BY）和数据联结（JOIN）。将这些基本操作组合起来，可以实现很多复杂的查询。

在 Streaming SQL 中，数据映射和数据过滤显然都是必备而且很容易理解的。数据映射就是从流中取出数据的一部分属性，并作为一个新的流输出，它定义了输出流的格式。数据过滤就是根据数据的某些属性，挑选出符合条件的。

让我们来看一个简单的例子吧。假设，有一个锅炉温度的数据流 BoilerStream，它包含的每个数据都有一个 ID 和一个摄氏温度（t），我们要拿出所有高于 350 摄氏度的数据，并且把温度转换为华氏度。

Select id, t*7/5 + 32 as tF from BoilerStream[t > 350];  //Siddhi Streaming SQL

Select id, t*7/5 + 32 as tF from BoilerStream Where t > 350; //Kafka KSQL

你可以看出，这两种语言与 SQL 都极为类似，相信你都可以直接看懂它的意思。

Streaming SQL 允许我们用类似于 SQL 的命令形式去处理无边界的流数据，它有如下几个优点：

简单易学，使用方便：SQL 可以说是流传最广泛的数据处理语言，对大部分人来说，Streaming SQL 的学习成本很低。

效率高，速度快：SQL 问世这么久，它的执行引擎已经被优化过很多次，很多 SQL 的优化准则被直接借鉴到 Streaming SQL 的执行引擎上。

代码简洁，而且涵盖了大部分常用的数据操作。

除了上面提到过的数据映射和数据过滤，Streaming SQL 的 GROUP BY 也和 SQL 中的用法类似。接下来，让我们一起了解 Streaming SQL 的其他重要操作：窗口（Window）、联结（Join）和模式（Pattern）。

窗口

在之前 Spark 和 Beam 的流处理章节中，我们都学习过窗口的概念。所谓窗口，就是把流中的数据按照时间戳划分成一个个有限的集合。在此之上，我们可以统计各种聚合属性如平均值等。

在现实世界中，大多数场景下我们只需要关心特定窗口，而不需要研究全局窗口内的所有数据，这是由数据的时效性决定的。

应用最广的窗口类型是以当前时间为结束的滑动窗口，比如“最近 5 小时内的车流量“，或“最近 50 个成交的商品”。

所有的 Streaming SQL 语法都支持声明窗口，让我们看下面的例子：

Select bid, avg(t) as T From BoilerStream#window.length(10) insert into BoilerStreamMovingAveage; // Siddhi Streaming SQL

Select bid, avg(t) as T From BoilerStream WINDOW HOPPING (SIZE 10, ADVANCE BY 1); // Kafka KSQL

这个例子中，我们每接收到一个数据，就生成最近 10 个温度的平均值，插入到一个新的流中。

在 Beam Window 中，我们介绍过固定窗口和滑动窗口的区别，而每种窗口都可以是基于时间或数量的，所以就有 4 种组合：

滑动时间窗口：统计最近时间段 T 内的所有数据，每当经过某个时间段都会被触发一次。

固定时间窗口：统计最近时间段 T 内的所有数据，每当经过 T 都会被触发一次

滑动长度窗口：统计最近 N 个数据，每当接收到一个（或多个）数据都会被触发一次。

固定长度窗口：统计最近 N 个数据，每当接收到 N 个数据都会被触发一次。

再度细化，基于时间的窗口都可以选择不同的时间特性，例如处理时间和事件时间等。此外，还有会话（Session）窗口等针对其他场景的窗口。

联结

当我们要把两个不同流中的数据通过某个属性连接起来时，就要用到 Join。

由于在任一个时刻，流数据都不是完整的，第一个流中后面还没到的数据有可能要和第二个流中已经有的数据 Join 起来再输出。所以，对流的 Join 一般要对至少一个流附加窗口，这也和第 20 讲中提到的数据水印类似。

让我们来看一个例子，流 TempStream 里的数据代表传感器测量的每个房间的温度，每分钟更新一次；流 RegulatorStream 里的数据代表每个房间空调的开关状态。我们想要得到所有温度高于 30 度但是空调没打开的的房间，从而把它们的空调打开降温：

from TempStream[temp > 30.0]#window.time(1 min) as T

  join RegulatorStream[isOn == false]#window.length(1) as R

  on T.roomNo == R.roomNo

select T.roomNo, R.deviceID, 'start' as action

insert into RegulatorActionStream; // Siddhi Streaming SQL

在上面的代码中，我们首先对 TempStream 流施加了一个长度为 1 分钟的时间窗口，这是因为温度每分钟会更新一次，上一分钟的数据已然失效；然后把它和流 RegulatorStream 中的数据根据房间 ID 连接起来，并且只选出了大于 30 度的房间和关闭的空调，插入新的流 RegulatorActionStream 中，告诉你这些空调需要被打开。

模式

通过上面的介绍我们可以看出，Streaming SQL 的数据模型继承自 SQL 的关系数据模型，唯一的不同就是每个数据都有一个时间戳，并且每个数据都是假设在这个特定的时间戳才被接收。

那么我们很自然地就会想研究这些数据的顺序，比如，事件 A 是不是发生在事件 B 之后？

这类先后顺序的问题在日常生活中很普遍。比如，炒股时，我们会计算某只股票有没有在过去 20 分钟内涨 / 跌超过 20%；规划路线时，我们会看过去 1 小时内某段路的车流量有没有在下降。

这里你不难看出，我们其实是在检测某个模式有没有在特定的时间段内发生。

股票价格涨 20% 是一个模式，车流量下降也是一个模式。在流数据处理中，检测模式是一类重要的问题，我们会经常需要通过对最近数据的研究去总结发展的趋势，从而“预测”未来。

在 Siddhi Streaming SQL 中，“->”这个操作符用于声明发生的先后关系。一起来看下面这个简单例子：

from every( e1=TempStream ) -> e2=TempStream[ e1.roomNo == roomNo and (e1.temp + 5) <= temp ]

    within 10 min

select e1.roomNo, e1.temp as initialTemp, e2.temp as finalTemp

insert into AlertStream;

这个 query 检测的模式是 10 分钟内房间温度上升超过 5 度。对于每一个接收到的温度信号，把它和之前 10 分钟内收到的温度信号进行匹配。如果房间号码相同，并且温度上升超过 5 度，就插入到输出流。

很多流处理平台同样支持模式匹配，比如 Apache Flink 就有专门的 Pattern API，我建议你去了解一下。

小结

今天我们初步了解了 Streaming SQL 语言的基本功能。

虽然没有统一的语法规范，但是各个 Streaming SQL 语言都支持相似的操作符，如数据映射、数据过滤、联结、窗口和模式等，大部分操作符都是继承自 SQL，只有模式是独有的，这是由于流数据天生具有的时间性所导致。

Streaming SQL 大大降低了开发人员实现流处理的难度，让流处理变得就像写 SQL 查询语句一样简单。它现在还在高速发展，相信未来会变得越来越普遍。

思考题

你觉得 Streaming SQL 的发展前景如何？欢迎留言与我一起讨论。

欢迎你把自己的学习体会写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

40 | 大规模数据处理未来之路

你好，我是蔡元楠。

今天我要分享的内容是“大规模数据处理实战”专栏的最后一讲。

我相信通过整个专栏的系统学习，你已经掌握了大规模数据处理的基础概念与设计模式。同时，我也相信，专栏中对现实世界中常见的大规模数据处理架构的深入探讨，可以在解决现实难题时为你提供一些思路。

但我更希望的是，通过模块六中对大规模数据处理在未来的应用与展望讲解，让你吃下一颗定心丸，那就是，大规模数据处理技术是在放眼未来的几十年中都依然会是炙手可热的一项技术，不会被淘汰。

你不难发现，我在专栏的后半部分，花了不少的篇幅来专门介绍 Apache Beam 的各种概念、底层思想以及实际应用的。我个人是十分认同 Google 所推崇的 Dataflow Model 的计算模型，也相信未来 Apache Beam 的发展前景是很好的。

所以在专栏的最后一讲，我想和你讲讲我对数据处理框架和对 Beam 的一些看法和展望。

技术迭代带来的烦恼

在专栏的后半部分，我们不断深入探讨了 Apache Beam。有同学曾经在留言中提过一个问题：“我已经掌握好 Spark 了，也觉得 Spark 的语法更简练，为什么还需要学习 Beam 呢？”

对于这个问题，我相信在你刚刚接触 Beam 的时候，多多少少都会有相同的疑问。

我来给你举个例子，带你穿越时间，去看看一个常见的大规模数据处理框架在面临迁移时会遇到的烦恼。

在 2006 年，Hadoop 刚刚发布，在选择数据处理框架的时候，你的首选肯定是 Apache Hadoop。当时 Hadoop 在开源社区横空出世，让所有工程师们都可以利用这个框架来处理自己的数据，尤其是利用 Hadoop 自带的 MapReduce 计算模型，可以让整个数据处理的效率有质的飞跃。

而在 2009 年，Spark 这个计算框架在加州伯克利大学的 AMPLab 实验室中诞生。2010 年，Spark 正式开源了。“Spark 的数据处理效率远在 Hadoop 之上”的结论经过了业界的验证。2014 年，Spark 更是成为了 Apache 的顶级项目。

如果你是在这个时候进入 IT 行业的工程师，更加可能选择直接学习 Spark，而只会把少量时间放在 Hadoop 的学习上。在 2014 年进行创业的小公司同样可能也会直接使用 Spark 来进行底层数据处理基础设施的搭建。

那之前已经花了很多时间在搭建 Hadoop MapReduce 作为公司基础设施的公司，或者是已经很深入学习了 Hadoop 的工程师这个时候该怎么办呢？

一种做法是放弃现有的技术框架，重新花费大量时间去学习新的数据处理框架。这太累了。对于工程师来说，平时本来就有着做不完的任务和业绩压力，还需要抽空学习新的技术和进行代码的迁移，这无疑让工程师们有着非常大的压力和负担。

当然，还有一种做法是保持现有的技术框架，不断优化现有基础设施，并且寄希望于老框架可以有新的功能发布让其性能得以提高。

那我们再把时间往后推一点，到了 2014 年，也就是 Flink 项目刚刚面世的时候。这时候的互联网应用场景已经有了极大的变化，批处理加上流处理同时存在的状况常常遇见。而 Flink 除了在处理大规模数据有着极高效率之外，它的批流统一功能也恰恰是满足了这种批流处理同时存在的场景。

2018 年初，阿里巴巴更是看好 Flink 的前景，收购了 Apache Flink 背后的商业母公司——德国的 Data Artisans。

讲到这里，你不难发现，当有新的技术框架出现的时候，工程师就会陷入一个选择的困难，纠结到底是抛弃原有的技术架构，还是花大量时间去做技术迁移。

其实，如果一开始就有 Beam 模型存在的话，你可能就不必有这个烦恼了。

因为我们完全不需要担心某一个 Runner，也就是具体的数据处理技术框架过时之后所带来的技术迁移成本。如果你想要完成底层处理框架的迁移，只需要更改一些 Runner 的接口就可以了。

Apache Beam 能带来什么？

那么，我们来看看对应用它的工程师来说，Apache Beam 能带来什么？

因为 Apache Beam 是根据 Dataflow Model 倡导 API 的实现的，所以它完全能够胜任批流统一的任务。同时，因为 Apache Beam 有着中间的抽象转换层，工程师可以从 API 中解放出来，不需要学习新 Runner 的 API 语法。这也就是我说过多次的“编写一套程序就可以放在不同的 Runner 上面跑”。

除了对工程师来说能够极大地减少新技术学习的时间成本，Apache Beam 还能够推动大规模数据处理领域的最新技术发展。

从上面我所举的例子中你可以看到，在一项新技术从诞生到流行的过程中，可能会有很多公司因为技术迁移成本太大而选择放弃采用最新的技术，这其实还影响了新技术的传播使用。因为当一部分工程师选择留守原本技术框架的时候，新技术框架也就相应地缺少了这部分的用户群体。

那我们来想象一下，如果所有的工程师都在使用 Beam 来进行项目编写的话会有什么样的效果。

因为有了 Beam 的抽象层，你可以非常轻松地更换不同的底层 Runner。这就意味着我们可以先选择一个处理数据效率最高的一个 RunnerA。如果有其他的 Runner 优化了自身，比如 RunnerB，从而拥有更高效率的时候，工程师们又可以将底层 Runner 换成 RunnerB。

这些 Runner 其实就是大数据处理框架的本身，像 Spark、Apex、Storm、Flink 这些数据处理架构。这对整个技术行业都可以起到一种良性的竞争。如果 Runner 要想争取更多用户的话，就必须努力提升自身数据处理的效率来让用户选择自己。

做底层数据处理框架的工程师则可以专心优化自身的效率和增加自身的功能，而不必担心迁移。

且 Apache Beam 还有着自己的社区。所以，在实际工程中如果遇到一些特别的、没有在官方文档中解释的问题，你就可以到社区去求助了。有了社区交流之后，全世界的工程师们都可以对自己的工程提出问题，解决问题，实现解决问题的思路。

Beam Runner 功能的迭代速度

最后你可能会问，Apache Beam 的一些功能现在还没有，那还是先观望观望吧。那我来以 Flink 支持整个 Dataflow 的功能来告诉你 Beam Runner 功能的迭代速度有多快。

Kostas Tzoumas（Data Artisans 的联合创始人以及 Flink 的作者）在 Beam 出现的时候就表达过自己的看法，他坚信 Beam Model 是批流数据处理的正确编程模型。所以，在 Dataflow Model 论文发布之初，他们就根据 Dataflow Model 的思想重写了 Flink 的 0.10 版本的 DataStream API。

这整个过程花费了多少时间呢？

在 2014 年的 12 月，Google 正式发布 Google Cloud Dataflow SDK。在 2015 年的 1 月，Data Artisans 紧随 Google 的脚步，根据 Dataflow Model 的概念，开始了 DataStream API 的重写工作。

在 2015 年 3 月，Flink 已经可以对批处理进行支持了。到了 2015 年的 12 月，Flink 可以支持设置窗口和水印的流处理。其实这个时候 DataStream API 已经很接近 Dataflow Model 的概念了。所以在 2016 年 3 月，Data Artisans 正式开始在 Apache Beam 的 Runner 代码库中贡献 Flink 的 Runner 代码。

在 2016 年 5 月的时候，Flink 对批处理也支持了窗口的概念，同时也对整个批处理完成了集成测试（Integration Test）。在 2016 年 8 月，Flink 完成了对流处理的集成测试。自此，Flink DataStream API 宣告完成了对 Dataflow Model 的支持。

整个过程从无到有，一共只花费了近一年半的时间。所以你完全可以不必对功能不完整抱有太多的担心。

小结

今天，我给你阐述了我自己对于 Beam 的一些看法，同时也希望借助我所举出的一些例子，能够让你明白 Beam 对于未来数据处理发展的重要性。

思考题

你对 Apache Beam 还有什么疑问吗？欢迎提问与我探讨。

欢迎你把自己的学习体会写在留言区，与我和其他同学一起讨论。如果你觉得有所收获，也欢迎把文章分享给你的朋友。

结束语 | 世间所有的相遇，都是久别重逢

今天和往常一样。我开车沿着硅谷的 101 公路下班，101 还是一样的堵。

今天和往常又不一样。比如，今天午饭的话题不一样。往日我会利用午饭或者喝咖啡的时间和同事一起讨论一些极客时间专栏读者提出的问题。而且，今天回到家里也不需要赶着写稿。如果是往日，编辑催稿的微信早就已经堆积成了 99+ 未读消息。

专栏成功的指标 = 所有读者收获之和

我还清楚地记得，4 月 15 日（北京时间），因为时差原因，我在凌晨 2、3 点等待着专栏上线，守着手机屏幕，看新的读者加入。我在极客时间的留言管理页面一遍一遍下拉刷新，期待着去回复包括你在内的读者留言。说不定我一个人就给极客时间的服务器 QPS 增加了 1 或者 2（笑）。

写作的过程中，我也碰到过很多困难。

专栏占用了很多时间，有时候为了查证一个技术点我会研读 Apache Beam 代码到深夜，女朋友甚至因此和我吵过架。不仅是写作的时间需要去平衡，我的全职工作和生活也需要。在专栏更新过半的时候，因为家人身体原因，我必须一直往返于湾区和德州，没有完整的时间录音，不得不更换了主播来录音。

为此，我也在这里，再次和你道歉。

当然，也有写作时间之外的阻力。我在开篇词中对专栏的内容要求是，每一篇专栏都是原汁原味的硅谷技术分享。这也带来了一个意外的问题。

第 1 讲“为什么 MapReduce 会被硅谷一线大厂淘汰”一经在知乎分享，就一跃成为 MapReduce 话题点赞排名第一的文章。它的影响力超过了 Google 内容发行部门的预计，以至于负责 Google 内容审查的部门找到我，要求一起审查专栏中还未发布的内容。

这样的小插曲其实我也没有预料到。在经历了 14 天详细的审查之后，我的专栏终于通过审核，被认定为是开源技术分享，而并不涉及商业机密。

专栏写作期间，有时候和朋友吃个饭我都不得不提前告辞：“今晚需要回家写稿”。朋友都问我：“为什么你要花这么多时间去写专栏？”

是啊，为什么？

因为你。专栏上线的十几天的时候，在 LinkedIn、微信等各种社交渠道里，有来自 Apple、Uber、思科等各种公司的读者联系到我，和我说： “蔡老师，谢谢你，我看了你的专栏，真的收获很大。”

你的收获，你的成长，才是我如此认真写作的动力。即使订阅量只有 1，我也会为了这 1 份订阅背后的信任，100% 交付每一篇内容。

万物皆数据

记得在刚入职 Google 的时候，组里希望我能在了解大组业务的同时，熟悉 Google 内部对于大规模数据处理的整个流程和做法。而组里布置给我的第一个启动项目就是修改一个大型 MapReduce 任务中的一小部分逻辑。

当时我着了迷似地研究组内数据处理的整套流程。

我逐渐发现，Google 内部所使用的 API 和我在上学时自学的 Apache Spark 非常不同。即便需要新加或者修改的逻辑非常多，再加上数据异常的检测（Monitoring）和执行流程日志（Logging）的输出，整个代码加起来也很难超过 100 行。

而我组里的技术领导（Tech Lead）更是告诉我，在查看自己所修改的 Pipeline 的时候，可以利用 DAG 来查看一整套执行流程图，我更是觉得十分神奇。

后来我才慢慢知道，其实在我加入 Google 的时候，Google 已经完全淘汰了以前的 MapReduce Framework，而将 Google Dataflow 的整套思想都运用到了这个内部称为 Flume 的大规模数据处理 Framework 中去。在新入职员工的内部培训中，讲师就说过——Flume 是每个 Noogler 的必修课。

在经历了几年的技术历练之后，我开始成为 Google 的面试官之一。

一般来说，面试题目除了基本的数据结构和算法外，还会涉及到系统设计。其实这些题目或多或少都在考察着我们如何处理转换数据，如何保存数据。在和 Google 不同的组之间进行交流后，我也更加清楚地了解到，其实每个组都在做着数据处理的任务。

我们自己的生活难道不也是完完全全被大规模数据处理充斥着的吗？

例子太多了，日常生活购物网站（淘宝、Amazon）在处理着所有客户的交易商品数据，平时使用的支付软件（支付宝、Venmo）在处理着我们的支付数据，社交软件（微博、Facebook 和 Twitter）在处理着大规模社交信息流数据。

其实我们一直都生活在一个被大规模数据处理所包围着环境中，我相信等到 5G 全面到来，这种现象会更加明显。而我也越来越觉得，学习好大规模数据处理真的是一门必修课。

我的下一步

虽然我们对于大规模数据处理的学习已经告一段落，但是我坚信“万物皆数据”，坚信数据会改变人类生活。

接下来，我会回收更多的时间精力，继续从事人工智能医疗健康的研发工作。除此之外，我还会参与录制 Google 官方的 TensorFlow 视频课程。

感谢你在这三个月里的努力学习，希望你能学有所得，也希望有机会能与你再次重逢！