21 批处理与流式:从 MapReduce 到流批一体
开篇提问
先给你一个场景,你来选方案:
你要统计”每个城市每分钟的订单量”,用于实时大屏展示和运营监控。
- 批处理方案:每小时跑一次,把这一小时的数据全扫一遍,算出结果。
- 流式方案:每来一条订单就立刻更新计数,实时更新大屏。
请问:哪个方案更”对”?
先别急着选。我给你三个更尖锐的问题,你把这三个问题想清楚,答案会自己浮出来:
- 你的大屏,真的需要”这一秒”的数据吗?晚 1 分钟看到结果,会死人吗?
- 如果一条订单因为网络抖动,在 10:00 那一小时已经”封账”之后,才姗姗来迟地到达——你把它算进哪一小时?
- 你算出”10:00-11:00 订单量 100 万”之后,发现中间有台机器当时在故障,少算了一部分——你能重跑一遍修正吗?
答案可能出乎意料:两个都不完全对,而且这个问题问法本身就有问题。
为什么?因为”批处理 vs 流式”这个二分法,掩盖了真正需要想清楚的三个问题:
- 结果的”新鲜度”要求是多少?(延迟 vs 成本的权衡,第 3 章)
- 数据会不会”迟到”? 如果一条订单因为网络延迟,在你算完 10:00 那一批之后才到达,怎么处理?
- 结果错了能不能改? 批处理可以重跑修正,流式呢?
这三个问题,才是批流之争的真正内核。而它们的答案,决定了你会选什么架构。
这一章的核心认知跃迁,你会反复遇到:批处理和流式不是两个对立的东西,它们是同一个东西的两个极端。 这句话你现在可能不以为然,读完这一章,你会点头。
子主题一:批处理与流式的本质区别
先精确定义两者的区别,不要停留在”一个慢一个快”这种表面理解:
批处理(Batch Processing):
- 输入是”有界的”(bounded)——处理的是一批已经完整的数据集
- 处理完就结束,产出一个完整结果
- 可以重跑:结果错了,重跑一遍就行
- 典型:MapReduce、Spark、每日报表
流式处理(Stream Processing):
- 输入是”无界的”(unbounded)——数据永无止境地流过来
- 处理永不结束,持续产出增量结果
- 不能简单”重跑”:数据是流过去的,重跑意味着要重新消费历史数据
- 典型:Flink、Kafka Streams、实时大屏
这个”有界 vs 无界”的区分,才是本质。 而”延迟”只是它的一个表现——因为批处理要等”整批数据到齐”才能开始算,所以慢。
一个极其重要的洞察(这是理解现代流批一体的关键)
批处理其实是流式处理的一个特例——当数据有界时的流式处理。
这句话来自 Google 的 Dataflow 模型论文(2015),它彻底改变了人们对批流的认知。如果批处理只是”有界的流”,那理论上可以用一套引擎同时支持两者——这就是”流批一体”的理论基础。
你先感受一下这个洞察的”破壁感”:
过去十几年,业界一直认为批和流是两套不同的东西,要用两套引擎、写两份代码。而 Dataflow 说:别搞两套了,批就是”流的一个特殊情况”,一套引擎、一份代码,通过配置就能同时覆盖两者。
这种”把两个看似对立的概念,看成同一个概念的两种取值”的思考方式,是全书反复出现的一种高阶思维。它和第 4 章”通用 vs 专用”、第 1 章”时空权衡”的底层逻辑是相通的:当你找到一个更高的抽象,把两个东西统一进去,你就能用一套机制解决两类问题。
子主题二:Lambda 架构——一次”优雅”的妥协
在流批一体成熟之前,业界的主流方案是 Lambda 架构(Nathan Marz 提出)。
Lambda 的结构:三层
- 批处理层(Batch Layer):定期(比如每小时)全量重算,产出准确但滞后的结果
- 速度层(Speed Layer / 流式层):实时处理,产出及时但可能不准的结果
- 服务层(Serving Layer):合并两者的结果,对外提供查询
核心思想:用”快的、近似的”补”慢的、准确的”,两者互补。
为什么说它是一个”补丁”而不是优雅的架构
这个架构看起来很聪明,但它有一个致命缺陷:同样的业务逻辑要实现两遍——一遍写批处理(MapReduce/Spark),一遍写流式(Storm/Flink)。
这个代价在工程上极其昂贵:
- 两份代码要保持一致(改了一处,另一处必须同步改)
- 两份代码可能产出不同的结果(因为语义难以完全一致)
- 运维两套系统
- 团队要掌握两套技术栈
一个重要的历史视角
Lambda 架构是一个”承认现实局限”的妥协方案——当时的流式引擎做不到”准确且及时”,所以只能叠一层批处理来兜底。它是特定历史阶段的最优解,但本质上是一个”补丁”,不是一个优雅的架构。
这里有个值得你停下来想的点:Lambda 架构的”两份代码”问题,本质是什么?
本质是:它把”正确性”和”及时性”这两个目标,拆分到了两个不同的系统里分别实现,然后在外层拼起来。 而只要目标是拆开的,就必然有”拼缝”——拼缝处,就是不一致和 bug 滋生的地方。
这和第 19 章讲的”把状态挪到专门的地方”、第 1 章讲的”把权衡转移到别处”是同一个思想的投影:当你没法在一个系统里同时满足两个目标时,你会本能地”用两个系统分别满足、再拼接”。这是最自然的做法,但拼缝处总是要付代价。
流批一体的价值,恰恰在于消灭了这个拼缝。
子主题三:流式的三大难题——时间、窗口、正确性
流式处理比批处理难得多,难在三个核心问题:
难题一:事件时间 vs 处理时间
这是流式处理最根本的一个区分:
- 事件时间(Event Time):事情实际发生的时间(订单在 10:00:05 创建)
- 处理时间(Processing Time):系统处理它的时间(因为网络延迟,10:00:47 才被处理)
批处理通常可以不管这个区分(因为它等数据到齐再算)。但流式必须面对:数据会迟到、会乱序。
如果你按”处理时间”统计”10:00-10:01 的订单量”,那么一条 10:00:05 发生、10:01:10 才到达的订单,会被算进 10:01——统计结果是错的。
所以流式系统必须支持”按事件时间”处理,并且要能处理迟到数据。
这里藏着一个深刻的观察:“时间”本身,在分布式系统里是一个模糊的概念。 你的服务器认为的”现在”,和产生这条数据的手机认为的”现在”,可能差着好几秒。批处理之所以简单,是因为它”等大家都到齐了再算”,把这个时间模糊性给回避了;流式之所以难,是因为它必须在时间模糊性还存在的情况下,就给出一个答案。
难题二:乱序与水位线(Watermark)
既然数据会乱序,那系统怎么知道”10:00 这一分钟的数据都到齐了,可以输出最终结果了”?
答案是水位线(Watermark):
水位线 W 表示”我认为不会再有事件时间早于 W 的数据到来了”。
当水位线超过 10:01,系统就认为 10:00-10:01 这个窗口的数据已经完整,可以输出最终(或接近最终)的结果。
水位线的本质是一种”启发式”判断——你不可能真的知道未来还有没有迟到数据,只能”猜”。这就带来权衡:
- 水位线太激进(过早认为数据到齐)→ 迟到数据被丢弃或处理不当,结果不准
- 水位线太保守(等很久)→ 延迟高,失去实时的意义
这是又一个”正确性 vs 延迟”的权衡,和第 2 章的主线一脉相承。
难题三:窗口(Window)的类型
流式计算需要把无界数据切成有限的”窗口”来聚合。窗口有几种:
- 滚动窗口(Tumbling):固定大小、不重叠(每分钟一个窗口)
- 滑动窗口(Sliding):固定大小、有重叠(每分钟统计”过去 5 分钟”)
- 会话窗口(Session):按”活跃间隙”切分(用户 30 分钟无活动就认为会话结束)
- 全局窗口:所有数据一个窗口(配合自定义的触发条件)
窗口的选择,取决于你的业务语义。选错窗口类型,算出来的结果就是错的——这是流式开发里最常见的错误之一。
一个帮助你理解”窗口”的类比:统计地铁客流
想象你要统计”某个地铁站的人流量”:
- 滚动窗口:每 10 分钟数一次”这 10 分钟进了多少人”。清晰,但两个窗口边界处的人会被人为切开。
- 滑动窗口:每 1 分钟报告一次”过去 10 分钟进了多少人”。平滑,但同一个人会被数 10 次(如果你在累加的话,要去重)。
- 会话窗口:从”第一个人进站”开始计时,如果 30 分钟没人再进,就认为”这一波客流结束了”。
你看,窗口不是技术细节,它是**“你如何定义’一段时间’这件事”的业务语义**。选错窗口,等于你问错了问题——算出来的东西根本不是你要的。
子主题四:流批一体——Dataflow 模型与 Flink
2015 年,Google 发表 Dataflow 模型论文,提出了统一批流的理论框架。它的核心洞察是:
批和流的区别,不在于”怎么算”,而在于”结果什么时候产出、以及怎么修正”。
Dataflow 模型把流式计算拆解成几个正交的问题:
1. What(算什么):聚合逻辑(求和、计数)——批流都一样 2. Where(在哪个窗口里算):窗口模型(按事件时间) 3. When(什么时候输出结果):触发器(Trigger)——这是关键创新 4. How(怎么修正之前的结果):累积模式(Accumulation)
第 3、4 点是最关键的创新。 传统流式引擎只能”来一条算一条、输出一次”,而 Dataflow 引入了:
触发器(Trigger):决定”什么时候输出窗口的结果”
- 可以是”水位线到达时输出”(一次输出最终结果)
- 可以是”每来一条就输出”(完全实时,但结果不断更新)
- 可以是”每 1 分钟输出一次”(折中)
累积模式(Accumulation):决定”后面的输出怎么对待前面的结果”
- 丢弃(Discarding):每次输出都是独立的增量,调用方自己累加
- 累积(Accumulating):每次输出都是”到目前为止的完整结果”,会覆盖之前的值
- 累积并撤回(Retracting):先发一个”撤回上次结果”的信息,再发新结果
这个模型优雅在哪
有了触发器和累积模式,“批处理”就变成了流式的一个特例:
- 批处理 = “在水位线到达时才触发一次” + “丢弃模式”
- 完全实时 = “每来一条就触发” + “累积模式”
这意味着:一套引擎、一份代码,通过配置不同的触发器,就能实现从”完全实时”到”批处理”的任意中间状态。
这就是流批一体的理论基础。Flink 是这套理论最成功的工程实现——它用检查点(Checkpoint)+ 有状态计算,实现了精确一次(exactly-once)语义的流式处理。
这个抽象的通用价值
Dataflow 的 “What/Where/When/How” 拆解,不只是流式计算的事。它是一个通用的**“把复杂问题正交分解”**的范式:
当你面对一个看似”一堆条件纠缠在一起”的问题时,试着把它拆成几个正交的、可以独立调节的维度。 一旦拆开,你就能在每个维度上独立地调参数,而不是在混沌里瞎试。
这和第 25 章”定义问题”、第 16 章”抽象与分层”是同一个思想家族:正交分解是降低复杂度的最有力武器。
子主题五:精确一次语义(Exactly-Once)——流式的圣杯
流式处理里最难的一个承诺:精确一次(Exactly-Once)——每条数据被处理且只被处理一次,即使发生故障。
为什么难?
因为流式系统里,消息可能重复(at-least-once 传递),故障可能发生在任意时刻。
想象一下:你从 Kafka 消费了一条消息,更新了数据库,但还没来得及告诉 Kafka”我处理完了”(提交 offset),机器就挂了。重启后,Kafka 以为你没处理,又发了一次——于是这条消息被处理了两次。
这就是”至少一次”(at-least-once)和”精确一次”(exactly-once)之间的鸿沟。
Flink 实现精确一次的核心机制:分布式快照(Chandy-Lamport 算法)
原理(直觉版):
- 系统在数据流中插入特殊的检查点屏障(Checkpoint Barrier)
- 当算子收到屏障,就把自己当前的状态做快照、持久化
- 所有算子都完成快照后,这个检查点就算”完成”
- 如果故障发生,回退到最近的检查点:状态恢复、数据源从对应位置重放
关键:状态和数据位置一起快照。 这样故障恢复后,状态和数据流的位置是一致的——不会重复计算,也不会漏算。
一个帮助你理解的类比:游戏存档
Flink 的检查点机制,特别像游戏的存档(checkpoint/save):
- 你打到一个 Boss 前,存档(记录”我在哪、我有什么装备”= 快照状态)。
- 你被 Boss 打死了(故障)。
- 你读档,回到存档点(回退到检查点),重新打。
关键细节是:存档必须”一致”——如果你的存档记录的是”我在 Boss 房门口”,但装备栏却是”打 Boss 之前的装备”,那读档后就会错乱。这就是为什么 Flink 要”状态和数据位置一起快照”——保证回退之后,所有东西都在同一个一致的时点上。
一个重要的现实提醒
“精确一次”通常指的是”状态更新的精确一次”,而对外部系统的副作用(比如写数据库、发消息)不一定能保证精确一次——除非用幂等写入或两阶段提交。
这个提醒背后是一个更普遍的真理:“精确一次”从来不是免费的,它需要”外部系统也配合”。你内部的流式引擎可以做到精确一次,但只要你往外写了一个不幂等的数据库、发了一条不可撤回的短信,精确一次就破功了。所以精确一次是端到端的承诺,不是单点的。
演进脉络
把批流之争的演进串起来,你会看到一条”从拼凑走向统一”的主线。
- 2004:Google 发表 MapReduce 论文,批处理成为大数据的主流范式。这是”批”的黄金时代——当时大家觉得”把数据攒一批、离线算”就够了,实时性是奢侈品。
- 2010s 初:Storm 等第一代流式引擎出现,但只能保证 at-least-once,且语义简陋。“流”开始崛起,但还不成熟。
- 2011-2013:Lambda 架构被提出并广泛采用,用”批+流两套”拼凑出”准确+及时”。这是”拼凑”的巅峰,也是它问题最暴露的时候。
- 2014:Spark Streaming 用微批(micro-batch) 实现流式——把流切成很小的批次(比如 1 秒一批)用批引擎处理。这是”用批模拟流”,延迟只能到秒级。这一步很关键:它第一次暗示了”批和流可以统一”——用批引擎做流,只是把”批”切得足够小。
- 2015:Google 发表 Dataflow 模型,从理论上统一了批流。这是认知的质变:批就是有界的流。
- 2015 至今:Flink 成为流批一体的代表;Kafka 成为流式数据的事实标准基础设施。
- 现代:流批一体成为主流架构;Flink/Spark 都支持同一套 API 处理有界和无界数据。
主线:批处理与流式的演进,是从”两套系统拼凑”(Lambda)走向”一套理论统一”(Dataflow/Flink)。关键的认知跃迁是:批处理只是流式的特例(有界数据),而”延迟”和”正确性”不是二选一,而是通过触发器和累积模式可以精细调节的旋钮。
一个值得你带走的”突破口”复盘
这条演进主线里,最精彩的一次”认知跃迁”,是 Dataflow 对批流的重新定义。
在 Dataflow 之前,大家问的问题是:“批和流是两套东西,我怎么把两套系统拼接好?“——于是有了 Lambda,有了”两份代码”的痛苦。
Dataflow 换了个问法:“批和流,本质上差在哪?” 答案是:差在”结果什么时候产出、怎么修正”。于是”批 vs 流”从一个”选型问题”变成了一个”配置问题”。
这就是第 25 章要讲的”重新定义问题”的威力:换一个问法,整个问题的难度等级都变了。 你没解决”两套系统难维护”这个问题,你把它定义没了——批和流根本不需要两套系统。
本章思考题
【思考题】
-
批处理和流式的本质区别是什么(不是”一个快一个慢”)?为什么说”批处理是流式的特例”?
-
Lambda 架构的三层是什么?它为什么被称为”补丁”而不是优雅的架构?它的”两份代码”问题本质是什么?
-
流式处理的”事件时间 vs 处理时间”问题是什么?水位线(Watermark)的权衡是什么?
-
Dataflow 模型的 What/Where/When/How 拆解是什么?它如何让”批处理”变成流式的一个特例?这个拆解体现了什么通用思维?
-
什么是”精确一次”语义?Flink 用什么机制实现它?它的边界在哪?
【参考答案】
-
本质区别:批处理输入是有界的(一批完整数据,处理完即结束、可重跑),流式输入是无界的(数据永无止境地来、处理永不结束、不能简单重跑)。称”批处理是流式的特例”,因为当数据有界时,流式引擎处理的就是批处理——Dataflow 模型证明:两者的差别不在”怎么算”,而在”结果什么时候产出、怎么修正”,通过不同的触发器配置,一套引擎可实现从实时到批处理的任意形态。这是”把两个对立概念看成同一概念的两个取值”的高阶思维。
-
三层:批处理层(定期全量重算,准确但滞后)、速度层/流式层(实时处理,及时但可能不准)、服务层(合并两者结果对外提供查询)。称其为”补丁”,因为同样的业务逻辑要实现两遍(批一份、流一份),两份代码要保持一致、可能产出不同结果、要运维两套系统、团队要掌握两套技术栈。它的”两份代码”问题本质:把”正确性”和”及时性”拆到两个系统分别实现、再外层拼接,而只要目标是拆开的,拼缝处就必然是不一致和 bug 滋生地——流批一体的价值恰在消灭这个拼缝。
-
事件时间是事情实际发生的时间,处理时间是系统处理它的时间;数据会迟到乱序,若按处理时间统计会算错窗口(如 10:00:05 发生、10:01:10 到达的订单被算进 10:01)。水位线 W 表示”认为不会再有事件时间早于 W 的数据到来”,系统据此判断某窗口数据已完整可输出。权衡:水位线太激进(过早认为到齐)→ 迟到数据被丢弃,结果不准;太保守(等很久)→ 延迟高,失去实时意义。这是”正确性 vs 延迟”的权衡。深层观察:“时间”在分布式系统里是模糊概念,批处理靠”等齐再算”回避了它,流式则必须在时间模糊性还在时给出答案。
-
What(算什么,聚合逻辑)、Where(在哪个窗口算,按事件时间)、When(何时输出,触发器 Trigger)、How(怎么修正,累积模式 Accumulation)。有了触发器和累积模式,批处理 = “水位线到达才触发一次 + 丢弃模式”,完全实时 = “每来一条触发 + 累积模式”——一套引擎一份代码,通过配置触发器覆盖从实时到批的任意形态。体现的通用思维:正交分解——把”一堆条件纠缠”的复杂问题拆成几个可独立调节的正交维度,就能在每个维度独立调参而非在混沌里瞎试(同第 16、25 章)。
-
精确一次:每条数据被处理且只被处理一次,即使发生故障。难点在于消息可能重复(at-least-once),故障可能发生在任意时刻(如”更新了数据库但没提交 offset 就挂了”)。Flink 用分布式快照(Chandy-Lamport 算法)实现:在数据流中插入检查点屏障,算子收到屏障就持久化自己的状态,所有算子完成快照则该检查点完成;故障时回退到最近检查点,状态恢复 + 数据源从对应位置重放,因状态和数据位置一起快照,故不重复也不漏算(类比游戏存档:存档必须一致,否则读档错乱)。边界:它保证的是状态更新的精确一次,对外部系统的副作用(写数据库、发消息)不自动精确一次,除非用幂等写入或两阶段提交——精确一次是端到端的承诺,不是单点的。