Streaming Systems:流处理真正难的不是实时,而是时间、状态和修正
很多人第一次接触流处理,会把它理解成“更快的批处理”:数据一来就算,结果立刻出来。
这个理解不算错,但太浅了。《Streaming Systems》真正想讲的不是怎么把延迟压低,而是当数据没有结束点、事件会乱序、结果会被迟到数据推翻时,系统该怎么给出一个可解释的答案。
流处理最难的地方不是实时,而是时间、状态和修正。
这篇文章整理自《Streaming Systems》英文原文。我不会逐章复述原书,而是把全书重组为一条学习主线:先把术语定稳,再看 Beam Model 的四个问题,最后把精确一次、流与表、持久化状态、Streaming SQL 和 join 串起来。
术语约定
| English | 统一译名 | 说明 |
|---|---|---|
| streaming system | 流处理系统 | 以无界数据为一等对象设计的数据处理系统。不要等同于低延迟、近似或不正确。 |
| bounded data | 有界数据 | 大小有限、最终完整的数据集。 |
| unbounded data | 无界数据 | 持续到达、理论上没有结束点的数据集。 |
| stream | 流 | 随时间运动的数据记录序列,也可理解为表变化的观察结果。 |
| table | 表 | 某个时间点上静止的数据快照,也可由流累积得到。 |
| event time | 事件时间 | 事件真实发生的时间,由业务事实决定。 |
| processing time | 处理时间 | 数据被系统观察、处理或输出的时间,由运行环境决定。 |
| watermark | 水位线 | 系统对“事件时间进度”的估计或保证:小于等于某时间的输入大体已经到齐。 |
| trigger | 触发器 | 决定何时把中间结果物化输出的机制。 |
| window | 窗口 | 在事件时间维度上把数据分组的边界。 |
| pane | 窗格 | 同一个窗口在不同触发时刻输出的一次结果。 |
| accumulation mode | 累积模式 | 多次输出之间如何关联:丢弃、累积、累积并撤回。 |
| allowed lateness | 允许迟到 | 窗口结束后仍保留状态、继续接纳迟到数据的时间范围。 |
| retraction | 撤回 | 对之前输出结果的反向修正,用于保持下游一致。 |
| exactly-once | 精确一次 | 在系统语义上每条记录对最终结果只产生一次影响,不等于用户代码只执行一次。 |
| persistent state | 持久化状态 | 可跨故障、重启、升级保存的计算状态。 |
| time-varying relation, TVR | 时变关系 | 关系随时间变化的完整视图,是理解 Streaming SQL 的核心抽象。 |
| materialized view | 物化视图 | 从输入关系维护出来的结果表,本质上会随输入变化持续更新。 |
| validity window | 有效性窗口 | 表示某个值从何时开始有效、到何时被新值取代的时间区间。 |
全书主线
《Streaming Systems》的核心不是“如何写某个框架的 API”,而是建立一套理解数据处理的统一模型:
- 流处理系统不应该被定义为低延迟系统,而应该被定义为能原生处理无界数据的系统。
- 批处理和流处理不是两套本质不同的世界。设计良好的流处理模型可以覆盖批处理,只是执行效率和成本模型不同。
- 数据处理最难的问题不是把记录一条条搬过去,而是正确处理时间:事件时间、处理时间、迟到数据、窗口完成、结果修正。
- 水位线、触发器、窗口和累积模式四者组合,构成 Beam Model 的核心表达能力。
- 流和表是一体两面:流经过聚合成为表,表的变化被观察又成为流。
- SQL 可以自然地扩展到流处理,但前提是不要把流和表硬拆成不同代数,而要用“时变关系”保留关系代数闭包。
- 精确一次并不是神秘魔法,而是围绕 shuffle、状态、source、sink、幂等写入和重复检测建立起来的一组工程约束。
关键图片
这些图最值得在阅读本文时打开对照:






第 1 章:Streaming 101
第一章先纠正一个常见误解:流处理不是“不准确的快速处理”,也不是“只能做近似结果”。流处理系统应该被理解为一种针对无界数据设计的数据处理系统。无界数据持续到达,没有自然结束点;如果系统的所有核心假设都建立在“输入最终会完整结束”之上,那么它只能绕着无界数据打补丁。
为什么需要流处理
书中给出三个实际原因:
- 更低延迟。业务希望尽快看到结果,例如监控、风控、推荐、广告计费。
- 处理无界数据。很多现实数据本来就是持续发生的,例如点击流、交易流、传感器事件、日志。
- 更平滑地摊开工作量。把一天的数据攒到夜间批处理,往往造成资源峰值;持续处理可以把计算均匀分布到时间轴上。
但作者强调,低延迟只是流处理的一个常见收益,不是定义。一个系统可以低延迟但语义混乱,也可以流式但为了完整性选择延迟输出。
有界与无界,表与流
书中把数据从两个维度看:
- 基数维度:有界数据与无界数据。
- 构成维度:表与流。
有界数据适合传统批处理,因为系统可以等输入完整后再计算。无界数据没有“输入完整”的时刻,所以必须回答:什么时候输出?后来又来了数据怎么办?输出是否要修正?
表与流的区分更重要。表是某一刻的静态视图,流是随时间发生的数据变化。一个用户积分表可以被看作当前状态,也可以被看作积分变动事件的累积结果。反过来,观察这张表的每次变化,也能得到一条更新流。
事件时间与处理时间
事件时间是业务事实的时间,例如用户实际点击、交易实际发生、传感器实际采样。处理时间是系统看到这条数据的时间。二者经常不同,差异来自网络延迟、离线缓存、重试、移动设备断网、日志上传延迟等。
如果只按处理时间做窗口,系统得到的是“我什么时候看见这些数据”,不是“这些事件什么时候发生”。这在监控系统吞吐时可能正确,但在统计某小时真实成交额、广告转化归因、用户会话时通常错误。
因此,事件时间是流处理正确性的基础。真正的问题变成:系统如何在不知道未来是否还有迟到数据的情况下,给出可用结果?
Lambda Architecture 的历史位置
Lambda Architecture 曾经流行,是因为早期流处理系统速度快但不够正确,所以工程上用两套管道解决问题:
- 批处理层慢但准确,负责最终结果。
- 流处理层快但可能不准确,负责近期结果。
作者认为这不是理想形态。两套逻辑会带来不一致、重复实现、运维复杂度和结果不可预测。理想系统应该用一套模型同时覆盖批和流,在需要低延迟时可以早出结果,在需要完整性时可以等待和修正。
窗口
窗口把无界数据切成有限的、可聚合的子集合。常见窗口包括:
- 固定窗口:例如每 5 分钟一个窗口,不重叠。
- 滑动窗口:例如窗口长度 10 分钟,每 1 分钟滑动一次,窗口之间重叠。
- 会话窗口:根据某个 key 的活动间隔动态形成,例如用户 30 分钟无操作则会话结束。
固定窗口适合周期统计;滑动窗口适合移动指标;会话窗口适合用户行为,因为用户活动天然不按整点切分。
第一章的核心结论是:如果系统只提供处理时间窗口,它无法从根上解决事件时间正确性;如果系统支持事件时间、水位线、触发器和修正,它才有资格成为通用流处理系统。
第 2 章:What, Where, When, How
第二章给出 Beam Model 的四个核心问题,这是全书最重要的学习框架之一。
- What:计算什么结果?
- Where:在事件时间的哪里计算?
- When:在处理时间的什么时候输出?
- How:多次输出之间如何关联?
这四个问题把流处理从“写一个实时任务”提升为清晰的语义模型。
What:计算什么
What 对应转换逻辑,也就是从输入得到什么输出。比如过滤、解析、映射、求和、计数、分组、连接等。对于有界数据,What 往往是开发者最关注的问题;但对于无界数据,仅仅知道“算什么”远远不够。
例如统计游戏中每个队伍的得分。批处理里可以等所有游戏事件都到齐后算一个总分。但如果事件不断到达,就必须继续回答 Where、When、How。
Where:在事件时间的哪里计算
Where 对应窗口。还是游戏得分例子,如果只算全局总分,结果会无限增长;如果按两分钟事件时间窗口统计,就可以得到每个时间段每个队伍的得分。
窗口的关键点是:窗口是事件时间维度上的分组,而不是处理时间上的批次。窗口边界表达业务问题本身。
When:什么时候输出
When 对应触发器。触发器回答:窗口里的结果何时物化出来?
常见触发方式包括:
- 每条记录到达就触发,适合低延迟但输出频繁。
- 按处理时间延迟触发,例如每分钟输出一次当前结果。
- 水位线越过窗口结束时触发,适合输出“系统认为基本完整”的结果。
- 组合触发,例如先早期输出若干次,水位线到达时输出一次准完整结果,迟到数据到来后再输出修正。
触发器让系统显式表达“延迟、成本、完整性”之间的取舍。没有触发器,系统只能把这些选择藏在框架默认行为里。
水位线
水位线是系统对事件时间完整性的描述。直观地说,如果水位线到达 12:05,系统认为事件时间小于等于 12:05 的非迟到数据已经基本处理完。
水位线有两类:
- 完美水位线:系统有足够信息保证不会再有更早事件到来。
- 启发式水位线:系统基于观察估计进度,可能出现迟到数据。
真实系统中,完美水位线通常需要很强条件,例如输入日志分区有序且集合已知。移动端事件、消息队列、动态分片等场景更多依赖启发式水位线。
How:结果如何修正
同一个窗口可能输出多次。How 对应累积模式,决定每次输出和之前输出的关系。
- 丢弃模式:每次只输出自上次触发以来的新增贡献。下游如果要最终值,需要自己累加。
- 累积模式:每次输出当前窗口到目前为止的完整值。下游可以覆盖旧值。
- 累积并撤回模式:输出新完整值,同时撤回旧完整值。下游如果还要重新分组、连接或维护一致状态,会更容易。
书中用同一个窗口的得分变化说明三者差异:如果窗口先看到 3 分,后来又看到 9 分,丢弃模式输出新增贡献;累积模式输出先前总值和新总值;累积并撤回模式还会显式告诉下游旧值应被抵消。
允许迟到与状态回收
允许迟到解决的是“水位线之后还要等多久”的问题。窗口结束后,系统不会无限保留状态,否则无界数据会造成无界状态。允许迟到定义一个边界:
- 水位线过窗口结束时,可以输出准完整结果。
- 在允许迟到范围内到达的数据,可以更新窗口并触发修正。
- 超过允许迟到后,窗口状态被清理,更晚数据被丢弃或进入旁路处理。
这不是准确性与不准确性的区别,而是完整性边界。工程上必须明确接受什么数据、丢弃什么数据。
第 3 章:Watermarks
第三章把水位线从概念推进到系统实现。
水位线的定义
水位线可以理解为“系统尚未完成的最老事件时间”。它单调前进,代表系统对上游进度的认识。
水位线有两个重要作用:
- 完整性信号:当水位线超过某个窗口结束时间,系统可以认为该窗口的准完整输入已经到达。
- 可观测性信号:如果水位线停住,通常意味着某个分区、消息、worker 或外部依赖卡住。水位线不仅服务结果语义,也服务运维诊断。
水位线如何产生
水位线首先由 source 产生。source 对输入了解越多,水位线越准确。
完美水位线的典型条件:
- 输入数据的事件时间有序。
- 输入分区集合已知。
- 系统能确认每个分区的读取进度。
启发式水位线的典型场景:
- 动态产生的新分区。
- 消息队列中事件时间乱序。
- 移动端离线后集中上传。
- 发布者可能重试或延迟发送。
启发式水位线可以很好用,但必须接受迟到数据存在的事实。
水位线如何传播
source 生成水位线后,系统内部各阶段要传播水位线。一个阶段的输出水位线通常受两件事限制:
- 上游输入水位线。
- 当前阶段内部尚未完成的事件时间较早的工作。
如果一个阶段已经收到很新的输入,但内部还缓存着旧事件时间的数据没有处理,那么它不能盲目前推输出水位线。否则下游会误以为旧数据已经不会再来。
这里引出“阶段延迟”:输入水位线和输出水位线之间的差距。如果差距大,说明该阶段内部有积压或等待。
输出时间戳
流处理中每条输出也有事件时间。输出时间戳会影响下游水位线和窗口归属。
例如一个窗口聚合输出可以选择窗口结束时间作为输出时间戳,也可以选择窗口内最早元素时间。不同选择会影响下游认为这条结果属于哪个时间位置,以及下游能否推进水位线。
尤其是重叠窗口、会话窗口和多阶段聚合中,输出时间戳不是小细节,而是保证端到端事件时间正确性的关键。
百分位水位线
有些系统会用类似百分位的策略平衡延迟与完整性。直觉是:不要为了极少数极端迟到数据无限等待。选择更高百分位会提高完整性但增加延迟;选择更低百分位会降低延迟但增加迟到修正。
这再次说明水位线不是单纯技术指标,而是产品和业务语义的一部分。
事件时间水位线与处理时间水位线
书中强调可以同时观察事件时间水位线和处理时间水位线,以区分两类问题:
- 二者都落后:系统可能真的卡住,例如 worker 故障、外部依赖阻塞。
- 事件时间落后但处理时间正常:系统还在处理,只是数据本身的事件时间分布导致等待,例如窗口未完成或数据乱序。
这对排障非常重要。很多实时任务不是“慢”,而是“为了正确性正在等”。
Dataflow、Flink、Cloud Pub/Sub 的水位线思路
Dataflow 倾向使用集中式、带全局可见性的水位线聚合。优点是便于监控、诊断和做全局决策;代价是需要构建可扩展的中心聚合机制。
Flink 倾向把水位线作为数据流中的特殊记录传播。优点是自然分布式、延迟低、没有中心瓶颈;代价是全局可见性较弱,某些需要全局信息的 source 水位线较难表达。
Cloud Pub/Sub 的场景更复杂,因为消息可能乱序、延迟、重试。它用启发式方法估计 backlog 的事件时间边界,结合稀疏直方图等机制,在没有完美知识的情况下提供实用水位线。
第 4 章:Advanced Windowing
第四章处理更复杂的窗口模型,尤其是处理时间窗口、会话窗口和自定义窗口。
处理时间窗口什么时候合理
处理时间窗口并非错误。它适合回答“系统在某段处理时间内观察到了什么”。
例如监控每分钟系统接收的请求数,业务关心的是系统当前负载;此时处理时间窗口自然合理。
但如果问题是“用户在现实世界的哪一分钟完成了多少交易”,处理时间窗口就会扭曲事实。延迟上传的数据会落入错误窗口。
用事件时间模型表达处理时间需求
作者给出两个思路:
- 使用全局事件时间窗口,再用处理时间触发器周期性输出。这样本质上是在事件时间模型里表达“按处理时间观察进度”。
- 使用入口时间作为事件时间。数据进入系统时打上时间戳,之后仍用事件时间窗口和水位线处理。
第二种方式在某些系统监控场景很有用,因为入口时间可以形成相对可靠的完美或近似完美水位线。
会话窗口
会话窗口是本章重点。它不是按固定边界切分,而是按活动间隔合并。例如用户连续操作,只要两次操作间隔不超过 30 分钟,就属于同一个会话。
实现上可以把每条事件先看成一个“原始会话”:从事件时间开始,到事件时间加 gap 结束。随后系统不断合并相互重叠的会话。
难点在于乱序数据。一个迟到事件可能把两个已经输出过的会话连接成一个更大的会话。这时如果下游已经看到了两个旧会话,系统必须能撤回旧结果并输出新结果。没有撤回语义,会话窗口的正确性很难保持。
自定义窗口
书中讨论了几类自定义窗口:
- 非对齐固定窗口:不同 key 的窗口边界错开,用来平滑系统负载。代价是跨 key 对齐分析变难。
- 按元素或 key 决定大小的窗口:例如不同客户有不同统计周期。
- 有界会话窗口:限制会话最大长度、最大事件数或最大密度,避免异常行为让会话无限膨胀。
这些例子说明窗口不是简单工具函数,而是业务语义、资源消耗和结果可解释性的结合点。
第 5 章:Exactly-Once and Side Effects
第五章解释精确一次语义,并澄清一个重要误解:精确一次不等于用户代码只运行一次。
精确一次到底保证什么
在分布式系统里,失败、重试、RPC 超时、worker 重启都很正常。一个用户函数可能执行多次,甚至并发执行多次。系统能保证的是:在它管理的语义边界内,一条输入记录对下游结果只产生一次有效影响。
也就是说,精确一次是结果语义,不是执行次数语义。
如果用户函数向外部系统发送邮件、扣款、写一个没有幂等能力的服务,而这个副作用不受数据处理系统事务控制,那么框架无法自动保证外部世界也精确一次。
准确性与完整性
书中区分 accuracy 与 completeness。
精确一次解决准确性:不重复、不丢失系统承诺处理的数据。
允许迟到和状态回收影响完整性:超过系统设定边界的数据可能被丢弃。这不是精确一次失败,而是系统明确规定了输入完整性的截止线。
批处理也有完整性问题。例如你每天凌晨跑昨天数据,如果某些日志中午才补到,昨天的批结果同样不完整。
Shuffle 中的重复
shuffle 是精确一次的核心挑战之一。上游发送数据给下游时,如果 RPC 失败,上游不知道下游到底有没有收到。为了避免丢数据,上游会重试;重试又可能产生重复。
常见解决思路是:
- 给每条 shuffle 消息分配唯一 ID。
- 接收方保存已见 ID。
- 重复消息到达时丢弃。
如果用户代码非确定性,例如生成随机数或读取当前时间,系统还需要在合适边界把输出和 ID 一起检查点化,否则重试可能产生不同输出。
性能优化
重复检测听起来昂贵,因为每条消息都要查状态。系统会用多种优化降低成本:
- 图优化与融合:减少物理 shuffle 次数。
- combiner lifting:在 shuffle 前先局部聚合,减少消息量。
- Bloom filter:快速判断某个 ID 肯定没见过;只有可能见过时才查昂贵状态。
- 按时间分桶的 Bloom filter:避免过滤器长期饱和,也方便状态回收。

Source 与 Sink
source 要尽可能保证每条外部输入只被系统认领一次。例如文件 source 可以按偏移、分片、范围管理进度;消息系统可以依赖稳定消息 ID 或发布者提供的幂等 ID。
sink 更难,因为外部系统不一定支持事务。书中给出两个典型策略:
- 文件 sink 先写临时文件,成功后原子 rename。
- BigQuery sink 先生成稳定 UUID,通过 reshuffle 固化这个 UUID,再利用 BigQuery 的 insert ID 去重。
通用原则是:把非幂等准备工作和幂等提交拆开,让失败重试不会产生新的不可控外部副作用。
Spark 与 Flink 的路线
Spark Streaming 通过微批把流处理转化为一系列小批处理。它利用批处理的确定性和检查点获得强一致性,但延迟和表达能力受微批模型影响。
Flink 使用分布式快照,类似 Chandy-Lamport barrier。系统周期性产生一致快照;失败后回滚到最近快照。对外部 sink,通常要等快照完成后再确认不可撤销输出。
这些系统路线不同,但目标相同:在故障和重试不可避免的现实中,让最终结果语义可解释。
第 6 章:Streams and Tables
第六章是全书理论核心。理解这一章,就能把前面所有概念重新组织起来。
流和表是一体两面
书中的核心关系很简洁:
- 流到表:把流中的更新累积起来,就得到表。
- 表到流:观察表随时间发生的变化,就得到流。
表是静止的数据,流是运动中的数据。二者不是对立技术栈,而是同一数据事实的不同视图。
用流和表重新解释 MapReduce
MapReduce 看似传统批处理,但也可以被解释为流与表的变换:
- 从输入表读取,形成记录流。
- Map 把流转成另一条流。
- shuffle 和分组把流落成中间表。
- Reduce 从中间表读取分组数据,形成流。
- 最终写出结果表。
这说明“流”并不等于“无界”。有界批处理内部也有数据流动,只是它通常被隐藏在执行引擎里。
用流和表重述四个问题
What:
非分组操作通常是流到流;分组聚合是流到表。
Where:
窗口是在分组中加入事件时间维度。key 仍然是并行和原子性的核心单位,窗口是 key 下的时间子分组。会话窗口合并时,本质上是在 key 内调整这些时间分组。
When:
触发器把表再次转成流。表中间状态什么时候被观察并输出,取决于触发策略。批处理默认触发器可以理解为“输入完整后触发一次”。
How:
累积模式决定输出流携带什么语义:增量、当前值,还是当前值加撤回。
通用数据处理理论
第六章最后把模型推广成通用理论:
- stream -> stream:非分组变换,例如 map、filter、flatMap。
- stream -> table:分组、聚合、窗口,把运动中的数据累积成状态。
- table -> stream:触发,把状态变化重新观察为流。
- table -> table:没有直接操作,必须先让数据动起来。
这个理论的价值在于,它让批、流、SQL、物化视图、窗口聚合、状态管理都落在同一套概念里。

第 7 章:Persistent State
第七章讨论持久化状态。无界流处理如果没有持久化状态,就无法在真实分布式环境中可靠运行。
为什么需要持久化状态
有界批处理失败后,通常可以重新读取完整输入重新计算。无界流处理不同:
- 输入可能不会永远保留。
- 任务可能运行数月或数年。
- 系统会经历 worker 故障、升级、扩缩容。
- 窗口、去重、join、会话、定时器都依赖跨记录状态。
持久化状态让系统在失败后恢复到一致位置,同时避免从头重放大量历史数据。
隐式状态:分组与 Combine
最朴素的 GroupByKey 会保存每个 key/window 下的所有原始值。这样语义简单,但状态很大,计算也可能重复。
CombineFn 提供更高效的方式。只要操作满足结合律和交换律,就可以用累加器表示中间状态。例如平均值不必保存所有数,只需要保存 sum 和 count。
增量 Combine 的收益包括:
- 状态更小。
- 数据到达时即可局部合并。
- shuffle 前可以先聚合,减少网络传输。
- 对热点 key 更友好。
显式状态与定时器
有些逻辑无法用普通窗口聚合表达。书中用广告转化归因举例:
用户先访问页面,再看到广告曝光,之后完成目标行为。系统要把目标行为归因到合适的广告曝光上。问题复杂在于:
- visit、impression、goal 可能乱序到达。
- 目标行为可能先于相关上下文到达系统。
- 系统需要沿用户行为链条向前追溯。
- 需要去重,避免同一曝光被重复归因。
这种场景需要显式状态结构,例如 MapState、SetState、ValueState,以及事件时间定时器。定时器可以让系统等到水位线推进到某个时间后,再认为相关早期事件已经足够完整,从而执行归因逻辑。

显式状态是命令式流处理能力。它不是窗口和触发器的替代品,而是当声明式聚合不足以表达业务状态机时的补充。
第 8 章:Streaming SQL
第八章试图回答:SQL 如何正确地处理流?
作者认为很多早期 Streaming SQL 的问题,是把流当成一种特殊输入,试图给它发明一套和关系代数不同的规则。更好的做法是保留关系代数,把时间纳入关系变化。
关系代数的闭包
传统 SQL 的强大之处在于闭包:关系操作的输入是关系,输出仍然是关系。因此查询可以组合、嵌套、优化。
如果 Streaming SQL 把 stream 和 table 设计成完全不同的东西,就容易破坏闭包。某些操作输出流,某些操作输出表,组合规则变复杂,优化器也很难保持统一。
时变关系
时变关系是本章核心。一个时变关系不是单个静态表,而是表随时间演化的完整历史。你可以把它想成一系列相邻时间区间上的关系快照。
对时变关系应用关系操作,等价于对每个时间点上的关系应用传统关系操作。这样 SQL 的代数规则仍然成立。
同一个时变关系可以用三种方式观察:
- TABLE:某个时间点的快照。
- STREAM:变化日志,即插入、更新、删除事件。
- TVR:完整的时间演化概念。
因此,流和表不是不同代数,而是时变关系的不同物理呈现。
物化视图其实就是流式的
物化视图维护的是一个结果表。当基础表变化时,物化视图也要更新。这个过程本质上就是流处理:输入变化流驱动结果表变化。
所以 Streaming SQL 不应被看成 SQL 的边缘扩展,而应被看成把 SQL 已有的“关系随时间变化”显式化。
窗口、触发器与系统列
为了让 SQL 表达 Beam Model,需要把一些流处理概念纳入 SQL:
- 窗口函数:固定窗口、滑动窗口、会话窗口、有效性窗口等。
- 水位线:系统级完整性估计。
- 触发器:决定何时把时变关系的变化输出为流。
- 系统列:描述输出时机、输出序号、撤回标记等元信息。
书中提出一些系统列思路,例如:
- 修改时间:结果行最后一次被系统修改的处理时间。
- 发射时机:早期、准时、迟到。
- 发射序号:同一窗口多次输出的编号。
- 撤回标记:表示这条输出是正常结果还是撤回旧结果。
这些列不是业务数据,但对下游维护一致结果非常关键。
为什么撤回在 SQL 中重要
会话窗口是最好的例子。假设先输出了两个独立会话,迟到事件后来把它们连接成一个更大回话。没有撤回时,下游 key-value 存储必须自己读旧值、删旧会话、写新会话,而且这个过程很难幂等。
如果系统输出撤回,下游只需按流语义处理:删除被撤回的旧行,写入新行。这更符合关系变化日志的模型。
Streaming SQL 的理想形态
一个可靠的 Streaming SQL 应该:
- 不破坏 SQL 的关系闭包。
- 用时变关系统一表和流。
- 提供窗口、水位线、触发器、撤回等流处理语义。
- 保持默认行为简单,例如表输入默认输出表,含流输入默认输出流。
- 允许高级用户显式控制输出形态和修正语义。
第 9 章:Streaming Joins
第九章讲连接。作者的观点是:所有 join 从本质上都可以看作 streaming join,因为 join 需要按 key 或谓词把数据组织成状态,再随着输入变化输出结果变化。
用 FULL OUTER JOIN 统一理解 join 类型
书中从 FULL OUTER JOIN 出发,因为它包含最完整的信息:
- 匹配成功的左右记录。
- 左侧未匹配记录,右侧为空。
- 右侧未匹配记录,左侧为空。
其他 join 可以看作在 FULL OUTER JOIN 结果上的过滤:
- INNER JOIN 只保留匹配行。
- LEFT JOIN 保留左侧全部及匹配右侧。
- RIGHT JOIN 类似。
- ANTI JOIN 保留未匹配的一侧。
- SEMI JOIN 保留存在匹配的一侧,但不展开右侧数据。
流式场景下,未匹配状态可能后来变成匹配状态。因此系统可能需要撤回之前的“未匹配”输出,再发出新的匹配输出。
无窗口 join 并不一定错误
很多人认为无界流 join 必须加窗口。作者指出这不总是正确。对于某些业务,例如维护用户表和订单流的关联,全局状态加 per-record trigger 可能更自然。这更像维护物化视图,而不是按固定时间段切开。
窗口 join 的价值在于:
- 限定状态范围。
- 表达业务上的时间邻近关系。
- 让结果和水位线完成时机绑定。
是否加窗口应由业务语义决定,而不是由“流处理必须有窗口”这个误解决定。
固定窗口 join
固定窗口 join 会把左右两侧数据放到相同事件时间窗口内,只连接同一窗口的数据。水位线越过窗口结束后,系统输出该窗口结果。
这种方式适合明确要求“同一时间段内发生”的连接,例如同一分钟内的曝光和点击。但它不适合所有时间关系。
时态有效性 join
时态有效性是本章最重要的模型。书中用货币汇率举例:
- 汇率不是只在某个时间点有效。
- 一条汇率从它的事件时间开始有效,直到下一条同币种汇率到来。
- 订单要使用订单事件时间所在有效区间内的汇率。
如果 12:00 的汇率是 114,12:06 的汇率是 118,那么 12:03 的订单应使用 114。若后来迟到到达一条 12:03 的汇率 116,那么它会把有效区间拆开:12:00 到 12:03 使用 114,12:03 到 12:06 使用 116。已经按 114 计算的订单可能需要撤回并重新输出。
这说明 join 的难点不只是 key 匹配,而是时间有效性和修正语义。
使用水位线触发可以等待汇率有效区间相对完整后再输出,从而减少修正。但如果水位线是启发式的,迟到修正仍可能发生。
第 10 章:The Evolution of Large-Scale Data Processing
第十章回顾大规模数据处理系统的演进。它不是完整历史,而是围绕 MapReduce/Hadoop 这一脉络解释各系统贡献。
MapReduce
MapReduce 的贡献是把大规模分布式数据处理抽象成简单的 map 和 reduce API,并由执行引擎处理容错、调度和数据移动。它让普通工程师也能写大规模批处理。
Hadoop
Hadoop 的贡献是开源生态。它把 MapReduce 思路带给更广泛社区,并催生 Pig、Hive、HBase、Crunch 等工具。Hadoop 的重要性不只在技术实现,也在生态扩散。
Flume
Flume 的贡献是更高层的逻辑管道和自动优化。开发者描述逻辑转换,系统优化执行图,例如融合阶段、提升 combiner、动态均衡工作。后来这些思想影响 Dataflow 和 Beam。
Storm
Storm 把低延迟流处理带到大众视野。它强调实时性,但在强一致和事件时间语义上较弱。这种局限推动了 Lambda Architecture:用快但弱的流层配合慢但准的批层。
Spark
Spark 的贡献是把强一致性重新带回近实时处理。微批模型利用批处理引擎的确定性和容错机制,降低了实时计算的复杂度。但微批仍然有模型上的限制。
MillWheel
MillWheel 重点解决乱序流处理。它强调强一致、精确一次、水位线、定时器和持久化状态,是现代事件时间流处理的重要前身。
Kafka
Kafka 的核心贡献是持久化流。它把日志作为可重放、可持久保存的数据结构,使流不仅是传输通道,也成为系统间共享事实的基础。Kafka 也推动了流与表的统一理解。
Cloud Dataflow
Cloud Dataflow 把 MillWheel 的时间语义和 Flume 的高层可优化管道结合起来,形成统一批流模型。Beam Model 正是在这一背景下提炼出来的。
Flink
Flink 在开源世界推进了很多流处理创新,包括事件时间、水位线、分布式快照、savepoint、Streaming SQL 等。它把现代流处理语义带入更广泛实践。
Beam
Beam 的目标是可移植的编程模型。它试图像 SQL 之于关系处理那样,成为程序化数据处理的共同表达层。Beam 的挑战是既不能退化成最低公分母,也不能变成无所不包的复杂集合。

贯穿全书的核心模型
模型一:无界数据的四问
面对任何无界数据任务,都可以先问:
- What:到底要计算什么业务结果?
- Where:结果属于哪些事件时间范围?
- When:何时输出早期结果、准完整结果和迟到修正?
- How:多次输出之间是增量、覆盖还是撤回?
这个框架能帮助你避免“先写代码再猜语义”。
模型二:事件时间优先
只要业务问题描述的是事件真实发生时间,就应优先用事件时间建模。处理时间可以用于触发、监控、入口时间分析和系统负载观察,但不应偷偷替代业务时间。
模型三:水位线不是完成事实,而是系统承诺
完美水位线是强保证,启发式水位线是估计。系统通过水位线表达它愿意在什么时候把结果称为“准完整”。迟到数据策略必须和水位线一起设计。
模型四:表和流互相转换
分组让流变成表,触发让表变成流。理解这一点后,窗口聚合、物化视图、Streaming SQL、join 和状态管理都能落入同一套框架。
模型五:撤回是高级流处理的必要能力
当结果会被迟到数据改变,尤其是会话窗口、外连接、时态 join、二次聚合时,撤回能显著简化下游一致性。没有撤回,下游往往被迫做复杂且不可靠的读改写。
工程实践启发
设计流处理任务时先写语义契约
不要一开始就选 Kafka、Flink、Spark 或 Beam API。先写清楚:
- 输入事件时间字段是什么?
- 乱序和迟到的可接受范围是多少?
- 窗口是否符合业务语义?
- 需要早期结果吗?
- 迟到数据是修正、旁路还是丢弃?
- 下游能处理覆盖或撤回吗?
- 外部 sink 是否幂等?
这些答案比框架 API 更重要。
低延迟与正确性不是二选一
现代流处理模型允许同时拥有早期结果和最终修正。低延迟输出可以是 speculative result,水位线输出可以是准完整结果,迟到数据可以产生修正。关键是让这些状态显式可见。
批流一体不是同一个执行模式
批流一体的真正含义是同一套语义模型可以覆盖有界和无界数据。它不意味着批和流必须用完全相同的物理执行策略。优秀系统会根据输入有界性、窗口、状态和触发器选择合适执行方式。
Exactly-once 要看边界
当系统声称精确一次时,要继续问:
- 覆盖 source 吗?
- 覆盖 shuffle 吗?
- 覆盖 state 吗?
- 覆盖 sink 吗?
- 覆盖用户函数里的外部副作用吗?
很多系统只能保证其管理边界内的结果语义,不能自动保证任意外部调用。
SQL 与流处理最终会靠近
如果把流处理理解为时变关系的持续维护,SQL 就不再只是批查询语言。窗口、水位线、触发器、撤回和系统列会成为 Streaming SQL 的关键扩展点。
复习问题
- 为什么说“流处理系统是批处理系统的严格超集”需要加上执行效率这个限定?
- 事件时间和处理时间分别适合回答什么问题?
- 水位线到达窗口结束时间后,为什么仍然可能有迟到数据?
- 允许迟到解决的是准确性问题还是完整性问题?
- 丢弃模式、累积模式、累积并撤回模式分别适合什么下游?
- 会话窗口为什么比固定窗口更需要撤回语义?
- 为什么精确一次不等于用户代码只运行一次?
- Bloom filter 在 shuffle 去重中解决什么性能问题?
- 如何用“流到表、表到流”解释窗口聚合?
- 显式状态和普通窗口聚合的边界在哪里?
- 时变关系如何保持 SQL 关系代数的闭包?
- 为什么某些无界 join 不一定需要窗口?
- 时态有效性 join 与普通固定窗口 join 的区别是什么?
- Lambda Architecture 解决了什么历史问题,又引入了什么长期成本?
最后总结
《Streaming Systems》真正想教的是一套处理时间、状态和变化的思维方式。它把流处理从“实时计算工具”提升为通用数据处理模型:
- 用事件时间表达业务事实。
- 用窗口定义事件时间范围。
- 用水位线描述输入完整性。
- 用触发器控制结果物化时机。
- 用累积与撤回表达结果修正。
- 用持久化状态承载长期计算。
- 用流与表统一理解批、流、SQL 和物化视图。
掌握这些概念后,再去学习 Beam、Flink、Spark Structured Streaming、Kafka Streams 或 Streaming SQL,会更容易看清每个系统的真正差异:它们不是在“能不能实时处理”上不同,而是在时间语义、状态模型、触发机制、撤回能力、source/sink 边界和执行优化上做了不同取舍。