第 10 章假设输入是一个已知、有限的数据集。但现实中数据持续到达、永不结束:用户点击、传感器读数、支付、日志行。等积累一天的量再处理引入一天的延迟。流处理(stream processing)把批处理的不可变事件想法应用到无界、增量到达的数据,在每个事件发生后不久就处理它。本章覆盖事件流如何传输(消息代理和日志)、数据库和流如何关联(CDC 和事件溯源),以及如何真正在流上计算(时间、窗口、join 和容错)。
- 事件是一个小的、不可变的记录,记录发生的某事,带时间戳。生产者追加事件;消费者处理它们;相关事件形成一个 topic/流。
- 两种代理风格——传统队列(AMQP/JMS)在 ack 后删除消息并负载均衡工作;基于日志的代理(Kafka)保留一个仅追加、可重放的日志并按分区保序。
- 变更数据捕获(CDC)把数据库的写日志变成流,使派生系统(索引、缓存、仓库)保持同步——DB 是 leader,派生物是 follower。
- 事件溯源(event sourcing)把所有变更存为一个不可变事件日志,它是真相来源;当前状态通过重放事件派生。
- 事件时间 vs 处理时间是核心头痛——事件迟到且乱序到达,所以"这个窗口完整了吗?"没有确定答案。
- 流 join 与容错——stream-stream、stream-table、table-table join;通过 checkpointing、幂等或原子提交实现 exactly-once。
流是不可变事件的无界序列。像 Kafka 这样基于日志的代理(仅追加、保留、可重放)泛化了消息传递和数据库复制:变更数据捕获和事件溯源都把变更日志当主、从它派生状态。在流上计算迫使你面对时间(事件 vs 处理时间、窗口)、把流与通过 CDC 保持最新的表 join,并在永不结束的输入上达到 exactly-once 结果。
传输事件流
流处理里,"记录"的等价物是事件(event):一个小的、自包含、不可变的对象,记录发生的某事,通常带时间戳。事件由一个生产者(publisher)生成一次,潜在地被多个消费者(subscriber)处理;相关事件被分组进一个topic 或流。你可以轮询数据存储找新事件,但频繁轮询昂贵,所以更好的是消费者在新事件出现时被通知——这正是消息系统提供的。
消息代理:队列 vs 日志
你能把事件直接从生产者发给消费者(如 UDP 多播,或 ZeroMQ 这样的无代理库),但大多数系统用一个消息代理作为缓冲事件的持久中介。有两种根本不同的代理设计:
| 方面 | 传统队列(AMQP/JMS) | 基于日志(Kafka) |
|---|---|---|
| 存储模型 | ack 后消息删除 | 仅追加日志,保留 |
| 消费 | 分给一个消费者,负载均衡 | 消费者跟踪一个 offset |
| 有序 | 重投时丢失 | 分区内保留 |
| 重放 | 无——处理后没了 | 有——从任意 offset 重读 |
| 最适合 | 任务队列、慢/可变作业 | 高吞吐、有序、可重放 |
传统消息队列把每条消息分给一个消费者并在确认后删除它;多个消费者分享负载,但顺序在重投间不保留。它们适合任务队列,每条消息触发某(可能慢的)工作。基于日志的代理如 Kafka 把消息存在磁盘上一个分区的仅追加日志里;消费者只是记录它的 offset(日志中的位置),消息即便被读后也保留。这让你能重放旧事件、给高吞吐和分区内有序,并使代理行为很像数据库的复制日志——本章其余部分利用的一个连接。
数据库与流
本章的深层洞见:复制日志是一个事件流,而那种等价让我们保持许多系统同步。应用常需要把同一数据写到几个地方(一个数据库、一个搜索索引、一个缓存、一个仓库)。从应用做双写(dual writes)易错——并发写竞争且部分失败让系统不一致。流提供更干净的方式。
变更数据捕获(CDC)
变更数据捕获观察对数据库的所有写——通常通过 tail 它的复制日志——并把它们作为变更事件流提供。下游系统(搜索索引、缓存、数据仓库)消费那个流并按序应用同样的变更。原始数据库实际是 leader、派生系统是 follower,所以它们最终(最终地)一致。Debezium 等工具对常见数据库实现 CDC。因为消费者可能需要 bootstrap,流能与一个初始快照结合,而日志压实(Kafka 每 key 只保留最新事件)让消费者仅从日志重建完整状态。
# CDC:每个 DB 写变成一个不可变、有序的事件
offset 1001 {"op":"insert", "table":"cart", "key":42, "qty":1}
offset 1002 {"op":"update", "table":"cart", "key":42, "qty":3}
offset 1003 {"op":"delete", "table":"cart", "key":42}
索引、缓存和仓库各按序消费这个日志
→ 全都与数据库保持同步,无脆弱的双写。
事件溯源(Event Sourcing)
一个来自领域驱动设计社区的相关想法:事件溯源把对应用状态的所有变更存为一个不可变、仅追加的事件日志——而那些事件,而非一个可变的"当前状态"表,是真相来源。当前状态通过重放事件派生。与 CDC 的区别:事件溯源事件在应用级别建模(一个有意义的动作如"学生取消注册"),捕获意图,而 CDC 事件是低级行变更。把日志当主的好处很大:你能重建任何派生视图、通过重放历史调试、随时间演化你如何解释事件。一个有用的纪律是分离命令(command)(可能被拒绝的请求)和事件(event)(已发生且不可变的事实)。
不可变事件加派生状态与批处理的不可变输入是同一原则——只是连续的。真相是一个发生之事的有序日志;数据库、索引和缓存都是那个日志的物化视图。这种重构("把数据库翻里朝外"的想法)是通往第 12 章的桥梁。
处理流
一旦你有了流,你计算什么?常见用途:复杂事件处理(complex event processing)(搜索事件模式,如欺诈检测)、流分析(stream analytics)(时间窗口上的滚动聚合和速率),以及维护物化视图(让一个派生数据集持续保持最新)。
关于时间的推理
流处理中最棘手的问题是时间。有两个时钟:事件时间(event time)(事件实际发生时)和处理时间(processing time)(流处理器处理它时)。它们因网络延迟、缓冲、代理积压和消费者重启而分歧。若你按处理时间聚合,一个落后再追上的消费者会产生一个误导性尖峰。按事件时间聚合更正确但引出一个难题:"窗口完整了吗?"——你永远无法完全确定不会有更多过去时间窗口的straggler(掉队)事件到达。系统通过在超时后宣告窗口关闭来处理掉队者(并忽略或为迟到事件纠正)。
窗口与 Join
无界流上的聚合在窗口上操作:tumbling(固定、不重叠)、hopping(固定、重叠)、sliding(彼此一个间隔内的事件)和 session 窗口(按活动突发分组)。Join 流比批 join 更微妙,因为两侧都在移动:
- Stream-stream(窗口)join——匹配两个流里时间上接近发生的事件(如一个搜索查询和随后的点击)。处理器在一个窗口里缓冲近期事件。
- Stream-table(enrichment)join——用一个表的数据增强每个事件(如给一个活动事件加用户画像)。处理器保留一个本地副本的表,通过来自数据库的 CDC 保持最新。
- Table-table join——维护一个本身是两个变化表 join 的物化视图,随任一侧变化更新。
一个反复出现的复杂性:因为输入顺序和时序不确定,流 join 的结果能取决于每侧数据何时到达。
容错与 Exactly-Once
批作业通过在不可变输入上重跑任务容错——但流是无界的,所以你不能只是"从头重跑"。相反,流处理器用 microbatching(Spark Streaming 把流切成小批)或周期性 checkpointing 状态(Flink),使失败作业从上一个 checkpoint 恢复。目标是 exactly-once 语义(更精确地,effectively once):每个事件影响输出仿佛被恰好处理一次,即便跨失败和重试。这通过 幂等 操作、输出加 offset 的原子提交,和丢弃自上一个 checkpoint 以来的工作的某种组合实现。
流处理是批处理的连续孪生,被不可变、有序事件日志作为真相来源的想法统一。基于日志的代理(Kafka)通过可重放和持久使这实用。真正难的部分是流独有的:调和事件时间与处理时间、决定一个窗口何时"完成",并在永不结束的输入上获得 exactly-once 结果。
基于日志的代理 vs 传统队列?队列在 ack 后删除消息并负载均衡;日志(Kafka)保留一个仅追加、有序、可重放的记录,消费者按 offset 读。
CDC vs 事件溯源?两者都把变更日志当主;CDC 从 DB 日志捕获低级行变更,事件溯源记录应用级事件作为真相来源、通过重放派生状态。
事件时间 vs 处理时间?事件时间 = 它发生时;处理时间 = 它被处理时。它们因延迟分歧,所以窗口聚合必须处理迟到的"掉队"事件和"窗口完整了吗?"问题。
流怎么达到 exactly-once?checkpointing/microbatching 加幂等和输出-加-offset 的原子提交,使重试不重复计数。