NoBug World NoBug World

Streaming Systems:流处理真正难的不是实时,而是时间、状态和修正

AI 辅助创作声明 本文的内容、结构或代码排版等, 100% 由 AI 辅助生成。

很多人第一次接触流处理,会把它理解成“更快的批处理”:数据一来就算,结果立刻出来。

这个理解不算错,但太浅了。《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”,而是建立一套理解数据处理的统一模型:

  1. 流处理系统不应该被定义为低延迟系统,而应该被定义为能原生处理无界数据的系统。
  2. 批处理和流处理不是两套本质不同的世界。设计良好的流处理模型可以覆盖批处理,只是执行效率和成本模型不同。
  3. 数据处理最难的问题不是把记录一条条搬过去,而是正确处理时间:事件时间、处理时间、迟到数据、窗口完成、结果修正。
  4. 水位线、触发器、窗口和累积模式四者组合,构成 Beam Model 的核心表达能力。
  5. 流和表是一体两面:流经过聚合成为表,表的变化被观察又成为流。
  6. SQL 可以自然地扩展到流处理,但前提是不要把流和表硬拆成不同代数,而要用“时变关系”保留关系代数闭包。
  7. 精确一次并不是神秘魔法,而是围绕 shuffle、状态、source、sink、幂等写入和重复检测建立起来的一组工程约束。

关键图片

这些图最值得在阅读本文时打开对照:

事件时间与处理时间的二维关系

常见窗口策略

水位线与迟到数据

三种累积模式

流与表的关系

Beam 管道中的流与表

第 1 章:Streaming 101

第一章先纠正一个常见误解:流处理不是“不准确的快速处理”,也不是“只能做近似结果”。流处理系统应该被理解为一种针对无界数据设计的数据处理系统。无界数据持续到达,没有自然结束点;如果系统的所有核心假设都建立在“输入最终会完整结束”之上,那么它只能绕着无界数据打补丁。

为什么需要流处理

书中给出三个实际原因:

  1. 更低延迟。业务希望尽快看到结果,例如监控、风控、推荐、广告计费。
  2. 处理无界数据。很多现实数据本来就是持续发生的,例如点击流、交易流、传感器事件、日志。
  3. 更平滑地摊开工作量。把一天的数据攒到夜间批处理,往往造成资源峰值;持续处理可以把计算均匀分布到时间轴上。

但作者强调,低延迟只是流处理的一个常见收益,不是定义。一个系统可以低延迟但语义混乱,也可以流式但为了完整性选择延迟输出。

有界与无界,表与流

书中把数据从两个维度看:

  1. 基数维度:有界数据与无界数据。
  2. 构成维度:表与流。

有界数据适合传统批处理,因为系统可以等输入完整后再计算。无界数据没有“输入完整”的时刻,所以必须回答:什么时候输出?后来又来了数据怎么办?输出是否要修正?

表与流的区分更重要。表是某一刻的静态视图,流是随时间发生的数据变化。一个用户积分表可以被看作当前状态,也可以被看作积分变动事件的累积结果。反过来,观察这张表的每次变化,也能得到一条更新流。

事件时间与处理时间

事件时间是业务事实的时间,例如用户实际点击、交易实际发生、传感器实际采样。处理时间是系统看到这条数据的时间。二者经常不同,差异来自网络延迟、离线缓存、重试、移动设备断网、日志上传延迟等。

如果只按处理时间做窗口,系统得到的是“我什么时候看见这些数据”,不是“这些事件什么时候发生”。这在监控系统吞吐时可能正确,但在统计某小时真实成交额、广告转化归因、用户会话时通常错误。

因此,事件时间是流处理正确性的基础。真正的问题变成:系统如何在不知道未来是否还有迟到数据的情况下,给出可用结果?

Lambda Architecture 的历史位置

Lambda Architecture 曾经流行,是因为早期流处理系统速度快但不够正确,所以工程上用两套管道解决问题:

  1. 批处理层慢但准确,负责最终结果。
  2. 流处理层快但可能不准确,负责近期结果。

作者认为这不是理想形态。两套逻辑会带来不一致、重复实现、运维复杂度和结果不可预测。理想系统应该用一套模型同时覆盖批和流,在需要低延迟时可以早出结果,在需要完整性时可以等待和修正。

窗口

窗口把无界数据切成有限的、可聚合的子集合。常见窗口包括:

  1. 固定窗口:例如每 5 分钟一个窗口,不重叠。
  2. 滑动窗口:例如窗口长度 10 分钟,每 1 分钟滑动一次,窗口之间重叠。
  3. 会话窗口:根据某个 key 的活动间隔动态形成,例如用户 30 分钟无操作则会话结束。

固定窗口适合周期统计;滑动窗口适合移动指标;会话窗口适合用户行为,因为用户活动天然不按整点切分。

第一章的核心结论是:如果系统只提供处理时间窗口,它无法从根上解决事件时间正确性;如果系统支持事件时间、水位线、触发器和修正,它才有资格成为通用流处理系统。

第 2 章:What, Where, When, How

第二章给出 Beam Model 的四个核心问题,这是全书最重要的学习框架之一。

  1. What:计算什么结果?
  2. Where:在事件时间的哪里计算?
  3. When:在处理时间的什么时候输出?
  4. How:多次输出之间如何关联?

这四个问题把流处理从“写一个实时任务”提升为清晰的语义模型。

What:计算什么

What 对应转换逻辑,也就是从输入得到什么输出。比如过滤、解析、映射、求和、计数、分组、连接等。对于有界数据,What 往往是开发者最关注的问题;但对于无界数据,仅仅知道“算什么”远远不够。

例如统计游戏中每个队伍的得分。批处理里可以等所有游戏事件都到齐后算一个总分。但如果事件不断到达,就必须继续回答 Where、When、How。

Where:在事件时间的哪里计算

Where 对应窗口。还是游戏得分例子,如果只算全局总分,结果会无限增长;如果按两分钟事件时间窗口统计,就可以得到每个时间段每个队伍的得分。

窗口的关键点是:窗口是事件时间维度上的分组,而不是处理时间上的批次。窗口边界表达业务问题本身。

When:什么时候输出

When 对应触发器。触发器回答:窗口里的结果何时物化出来?

常见触发方式包括:

  1. 每条记录到达就触发,适合低延迟但输出频繁。
  2. 按处理时间延迟触发,例如每分钟输出一次当前结果。
  3. 水位线越过窗口结束时触发,适合输出“系统认为基本完整”的结果。
  4. 组合触发,例如先早期输出若干次,水位线到达时输出一次准完整结果,迟到数据到来后再输出修正。

触发器让系统显式表达“延迟、成本、完整性”之间的取舍。没有触发器,系统只能把这些选择藏在框架默认行为里。

水位线

水位线是系统对事件时间完整性的描述。直观地说,如果水位线到达 12:05,系统认为事件时间小于等于 12:05 的非迟到数据已经基本处理完。

水位线有两类:

  1. 完美水位线:系统有足够信息保证不会再有更早事件到来。
  2. 启发式水位线:系统基于观察估计进度,可能出现迟到数据。

真实系统中,完美水位线通常需要很强条件,例如输入日志分区有序且集合已知。移动端事件、消息队列、动态分片等场景更多依赖启发式水位线。

How:结果如何修正

同一个窗口可能输出多次。How 对应累积模式,决定每次输出和之前输出的关系。

  1. 丢弃模式:每次只输出自上次触发以来的新增贡献。下游如果要最终值,需要自己累加。
  2. 累积模式:每次输出当前窗口到目前为止的完整值。下游可以覆盖旧值。
  3. 累积并撤回模式:输出新完整值,同时撤回旧完整值。下游如果还要重新分组、连接或维护一致状态,会更容易。

书中用同一个窗口的得分变化说明三者差异:如果窗口先看到 3 分,后来又看到 9 分,丢弃模式输出新增贡献;累积模式输出先前总值和新总值;累积并撤回模式还会显式告诉下游旧值应被抵消。

允许迟到与状态回收

允许迟到解决的是“水位线之后还要等多久”的问题。窗口结束后,系统不会无限保留状态,否则无界数据会造成无界状态。允许迟到定义一个边界:

  1. 水位线过窗口结束时,可以输出准完整结果。
  2. 在允许迟到范围内到达的数据,可以更新窗口并触发修正。
  3. 超过允许迟到后,窗口状态被清理,更晚数据被丢弃或进入旁路处理。

这不是准确性与不准确性的区别,而是完整性边界。工程上必须明确接受什么数据、丢弃什么数据。

第 3 章:Watermarks

第三章把水位线从概念推进到系统实现。

水位线的定义

水位线可以理解为“系统尚未完成的最老事件时间”。它单调前进,代表系统对上游进度的认识。

水位线有两个重要作用:

  1. 完整性信号:当水位线超过某个窗口结束时间,系统可以认为该窗口的准完整输入已经到达。
  2. 可观测性信号:如果水位线停住,通常意味着某个分区、消息、worker 或外部依赖卡住。水位线不仅服务结果语义,也服务运维诊断。

水位线如何产生

水位线首先由 source 产生。source 对输入了解越多,水位线越准确。

完美水位线的典型条件:

  1. 输入数据的事件时间有序。
  2. 输入分区集合已知。
  3. 系统能确认每个分区的读取进度。

启发式水位线的典型场景:

  1. 动态产生的新分区。
  2. 消息队列中事件时间乱序。
  3. 移动端离线后集中上传。
  4. 发布者可能重试或延迟发送。

启发式水位线可以很好用,但必须接受迟到数据存在的事实。

水位线如何传播

source 生成水位线后,系统内部各阶段要传播水位线。一个阶段的输出水位线通常受两件事限制:

  1. 上游输入水位线。
  2. 当前阶段内部尚未完成的事件时间较早的工作。

如果一个阶段已经收到很新的输入,但内部还缓存着旧事件时间的数据没有处理,那么它不能盲目前推输出水位线。否则下游会误以为旧数据已经不会再来。

这里引出“阶段延迟”:输入水位线和输出水位线之间的差距。如果差距大,说明该阶段内部有积压或等待。

输出时间戳

流处理中每条输出也有事件时间。输出时间戳会影响下游水位线和窗口归属。

例如一个窗口聚合输出可以选择窗口结束时间作为输出时间戳,也可以选择窗口内最早元素时间。不同选择会影响下游认为这条结果属于哪个时间位置,以及下游能否推进水位线。

尤其是重叠窗口、会话窗口和多阶段聚合中,输出时间戳不是小细节,而是保证端到端事件时间正确性的关键。

百分位水位线

有些系统会用类似百分位的策略平衡延迟与完整性。直觉是:不要为了极少数极端迟到数据无限等待。选择更高百分位会提高完整性但增加延迟;选择更低百分位会降低延迟但增加迟到修正。

这再次说明水位线不是单纯技术指标,而是产品和业务语义的一部分。

事件时间水位线与处理时间水位线

书中强调可以同时观察事件时间水位线和处理时间水位线,以区分两类问题:

  1. 二者都落后:系统可能真的卡住,例如 worker 故障、外部依赖阻塞。
  2. 事件时间落后但处理时间正常:系统还在处理,只是数据本身的事件时间分布导致等待,例如窗口未完成或数据乱序。

这对排障非常重要。很多实时任务不是“慢”,而是“为了正确性正在等”。

Dataflow、Flink、Cloud Pub/Sub 的水位线思路

Dataflow 倾向使用集中式、带全局可见性的水位线聚合。优点是便于监控、诊断和做全局决策;代价是需要构建可扩展的中心聚合机制。

Flink 倾向把水位线作为数据流中的特殊记录传播。优点是自然分布式、延迟低、没有中心瓶颈;代价是全局可见性较弱,某些需要全局信息的 source 水位线较难表达。

Cloud Pub/Sub 的场景更复杂,因为消息可能乱序、延迟、重试。它用启发式方法估计 backlog 的事件时间边界,结合稀疏直方图等机制,在没有完美知识的情况下提供实用水位线。

第 4 章:Advanced Windowing

第四章处理更复杂的窗口模型,尤其是处理时间窗口、会话窗口和自定义窗口。

处理时间窗口什么时候合理

处理时间窗口并非错误。它适合回答“系统在某段处理时间内观察到了什么”。

例如监控每分钟系统接收的请求数,业务关心的是系统当前负载;此时处理时间窗口自然合理。

但如果问题是“用户在现实世界的哪一分钟完成了多少交易”,处理时间窗口就会扭曲事实。延迟上传的数据会落入错误窗口。

用事件时间模型表达处理时间需求

作者给出两个思路:

  1. 使用全局事件时间窗口,再用处理时间触发器周期性输出。这样本质上是在事件时间模型里表达“按处理时间观察进度”。
  2. 使用入口时间作为事件时间。数据进入系统时打上时间戳,之后仍用事件时间窗口和水位线处理。

第二种方式在某些系统监控场景很有用,因为入口时间可以形成相对可靠的完美或近似完美水位线。

会话窗口

会话窗口是本章重点。它不是按固定边界切分,而是按活动间隔合并。例如用户连续操作,只要两次操作间隔不超过 30 分钟,就属于同一个会话。

实现上可以把每条事件先看成一个“原始会话”:从事件时间开始,到事件时间加 gap 结束。随后系统不断合并相互重叠的会话。

难点在于乱序数据。一个迟到事件可能把两个已经输出过的会话连接成一个更大的会话。这时如果下游已经看到了两个旧会话,系统必须能撤回旧结果并输出新结果。没有撤回语义,会话窗口的正确性很难保持。

自定义窗口

书中讨论了几类自定义窗口:

  1. 非对齐固定窗口:不同 key 的窗口边界错开,用来平滑系统负载。代价是跨 key 对齐分析变难。
  2. 按元素或 key 决定大小的窗口:例如不同客户有不同统计周期。
  3. 有界会话窗口:限制会话最大长度、最大事件数或最大密度,避免异常行为让会话无限膨胀。

这些例子说明窗口不是简单工具函数,而是业务语义、资源消耗和结果可解释性的结合点。

第 5 章:Exactly-Once and Side Effects

第五章解释精确一次语义,并澄清一个重要误解:精确一次不等于用户代码只运行一次。

精确一次到底保证什么

在分布式系统里,失败、重试、RPC 超时、worker 重启都很正常。一个用户函数可能执行多次,甚至并发执行多次。系统能保证的是:在它管理的语义边界内,一条输入记录对下游结果只产生一次有效影响。

也就是说,精确一次是结果语义,不是执行次数语义。

如果用户函数向外部系统发送邮件、扣款、写一个没有幂等能力的服务,而这个副作用不受数据处理系统事务控制,那么框架无法自动保证外部世界也精确一次。

准确性与完整性

书中区分 accuracy 与 completeness。

精确一次解决准确性:不重复、不丢失系统承诺处理的数据。

允许迟到和状态回收影响完整性:超过系统设定边界的数据可能被丢弃。这不是精确一次失败,而是系统明确规定了输入完整性的截止线。

批处理也有完整性问题。例如你每天凌晨跑昨天数据,如果某些日志中午才补到,昨天的批结果同样不完整。

Shuffle 中的重复

shuffle 是精确一次的核心挑战之一。上游发送数据给下游时,如果 RPC 失败,上游不知道下游到底有没有收到。为了避免丢数据,上游会重试;重试又可能产生重复。

常见解决思路是:

  1. 给每条 shuffle 消息分配唯一 ID。
  2. 接收方保存已见 ID。
  3. 重复消息到达时丢弃。

如果用户代码非确定性,例如生成随机数或读取当前时间,系统还需要在合适边界把输出和 ID 一起检查点化,否则重试可能产生不同输出。

性能优化

重复检测听起来昂贵,因为每条消息都要查状态。系统会用多种优化降低成本:

  1. 图优化与融合:减少物理 shuffle 次数。
  2. combiner lifting:在 shuffle 前先局部聚合,减少消息量。
  3. Bloom filter:快速判断某个 ID 肯定没见过;只有可能见过时才查昂贵状态。
  4. 按时间分桶的 Bloom filter:避免过滤器长期饱和,也方便状态回收。

Bloom Filter 辅助重复检测

Source 与 Sink

source 要尽可能保证每条外部输入只被系统认领一次。例如文件 source 可以按偏移、分片、范围管理进度;消息系统可以依赖稳定消息 ID 或发布者提供的幂等 ID。

sink 更难,因为外部系统不一定支持事务。书中给出两个典型策略:

  1. 文件 sink 先写临时文件,成功后原子 rename。
  2. BigQuery sink 先生成稳定 UUID,通过 reshuffle 固化这个 UUID,再利用 BigQuery 的 insert ID 去重。

通用原则是:把非幂等准备工作和幂等提交拆开,让失败重试不会产生新的不可控外部副作用。

Spark Streaming 通过微批把流处理转化为一系列小批处理。它利用批处理的确定性和检查点获得强一致性,但延迟和表达能力受微批模型影响。

Flink 使用分布式快照,类似 Chandy-Lamport barrier。系统周期性产生一致快照;失败后回滚到最近快照。对外部 sink,通常要等快照完成后再确认不可撤销输出。

这些系统路线不同,但目标相同:在故障和重试不可避免的现实中,让最终结果语义可解释。

第 6 章:Streams and Tables

第六章是全书理论核心。理解这一章,就能把前面所有概念重新组织起来。

流和表是一体两面

书中的核心关系很简洁:

  1. 流到表:把流中的更新累积起来,就得到表。
  2. 表到流:观察表随时间发生的变化,就得到流。

表是静止的数据,流是运动中的数据。二者不是对立技术栈,而是同一数据事实的不同视图。

用流和表重新解释 MapReduce

MapReduce 看似传统批处理,但也可以被解释为流与表的变换:

  1. 从输入表读取,形成记录流。
  2. Map 把流转成另一条流。
  3. shuffle 和分组把流落成中间表。
  4. Reduce 从中间表读取分组数据,形成流。
  5. 最终写出结果表。

这说明“流”并不等于“无界”。有界批处理内部也有数据流动,只是它通常被隐藏在执行引擎里。

用流和表重述四个问题

What:

非分组操作通常是流到流;分组聚合是流到表。

Where:

窗口是在分组中加入事件时间维度。key 仍然是并行和原子性的核心单位,窗口是 key 下的时间子分组。会话窗口合并时,本质上是在 key 内调整这些时间分组。

When:

触发器把表再次转成流。表中间状态什么时候被观察并输出,取决于触发策略。批处理默认触发器可以理解为“输入完整后触发一次”。

How:

累积模式决定输出流携带什么语义:增量、当前值,还是当前值加撤回。

通用数据处理理论

第六章最后把模型推广成通用理论:

  1. stream -> stream:非分组变换,例如 map、filter、flatMap。
  2. stream -> table:分组、聚合、窗口,把运动中的数据累积成状态。
  3. table -> stream:触发,把状态变化重新观察为流。
  4. table -> table:没有直接操作,必须先让数据动起来。

这个理论的价值在于,它让批、流、SQL、物化视图、窗口聚合、状态管理都落在同一套概念里。

Beam 管道中的流与表

第 7 章:Persistent State

第七章讨论持久化状态。无界流处理如果没有持久化状态,就无法在真实分布式环境中可靠运行。

为什么需要持久化状态

有界批处理失败后,通常可以重新读取完整输入重新计算。无界流处理不同:

  1. 输入可能不会永远保留。
  2. 任务可能运行数月或数年。
  3. 系统会经历 worker 故障、升级、扩缩容。
  4. 窗口、去重、join、会话、定时器都依赖跨记录状态。

持久化状态让系统在失败后恢复到一致位置,同时避免从头重放大量历史数据。

隐式状态:分组与 Combine

最朴素的 GroupByKey 会保存每个 key/window 下的所有原始值。这样语义简单,但状态很大,计算也可能重复。

CombineFn 提供更高效的方式。只要操作满足结合律和交换律,就可以用累加器表示中间状态。例如平均值不必保存所有数,只需要保存 sum 和 count。

增量 Combine 的收益包括:

  1. 状态更小。
  2. 数据到达时即可局部合并。
  3. shuffle 前可以先聚合,减少网络传输。
  4. 对热点 key 更友好。

显式状态与定时器

有些逻辑无法用普通窗口聚合表达。书中用广告转化归因举例:

用户先访问页面,再看到广告曝光,之后完成目标行为。系统要把目标行为归因到合适的广告曝光上。问题复杂在于:

  1. visit、impression、goal 可能乱序到达。
  2. 目标行为可能先于相关上下文到达系统。
  3. 系统需要沿用户行为链条向前追溯。
  4. 需要去重,避免同一曝光被重复归因。

这种场景需要显式状态结构,例如 MapState、SetState、ValueState,以及事件时间定时器。定时器可以让系统等到水位线推进到某个时间后,再认为相关早期事件已经足够完整,从而执行归因逻辑。

广告转化归因中的状态

显式状态是命令式流处理能力。它不是窗口和触发器的替代品,而是当声明式聚合不足以表达业务状态机时的补充。

第 8 章:Streaming SQL

第八章试图回答:SQL 如何正确地处理流?

作者认为很多早期 Streaming SQL 的问题,是把流当成一种特殊输入,试图给它发明一套和关系代数不同的规则。更好的做法是保留关系代数,把时间纳入关系变化。

关系代数的闭包

传统 SQL 的强大之处在于闭包:关系操作的输入是关系,输出仍然是关系。因此查询可以组合、嵌套、优化。

如果 Streaming SQL 把 stream 和 table 设计成完全不同的东西,就容易破坏闭包。某些操作输出流,某些操作输出表,组合规则变复杂,优化器也很难保持统一。

时变关系

时变关系是本章核心。一个时变关系不是单个静态表,而是表随时间演化的完整历史。你可以把它想成一系列相邻时间区间上的关系快照。

对时变关系应用关系操作,等价于对每个时间点上的关系应用传统关系操作。这样 SQL 的代数规则仍然成立。

同一个时变关系可以用三种方式观察:

  1. TABLE:某个时间点的快照。
  2. STREAM:变化日志,即插入、更新、删除事件。
  3. TVR:完整的时间演化概念。

因此,流和表不是不同代数,而是时变关系的不同物理呈现。

物化视图其实就是流式的

物化视图维护的是一个结果表。当基础表变化时,物化视图也要更新。这个过程本质上就是流处理:输入变化流驱动结果表变化。

所以 Streaming SQL 不应被看成 SQL 的边缘扩展,而应被看成把 SQL 已有的“关系随时间变化”显式化。

窗口、触发器与系统列

为了让 SQL 表达 Beam Model,需要把一些流处理概念纳入 SQL:

  1. 窗口函数:固定窗口、滑动窗口、会话窗口、有效性窗口等。
  2. 水位线:系统级完整性估计。
  3. 触发器:决定何时把时变关系的变化输出为流。
  4. 系统列:描述输出时机、输出序号、撤回标记等元信息。

书中提出一些系统列思路,例如:

  1. 修改时间:结果行最后一次被系统修改的处理时间。
  2. 发射时机:早期、准时、迟到。
  3. 发射序号:同一窗口多次输出的编号。
  4. 撤回标记:表示这条输出是正常结果还是撤回旧结果。

这些列不是业务数据,但对下游维护一致结果非常关键。

为什么撤回在 SQL 中重要

会话窗口是最好的例子。假设先输出了两个独立会话,迟到事件后来把它们连接成一个更大回话。没有撤回时,下游 key-value 存储必须自己读旧值、删旧会话、写新会话,而且这个过程很难幂等。

如果系统输出撤回,下游只需按流语义处理:删除被撤回的旧行,写入新行。这更符合关系变化日志的模型。

Streaming SQL 的理想形态

一个可靠的 Streaming SQL 应该:

  1. 不破坏 SQL 的关系闭包。
  2. 用时变关系统一表和流。
  3. 提供窗口、水位线、触发器、撤回等流处理语义。
  4. 保持默认行为简单,例如表输入默认输出表,含流输入默认输出流。
  5. 允许高级用户显式控制输出形态和修正语义。

第 9 章:Streaming Joins

第九章讲连接。作者的观点是:所有 join 从本质上都可以看作 streaming join,因为 join 需要按 key 或谓词把数据组织成状态,再随着输入变化输出结果变化。

用 FULL OUTER JOIN 统一理解 join 类型

书中从 FULL OUTER JOIN 出发,因为它包含最完整的信息:

  1. 匹配成功的左右记录。
  2. 左侧未匹配记录,右侧为空。
  3. 右侧未匹配记录,左侧为空。

其他 join 可以看作在 FULL OUTER JOIN 结果上的过滤:

  1. INNER JOIN 只保留匹配行。
  2. LEFT JOIN 保留左侧全部及匹配右侧。
  3. RIGHT JOIN 类似。
  4. ANTI JOIN 保留未匹配的一侧。
  5. SEMI JOIN 保留存在匹配的一侧,但不展开右侧数据。

流式场景下,未匹配状态可能后来变成匹配状态。因此系统可能需要撤回之前的“未匹配”输出,再发出新的匹配输出。

无窗口 join 并不一定错误

很多人认为无界流 join 必须加窗口。作者指出这不总是正确。对于某些业务,例如维护用户表和订单流的关联,全局状态加 per-record trigger 可能更自然。这更像维护物化视图,而不是按固定时间段切开。

窗口 join 的价值在于:

  1. 限定状态范围。
  2. 表达业务上的时间邻近关系。
  3. 让结果和水位线完成时机绑定。

是否加窗口应由业务语义决定,而不是由“流处理必须有窗口”这个误解决定。

固定窗口 join

固定窗口 join 会把左右两侧数据放到相同事件时间窗口内,只连接同一窗口的数据。水位线越过窗口结束后,系统输出该窗口结果。

这种方式适合明确要求“同一时间段内发生”的连接,例如同一分钟内的曝光和点击。但它不适合所有时间关系。

时态有效性 join

时态有效性是本章最重要的模型。书中用货币汇率举例:

  1. 汇率不是只在某个时间点有效。
  2. 一条汇率从它的事件时间开始有效,直到下一条同币种汇率到来。
  3. 订单要使用订单事件时间所在有效区间内的汇率。

如果 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 在开源世界推进了很多流处理创新,包括事件时间、水位线、分布式快照、savepoint、Streaming SQL 等。它把现代流处理语义带入更广泛实践。

Beam

Beam 的目标是可移植的编程模型。它试图像 SQL 之于关系处理那样,成为程序化数据处理的共同表达层。Beam 的挑战是既不能退化成最低公分母,也不能变成无所不包的复杂集合。

大规模数据处理系统演进

贯穿全书的核心模型

模型一:无界数据的四问

面对任何无界数据任务,都可以先问:

  1. What:到底要计算什么业务结果?
  2. Where:结果属于哪些事件时间范围?
  3. When:何时输出早期结果、准完整结果和迟到修正?
  4. How:多次输出之间是增量、覆盖还是撤回?

这个框架能帮助你避免“先写代码再猜语义”。

模型二:事件时间优先

只要业务问题描述的是事件真实发生时间,就应优先用事件时间建模。处理时间可以用于触发、监控、入口时间分析和系统负载观察,但不应偷偷替代业务时间。

模型三:水位线不是完成事实,而是系统承诺

完美水位线是强保证,启发式水位线是估计。系统通过水位线表达它愿意在什么时候把结果称为“准完整”。迟到数据策略必须和水位线一起设计。

模型四:表和流互相转换

分组让流变成表,触发让表变成流。理解这一点后,窗口聚合、物化视图、Streaming SQL、join 和状态管理都能落入同一套框架。

模型五:撤回是高级流处理的必要能力

当结果会被迟到数据改变,尤其是会话窗口、外连接、时态 join、二次聚合时,撤回能显著简化下游一致性。没有撤回,下游往往被迫做复杂且不可靠的读改写。

工程实践启发

设计流处理任务时先写语义契约

不要一开始就选 Kafka、Flink、Spark 或 Beam API。先写清楚:

  1. 输入事件时间字段是什么?
  2. 乱序和迟到的可接受范围是多少?
  3. 窗口是否符合业务语义?
  4. 需要早期结果吗?
  5. 迟到数据是修正、旁路还是丢弃?
  6. 下游能处理覆盖或撤回吗?
  7. 外部 sink 是否幂等?

这些答案比框架 API 更重要。

低延迟与正确性不是二选一

现代流处理模型允许同时拥有早期结果和最终修正。低延迟输出可以是 speculative result,水位线输出可以是准完整结果,迟到数据可以产生修正。关键是让这些状态显式可见。

批流一体不是同一个执行模式

批流一体的真正含义是同一套语义模型可以覆盖有界和无界数据。它不意味着批和流必须用完全相同的物理执行策略。优秀系统会根据输入有界性、窗口、状态和触发器选择合适执行方式。

Exactly-once 要看边界

当系统声称精确一次时,要继续问:

  1. 覆盖 source 吗?
  2. 覆盖 shuffle 吗?
  3. 覆盖 state 吗?
  4. 覆盖 sink 吗?
  5. 覆盖用户函数里的外部副作用吗?

很多系统只能保证其管理边界内的结果语义,不能自动保证任意外部调用。

SQL 与流处理最终会靠近

如果把流处理理解为时变关系的持续维护,SQL 就不再只是批查询语言。窗口、水位线、触发器、撤回和系统列会成为 Streaming SQL 的关键扩展点。

复习问题

  1. 为什么说“流处理系统是批处理系统的严格超集”需要加上执行效率这个限定?
  2. 事件时间和处理时间分别适合回答什么问题?
  3. 水位线到达窗口结束时间后,为什么仍然可能有迟到数据?
  4. 允许迟到解决的是准确性问题还是完整性问题?
  5. 丢弃模式、累积模式、累积并撤回模式分别适合什么下游?
  6. 会话窗口为什么比固定窗口更需要撤回语义?
  7. 为什么精确一次不等于用户代码只运行一次?
  8. Bloom filter 在 shuffle 去重中解决什么性能问题?
  9. 如何用“流到表、表到流”解释窗口聚合?
  10. 显式状态和普通窗口聚合的边界在哪里?
  11. 时变关系如何保持 SQL 关系代数的闭包?
  12. 为什么某些无界 join 不一定需要窗口?
  13. 时态有效性 join 与普通固定窗口 join 的区别是什么?
  14. Lambda Architecture 解决了什么历史问题,又引入了什么长期成本?

最后总结

《Streaming Systems》真正想教的是一套处理时间、状态和变化的思维方式。它把流处理从“实时计算工具”提升为通用数据处理模型:

  1. 用事件时间表达业务事实。
  2. 用窗口定义事件时间范围。
  3. 用水位线描述输入完整性。
  4. 用触发器控制结果物化时机。
  5. 用累积与撤回表达结果修正。
  6. 用持久化状态承载长期计算。
  7. 用流与表统一理解批、流、SQL 和物化视图。

掌握这些概念后,再去学习 Beam、Flink、Spark Structured Streaming、Kafka Streams 或 Streaming SQL,会更容易看清每个系统的真正差异:它们不是在“能不能实时处理”上不同,而是在时间语义、状态模型、触发机制、撤回能力、source/sink 边界和执行优化上做了不同取舍。