某电商平台的数据团队维护着两套代码:一套 MapReduce 任务每天凌晨跑全量订单统计,另一套 Storm 拓扑实时计算 GMV 大屏指标。两套系统使用不同的编程模型,实现同一份业务逻辑——按区域统计销售额、按品类计算转化率。问题从第一天就存在:批处理代码更新了折扣计算规则,流处理代码忘了同步,导致实时大屏与 T+1 报表的数字对不上。运营团队质疑数据准确性,开发团队疲于在两套代码之间做回归测试。这不是个别现象,而是整个行业在 2014 年前后面临的普遍困境。
Google 于 2015 年发表的 Dataflow 论文,正是对这一困境的系统性回应。该论文提出了一套统一的数据处理模型,用四个正交维度——计算什么、窗口在哪、何时触发、如何修正——覆盖了从批处理到流处理的全部语义空间。这一模型后来演变为 Apache Beam 项目,并深刻影响了 Apache Flink 的设计方向。
本文从论文原文出发,逐层拆解 Dataflow 模型的核心抽象,回顾 Lambda/Kappa 两种架构的演进脉络,分析流批一体在工程实践中的真实状态。
一、流批二象性:同一逻辑的两套代码
在 Dataflow 模型出现之前,业界对数据处理存在一个根深蒂固的二分法:数据要么是有界的(Bounded),适合批处理;要么是无界的(Unbounded),需要流处理。这种二分法直接导致了技术栈的割裂。
批处理一侧,MapReduce 及其后继者(Hive、Spark)提供了成熟的编程模型:读取完整数据集,执行 map 和 reduce 操作,输出结果。整个计算建立在”输入有限、处理完整”的假设之上。Spark 通过 RDD(Resilient Distributed Dataset)抽象引入了内存计算,大幅提升了性能,但本质上仍然是对有界数据集的操作。
流处理一侧,Storm、Samza、Spark Streaming 各自提供了不同的编程接口。Storm 使用 Spout/Bolt 模型,Samza 基于 Kafka 消费者,Spark Streaming 将流切分为微批次(Micro-batch)。三者在窗口语义、状态管理、容错机制上差异显著。
一个典型的业务场景——“统计过去一小时内各城市的订单量”——在两套系统中的实现方式完全不同:
// 批处理版本(Spark)
Dataset<Row> orders = spark.read().parquet("hdfs:///orders/2026-04-12/");
Dataset<Row> result = orders
.filter(col("timestamp").between(startHour, endHour))
.groupBy(col("city"))
.agg(count("order_id").alias("order_count"));
result.write().parquet("hdfs:///reports/hourly/");// 流处理版本(Storm Bolt,简化)
public class CityCountBolt extends BaseWindowedBolt {
private Map<String, Long> counts = new HashMap<>();
@Override
public void execute(TupleWindow window) {
counts.clear();
for (Tuple t : window.get()) {
String city = t.getStringByField("city");
counts.merge(city, 1L, Long::sum);
}
// 输出 counts
}
}两段代码实现的业务逻辑完全一致,但编程接口、数据源接入方式、窗口定义方式、输出机制全部不同。当业务逻辑变更时(例如增加一个过滤条件”排除退货订单”),工程师必须在两套代码中分别修改并分别测试。随着业务复杂度增长,两套代码的行为差异会逐渐累积,成为数据质量事故的温床。
这种困境的根源在于:批和流被建模为两种本质不同的计算范式,而非同一范式在不同条件下的特化。Dataflow 模型的核心洞察恰恰是——批处理只是流处理的一个特例,即输入有界、窗口为全局、触发时机为数据完整时的流处理。
二、Google Dataflow 论文精读
2015 年,Tyler Akidau 等人在 VLDB 会议上发表了论文《The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded, Out-of-Order Data Processing》。这篇论文的标题本身就揭示了核心关切:在大规模、无界、乱序数据处理中,如何平衡正确性(Correctness)、延迟(Latency)和成本(Cost)三者的关系。
论文的出发点
论文开篇指出,已有的数据处理系统在以下三个维度上各有取舍:
- 正确性:批处理系统(如 MapReduce)通过等待所有数据到齐来保证正确性,但代价是高延迟。
- 延迟:流处理系统(如 Storm)通过即时处理来降低延迟,但在乱序数据和迟到数据(Late Data)场景下难以保证正确性。
- 成本:Lambda Architecture 试图兼顾两者,但付出了维护两套系统的工程成本。
Akidau 等人认为,问题的根源在于缺乏一个足够通用的抽象模型,能够在统一的框架内表达从批到流的全部语义,并允许用户在正确性、延迟和成本之间做出显式的、细粒度的权衡。
论文的核心贡献
论文提出了四项关键贡献:
- 统一模型:将有界和无界数据处理统一为一个模型,批处理成为流处理的特例。
- 四维分解:将数据处理的语义分解为四个正交维度——What、Where、When、How——每个维度独立可配置。
- 窗口(Windowing)的一般化:将窗口定义为事件时间(Event Time)上的函数,而非处理时间(Processing Time)的函数,从而正确处理乱序数据。
- 触发(Triggering)与累积(Accumulation)机制:提供了一套灵活的机制来控制结果的输出时机和修正方式。
这四项贡献不是孤立的设计决策,而是一个自洽的理论框架。下一节将逐一拆解每个维度。
三、核心抽象:What、Where、When、How
Dataflow 模型的精髓在于将数据处理的全部语义压缩为四个正交的问题。任何一个数据处理管道(Pipeline),无论是批处理还是流处理,都可以通过回答这四个问题来完整定义。
下图展示了四个维度的关系及其包含的核心概念:
What:计算什么——变换(Transformations)
第一个维度回答的问题是:对数据执行什么计算?在 Dataflow 模型中,这通过一组原语变换(Primitive Transformations)来表达。
PCollection 是 Dataflow 模型中数据的基本抽象,代表一个可能有界或无界的分布式数据集。每个元素携带一个时间戳(Timestamp),PCollection 中的元素按键值对(Key-Value Pair)组织。
核心变换原语包括:
- ParDo(Parallel Do):逐元素变换。对 PCollection 中的每个元素应用一个用户定义的函数(User-Defined Function,UDF),可以输出零个、一个或多个元素。ParDo 是 MapReduce 中 map 和 reduce 操作的一般化。
- GroupByKey:按键分组。将 PCollection
中具有相同键的元素收集到一起,产生
KV<K, Iterable<V>>形式的输出。这是 shuffle 操作的核心语义。 - CombinePerKey:按键聚合。在 GroupByKey 的基础上,对每个键的值集合应用一个结合律(Associative)和交换律(Commutative)聚合函数(如 sum、max)。由于结合律的约束,系统可以在 shuffle 之前执行局部聚合(Partial Aggregation),显著减少网络传输量。
- Flatten:合并多个 PCollection 为一个。
以下 Beam Python SDK 代码展示了这些变换的组合:
import apache_beam as beam
with beam.Pipeline() as p:
orders = (
p
| "ReadOrders" >> beam.io.ReadFromKafka(
consumer_config={"bootstrap.servers": "kafka:9092"},
topics=["orders"]
)
| "ParseJSON" >> beam.Map(parse_order_json) # ParDo
| "FilterValid" >> beam.Filter(lambda o: o["status"] == "paid") # ParDo
| "ToCityKV" >> beam.Map(lambda o: (o["city"], o["amount"])) # ParDo
| "SumByCity" >> beam.CombinePerKey(sum) # CombinePerKey
| "WriteResult" >> beam.io.WriteToBigQuery("project:dataset.city_revenue")
)这段代码既可以在批模式下运行(输入为有界的 Kafka
topic,读取到末尾即停止),也可以在流模式下运行(持续消费
Kafka),代码本身不需要任何修改。改变运行模式只需在 Pipeline
配置中指定不同的 Runner(如 DirectRunner
用于本地测试,FlinkRunner
用于生产流处理,SparkRunner 用于批处理)。
Where:窗口在哪——窗口化(Windowing)
第二个维度回答的问题是:在事件时间的哪个范围内进行聚合?无界数据天然没有”终点”,因此需要将无界数据切分为有限的片段来执行聚合操作。窗口化(Windowing)就是这种切分机制。
Dataflow 模型定义了一个通用的窗口函数(Window Function)接口:
AssignWindows(element) -> Set<Window>
MergeWindows(Set<Window>) -> Set<Window>
AssignWindows
将每个元素分配到一个或多个窗口,MergeWindows
允许窗口在运行时合并。基于这两个操作,可以定义四种标准窗口类型:
固定窗口(Fixed
Windows):将事件时间轴切分为等长的、不重叠的区间。例如”每小时一个窗口”。每个元素恰好属于一个窗口。实现上,AssignWindows
将元素的事件时间戳对窗口长度取整,得到所属窗口的起止时间。
orders | beam.WindowInto(beam.window.FixedWindows(3600)) # 1小时固定窗口滑动窗口(Sliding
Windows):窗口长度固定,但窗口之间存在重叠。例如”窗口长度
1 小时,每 5 分钟滑动一次”,则同一元素可能属于 12
个窗口。滑动窗口适用于需要计算移动平均值的场景。AssignWindows
为每个元素生成多个窗口归属。
orders | beam.WindowInto(beam.window.SlidingWindows(3600, 300)) # 1小时窗口,5分钟滑动会话窗口(Session
Windows):窗口的边界由数据本身决定。系统为每个键维护一个”间隙超时”(Gap
Duration),当某个键的两个连续元素之间的时间间隔超过该超时值时,前一个会话窗口关闭,新窗口开启。会话窗口是唯一需要
MergeWindows
操作的窗口类型——当迟到数据填补了两个会话之间的间隙时,两个窗口需要合并为一个。
orders | beam.WindowInto(beam.window.Sessions(600)) # 10分钟间隙的会话窗口全局窗口(Global Window):所有元素归入同一个窗口。这是批处理的默认语义——整个数据集就是一个窗口。对于无界数据,全局窗口必须配合触发器使用,否则窗口永远不会关闭。
orders | beam.WindowInto(beam.window.GlobalWindows())窗口化的一个关键设计决策是基于事件时间而非处理时间(Processing Time)。事件时间是事件实际发生的时间(例如用户下单的时间),处理时间是系统处理该事件的时间。两者之间存在偏差(Skew),且偏差不恒定——网络延迟、系统故障、消费者积压都会导致偏差波动。如果基于处理时间划分窗口,相同的输入数据在不同的运行条件下会产生不同的结果,这破坏了计算的确定性。
When:何时触发——触发器(Triggers)与水位线(Watermark)
第三个维度回答的问题是:何时将窗口内的计算结果输出?这是 Dataflow 模型最具创新性的部分之一,因为它直接解决了一个根本性矛盾:系统如何知道一个窗口的数据已经”到齐”?
对于有界数据,答案简单——输入读完就到齐了。但对于无界、乱序数据,答案是”永远不确定”。一条事件时间戳为 12:00 的数据可能在 12:05 到达,也可能在 13:00 才到达(例如用户手机离线后重新联网上传)。
水位线(Watermark) 是 Dataflow
模型对这一问题的回答。水位线是系统对”输入完整性”的一个启发式估计:如果当前水位线值为
t,则系统断言”事件时间小于 t
的数据大概率已经全部到达”。水位线有两种极端形态:
- 完美水位线(Perfect Watermark):系统确切知道所有数据的到达情况,水位线精确反映最晚数据的事件时间。某些有界数据源(如预排序的文件)可以提供完美水位线。
- 启发式水位线(Heuristic Watermark):系统根据已观测数据的统计特征估算水位线。这是大多数实际场景的情况。启发式水位线可能过于激进(导致迟到数据被遗漏)或过于保守(导致不必要的延迟)。
触发器(Trigger) 定义了窗口结果的输出策略。触发器与水位线配合,提供了以下能力:
- 事件时间触发器(Event Time Trigger):当水位线推进到窗口末尾时触发。这提供了最高的正确性保证。
- 处理时间触发器(Processing Time Trigger):按处理时间周期触发,例如”每分钟输出一次当前结果”。这提供了可预测的延迟,但结果可能不完整。
- 数据驱动触发器(Data-Driven Trigger):当窗口内的数据满足某个条件时触发,例如”收到 1000 条记录后输出”。
- 复合触发器(Composite
Trigger):将以上触发器组合。Dataflow
模型中最经典的复合触发器模式是
AfterWatermark,它同时支持三种触发时机。
// Beam Java SDK:复合触发器示例
PCollection<KV<String, Long>> result = orders
.apply(Window.<Order>into(FixedWindows.of(Duration.standardHours(1)))
.triggering(
AfterWatermark.pastEndOfWindow()
.withEarlyFirings(
AfterProcessingTime.pastFirstElementInPane()
.plusDelayOf(Duration.standardMinutes(1)))
.withLateFirings(AfterPane.elementCountAtLeast(1))
)
.withAllowedLateness(Duration.standardHours(2))
.accumulatingFiredPanes()
)
.apply(Sum.longsPerKey());这段代码定义了一个 1 小时固定窗口,触发策略为:
- 早期触发(Early Firings):在水位线到达窗口末尾之前,每分钟输出一次中间结果。这满足低延迟需求。
- 准时触发(On-time Firing):当水位线推进到窗口末尾时,输出一次”准时”结果。这是正确性的基准线。
- 迟到触发(Late Firings):在水位线推进到窗口末尾之后,每收到一条迟到数据就重新输出。允许的最大迟到时间为 2 小时。
通过这种方式,用户可以在同一个管道中同时获得低延迟的推测性结果和最终的正确结果,而不需要维护两套系统。
一个完整的窗口与触发示例
为了将上述概念具体化,我们用一个端到端的示例来演示窗口、触发器、水位线和累积模式如何协同工作。
场景设定:
- 窗口类型:5 分钟滚动窗口(Tumbling
Window),区间为
[10:00, 10:05)。 - 事件时间触发器:当水位线(Watermark)越过窗口结束时间
10:05时触发准时输出。 - 早期触发器:在水位线到达窗口末尾之前,周期性地输出推测性结果。
- 迟到触发器:水位线越过窗口结束时间后,每收到一条迟到数据即触发一次更新输出。
- 允许迟到时间:2 分钟(即水位线到达
10:07时窗口被清除)。 - 累积模式:Accumulating(累积模式),每次触发输出窗口内所有数据的完整聚合结果。
事件序列详细走查:
处理时间
t_proc=10:01,到达事件(key=A, value=3, event_time=10:02)。水位线当前为10:01:57,尚未到达窗口末尾。事件被归入窗口[10:00, 10:05),窗口内当前累积sum=3。此时窗口处于收集状态,尚未触发任何输出。处理时间
t_proc=10:03,到达事件(key=A, value=5, event_time=10:03:30)。水位线推进至10:03:27,仍未到达窗口末尾。事件归入同一窗口,累积sum=8。此时早期触发器(周期性处理时间触发器)触发,输出推测性结果sum=8。这是一个不完整但低延迟的中间结果。处理时间
t_proc=10:05:30,到达事件(key=B, value=2, event_time=10:05:10)。该事件属于下一个窗口[10:05, 10:10),不影响当前窗口。但更关键的是,水位线推进至10:05:07,越过了窗口[10:00, 10:05)的结束时间10:05。这触发了准时触发(On-time Firing),输出sum=8。在累积模式下,这个值包含窗口内迄今为止所有数据的完整聚合,下游可以将其视为”基本正确”的结果。处理时间
t_proc=10:06:00,到达迟到事件(key=A, value=4, event_time=10:04:50)。该事件的事件时间10:04:50落在窗口[10:00, 10:05)内,但水位线已经推进到10:05:07,事件时间小于水位线——这是一条迟到数据。由于当前水位线10:05:07尚未超过窗口结束时间加允许迟到时间(10:05 + 2min = 10:07),窗口仍然存活。迟到数据被纳入窗口,累积sum=12(即3+5+4)。迟到触发器触发,输出更新后的结果sum=12。处理时间
t_proc=10:07:30,水位线推进至10:07:00,超过了10:05 + 2min = 10:07。窗口[10:00, 10:05)被清除(Purged),不再接受任何迟到数据。此后到达的、事件时间落在该窗口内的数据将被丢弃。
下面的状态图展示了一个窗口从创建到清除的完整生命周期,以及触发器在各阶段的状态转移:
stateDiagram-v2
[*] --> Waiting: 窗口创建
Waiting --> Collecting: 元素到达
Collecting --> EarlyFired: 处理时间触发器(周期性输出)
EarlyFired --> Collecting: 继续收集
Collecting --> OnTimeFired: Watermark越过窗口结束时间
EarlyFired --> OnTimeFired: Watermark越过窗口结束时间
OnTimeFired --> AccumulatingLate: 迟到数据到达
AccumulatingLate --> LateFired: 迟到触发器触发
LateFired --> AccumulatingLate: 更多迟到数据
LateFired --> Purged: 超过允许迟到时间
OnTimeFired --> Purged: 超过允许迟到时间
Purged --> [*]
该状态图描述了触发器的完整状态机。窗口在创建后进入等待状态,随着元素到达开始收集数据;在水位线到达窗口末尾之前,早期触发器可以周期性地输出中间结果,触发后窗口回到收集状态继续累积数据。当水位线越过窗口结束时间,准时触发器触发,标志着窗口的”正式”输出完成。此后窗口进入迟到数据处理阶段,每收到迟到数据就触发一次更新输出,直到水位线超过允许迟到时间后窗口被彻底清除。
下面的时序图将上述五个步骤完整地串联起来,展示数据源、处理管道、窗口和输出端之间的交互过程:
sequenceDiagram
participant Source as 数据源
participant Pipeline as 处理管道
participant Window as 窗口[10:00,10:05)
participant Sink as 输出端
Source->>Pipeline: event(A,3) event_time=10:02
Pipeline->>Window: 元素归入窗口,sum=3
Note over Pipeline: Watermark=10:01:57
Source->>Pipeline: event(A,5) event_time=10:03:30
Pipeline->>Window: 元素归入窗口,sum=8
Note over Pipeline: Watermark=10:03:27
Window->>Sink: 早期触发输出 sum=8
Source->>Pipeline: event(B,2) event_time=10:05:10
Note over Pipeline: Watermark=10:05:07
Pipeline-->>Window: Watermark越过10:05
Window->>Sink: 准时触发输出 sum=8(累积模式)
Source->>Pipeline: late event(A,4) event_time=10:04:50
Pipeline->>Window: 迟到数据归入窗口,sum=12
Window->>Sink: 迟到触发输出 sum=12(累积模式)
Note over Pipeline: Watermark到达10:07:00
Pipeline-->>Window: 超过允许迟到时间(watermark+2min)
Note over Window: 窗口清除,不再接受数据
该时序图清晰地展示了事件在处理管道中的流转过程以及各触发时机的输出行为。从中可以看到三次不同性质的输出:早期触发提供了低延迟的推测性结果,准时触发提供了水位线意义上的”正确”结果,迟到触发则修正了因数据延迟而产生的偏差。整个过程在累积模式下运行,每次输出都是窗口内所有数据的完整聚合值,下游消费者只需关注最新的输出即可获得当前最准确的结果。
How:如何修正——累积模式(Accumulation Modes)
第四个维度回答的问题是:当一个窗口多次触发时,后续的输出如何与之前的输出关联?这个问题看似细节,实际上对下游消费者的正确性至关重要。
假设一个窗口在早期触发时输出了
sum = 3,在准时触发时又输出了
sum = ?。这个 ?
取决于累积模式:
丢弃模式(Discarding):每次触发只输出本次触发新增的数据的聚合结果。准时触发输出
sum = 5(即新增数据的和),两次输出之和
3 + 5 = 8
才是完整结果。下游如果需要完整结果,必须对所有窗格(Pane)求和。
累积模式(Accumulating):每次触发输出窗口内所有数据的当前聚合结果。准时触发输出
sum = 8(包含之前早期触发时已经计算过的数据)。下游只需取最后一个窗格的值即为完整结果。
累积并撤回模式(Accumulating and
Retracting):每次触发同时输出两个值——对之前输出的撤回(Retraction)和当前的完整结果。准时触发输出
retract = -3, sum = 8。下游将收到三个值:3(早期)、-3(撤回早期)、8(准时),求和得
8。
撤回模式的工程成本最高,因为系统需要持久化存储每个窗口之前输出的值。但它是唯一能保证下游在任意聚合操作下都得到正确结果的模式。例如,如果下游需要对多个窗口的结果再做一次 GroupByKey,累积模式下会出现重复计数,只有撤回模式能正确处理。
四个维度的组合构成了一个完整的语义空间:
| 维度 | 批处理默认值 | 流处理典型配置 |
|---|---|---|
| What | ParDo + GroupByKey | 同左 |
| Where | Global Window | Fixed / Session Window |
| When | 数据读完时触发一次 | AfterWatermark + Early/Late |
| How | 不适用(只触发一次) | Accumulating 或 Retracting |
批处理是上表左列的退化形式:全局窗口、完美水位线、单次触发、无需累积修正。这正是 Dataflow 模型”批是流的特例”这一论断的形式化表达。
四、Lambda Architecture 的困境
在 Dataflow 模型出现之前,业界对流批统一的主要尝试是 Lambda Architecture(Lambda 架构),由 Nathan Marz 于 2011 年在《Big Data》一书中提出。
Lambda 架构将数据处理系统分为三层:
- 批处理层(Batch Layer):定期(通常是每天或每小时)对全量数据运行批处理作业,产生精确但高延迟的结果。这一层的输出称为”批视图”(Batch View)。
- 速度层(Speed Layer):实时处理最新到达的数据,产生低延迟但可能不精确的结果。这一层的输出称为”实时视图”(Real-time View)。
- 服务层(Serving Layer):将批视图和实时视图合并,对外提供查询服务。当新的批视图就绪时,对应时间范围的实时视图被丢弃。
Lambda 架构的设计意图是合理的:用批处理保证正确性,用流处理弥补延迟,两者互补。然而,工程实践暴露了严重的问题:
双重代码路径:同一份业务逻辑必须分别用批处理框架(如 MapReduce/Spark)和流处理框架(如 Storm/Samza)实现。两套代码使用不同的编程模型和 API,即使逻辑相同,实现细节也大相径庭。代码同步的负担随着业务复杂度线性增长。
语义不一致的合并:服务层需要将批视图和实时视图的结果”合并”,但”合并”的语义高度依赖于具体业务逻辑。对于简单的计数聚合,合并可能只是取最新的批视图值加上实时增量;但对于复杂的聚合(如去重计数、百分位计算),合并逻辑本身就可能引入错误。
调试困难:当最终结果出现异常时,工程师需要同时检查批处理作业、流处理作业和合并逻辑三个环节,定位问题的复杂度呈乘法增长。
运维成本:维护两套计算引擎(加上服务层的存储和查询系统)意味着三倍的监控、报警和故障处理工作。
以一个广告计费系统为例。批处理层每小时运行 Spark 作业,扫描 HDFS 上的点击日志,按广告主维度统计有效点击数并写入 HBase。速度层运行 Storm 拓扑,实时消费 Kafka 中的点击事件,维护内存中的计数器并更新 Redis。服务层的查询接口需要从 HBase 读取最近完成的批次结果,从 Redis 读取该批次之后的增量,两者相加返回。
当广告主投诉”计费数字不对”时,工程师需要排查的环节包括:Spark 作业的过滤逻辑是否与 Storm 一致?HBase 写入是否有延迟?Redis 中的增量是否在批次切换时正确重置?Storm 拓扑在 failover 后是否存在重复计数?
Lambda 架构的问题不在于架构思想本身,而在于它把”统一”的责任推给了使用者,而没有在模型层面解决统一的问题。
五、Kappa Architecture 的简化
2014 年,LinkedIn 的 Jay Kreps 在 O’Reilly Radar 上发表文章《Questioning the Lambda Architecture》,提出了 Kappa Architecture(Kappa 架构)作为 Lambda 架构的替代方案。
Kappa 架构的核心思路极其简洁:去掉批处理层,只保留流处理层。所有数据处理都通过流处理管道完成。当需要重新处理历史数据时(例如修复了一个 bug,需要重算过去一个月的结果),不是启动批处理作业,而是将流处理管道的消费位点(Offset)重置到历史位置,让流处理管道重新消费历史数据。
Lambda: [Kafka] --> [Batch Layer (Spark)] --> [Serving Layer] <-- Query
\-> [Speed Layer (Storm)] -/
Kappa: [Kafka] --> [Stream Processor (Flink)] --> [Serving Layer] <-- Query
(replay from offset 0 for reprocessing)
Kappa 架构的前提条件是:
- 可重放的数据源:输入数据必须持久化存储,支持从任意位置重新消费。Kafka 的日志保留机制天然满足这一要求。
- 流处理引擎的吞吐量足够:流处理管道在重放历史数据时需要达到接近批处理的吞吐量,否则重处理的时间成本不可接受。
Kappa 架构解决了 Lambda 架构的双重代码路径问题——只有一套代码,一套系统。但它也有局限性:
重处理成本:对于 PB 级别的历史数据,流式重放的效率通常低于专门优化的批处理引擎。批处理引擎可以利用列式存储的谓词下推(Predicate Pushdown)、分区裁剪(Partition Pruning)等优化手段,而流处理引擎在重放场景下难以充分利用这些优化。
状态管理复杂度:流处理管道的状态(如窗口聚合的中间结果)在重处理时需要完全重建,状态的大小和管理成本在大规模场景下不可忽视。
资源争用:重处理任务与实时处理任务共享同一套计算资源,可能导致实时处理的延迟上升。虽然可以通过启动独立的流处理实例来隔离,但这又引入了额外的运维复杂度。
Kappa 架构的贡献在于明确了一个方向:统一应该在模型层面实现,而不是通过两个系统的拼接。Dataflow 模型在理论上完善了这一方向。
六、从 Dataflow 到 Beam 与 Flink
Google Cloud Dataflow 与 Apache Beam
Google Dataflow 论文的实践载体最初是 Google Cloud Dataflow,一个全托管的数据处理服务。2016 年,Google 将 Dataflow SDK 捐献给 Apache 基金会,成为 Apache Beam(Batch + strEAM 的缩写)项目。
Beam 的架构分为两层:
- SDK 层:提供统一的编程接口(Java、Python、Go),用户用 Beam API 编写管道逻辑。SDK 层实现了 Dataflow 模型的全部四个维度的抽象。
- Runner 层:将 Beam 管道翻译为具体执行引擎的作业。目前支持的 Runner 包括 Apache Flink Runner、Apache Spark Runner、Google Cloud Dataflow Runner、Samza Runner、Direct Runner(本地测试用)等。
这种分层设计实现了编程接口与执行引擎的解耦。理论上,同一份 Beam 代码可以不做修改地在不同引擎上运行。但实践中,各 Runner 对 Beam 模型的支持程度不同——Flink Runner 的支持最为完整,Spark Runner 在某些高级触发器和窗口类型上存在限制。
以下是一个完整的 Beam Java 管道示例,展示了四个维度的完整配置:
Pipeline p = Pipeline.create(options);
p.apply("ReadFromKafka", KafkaIO.<String, String>read()
.withBootstrapServers("kafka:9092")
.withTopic("page_views")
.withKeyDeserializer(StringDeserializer.class)
.withValueDeserializer(StringDeserializer.class))
// What: 解析并按用户ID分组
.apply("ParseEvent", ParDo.of(new ParseEventFn()))
.apply("ExtractUserKey", WithKeys.of(event -> event.getUserId()))
// Where: 30分钟会话窗口
.apply("SessionWindow", Window.<KV<String, Event>>into(
Sessions.withGapDuration(Duration.standardMinutes(30)))
// When: 复合触发策略
.triggering(AfterWatermark.pastEndOfWindow()
.withEarlyFirings(
AfterProcessingTime.pastFirstElementInPane()
.plusDelayOf(Duration.standardSeconds(30)))
.withLateFirings(AfterPane.elementCountAtLeast(1)))
.withAllowedLateness(Duration.standardDays(1))
// How: 累积并撤回模式
.accumulatingAndRetractingFiredPanes())
.apply("CountPerSession", Count.perKey())
.apply("WriteToBigQuery", BigQueryIO.writeTableRows()
.to("project:analytics.user_sessions")
.withWriteDisposition(WriteDisposition.WRITE_APPEND));
p.run().waitUntilFinish();这段代码定义了一个用户会话分析管道:从 Kafka 读取页面浏览事件,按用户 ID 分组,使用 30 分钟间隙的会话窗口,在水位线到达前每 30 秒输出一次早期结果,允许最多 1 天的迟到数据,使用累积并撤回模式确保下游正确性。
Flink 对 Dataflow 模型的吸收
Apache Flink 在发展过程中深度吸收了 Dataflow 模型的思想,但走了一条不同于 Beam 的路径。Flink 没有采用 Beam 的”SDK + Runner”分层架构,而是将 Dataflow 模型的核心概念直接内化到自己的引擎中。
Flink 对 Dataflow 模型四个维度的映射:
| Dataflow 维度 | Flink 对应概念 |
|---|---|
| What | DataStream API / Table API / SQL |
| Where | WindowAssigner(TumblingWindow、SlidingWindow、SessionWindow、GlobalWindow) |
| When | Trigger 接口 + Watermark 策略(WatermarkStrategy) |
| How | PurgeOnFire / AccumulateOnFire(通过 Trigger 返回值控制) |
Flink 的水位线机制实现了论文中 Watermark
的全部语义。WatermarkStrategy
接口允许用户定义水位线的生成方式——可以基于数据中的时间戳字段周期性生成,也可以在每条记录到达时即时生成。Flink
的事件时间处理和乱序处理能力直接来源于 Dataflow 模型。
// Flink DataStream API:水位线和窗口
DataStream<Event> stream = env
.addSource(new FlinkKafkaConsumer<>("events", schema, props))
.assignTimestampsAndWatermarks(
WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(10))
.withTimestampAssigner((event, ts) -> event.getTimestamp()));
stream
.keyBy(Event::getUserId)
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.trigger(EventTimeTrigger.create())
.allowedLateness(Time.hours(2))
.aggregate(new CountAggregate())
.addSink(new ElasticsearchSink<>(config));Flink 在流批一体方面的独特贡献是 Table API 和 Flink SQL。通过将流和批都抽象为”动态表”(Dynamic Table),Flink 可以用相同的 SQL 查询处理有界和无界数据:
-- 同一条 SQL,在批模式下读取历史数据,在流模式下持续增量计算
SELECT
city,
TUMBLE_START(event_time, INTERVAL '1' HOUR) AS window_start,
COUNT(order_id) AS order_count,
SUM(amount) AS total_revenue
FROM orders
GROUP BY
city,
TUMBLE(event_time, INTERVAL '1' HOUR);这条 SQL 在批模式下等价于一个标准的 GROUP BY 查询,在流模式下等价于一个持续查询(Continuous Query),每个窗口关闭时输出一行结果。动态表的概念将关系代数(Relational Algebra)与流处理语义统一起来——插入一行等价于一条新事件到达,更新一行等价于撤回旧值并发出新值。
七、流批一体的工程现实
Dataflow 模型在理论上实现了流与批的统一,但工程实践中的流批一体距离”写一份代码、两种模式下都能最优运行”的理想状态仍有差距。
性能特征的差异
批处理引擎经过多年优化,在处理有界数据时具有流处理引擎难以企及的优势:
- 全局排序和分区优化:批处理引擎可以在作业开始前分析整个数据集的分区分布,选择最优的 shuffle 策略(如 sort-merge join vs. broadcast join)。流处理引擎在数据持续到达的情况下无法执行这种全局优化。
- 列式存储的充分利用:Parquet、ORC 等列式格式支持谓词下推和列裁剪,批处理引擎可以跳过不需要的数据块。流处理引擎消费的是行式的消息流(如 Kafka),无法利用这些优化。
- 资源利用效率:批处理作业的资源需求随时间均匀分布(或可预测地波动),而流处理作业必须为峰值流量预留资源,导致低谷期资源浪费。
Flink 从 1.12 版本开始引入批执行模式(Batch Execution Mode),在处理有界数据时采用批处理优化策略(如 sort-based shuffle 替代 hash-based shuffle、避免不必要的状态持久化),显著缩小了与纯批引擎的性能差距,但仍未完全消除。
状态管理的挑战
流处理引擎需要维护大量的运行时状态:窗口内的聚合中间值、水位线的进度、每个键的会话窗口边界。这些状态的大小可能达到 TB 级别,需要高效的状态后端(State Backend)支持。
Flink 的 RocksDB 状态后端允许将状态存储在本地磁盘上,通过异步快照(Checkpoint)实现容错。但状态的序列化/反序列化开销在热路径上不可忽视,尤其是在高吞吐场景下。
批处理没有这个问题——中间结果要么全部在内存中,要么溢出到磁盘后不需要再次读取(或只读取一次),没有持续的状态访问开销。
Exactly-Once 语义的代价
流处理引擎为了保证端到端的精确一次语义(Exactly-Once Semantics),需要在 Checkpoint 机制和外部系统的事务之间做协调。Flink 的两阶段提交(Two-Phase Commit)协议将 Checkpoint 与 Kafka 事务绑定,确保在故障恢复时不会产生重复或遗漏。
这种协调机制引入了额外的延迟(Checkpoint 间隔通常设为秒到分钟级别)和吞吐量下降(Checkpoint Barrier 的对齐会暂停部分算子的处理)。批处理引擎通过任务级重试(Task-level Retry)实现容错,不需要运行时的 Checkpoint 开销。
实际落地模式
在当前的工程实践中,流批一体的落地通常采用以下模式之一:
模式一:统一 SQL 层。用 Flink SQL 或 Spark Structured Streaming 的 SQL 接口编写业务逻辑,由引擎自动选择批或流执行计划。这种模式在 ETL(Extract-Transform-Load)和聚合分析场景下效果良好,但在需要复杂状态管理或自定义窗口逻辑的场景下受 SQL 表达能力限制。
模式二:统一 API 层。用 Beam SDK 或 Flink DataStream API 编写管道逻辑,通过配置切换批/流执行模式。这种模式的灵活性最高,但对开发者要求也最高——需要理解 Dataflow 模型的全部概念才能正确使用。
模式三:分层统一。在存储层统一(如使用 Apache Iceberg 或 Apache Hudi 作为统一的表格式,同时支持批量读取和增量读取),在计算层仍然允许使用不同的引擎。这种模式务实但不彻底。
# 模式三示例:用 Iceberg 统一存储,不同引擎读取同一张表
# 批处理(Spark)
spark.read.format("iceberg").load("catalog.db.orders").filter(...)
# 流处理(Flink SQL)
# CREATE TABLE orders WITH ('connector' = 'iceberg', 'catalog' = '...');
# SELECT * FROM orders /*+ OPTIONS('streaming' = 'true') */;真实案例中的取舍
某金融科技公司的风控系统需要同时满足两个需求:实时拦截可疑交易(延迟要求 < 100ms),以及每日生成完整的风险评估报告。团队最初尝试用 Flink 统一处理两个需求,但发现问题:
- 实时拦截管道需要极低的 Checkpoint 间隔(100ms),导致状态后端压力极大。
- 日报生成需要扫描全天数据并执行复杂的聚合和 join 操作,在 Flink 流模式下的性能远不如 Spark 批模式。
- 两个管道共享集群资源时,实时管道的延迟在日报生成期间出现毛刺。
最终方案是:实时拦截用 Flink 流处理,日报生成用 Spark 批处理,两者通过 Iceberg 表共享数据。业务逻辑的核心部分(特征计算函数)被抽取为独立的 Java 库,在两个引擎中共享调用。这种方案没有实现理论上的流批一体,但在工程约束下达到了合理的平衡。
八、未来展望
Dataflow 模型奠定了流批统一的理论基础,但数据处理领域的演进并未停止。以下几个方向正在拓展 Dataflow 模型的边界。
增量计算(Incremental Processing)
传统批处理每次运行都重新计算全量数据,即使输入数据只变化了很小的一部分。增量计算的思路是只重新计算受变化影响的部分,在保持批处理正确性保证的同时,大幅降低计算成本。
数据库领域的增量视图维护(Incremental View Maintenance,IVM)技术正在被引入数据处理框架。Materialize、RisingWave 等系统将 SQL 查询编译为增量计算图(Differential Dataflow),当输入表发生变更时,只计算变更部分对查询结果的影响。
这种方法模糊了”批”与”流”的边界——它既不是定期执行的批处理,也不是逐条处理的流处理,而是按变更集(Changeset)驱动的增量处理。从 Dataflow 模型的角度看,增量计算可以理解为一种特殊的触发策略:当输入数据发生变更时触发,并使用撤回模式修正之前的输出。
物化视图(Materialized Views)的复兴
数据库中的物化视图概念——预计算查询结果并在输入变化时自动更新——正在以新的形式出现在数据处理系统中。Flink 的 Dynamic Table 本质上就是一个物化视图:它持续维护一个查询结果的最新状态,当输入数据到达时自动更新。
这种趋势的深层含义是:数据处理系统和数据库系统正在融合。传统上,数据处理系统(MapReduce、Spark、Flink)负责数据变换,数据库系统(MySQL、PostgreSQL)负责数据存储和查询。物化视图的自动维护要求系统同时具备高效的增量计算能力和高效的状态存储与查询能力。
实时机器学习管道
机器学习管道对流批一体提出了特殊的需求。模型训练通常是批处理任务(扫描全量训练数据),而模型推理(特征计算和预测)需要实时完成。两者之间的桥梁是特征工程(Feature Engineering)——训练时和推理时必须使用完全相同的特征计算逻辑,否则会产生训练-推理偏差(Training-Serving Skew)。
这与本文开篇的场景完全平行——不同的代码计算相同的业务逻辑导致结果不一致。Dataflow 模型为特征工程提供了一个天然的统一框架:用相同的 Beam 管道定义特征计算逻辑,在训练时以批模式运行,在推理时以流模式运行。
Feast、Tecton 等特征平台(Feature Store/Feature Platform)正在朝这个方向演进。核心挑战在于:训练时的特征通常需要”时间旅行”(Point-in-Time Correct Feature)——在历史的某个时间点,特征的值是什么?这要求系统不仅维护当前状态,还要维护状态的历史版本,这将 Dataflow 模型的复杂度提升到新的层次。
声明式数据管道
当前的数据处理 API(包括 Beam SDK 和 Flink DataStream API)仍然是命令式的——用户明确指定数据如何流动、如何变换。SQL 向声明式方向迈进了一步,但受限于关系代数的表达能力。
下一代数据处理系统可能会进一步提高抽象层次:用户只声明”我需要什么数据、满足什么正确性约束、在什么延迟预算内”,系统自动选择最优的执行策略——是批处理、流处理还是增量处理。这本质上是将 Dataflow 模型四个维度中的 When 和 How 从用户手中收回,由系统根据约束条件自动决定。
这一愿景的实现还需要在成本模型(Cost Model)、自适应优化(Adaptive Optimization)和自动调参(Auto-tuning)等方面取得突破。
参考文献
- Akidau, T., Bradshaw, R., Chambers, C., et al. (2015). “The Dataflow Model: A Practical Approach to Balancing Correctness, Latency, and Cost in Massive-Scale, Unbounded, Out-of-Order Data Processing.” Proceedings of the VLDB Endowment, 8(12), 1792-1803. https://vldb.org/pvldb/vol8/p1792-Akidau.pdf
- Akidau, T., Chernyak, S., Lax, R. (2018). Streaming Systems: The What, Where, When, and How of Large-Scale Data Processing. O’Reilly Media. https://www.oreilly.com/library/view/streaming-systems/9781491983867/
- Kreps, J. (2014). “Questioning the Lambda Architecture.” O’Reilly Radar. https://www.oreilly.com/radar/questioning-the-lambda-architecture/
- Marz, N., Warren, J. (2015). Big Data: Principles and Best Practices of Scalable Real-Time Data Systems. Manning Publications.
- Apache Beam Programming Guide. https://beam.apache.org/documentation/programming-guide/
- Carbone, P., Katsifodimos, A., Ewen, S., et al. (2015). “Apache Flink: Stream and Batch Processing in a Single Engine.” Bulletin of the IEEE Computer Society Technical Committee on Data Engineering, 38(4). https://asterios.katsifodimos.com/assets/publications/flink-deb.pdf
- Akidau, T. (2015). “The World Beyond Batch: Streaming 101.” O’Reilly. https://www.oreilly.com/radar/the-world-beyond-batch-streaming-101/
- Akidau, T. (2016). “The World Beyond Batch: Streaming 102.” O’Reilly. https://www.oreilly.com/radar/the-world-beyond-batch-streaming-102/
- Apache Flink Documentation: Windowing. https://nightlies.apache.org/flink/flink-docs-stable/docs/dev/datastream/operators/windows/
- McSherry, F., Murray, D. G., Isaacs, R., Isard, M. (2013). “Differential Dataflow.” CIDR 2013. https://www.cidrdb.org/cidr2013/Papers/CIDR13_Paper111.pdf
上一篇:Flink 深度拆解 下一篇:ZooKeeper 内核