21 批处理与流式:从 MapReduce 到流批一体

开篇提问

先给你一个场景,你来选方案:

你要统计”每个城市每分钟的订单量”,用于实时大屏展示和运营监控。

  • 批处理方案:每小时跑一次,把这一小时的数据全扫一遍,算出结果。
  • 流式方案:每来一条订单就立刻更新计数,实时更新大屏。

请问:哪个方案更”对”?

先别急着选。我给你三个更尖锐的问题,你把这三个问题想清楚,答案会自己浮出来:

  1. 你的大屏,真的需要”这一秒”的数据吗?晚 1 分钟看到结果,会死人吗?
  2. 如果一条订单因为网络抖动,在 10:00 那一小时已经”封账”之后,才姗姗来迟地到达——你把它算进哪一小时?
  3. 你算出”10:00-11:00 订单量 100 万”之后,发现中间有台机器当时在故障,少算了一部分——你能重跑一遍修正吗?

答案可能出乎意料:两个都不完全对,而且这个问题问法本身就有问题。

为什么?因为”批处理 vs 流式”这个二分法,掩盖了真正需要想清楚的三个问题:

  1. 结果的”新鲜度”要求是多少?(延迟 vs 成本的权衡,第 3 章)
  2. 数据会不会”迟到”? 如果一条订单因为网络延迟,在你算完 10:00 那一批之后才到达,怎么处理?
  3. 结果错了能不能改? 批处理可以重跑修正,流式呢?

这三个问题,才是批流之争的真正内核。而它们的答案,决定了你会选什么架构。

这一章的核心认知跃迁,你会反复遇到:批处理和流式不是两个对立的东西,它们是同一个东西的两个极端。 这句话你现在可能不以为然,读完这一章,你会点头。


子主题一:批处理与流式的本质区别

先精确定义两者的区别,不要停留在”一个慢一个快”这种表面理解:

批处理(Batch Processing):

  • 输入是”有界的”(bounded)——处理的是一批已经完整的数据集
  • 处理完就结束,产出一个完整结果
  • 可以重跑:结果错了,重跑一遍就行
  • 典型:MapReduce、Spark、每日报表

流式处理(Stream Processing):

  • 输入是”无界的”(unbounded)——数据永无止境地流过来
  • 处理永不结束,持续产出增量结果
  • 不能简单”重跑”:数据是流过去的,重跑意味着要重新消费历史数据
  • 典型:Flink、Kafka Streams、实时大屏

这个”有界 vs 无界”的区分,才是本质。 而”延迟”只是它的一个表现——因为批处理要等”整批数据到齐”才能开始算,所以慢。

一个极其重要的洞察(这是理解现代流批一体的关键)

批处理其实是流式处理的一个特例——当数据有界时的流式处理。

这句话来自 Google 的 Dataflow 模型论文(2015),它彻底改变了人们对批流的认知。如果批处理只是”有界的流”,那理论上可以用一套引擎同时支持两者——这就是”流批一体”的理论基础。

你先感受一下这个洞察的”破壁感”:

过去十几年,业界一直认为批和流是两套不同的东西,要用两套引擎、写两份代码。而 Dataflow 说:别搞两套了,批就是”流的一个特殊情况”,一套引擎、一份代码,通过配置就能同时覆盖两者。

这种”把两个看似对立的概念,看成同一个概念的两种取值”的思考方式,是全书反复出现的一种高阶思维。它和第 4 章”通用 vs 专用”、第 1 章”时空权衡”的底层逻辑是相通的:当你找到一个更高的抽象,把两个东西统一进去,你就能用一套机制解决两类问题。


子主题二:Lambda 架构——一次”优雅”的妥协

在流批一体成熟之前,业界的主流方案是 Lambda 架构(Nathan Marz 提出)。

Lambda 的结构:三层

  1. 批处理层(Batch Layer):定期(比如每小时)全量重算,产出准确但滞后的结果
  2. 速度层(Speed Layer / 流式层):实时处理,产出及时但可能不准的结果
  3. 服务层(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 分钟没人再进,就认为”这一波客流结束了”。

你看,窗口不是技术细节,它是**“你如何定义’一段时间’这件事”的业务语义**。选错窗口,等于你问错了问题——算出来的东西根本不是你要的。


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)之间的鸿沟。

原理(直觉版):

  1. 系统在数据流中插入特殊的检查点屏障(Checkpoint Barrier)
  2. 当算子收到屏障,就把自己当前的状态做快照、持久化
  3. 所有算子都完成快照后,这个检查点就算”完成”
  4. 如果故障发生,回退到最近的检查点:状态恢复、数据源从对应位置重放

关键:状态和数据位置一起快照。 这样故障恢复后,状态和数据流的位置是一致的——不会重复计算,也不会漏算。

一个帮助你理解的类比:游戏存档

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 章要讲的”重新定义问题”的威力:换一个问法,整个问题的难度等级都变了。 你没解决”两套系统难维护”这个问题,你把它定义没了——批和流根本不需要两套系统。


本章思考题

【思考题】

  1. 批处理和流式的本质区别是什么(不是”一个快一个慢”)?为什么说”批处理是流式的特例”?

  2. Lambda 架构的三层是什么?它为什么被称为”补丁”而不是优雅的架构?它的”两份代码”问题本质是什么?

  3. 流式处理的”事件时间 vs 处理时间”问题是什么?水位线(Watermark)的权衡是什么?

  4. Dataflow 模型的 What/Where/When/How 拆解是什么?它如何让”批处理”变成流式的一个特例?这个拆解体现了什么通用思维?

  5. 什么是”精确一次”语义?Flink 用什么机制实现它?它的边界在哪?

【参考答案】

  1. 本质区别:批处理输入是有界的(一批完整数据,处理完即结束、可重跑),流式输入是无界的(数据永无止境地来、处理永不结束、不能简单重跑)。称”批处理是流式的特例”,因为当数据有界时,流式引擎处理的就是批处理——Dataflow 模型证明:两者的差别不在”怎么算”,而在”结果什么时候产出、怎么修正”,通过不同的触发器配置,一套引擎可实现从实时到批处理的任意形态。这是”把两个对立概念看成同一概念的两个取值”的高阶思维。

  2. 三层:批处理层(定期全量重算,准确但滞后)、速度层/流式层(实时处理,及时但可能不准)、服务层(合并两者结果对外提供查询)。称其为”补丁”,因为同样的业务逻辑要实现两遍(批一份、流一份),两份代码要保持一致、可能产出不同结果、要运维两套系统、团队要掌握两套技术栈。它的”两份代码”问题本质:把”正确性”和”及时性”拆到两个系统分别实现、再外层拼接,而只要目标是拆开的,拼缝处就必然是不一致和 bug 滋生地——流批一体的价值恰在消灭这个拼缝。

  3. 事件时间是事情实际发生的时间,处理时间是系统处理它的时间;数据会迟到乱序,若按处理时间统计会算错窗口(如 10:00:05 发生、10:01:10 到达的订单被算进 10:01)。水位线 W 表示”认为不会再有事件时间早于 W 的数据到来”,系统据此判断某窗口数据已完整可输出。权衡:水位线太激进(过早认为到齐)→ 迟到数据被丢弃,结果不准;太保守(等很久)→ 延迟高,失去实时意义。这是”正确性 vs 延迟”的权衡。深层观察:“时间”在分布式系统里是模糊概念,批处理靠”等齐再算”回避了它,流式则必须在时间模糊性还在时给出答案。

  4. What(算什么,聚合逻辑)、Where(在哪个窗口算,按事件时间)、When(何时输出,触发器 Trigger)、How(怎么修正,累积模式 Accumulation)。有了触发器和累积模式,批处理 = “水位线到达才触发一次 + 丢弃模式”,完全实时 = “每来一条触发 + 累积模式”——一套引擎一份代码,通过配置触发器覆盖从实时到批的任意形态。体现的通用思维:正交分解——把”一堆条件纠缠”的复杂问题拆成几个可独立调节的正交维度,就能在每个维度独立调参而非在混沌里瞎试(同第 16、25 章)。

  5. 精确一次:每条数据被处理且只被处理一次,即使发生故障。难点在于消息可能重复(at-least-once),故障可能发生在任意时刻(如”更新了数据库但没提交 offset 就挂了”)。Flink 用分布式快照(Chandy-Lamport 算法)实现:在数据流中插入检查点屏障,算子收到屏障就持久化自己的状态,所有算子完成快照则该检查点完成;故障时回退到最近检查点,状态恢复 + 数据源从对应位置重放,因状态和数据位置一起快照,故不重复也不漏算(类比游戏存档:存档必须一致,否则读档错乱)。边界:它保证的是状态更新的精确一次,对外部系统的副作用(写数据库、发消息)不自动精确一次,除非用幂等写入或两阶段提交——精确一次是端到端的承诺,不是单点的。