流式计算的理论基础:时间、窗口与一致性

流式计算不只是"把批处理变快"——它是一个完全不同的计算范式,背后有一套独特的理论体系。这套理论由 Google 的 DataFlow 论文(2015 年)奠定基础,随后被 Apache Flink、Spark Structured Streaming 等系统实现落地。

本文从理论角度系统梳理流式计算的核心概念:时间模型、Watermark、窗口、触发器、状态一致性、Exactly-Once 语义,以及流批统一的理论基础。理解这些理论,是真正掌握 Flink 等流处理系统的前提。

一、流与批:两种截然不同的计算模型

数据的有界性:根本分歧

批处理假设数据是有界的(Bounded):先收集,再计算,输出一个确定的结果。

批处理模型:
  f(D) → R
  D 是有限数据集,f 是计算函数,R 是确定的结果

  一天的订单数据 → 计算 → 今天的总销售额(确定值)

流处理假设数据是无界的(Unbounded):数据像河流一样持续流入,计算结果随时间不断更新。

流处理模型:
  f(S, t) → R(t)
  S 是无界数据流,f 是持续计算函数,R(t) 是在时刻 t 的当前结果

  持续流入的订单 → 持续计算 → 当前累计销售额(实时更新)

这不只是工程实现的差异,而是数学模型的根本不同。批处理求的是一个静态函数的值;流处理求的是一个时序函数,它的"答案"在不断演化。

Lambda 架构:批流并存时代的妥协

早期(2012 年前后),人们没有成熟的流处理框架,于是提出了 Lambda 架构:用批处理保证准确性,用流处理保证低延迟,两套系统并行运行,结果合并:

graph TD
    RAW[原始数据]
    RAW --> BATCH[批处理层 Hadoop
每天跑一次
精确结果 T+1] RAW --> SPEED[速度层 Storm
实时处理
近似结果 秒级] BATCH --> SERVE[服务层
合并两层结果
对外提供查询] SPEED --> SERVE style RAW fill:#f9f,stroke:#333 style BATCH fill:#bbf,stroke:#333 style SPEED fill:#fbb,stroke:#333 style SERVE fill:#bfb,stroke:#333

Lambda 架构的存在本质上是在承认:当时的流处理系统无法同时保证低延迟和高正确性。随着 Flink 的成熟,这个妥协方案已经逐渐退出历史舞台。

二、时间模型:流处理最深的理论问题

流处理里最核心也最容易被低估的理论问题是:时间有两个维度,而且它们可以不一致

Event Time 与 Processing Time

Event Time(事件时间):
  事件真实发生的时刻,记录在数据里
  例:用户在 10:00:01 点击了按钮,这个时间戳写在日志里

Processing Time(处理时间):
  数据被流处理系统看到的时刻,即"系统时钟"
  例:这条日志在 10:00:05 到达 Flink,被处理的时间是 10:00:05

两者之间的差距叫做摄入延迟(Ingestion Lag),来源于:

  • 网络传输延迟(手机 App 在弱网下积攒日志,网络恢复后批量上传)
  • 消息队列堆积(Kafka 消费者滞后)
  • 时钟不同步(不同服务器时钟偏差)
真实场景:用户手机在地铁里断网,累积了 5 分钟的行为日志,出站后上传

Event Time 视角:
  10:00:01 点击 → 10:00:03 点击 → 10:00:05 点击 → 10:04:58 点击

Processing Time 视角(这些数据在 10:05:10 到达系统):
  10:05:10 处理 [et=10:00:01]
  10:05:10 处理 [et=10:00:03]
  10:05:10 处理 [et=10:00:05]
  10:05:10 处理 [et=10:04:58]  ← 5 分钟前的数据,现在才到

如果用 Processing Time 统计"每分钟点击次数",这 4 次点击都会被算进 10:05 这一分钟,结论完全错误。正确答案应该是:10:00 有 3 次,10:04 有 1 次。

结论:只有基于 Event Time 的计算才能得到语义正确的结果,但这带来了一个根本性的困难:系统无法知道某个时间窗口的数据是否已经全部到达

乱序(Out-of-order):流处理的常态

即使同一批数据,Event Time 也可能乱序:

数据到达顺序(Processing Time 排序):
  [et=10:00:05] [et=10:00:03] [et=10:00:08] [et=10:00:01] [et=10:00:06]
       ↑                              ↑
  先到的是 05                    后到的是 01(乱序!)

原因:不同请求走了不同的网络路径,延迟不同;
      微服务多实例各自攒批发送,攒满触发时间不同

乱序是流处理的常态,不是特例。任何实用的流处理系统都必须正确处理乱序数据。

三、Watermark:对时间进度的系统性断言

Watermark 是解决"何时关闭窗口"问题的核心机制,也是整个流处理理论最精妙的部分。

Watermark 的形式化定义

Watermark(t) 是流中的一个特殊标记,表示:

系统断言:在此标记之后,不会再出现 event_time ≤ t 的数据。

graph LR
    D1["[et=10:00:05]"]
    D2["[et=10:00:03]"]
    W1{{"W(10:00:02) 窗口≤10:00:02 可以关闭"}}
    D3["[et=10:00:08]"]
    W2{{"W(10:00:07) 窗口≤10:00:07 可以关闭"}}
    D4["[et=10:00:01?] 迟到数据! et < W(10:00:07)"]

    D1 --> D2 --> W1 --> D3 --> W2 --> D4

    style W1 fill:#ff9,stroke:#c90
    style W2 fill:#ff9,stroke:#c90
    style D4 fill:#fcc,stroke:#c00

当 Watermark 超过某个窗口的结束时间,该窗口就可以安全关闭并输出结果了。

Watermark 的生成策略

最常见的 Watermark 生成策略是:

W(t_processing) = max(event_time seen so far) - allowed_lateness

其中 allowed_lateness 是允许的最大延迟容忍时间(如 5 分钟)

例:
  已观察到的最大 event_time = 10:05:30
  allowed_lateness = 5 分钟
  Watermark = 10:05:30 - 5min = 10:00:30

  意思是:我猜测 ≤ 10:00:30 的数据应该都到了
  所以 [10:00:00, 10:00:30] 之前的窗口可以关闭

这个 allowed_lateness 参数揭示了一个无法消除的根本权衡

allowed_lateness 设置结果延迟正确性适用场景
0(激进 Watermark)最低差(大量数据被视为迟到)允许一定数据丢失的监控指标
5 分钟(保守)较高(5 分钟)好(覆盖大多数延迟)实时报表,对准确性要求高
30 分钟(极保守)极好金融结算等精确场景

这个权衡没有标准答案,取决于业务对延迟和准确性的相对重视程度。

多流场景:Watermark 的取最小值原则

当多个并行流合并时(如 Flink 的多个 Partition),Watermark 取所有上游的最小值

graph LR
    P1["Partition 1 W(10:05:30)"]
    P2["Partition 2 W(10:03:10)"]
    P3["Partition 3 W(10:05:15)"]
    MERGE{{"合并算子 Watermark = min = 10:03:10"}}
    OUT["下游 Watermark = 10:03:10"]
    IDLE["⚠️ 若某 Partition 停止发数据 Watermark 永远不推进 → 所有窗口无法关闭 → 需 idle source 处理机制"]

    P1 --> MERGE
    P2 --> MERGE
    P3 --> MERGE
    MERGE --> OUT
    P2 -. "瓶颈" .-> IDLE

    style MERGE fill:#ff9,stroke:#c90
    style IDLE fill:#fcc,stroke:#c00
    style P2 fill:#fbb,stroke:#c00

四、窗口:给无界流分段计算

无界流必须切成有限片段才能计算聚合,这就是窗口(Window)的作用。窗口定义了"在哪段数据上做计算"。

滚动窗口(Tumbling Window)

大小固定,不重叠,每条数据恰好属于一个窗口。适合:每分钟统计一次、每小时汇总一次。特点:计算结果之间相互独立,无重叠,状态不复用。

gantt
    title 滚动窗口(窗口大小 = 1 分钟,无重叠)
    dateFormat mm:ss
    axisFormat %M:%S
    section 窗口
    W1 [0,60)  : 00:00, 60s
    W2 [60,120) : 01:00, 60s
    W3 [120,180) : 02:00, 60s
    W4 [180,240) : 03:00, 60s

滑动窗口(Sliding Window)

窗口大小固定,但步长小于窗口大小,窗口间有重叠。同一条数据可能属于多个窗口(最多 size/slide 个)。适合:过去 5 分钟的滑动平均、移动窗口统计。代价:每条数据被计算 size/slide 次,计算量更大,状态也更多。

gantt
    title 滑动窗口(窗口大小 5 分钟,步长 1 分钟)
    dateFormat mm:ss
    axisFormat %M:%S
    section 窗口
    W1 [0,5min)   : 00:00, 300s
    W2 [1,6min)   : 01:00, 300s
    W3 [2,7min)   : 02:00, 300s
    W4 [3,8min)   : 03:00, 300s

会话窗口(Session Window)

基于活动间隔动态划分,窗口大小不固定。适合:用户会话分析,同一次访问中的行为归为一组。特点:窗口边界由数据本身决定,不是预先固定的。挑战:需要动态合并窗口(当两个 session 之间的间隔被一条数据填上时)。

gantt
    title 会话窗口(间隔超过 30 分钟则分割)
    dateFormat HH:mm
    axisFormat %H:%M
    section 用户活动
    点击点击点击 (Session 1) : 10:00, 15m
    30分钟无活动             : crit, 10:15, 30m
    点击点击 (Session 2)     : 10:45, 10m
    30分钟无活动             : crit, 10:55, 30m
    点击 (Session 3)         : 11:25, 5m

窗口的理论本质:数据分配函数

从理论角度,窗口可以被看作一个函数:

assign(event) → Set<Window>

对每条输入数据,返回它属于哪些窗口

滚动窗口:assign(event) = { [floor(et/size)*size, floor(et/size)*size + size) }
滑动窗口:assign(event) = { 所有包含 et 的 [n*slide, n*slide+size) }
会话窗口:assign(event) = { [et, et+gap) },之后动态合并相交窗口

这个视角揭示了窗口类型之间的统一性——它们只是数据分配策略不同,底层计算机制是一样的。

五、触发器(Trigger):何时输出结果

窗口定义了"在哪算",触发器定义了"什么时候把结果发出去"。两者是正交的设计,可以自由组合。

三种基本触发策略

1. Watermark 触发(默认)
   条件:Watermark 超过窗口结束时间
   语义:"我认为这个窗口的数据差不多到齐了"
   特点:结果准确,但延迟 = 窗口等待时间 + allowed_lateness

2. Processing Time 触发
   条件:距离窗口创建已过去 N 秒
   语义:不管数据到没到齐,每隔 N 秒发一次结果
   特点:低延迟,但结果可能不完整(Early firing,早期触发)

3. 数据量触发
   条件:窗口内积累了 N 条数据
   语义:攒够一批就发
   特点:吞吐可预测,但时间不确定

组合触发器:DataFlow 模型的精华

Google DataFlow 论文的核心贡献之一是提出了可组合的触发器:

典型生产模式:
  Early firing(早期):Processing Time 每 30 秒触发,输出不完整但低延迟的结果
  On-time firing(准时):Watermark 触发,输出认为完整的结果
  Late firing(迟到):对超过 Watermark 还到来的数据再次触发

// Flink 实现示例
DataStream<Tuple2<String, Long>> result = stream
    .keyBy(0)
    .window(TumblingEventTimeWindows.of(Time.minutes(1)))
    .trigger(
        ContinuousEventTimeTrigger.of(Time.seconds(10)) // 早期:每 10 秒一次
        // 准时触发由 EventTimeTrigger 默认处理
    )
    .allowedLateness(Time.minutes(5))   // 允许 5 分钟迟到数据
    .sideOutputLateData(lateOutputTag)  // 超级迟到数据发到侧输出
    .sum(1);

这个模式在实践中极为常见:用户先看到一个"大约对"的实时结果,之后等数据到齐了再看到"完全准确"的最终结果,超迟到的数据则进入单独处理流程。

DataFlow 模型的四个问题

Google 的 DataFlow 论文用四个问题统一描述了流处理的所有核心概念:

问题对应概念示例
What:计算什么结果?聚合函数(Sum/Count/Join...)统计点击次数
Where:在哪些数据上算?窗口(Tumbling/Sliding/Session)每 1 分钟
When:什么时候输出?触发器(Watermark/PT/Count)数据到齐后 + 每 10 秒早期结果
How:迟到数据怎么处理?Accumulation Mode替换已有结果 / 累积

其中 "How" 对应的 Accumulation Mode 有三种:

  • Discarding:每次触发只输出这次新增的数据的结果,不累积
  • Accumulating:每次触发输出从窗口开始到现在的累积结果(最常用)
  • Accumulating & Retracting:不仅发新结果,还发一条"撤回"消息取消上次的结果(下游可以精确维护)

六、状态(State):流处理的记忆

无状态流处理(如过滤、映射)很简单。真正的挑战来自有状态计算——需要记住过去的数据才能计算当前结果。

状态的类型

Keyed State(键控状态):
  每个 Key 维护独立的状态,最常见
  例:统计每个用户的点击次数,每个 user_id 对应一个计数器

  ValueState<Long> count;        // 单个值
  ListState<Event> eventList;    // 值列表
  MapState<String, Long> map;    // 键值对
  ReducingState<Long> sum;       // 持续聚合
  AggregatingState<Event, Stats> // 自定义聚合

Operator State(算子状态):
  整个算子实例共享一个状态,不按 Key 分区
  例:Kafka Source 记录每个 Partition 的消费 offset

Checkpoint:状态一致性的保障

流处理系统长期运行,机器随时可能崩溃。如何保证崩溃恢复后状态仍然正确,是流处理的核心挑战。

Flink 使用 Chandy-Lamport 算法的变体来做分布式快照(Checkpoint):

sequenceDiagram
    participant JM as JobManager
    participant SRC as Source(Kafka)
    participant OP as 中间算子
    participant STORE as 持久化存储
HDFS/S3 JM->>SRC: 触发 Checkpoint(ckpt=5) SRC->>SRC: 快照自身状态(Kafka offset) SRC->>OP: 数据流:[d1][d2][d3][Barrier(ckpt=5)][d4]... Note over OP: 收到 Barrier 后
等待所有输入对齐 OP->>OP: 对自己的状态做快照 OP->>OP: 向下游广播 Barrier SRC->>STORE: 写入状态快照 OP->>STORE: 写入状态快照 STORE-->>JM: Checkpoint 完成确认 Note over JM,STORE: 崩溃恢复:从最近成功的 Checkpoint
恢复状态,Kafka Source 重置 offset 重放数据

状态后端(State Backend)的选择

HashMapStateBackend(原 MemoryStateBackend):
  状态存在 JVM 堆内存
  优点:访问极快(O(1),无序列化开销)
  缺点:受 JVM 堆大小限制,GC 压力大,不适合大状态
  适合:状态小、延迟要求极低的场景

EmbeddedRocksDBStateBackend(原 RocksDBStateBackend):
  状态存在本地磁盘(RocksDB LSM-Tree)
  优点:状态可以远超内存大小,增量 Checkpoint(只保存变化部分)
  缺点:访问需要序列化/反序列化,延迟高 10-100 倍
  适合:状态大(GB 到 TB 级)的场景,如用户画像、设备历史

选择原则:
  状态 < 几 GB → HashMapStateBackend
  状态 > 几 GB 或需要增量 Checkpoint → EmbeddedRocksDBStateBackend

无界状态:流处理的长期挑战

无界数据流会产生不断增长的状态。例如"对历史上所有出现过的 user_id 去重"——如果用户 ID 持续增长,状态将无限膨胀。

解决方案:

  • State TTL(状态过期):超过一定时间未访问的状态自动清理
  • 业务设计约束:把"全局去重"改为"7 天内去重",从语义上限制状态大小
  • 布隆过滤器:用概率数据结构近似去重,牺牲极小精度换取 O(1) 的恒定内存

七、Exactly-Once:流处理的语义圣杯

三种处理语义的对比

At-most-once(至多一次):
  实现:不做任何重试或确认
  结果:数据可能丢失,但不会重复
  适合:监控告警,偶尔丢一条数据可接受

At-least-once(至少一次):
  实现:崩溃后从上次 offset 重放,但不撤销已写入的结果
  结果:数据不丢失,但可能重复(同一条数据处理两次)
  适合:大多数日志分析,重复多算一次影响不大

Exactly-once(恰好一次):
  实现:复杂,需要 Source 可重放 + Sink 幂等/事务性写入
  结果:既不丢失也不重复
  适合:金融交易、精确计费等不能有误差的场景

Exactly-Once 的两阶段提交实现

Flink 实现端到端 Exactly-Once(从 Kafka 读到 Kafka 写)的核心是两阶段提交(2PC),与数据库事务中的 2PC 原理相同:

sequenceDiagram
    participant JM as JobManager
    participant SINK as Sink 算子
    participant KAFKA as Kafka(下游)

    rect rgb(220, 240, 255)
        Note over JM,KAFKA: Phase 1 — Pre-commit(预提交)
        JM->>SINK: 触发 Checkpoint
        SINK->>KAFKA: 写入数据到 Kafka Transaction
(数据对消费者不可见) SINK->>SINK: 快照状态:Transaction = pre-committed SINK-->>JM: Checkpoint 完成 end rect rgb(220, 255, 220) Note over JM,KAFKA: Phase 2 — Commit(提交) JM->>SINK: 所有算子 Checkpoint 完成通知 SINK->>KAFKA: Kafka Transaction Commit Note over KAFKA: 数据对消费者可见 end rect rgb(255, 240, 220) Note over JM,KAFKA: 崩溃恢复 — 场景 A:Checkpoint 未完成时崩溃 Note over SINK,KAFKA: 未 Commit 的 Transaction 超时自动 Abort
从上一个 Checkpoint 的 offset 重放
重新写入新 Transaction → 最终 Commit end rect rgb(255, 220, 220) Note over JM,KAFKA: 崩溃恢复 — 场景 B:Checkpoint 完成后 Commit 前崩溃 Note over SINK,KAFKA: 恢复到该 Checkpoint 状态
状态记录有 pre-committed Transaction
恢复后补充执行 Commit → 不会丢失 end

Exactly-Once 的代价

Exactly-Once 不是免费的:

  • 吞吐降低约 10-30%:Barrier 对齐需要等待最慢的上游,造成 stall
  • 延迟增加:数据要等到 Checkpoint 完成才对下游可见,延迟 = Checkpoint 间隔
  • Sink 必须支持事务:HDFS、Kafka 支持;MySQL 要用 JDBC 事务;部分存储不支持

对于非关键路径,At-least-once 往往是更经济的选择;只有金融级业务才需要全链路 Exactly-Once。

Flink 1.11 引入的 Unaligned Checkpoint(非对齐 Checkpoint)缓解了这个问题:不等待 Barrier 对齐,把未处理的数据一起纳入快照,以换取更低的 stall 时间,但快照大小会增加。

八、流批统一:两个世界的终极融合

批是流的特例

从理论角度,批处理是流处理的特殊情况:有界数据集可以看作一条带有确定终点的流。

批处理 = 流处理中 Watermark = +∞ 的特殊情况

等价关系:
  批处理"对全量数据计算"
  = 流处理"Watermark 推进到无穷大,触发所有窗口的最终计算"

Flink 的设计哲学:
  - 内部只有一套执行引擎(流引擎)
  - 批处理 = BoundedStream,Watermark 在数据结束时设为 MAX_VALUE
  - 自动触发所有未关闭的窗口,得到完整结果

Micro-batch:流是批的极限

反过来也可以成立:流处理是批处理的极限情况——把批的时间窗口缩到无限小,就趋近于流。

Spark Streaming / Spark Structured Streaming 的思路:
  把持续的数据流切成极小的"micro-batch"(如 1 秒一批)
  对每个 micro-batch 跑一次完整的批处理
  模拟出"连续流"的效果

批处理 batch_size → ∞:传统离线批处理
批处理 batch_size → 0:趋近于原生流处理

Micro-batch 的优缺点:
  优点:利用成熟的批处理引擎,Exactly-Once 更容易实现
  缺点:延迟有下限(一个 micro-batch 的时间),无法做到毫秒级

两种路径的本质区别

原生流(Native Streaming):Flink、Storm
  每条数据来了就处理
  延迟:毫秒级
  状态:持续维护,更复杂
  Exactly-Once:需要 2PC,有成本

Micro-batch:Spark Streaming
  每隔一段时间处理一批
  延迟:秒级(受 batch interval 限制)
  状态:每个 batch 结束后更新,相对简单
  Exactly-Once:利用批处理的原子性,相对容易

"Micro-batch 不是真正的流处理"这个判断在理论上是正确的:
  它的延迟模型、状态模型、时间语义都和原生流有本质差异。
  但在工程实践上,如果业务不需要毫秒级延迟,Spark Structured Streaming
  是更简单、更稳定的选择。

九、关键点总结

  • 流处理的本质是在不完整信息下做决策:数据永远可能迟到,系统只能用 Watermark 做有根据的猜测
  • Event Time vs Processing Time 是语义正确性的关键:用 Processing Time 计算的结果语义是错的,但实现最简单;用 Event Time 才能得到真正正确的结果,但需要处理乱序和迟到
  • Watermark 是时间进度的断言,not 保证:它表达"我认为 ≤t 的数据到齐了",而不是"一定到齐了",超过 Watermark 到来的数据就是迟到数据,需要单独处理策略
  • 窗口和触发器是正交的设计:窗口决定"在哪算",触发器决定"什么时候输出",DataFlow 模型用 What/Where/When/How 四个维度统一了流处理的所有场景
  • 有状态计算是流处理的真正难点:Checkpoint(基于 Chandy-Lamport 算法)保证状态一致性,状态后端的选择(内存 vs RocksDB)决定了性能上限
  • Exactly-Once 依赖两阶段提交:Checkpoint 触发时 Pre-commit,所有算子快照完成后 Commit,崩溃恢复时补充提交或回滚重试,代价是吞吐和延迟的损失
  • 流批统一是理论上的自然结论:批处理是 Watermark=∞ 时的流处理;Micro-batch 是 batch_size→0 时趋近于流处理;两条路径在延迟、状态复杂度、Exactly-Once 实现难度上有本质差异
  • 多流 Watermark 取最小值:是保证正确性的必要条件,但 idle source 需要特殊处理,否则会导致 Watermark 停滞