这一章在做什么
数据大到一台机器装不下、一次请求里算不完时,就交给批处理:读一批只读的输入,从头算出一份新的输出。输入不改,算错了删掉输出重跑就行。这一章先在一台机器上看排序和计数,再把同样的事搬到集群:数据放在分布式文件系统或对象存储里,编排器决定任务在哪跑、失败了怎么补,MapReduce 和数据流引擎靠「洗牌」把同一个键的数据送到一起。最后看批处理拿来做什么,结果怎么交给线上系统。
读之前:先预测
前面十章大多在讲请求和响应:在线系统收到请求,尽快给出结果。这一章换一种做法,也是全书「派生数据」这一块的开头。第 1 章分过「记录系统」和「派生数据」,批处理的输出就是派生数据;第 4 章的 LSM 树、有序段的合并、云数仓,在这里会以集群的规模再出现一次;第 7 章的按键哈希分片,就是洗牌把数据分给各个任务的办法。下一章把输入从有限的一批换成永不结束的流,第 13 章再把批处理和流处理放进同一套数据集成的思路里。
数据大到一台机器放不下时,怎么把「读进来、算一遍、写出去」这件事拆给成百上千台机器,让每台只管自己那一份,最后又能把同一个键的数据凑到一起?出了错,怎么只补出错的那一小块?
- 内存哈希表和排序都能做计数。各在什么条件下更合适?§1.3
- 对象存储和分布式文件系统,哪些操作看起来一样、其实不一样?§2.2
- 两个 mapper 吐出同一个键,框架怎么保证它们落到同一个 reducer?排序在这里起什么作用?§3.3§3.4
- 批处理的结果为什么不该在任务里一条条写进线上数据库?§4.4
这一章的骨架
这一章先在一台机器上把问题讲清(§1),再借「分布式操作系统」的类比,依次讲集群上的存储、编排、计算三层(§2–§3),最后是用途(§4)。容易乱的是 §2.3 和 §3:前者很长,调度、工作流、容错三件事混在一节里;后者从 MapReduce 讲到 SQL,中间的 §3.3 洗牌才是机制的核心,读到那里要回头和 §1.3 的排序对上。
边读边填:每行一个问题,读到答案就写一两句。只存在你这台设备的浏览器里,别人看不到。
| 要找的 | 带着这个问题读,找到了就写下来 |
|---|---|
| 排序的用处 | 工作集比内存大时,为什么排序还能做下去?它和第 4 章的哪个结构是同一个思路?§1.3 |
| 对象存储少了什么 | 和分布式文件系统比,对象存储少了哪几种操作?少了它们会怎样?§2.2 |
| 调度的两难 | 成组调度、干等、抢占,各自的毛病是什么?实际的调度器怎么办?§2.3 |
| 中间结果放哪 | 一个任务的输出是另一个任务的输入时,MapReduce、Spark、Flink 各怎么容错?§2.3 |
| 数据流引擎省了什么 | 同样的计算,数据流引擎比一串 MapReduce 作业快在哪几处?§3.2 |
| 两张大表怎么连 | 两边都大到要分片时,reducer 怎么把它们连起来?它的内存里要放多少东西?§3.4 |
| 结果交给线上 | 不在任务里逐条写线上库,书里给了哪两种做法?各自缺什么?§4.4 |
读完了?对照我找到的
- 排序的用处:在内存里排好一块就写成有序段,再把段归并,读写盘都是顺序的;就是 LSM 树的做法。
- 对象存储少了什么:没有追加(多数)、硬链接、文件锁和原子改名;改名是复制再删除,改一个「目录」要逐个对象改。
- 调度的两难:成组调度让节点空等甚至死锁;干等会饿死;抢占浪费已做的工作。实际用 FIFO、DRF、配额等启发式。
- 中间结果放哪:MapReduce 写回分布式文件系统;Spark 放内存、记住怎么算出来的,丢了重算;Flink 定期做检查点。
- 数据流引擎省了什么:只在需要时排序,相邻算子合并成一个任务,中间状态不写 DFS,不用等上一阶段全部结束,进程复用。
- 两张大表怎么连:两边都按连接键洗牌,二次排序让维度那条先到;reducer 一次只记住一条维度记录。
- 结果交给线上:写进 Kafka 一类的流,或在作业里建好整份数据库文件再批量导入;前者要另外补「全有或全无」,后者难做增量。
分节地图
全章约 1.2 万词,建议分四次读。第一次是热身,命令行和存储都不难;第二次的 §2.3 最长,可以在小标题处喘口气;第三次是机制的核心,慢一点读;第四次几个用途彼此独立,§4.4 最值得细读。
- 第一次坐下§0–§2.2 · 约 3900 词 · 从一台机器的命令行到集群上的存储
- Much of this book so far…约 1100 词开篇在线系统和批处理的分别,输入只读带来的好处,MapReduce 之后的变化和全章路线。
- Batch Processing with Unix Tools约 200 词用 Unix 工具做批处理一行网页服务器日志里有什么,为什么日志分析是批处理的老本行。
- Simple Log Analysis约 400 词简单的日志分析一串命令找出最热门的页面。只看每一步做了什么,不用记命令。
- Chain of Commands Versus Custom Program约 200 词命令链和自己写程序同一件事写成一段脚本。语法的差别是表面的,执行方式的差别在下一节。
- Sorting Versus In-Memory Aggregation约 400 词排序和内存里聚合哈希表和排序各在什么时候合适,工作集是什么。全章排序这条线的起点。
- Batch Processing in Distributed Systems约 100 词分布式系统里的批处理一台机器上的文件系统、调度器、管道,到集群上各对应什么。
- Distributed Filesystems约 900 词分布式文件系统和本地文件系统一层层对照:块、缓存、元数据、协议;方框讲共享磁盘和无共享。
- Object Stores约 700 词对象存储对象存储和文件系统哪里不同:对象不可变、没有真目录、改名、计算和存储分开。
- 第二次坐下§2.3–§3.2 · 约 3400 词 · 调度和容错,MapReduce 和数据流引擎
- Distributed Job Orchestration约 2000 词分布式作业编排编排器的三个部件,资源怎么分配,工作流的调度,任务失败了怎么办。全章最长的一节。
- Batch Processing Models约 100 词批处理的模型两种最常见的处理模型,以及为什么还要先懂 MapReduce。
- MapReduce约 700 词MapReduce 模型mapper 和 reducer 各做什么,怎样和前面命令行的例子一一对上。
- Dataflow Engines约 500 词数据流引擎Spark、Flink 这类引擎把整个工作流当一个作业,比 MapReduce 省在哪。
- 第三次坐下§3.3–§3.6 · 约 2300 词 · 洗牌和连接,再到 SQL 和 DataFrame
- Shuffling Data约 700 词洗牌分布式排序:map 的输出怎么按键分给各个 reduce。全章机制的核心,配合下面的模拟器读。
- Joins and Grouping约 600 词连接和分组排好序之后,reducer 怎么一次拿到一个键的全部数据,做连接和聚合。
- Query Languages约 800 词查询语言SQL 怎样成了批处理的共同语言;方框讲批处理和云数仓怎么越走越近。
- DataFrames约 300 词DataFrame 数据框DataFrame 从单机搬到分布式之后,哪些地方和原来不一样。
- 第四次坐下§4–§5 · 约 2500 词 · 批处理拿来做什么,结果怎么交出去
- Batch Use Cases约 200 词批处理的用途什么样的活适合批处理,几个行业里的例子。
- Extract–Transform–Load约 400 词抽取、转换、加载批处理做 ETL 的几个长处;数据流水线不再只归一个团队管。
- Analytics约 300 词分析在批处理系统上跑分析查询:预先聚合和临时查询两种用法。
- Machine Learning约 500 词机器学习特征、训练、批量推理、图计算,以及大模型训练前的数据处理。
- Serving Derived Data约 700 词把派生数据交给线上算好的结果怎么送回线上:为什么不逐条写库,流和批量导入各管什么。
- Summary约 400 词小结全章回顾。先自己回忆一遍再读它。
先用中文过一遍
输入只读、输出重算:批处理敢重跑的前提
在线数据库里,一个写错数据的 bug 上线了,把代码回滚也收不回那些错数据。批处理作业把输入当成只读的,每次运行都从头生成输出,也不去改外部系统,于是出了错的补救很简单:修好代码,删掉坏的输出,再跑一遍。旧的输出还留着的话,直接切回去也行。这一条贯穿全章:任务失败时只要把它的半截输出删掉、换台机器重跑;工作流里上下游之间靠文件交接;结果要交给线上系统时,麻烦也恰好出在「往外写」这一步(§4.4)。代价是哪怕输入只改了一个字节,也得把整份输入重新处理一遍。
计数的两种做法:哈希表,还是先排序
统计「每个商品卖出了几单」,最直接的办法是一个内存哈希表,读一行加一次。它要的内存只取决于有多少个不同的商品,和明细有多长无关;书里把一个作业需要随机访问的那部分内存叫工作集(working set)。另一种办法是先按商品排序,相同的商品就挨在一起了,从头扫一遍、数相邻的就行。排序的好处在内存不够时显出来:在内存里排好一块,写成一个有序段,最后把几个段归并,读写盘全是顺序的。这正是第 4 章 LSM 树刷盘、合并的那一套。分布式批处理选了排序这条路,原因也在这里。
map、洗牌、reduce:同一个键怎么凑到一起
把计数搬到集群上,输入已经切成很多分片,每个分片交给一个 map 任务。map 任务对每条记录调用一次你写的 mapper,吐出若干个「键,值」对,记录之间不留任何状态。难点在下一步:两个 map 任务都吐出了「杭州」,怎么让它们最后由同一个 reduce 任务处理?办法是每个 map 任务给每个 reduce 任务在本地盘上单独写一个文件,用键的哈希值决定一条记录进哪个文件,文件里的记录边写边排好序。map 跑完后,每个 reduce 任务去各个 map 那里把属于自己的文件拉过来,归并成一个有序序列,同一个键就挨在一起了,然后对每个键调用一次 reducer。这个「分桶、排序、拉取、归并」的过程就叫洗牌(shuffle),它其实是一个分布式排序,和洗扑克牌的「打乱」正好相反。
MapReduce 把这套 map、洗牌、reduce 做成固定的一步,复杂的计算就串成好几个作业,每个作业的输出写回分布式文件系统,下一个作业再读。数据流引擎(Spark、Flink)把整条工作流当成一个作业:只在真需要的地方排序,中间结果尽量放在内存或本地盘,下游不用等上游整个跑完。读 §3.2 时,留意它为此在容错上换了什么办法(§2.3 的末尾已经提了一句)。
用排序做连接:reducer 一次只看一个键
两张大表连接,比如几十亿条订单要按商家 ID 连上商家资料,拿到商家所在的城市。逐条订单去查商家表,就是几十亿次网络请求。洗牌给了另一条路:两张表各用一个 mapper,都把商家 ID 当键吐出来,订单那边的值是金额,商家那边的值是城市。洗牌之后,同一个商家的资料和它的全部订单到了同一个 reducer,还可以让框架把商家那条排在最前面(二次排序)。reducer 先记下城市,再把后面每条订单配上城市写出去,内存里任何时候只有一个商家的资料。下一个作业再按城市洗牌,就能算每个城市的订单额。
这一章点名的系统
| 英文 | 中文 | 一句话 |
|---|---|---|
| batch processing | 批处理 | 读一批只读的输入,从头生成输出,按吞吐量衡量(§0)。 |
| online system | 在线系统 | 收到请求尽快给出响应的系统,按响应时间衡量(§0)。 |
| human fault tolerance | 对人为错误的容错 | 代码写错了也能恢复:修好代码重跑,或切回旧输出(§0)。 |
| working set | 工作集 | 一个作业需要随机访问的那部分内存(§1.3)。 |
| distributed filesystem (DFS) | 分布式文件系统 | 文件切成大块,分散复制到多台机器上(§2.1)。 |
| object store | 对象存储 | 按键整个读写不可变的对象,没有真正的目录(§2.2)。 |
| job orchestrator | 作业编排器 | 集群上的「内核」:决定任务在哪台机器上跑、分多少资源(§2.3)。 |
| gang scheduling | 成组调度 | 一个作业要的资源凑齐了,才让它的任务一起开跑(§2.3)。 |
| preemption | 抢占 | 调度器杀掉正在跑的任务,把资源让给更要紧的(§2.3)。 |
| workflow | 工作流 | 批处理里指作业组成的有向无环图(DAG),上游的输出是下游的输入(§2.3)。 |
| MapReduce | MapReduce | 只写 mapper 和 reducer 两个函数,中间的排序由框架做(§3.1)。 |
| dataflow engine | 数据流引擎 | 把整条工作流当一个作业来执行的引擎,如 Spark、Flink(§3.2)。 |
| shuffle | 洗牌 | 分布式排序:按键把数据分给下游任务,并排好序(§3.3)。 |
| sort-merge join | 排序归并连接 | 两边按连接键排序后归并,在 reducer 里完成连接(§3.4)。 |
| secondary sort | 二次排序 | 同一个键之下,再规定值的先后,比如资料在前、事件按时间(§3.4)。 |
动手:模拟器
先猜:48 条订单里将近一半属于同一个商家(分布选「一个热键」)。reduce 任务从 2 个加到 6 个,reduce 阶段的用时能缩短到原来的几分之一?换成「均匀」分布再猜一次。猜好了,点「跑完」,再拖滑杆验证。书里这一章没有专门讲倾斜,它的道理在第 7 章 §3.3;这里看的是它在洗牌里的样子。
洗牌:按键分组与数据倾斜
三个 map 任务各读 16 条订单,键是商家 ID(s01–s12)。单步看订单怎么按键的哈希分桶、排序、被各个 reduce 拉走归并;再看哪个 reduce 拖慢了整个作业。
- 选「一个热键」,点「跑完」,把 reduce 任务数从 2 拖到 6,记下「最忙的 reduce」每次是多少。它最低能降到多少?为什么降不到平均值?
- 换成「均匀」,把 reduce 任务数拖到 5,再点「换一批数据」几次。没有热键时,各个 reduce 的条数也不一样多,甚至有一个一条都没分到,原因在哪?
- 回到「一个热键」,勾上「拆开最热的键」(第 7 章 §3.3 讲的加随机后缀)。最忙的 reduce 降了多少?多出来的第二轮在做什么?如果两个子键碰巧落到同一个 reduce 上,又会怎样?
词与短语
词条按在书里出现的顺序排,按节分组;读一节之前先把这一节的过一遍,读的时候就不用停。斜体小字是原句里连续的几个词,在电子书里搜它就能找到那一句。标着「每个字都认识」的,是字面意思会把你带偏的。行话(compaction、quorum 这类)不在这里,在上面的术语表里。
§0 Much of this book so far…
§1 Batch Processing with Unix Tools
§1.1 Simple Log Analysis
§1.2 Chain of Commands Versus Custom Program
§1.3 Sorting Versus In-Memory Aggregation
§2.1 Distributed Filesystems
§2.2 Object Stores
§2.3 Distributed Job Orchestration
§3 Batch Processing Models
§3.1 MapReduce
§3.2 Dataflow Engines
§3.3 Shuffling Data
§3.4 Joins and Grouping
§3.5 Query Languages
§3.6 DataFrames
§4 Batch Use Cases
§4.1 Extract–Transform–Load
§4.2 Analytics
§4.3 Machine Learning
§4.4 Serving Derived Data
下面是读完再看的:你的预测对不对、几道换成你来选的判断题、值得细读的句子和理解检查。先看了,读书时就只是在找答案。
读完:回到你的预测
答案是第二项(§1.3)。哈希表里每个商品只占一行:商品 ID 加一个计数器。工作集只取决于不同的商品有多少个,同一个商品卖出一百万单也还是一行。20 万行远远放得进 8 GB,明细是 50 GB 还是 500 GB 都无所谓,读明细本身是顺序的。书里也是这么说的:工作集够小,哈希表在笔记本上就能跑。排序的优势要等工作集比内存还大时才出现,那时它可以在内存里排好一块、写盘、再归并。想一想:如果统计的是「每个用户 × 每个商品」各买了几单,不同的组合有几十亿个,答案会变吗?
答案是第四项(§2.2)。对象存储没有真正的目录,斜杠只是键的一部分;改名靠把对象复制到新键再删掉旧的,不是原子的,「改一个目录名」就得逐个改里面的每个对象。所以 2 万个对象要做 2 万次复制和删除,中途来读 out/ 的人可能只看到一部分(这一句是本站从「非原子」推出来的,书里没有展开)。本地文件系统的 rename 才是原子的。这也是 §4.4 要专门讲「全有或全无」的原因之一。想一想:如果改成让下游去读一个「清单」对象,里面列着这次输出的全部文件,作业最后才写这个清单,问题解决了吗?
判断题:换成你来选
场景都是本站编的。先选,再看解释;解释里写了对应的节号,拿不准就回原书看那一节。
场景:一个每晚跑的作业,按广告主 ID 汇总当天的点击。reduce 阶段有 200 个任务,199 个三分钟内结束,最后一个跑了两个多小时。换了机器重跑,还是它最慢。
洗牌保证同一个键进同一个 reduce 任务(§3.3),所以一个超大的键只能由一个任务处理。换了机器还慢,说明不是机器的问题;加 reduce 任务数只能把别的键分得更散,分不开这一个键。map 输出总是排好序再交给 reduce 的。办法在第 7 章 §3.3:给热键加随机后缀拆成几份,再加一轮把部分结果合起来。
场景:一个批处理作业每晚给 5000 万用户算推荐列表,有 300 个 reduce 任务。线上 App 从一个键值存储里读推荐。要求:任何时刻用户看到的都是同一晚算出来的完整结果。
在作业里建好整份数据库文件再批量导入,切换版本是一次完成的,正合「同一晚的完整结果」的要求(书里提到 RocksDB 可以导入批处理生成的 SST 文件)。逐条写既慢又可能压垮线上库,任务重试还会写重复。按任务分批写、收到一条就可查,都会让用户看到一半新一半旧。走 Kafka 本身可以,但要等作业发出「完成」通知后再让数据可见。
场景:公司把不赶时间的报表作业放到云上的低价可抢占实例上跑。每个作业几百个任务,一晚上总有十几个任务被回收机器杀掉。
批处理每次从头生成输出,所以任务失败可以只重跑这一个任务,前提是任务彼此独立、输出先写在别处,成功后才算数(§2.3)。整个作业重跑在抢占频繁时可能永远跑不完;直接写最终目录会让下游读到半截或重复的数据;留空就失去了「全有或全无」。
场景:一条流水线由 6 个 MapReduce 作业串成,每个作业的结果写回 HDFS(三副本),下一个再读出来。每天要跑四个小时。团队打算用 Spark 重写同样的逻辑。
数据流引擎把整条工作流当一个作业,中间状态放内存或本地盘,不用每步复制三份写 DFS;不改分片的算子合并成一个任务;算子的输入一准备好就能开始(§3.2)。排序没有消失,只是只在需要的地方做(连接、分组仍要洗牌)。代价是中间数据丢了要重算,Spark 为此记住每份中间数据是怎么算出来的。
和本站其他书对照
值得停下来的句子
if you deploy buggy code that writes bad data to the database, rolling back the code will do nothing to fix that data.— §0。本站译:如果你上线了往数据库里写坏数据的 bug,回滚代码对修复那些数据毫无作用。
这句话说的是在线数据库,衬出来的是批处理最大的好处。批处理不改输入,只从头生成输出,坏数据可以整份扔掉重算。全章后面很多设计都靠这一点:任务失败了只重跑一个任务,工作流的上下游靠文件交接,§4.4 一再强调别从任务里直接写外部系统,因为那样就把这个好处丢了。
MapReduce was a step forward … but now it is largely obsolete and no longer used at Google.— §0。本站译:MapReduce 曾是一大进步……但现在它基本已经过时,Google 自己也不再用了。
值得停下来,是因为这一章仍然花了不少篇幅讲 MapReduce。作者的理由在 §3:后来的数据流引擎和云数仓都继承了它的思路,尤其是洗牌。读的时候把 MapReduce 当成一个讲清机制的最小模型,而不是一个推荐你去用的系统。
检查一下理解
1.(§0)为什么批处理作业上线了 bug,比在线数据库好补救?
输入是只读的,输出每次从头生成。修好代码重跑,或者切回保留着的旧输出就行;在线数据库里被 bug 写坏的数据,回滚代码也改不回来。
2.(§1.3)命令行里的 sort 怎么处理比内存还大的数据?
在内存里排好一块就写到盘上成为一个段,最后把几个有序段归并成一个大的有序文件;还会自动用上多个 CPU 核。所以瓶颈通常是从磁盘读输入的速度。
3.(§2.1)分布式文件系统的块为什么比本地文件系统大那么多?
块大,要记的元数据就少,在拍字节级的数据上差别很大;而且相对于读一整块的时间,定位到块的开销显得很小。
4.(§2.3)成组调度会带来什么问题?不用它又会怎样?
为凑齐一个作业要的全部资源而预留,节点会闲着,几个作业都在预留时还可能死锁。如果干等资源空出来,别的作业会先抢走,这个作业可能饿死;抢占别人的任务又浪费了已做的工作。
5.(§3.1)mapper 为什么不在两条记录之间保留状态?
每次调用只依赖传进来的那条记录,框架才能把调用分给很多台机器并行跑,任务失败时也能在别的机器上用同样的输入重来,结果不变。
6.(§3.3)map 的输出是怎么到 reduce 那里的?
每个 mapper 给每个 reducer 在本地盘写一个文件,按键的哈希决定写进哪个,文件内排好序;map 结束后,各个 reducer 把属于自己的文件拉过来归并,同一个键就挨在一起了。
7.(§3.4)排序归并连接里,reducer 的内存要装多少东西?
一次只处理一个键,二次排序让维度那条记录先到,所以内存里只要记住当前这一个用户(或商家)的那条记录,不需要向外部发请求。
8.(§4.4)把结果写进流系统,解决不了哪个问题?怎么补?
解决不了「全有或全无」:消费方可能先看到一部分结果。作业结束后要给下游发「完成」通知,消费方在收到通知之前不让这批数据被查到,像读已提交下看不到未提交的事务。
用一句话说这一章(写给一个没读过的朋友):
精彩回顾
一句话:批处理读只读的输入、从头算出输出,靠洗牌这个分布式排序把同一个键的数据送到一起,出了错就重跑。
在线库被 bug 写坏的数据收不回来;批处理的输出扔掉重算就行。全章的容错都站在这一点上。
§0同一个键出现一百万次,在哈希表里也只占一行。工作集比内存大时,排序加归并才开始占上风。
§1.3对象存储没有真目录,也没有原子改名;改一个「目录名」要把里面每个对象挨个搬一遍。
§2.2书里的例子:两个作业各要 100 核、集群只有 160 核,就已经左右为难;规模一大,求最优解是 NP 难的,只能靠启发式。
§2.3批处理的 shuffle 结果是排好序的。每个 map 给每个 reduce 写一个有序文件,reduce 拉来归并。
§3.3二次排序让 reducer 先拿到维度那条记录,再一条条配上事件;内存里只放一条,也不发网络请求。
§3.4逐条写太慢,并发写会压垮线上库,失败重试还会留下半截和重复。改走流,或建好整份库再批量导入。
§4.4