62.1 流处理模型与时间语义

一句话

流处理不是「批处理变快」,而是在数据不完整时做决策:时间语义决定一条事件属于哪个窗口,水位线决定什么时候敢宣布结果完整——两者一起决定结果的正确性与延迟。

六个误区

误区 现实
流处理 = 低延迟批处理 批处理有界、可等齐;流处理无界、永远等不齐,必须用时间语义定义「什么时候算完」
实时 = 正确 实时只说明「快」;用处理时间切窗口,结果会随到达顺序变化,重跑对不上
事件时间 = 处理时间 事件时间是业务发生时刻,处理时间是机器看到它的时刻;两者差值就是乱序/延迟
有 Kafka 就是流处理 Kafka 是传输层;没有状态、窗口、水位与容错,消费者只是「逐条处理」
exactly-once 是默认能力 端到端 exactly-once 要状态、位点、输出提交三方对齐(见 62.4 Exactly-once 语义)
窗口结果只追加 迟到数据会修正历史窗口;只追加会让同一个窗口出现多条互相矛盾的记录

三种计算模型

模型 数据边界 延迟 状态 正确性来源 典型
请求-响应 单次请求 毫秒 无/外部 事务与校验 API、交易
批处理 有界 分钟–小时 中间落盘 输入完整 + 重跑 离线数仓(61.3 批处理与调度)
流处理 无界 毫秒–秒 有状态 事件时间 + 水位 + 快照 实时风控、实时数仓

🔑 三者的分界不是「数据量大小」,而是数据有没有界、结果什么时候必须给出。批处理可以等「输入齐了」再算;流处理没有「齐」的时刻,只能定义「等到什么时候算齐」。

流与表:同一份数据的两个视角

视角 是什么 例子
流 按时间追加的事件序列 订单创建、支付、退款事件
表 流的最新状态(changelog) orders 表:每个订单一行,保留最新状态
流 → 表 按主键 upsert order_id 相同的事件覆盖旧状态
表 → 流 表的变更日志 CDC(46.4 CDC (变更数据捕获))

流处理里的「结果表」通常是不断更新的 changelog,不是一次性写完的文件。这也是为什么实时指标要支持 upsert/retract,而不能只 append。

三种时间语义

时间 定义 谁产生 优点 坑
事件时间 业务事件实际发生的时间 业务系统/客户端 可重放、结果稳定、能处理乱序 依赖业务时钟;可能缺失/漂移
处理时间 机器处理这条记录的时间 流处理器 实现简单、延迟低 结果依赖到达顺序,重跑不一致
摄入时间 数据进入消息队列的时间 Kafka broker 介于两者之间;事件时间不可信时可用 上游积压时,摄入时间≠业务时间

🔑 选时间语义的判据:这个结果需不需要重放/对账? 需要 → 事件时间;只做「当下监控」且可接受重跑不同 → 处理时间;事件时间字段不可信但又要接近业务时间 → 摄入时间。

时间本身也会漂移:机器时钟受 NTP 调整、闰秒、虚拟化影响(见 23.1 物理时钟与漂移)。事件时间来自业务系统,处理时间来自流处理器,两者不能混用。

水位线:把「等多久」变成一个数

水位线(watermark)是一个单调递增的时间戳,含义是:

「事件时间 ≤ W 的数据,我认为已经到齐了。」

最常见的生成方式:

W(t) = max(已见事件时间) - delay

窗口在 窗口结束时间 <= W 时触发。delay 就是「愿意等多久」:

  • delay = 0:来一条就推进,乱序数据会被判迟到;
  • delay = 5s:容忍 5 秒乱序;
  • delay = 5min:结果更完整,但晚 5 分钟才出。

水位线不是「保证没有迟到」,只是「声明我等到这里了」。真正的迟到处理有三种:丢弃、允许更新(allowed lateness)、侧输出到迟到流。

窗口类型、触发与迟到处理的细节见 62.2 窗口与水位线;状态与 checkpoint 见 62.3 状态管理与容错。

代码:同一批事件,三种时间语义的结果

package main

import (
	"fmt"
	"sort"
)

// EventTime/ArriveTime 都是「秒」,从 10:00:00 起算。
type Event struct {
	ID         string
	EventTime  int
	ArriveTime int
}

var events = []Event{
	{"e1", 10, 12},
	{"e2", 25, 28},
	{"e3", 65, 70},
	{"e4", 40, 85},   // 事件时间 10:00:40,迟到 45s
	{"e5", 130, 132},
	{"e6", 55, 140},  // 迟到 85s
	{"e7", 190, 195},
	{"e8", 100, 200}, // 迟到 100s
	{"e9", 250, 255},
	{"e10", 5, 260},  // 迟到 255s
}

func win(t int) int { return t / 60 * 60 }

func clock(sec int) string { return fmt.Sprintf("10:%02d:%02d", sec/60, sec%60) }

func main() {
	trueCnt := map[int]int{}
	for _, e := range events {
		trueCnt[win(e.EventTime)]++
	}

	procCnt := map[int]int{}
	for _, e := range events {
		procCnt[win(e.ArriveTime)]++
	}

	fmt.Println("=== 同一批事件,三种时间语义的结果 ===")
	fmt.Printf("%-9s %-6s %-6s %-6s %-6s\n", "window", "true", "proc", "et30", "et300")

	type run struct {
		cnt        map[int]int
		drop       int
		emitAt     map[int]int
		droppedIDs []string
	}
	runET := func(delay int) run {
		order := append([]Event(nil), events...)
		sort.Slice(order, func(i, j int) bool { return order[i].ArriveTime < order[j].ArriveTime })
		cnt := map[int]int{}
		closed := map[int]bool{}
		emitAt := map[int]int{}
		maxET, dropped := -1, 0
		var droppedIDs []string
		closeUntil := func(wm, now int) {
			for s := 0; s < 300; s += 60 {
				if !closed[s] && s+60 <= wm {
					closed[s] = true
					emitAt[s] = now
				}
			}
		}
		for _, e := range order {
			w := win(e.EventTime)
			if closed[w] {
				dropped++
				droppedIDs = append(droppedIDs, e.ID)
				continue
			}
			cnt[w]++
			if e.EventTime > maxET {
				maxET = e.EventTime
			}
			closeUntil(maxET-delay, e.ArriveTime)
		}
		for s := 0; s < 300; s += 60 {
			if !closed[s] {
				closed[s] = true
				emitAt[s] = 260
			}
		}
		return run{cnt, dropped, emitAt, droppedIDs}
	}

	r30, r300 := runET(30), runET(300)
	for s := 0; s < 300; s += 60 {
		fmt.Printf("%-9s %-6d %-6d %-6d %-6d\n", clock(s), trueCnt[s], procCnt[s],
			r30.cnt[s], r300.cnt[s])
	}
	fmt.Printf("%-9s %-6d %-6d %-6d %-6d\n", "total", len(events), len(events),
		sum(r30.cnt), sum(r300.cnt))
	fmt.Printf("%-9s %-6s %-6s %-6d %-6d\n", "late-drop", "-", "-", r30.drop, r300.drop)

	fmt.Println("\n=== 每个窗口「最终结果」的产出时刻 ===")
	fmt.Printf("%-9s %-11s %-11s %-11s\n", "window", "proc-emit", "et30-emit", "et300-emit")
	for s := 0; s < 300; s += 60 {
		fmt.Printf("%-9s %-11s %-11s %-11s\n", clock(s), clock(s+60), clock(r30.emitAt[s]), clock(r300.emitAt[s]))
	}

	fmt.Println("\n=== 被 ET+30s 丢弃的事件 ===")
	for _, e := range events {
		for _, id := range r30.droppedIDs {
			if e.ID == id {
				fmt.Printf("  %s 事件时间 %s,到达 %s,迟到 %ds\n",
					e.ID, clock(e.EventTime), clock(e.ArriveTime), e.ArriveTime-e.EventTime)
			}
		}
	}

	fmt.Println("\n结论:处理时间把「到达时刻」当业务时间,窗口看起来均匀(每窗 2 条),但和真实业务分布不符;")
	fmt.Println("      事件时间等水位,结果正确但延迟;水位延迟越大,越完整,也越晚出结果。")
}

func sum(m map[int]int) int {
	s := 0
	for _, v := range m {
		s += v
	}
	return s
}

本机实测(macOS,Apple M1,Go 1.27.1):

=== 同一批事件,三种时间语义的结果 ===
window    true   proc   et30   et300 
10:00:00  5      2      3      5     
10:01:00  2      2      1      2     
10:02:00  1      2      1      1     
10:03:00  1      2      1      1     
10:04:00  1      2      1      1     
total     10     10     7      10    
late-drop -      -      3      0     

=== 每个窗口「最终结果」的产出时刻 ===
window    proc-emit   et30-emit   et300-emit 
10:00:00  10:01:00    10:02:12    10:04:20   
10:01:00  10:02:00    10:03:15    10:04:20   
10:02:00  10:03:00    10:04:15    10:04:20   
10:03:00  10:04:00    10:04:20    10:04:20   
10:04:00  10:05:00    10:04:20    10:04:20   

=== 被 ET+30s 丢弃的事件 ===
  e6 事件时间 10:00:55,到达 10:02:20,迟到 85s
  e8 事件时间 10:01:40,到达 10:03:20,迟到 100s
  e10 事件时间 10:00:05,到达 10:04:20,迟到 255s

结论:处理时间把「到达时刻」当业务时间,窗口看起来均匀(每窗 2 条),但和真实业务分布不符;
      事件时间等水位,结果正确但延迟;水位延迟越大,越完整,也越晚出结果。

读数:

现象 数字 含义
处理时间每窗 2 条 2/2/2/2/2 到达被均匀摊开,看起来「很规整」,但与真实业务分布(5/2/1/1/1)不符
ET+30s 3/1/1/1/1,丢 3 条 等 30s 只补回一部分乱序;更晚的 e6/e8/e10 被丢
ET+300s 5/2/1/1/1,丢 0 条 结果完整,但所有窗口都等到 260s 才最终确定
首窗产出 处理时间 10:01:00 / ET30 10:02:12 / ET300 10:04:20 处理时间最快但不可重放;事件时间越等越完整、越晚

🔑 处理时间的结果依赖「事件怎么到」,事件时间的结果依赖「事件何时发生」。前者适合「现在系统还活着吗」这类监控,后者适合「昨天的 GMV 是多少」这类需要重放与对账的指标。

延迟与完整度的账

水位延迟 丢弃 首窗最终产出 适用
0(分析值) 本例约 4/10(e4/e6/e8/e10) 约窗口结束后 10s 只做趋势、能接受丢数据
30s 本例丢 3/10(e6/e8/e10) 窗口结束后 72s 一般业务实时指标
300s 0 窗口结束后 200s 财务、对账、必须完整

结果可见时间 ≈ 窗口结束 + 水位延迟 + 计算/排队时间。延迟每加一档,完整性上升、时效性下降;没有「又完整又即时」的免费选项。

⚠️ 在无界流里,ET+300s 不会「自己结束」。上表的 10:04:20 是流结束时的 flush;真实系统要靠空闲分区标记(idle source)或足够大的水位推进,否则一个不活跃的分区会让整个作业的水位停住。

结果更新:append 还是 upsert

端到端 exactly-once 的实现见 62.4 Exactly-once 语义;实时链路如何 upsert 到 OLAP 见 62.6 实时数仓。

模式 语义 下游要求 适用
append 每次触发都追加一条 下游自己按版本去重/取最新 日志、审计、不可变事件
upsert 同一 key 覆盖旧值 下游支持主键 upsert 指标、看板、实时数仓
retract 先撤回旧值再发新值 下游支持撤回 聚合结果修正、Flink 的 retract 流

迟到数据在窗口关闭后到达时,如果开了 allowed lateness,窗口会再次触发;此时必须发更新(upsert/retract),而不是再 append 一条。只 append 的系统会出现「同一个窗口两条数」,查询端 SUM 直接翻倍——62.6 实时数仓 会用实测展示这个坑。

生产落地:指名 → 可抄 → 可验 → 可退

指名:流引擎用 Apache Flink(事件时间、水位、状态、checkpoint 的主场);Kafka 生态内轻量场景用 Kafka Streams;托管方案用 Ververica / Amazon Managed Flink / Confluent Cloud。消息层用 Kafka(分区内有序,见 43.3 顺序与分区)。

可抄(Flink SQL 1.17+,未验证:本机未安装 Flink):

CREATE TABLE orders (
  order_id STRING,
  region   STRING,
  amount   DECIMAL(18,2),
  ts       TIMESTAMP(3),
  WATERMARK FOR ts AS ts - INTERVAL '5' SECOND
) WITH (
  'connector' = 'kafka',
  'topic' = 'orders',
  'properties.bootstrap.servers' = 'localhost:9092',
  'properties.group.id' = 'gmv-job',
  'scan.startup.mode' = 'group-offsets',
  'format' = 'json'
);

-- 空闲分区 30s 后标记 idle,避免一个冷分区卡住全局水位
SET 'table.exec.source.idle-timeout' = '30s';

Kafka Streams 侧要自定义 TimestampExtractor,从业务字段取事件时间,而不是用 broker 的 record.timestamp:

props.put(StreamsConfig.DEFAULT_TIMESTAMP_EXTRACTOR_CLASS_CONFIG,
          OrderTimeExtractor.class); // 返回 order.eventTime,缺失时抛异常而不是回退到处理时间

可验:本节 Go 程序输出 true/proc/et30/et300 四列,直接看出处理时间与事件时间的差异;Flink 侧看 Web UI 的 Watermark 指标与 currentInputWatermark,水位长期不动的第一嫌疑是空闲分区或某条事件时间极大。可退:调大 WATERMARK ... INTERVAL 或 allowed lateness;把作业切回处理时间只适用于「可接受重跑不一致」的监控;原始事件必须留在 Kafka(按保留期),否则任何时间语义都无法重放。

反模式

反模式 后果
用处理时间算财务报表 重跑/补数结果不同,审计对不上
水位线延迟设为 0 乱序事件全被判迟到,指标系统性偏低
水位线延迟设为「一天」 结果永远不出,实时链路退化成批处理
把摄入时间当事件时间 上游积压越久,指标时间越偏
迟到数据静默丢弃且无侧输出 丢了多少不知道,无法对账
窗口结果只 append 不更新 同一窗口多条,查询端重复计数
多输入算子水位取 max 快分区把水位推过慢分区,慢分区数据被丢
不保留原始事件 发现口径错误也无法重放修正

自检

Q1 流处理与批处理的本质区别是什么?为什么「低延迟」不是本质区别?

▸ 答案:本质区别是数据有没有界。批处理的输入有界,可以等齐再算;流处理的输入无界,永远没有「齐」的时刻,所以必须用时间语义与水位线定义「什么时候算完」,并用状态保存中间结果。低延迟只是流处理的一个副产品;一个微批系统也可以低延迟,但仍然按有界批次处理。

Q2 事件时间、处理时间、摄入时间各适合什么场景?

▸ 答案:事件时间适合需要重放、对账、按业务时间聚合的指标(GMV、风控、用户行为);处理时间适合只关心「现在系统状态」的监控(当前 QPS、当前队列长度),可接受重跑不一致;摄入时间适合事件时间字段不可信、但又希望接近业务时间的场景(客户端时钟不可靠时的日志分析),代价是上游积压会让摄入时间偏离业务时间。

Q3 为什么处理时间窗口的结果「不可重放」?用本节数字说明。

▸ 答案:处理时间按到达时刻分窗,结果依赖事件到达顺序。本节同一批 10 条事件,处理时间得到每窗 2 条;但按事件时间真实分布是 5/2/1/1/1。如果把到达顺序换一下(网络重试、积压),处理时间结果会变;事件时间结果不变。不可重放意味着补数、审计、A/B 对比都不可信。

Q4 水位延迟从 30s 调到 300s,结果和延迟各怎么变?

▸ 答案:本节 ET+30s 得到 7/10、丢 3 条,首窗在 10:02:12 产出;ET+300s 得到 10/10、丢 0,但所有窗口要到 10:04:20 才最终确定。延迟增加换来完整性,代价是结果可见时间变长;选择依据是业务能接受多晚看到「最终数」,以及迟到数据占比。

Q5 水位线能保证「没有迟到数据」吗?如果不能,迟到数据怎么处理?

▸ 答案:不能。水位线只是一个启发式声明,事件时间小于 W 的数据仍可能到达(网络延迟、上游补发、时钟错乱)。处理方式有三种:① 丢弃并计数/告警;② allowed lateness,在窗口关闭后一段时间内接受更新并重新触发;③ 侧输出到迟到流,离线修正或人工处理。三种都要能回答「丢了多少、为什么丢」。

Q6 多输入算子的水位线为什么要取 min,而不是 max?

▸ 答案:算子的输出水位代表「所有输入都至少到这里了」。取 max 会让快分区把水位推过慢分区,慢分区里事件时间较早的数据会被误判迟到而丢弃;取 min 才能保证没有输入被落下。代价是任何一个慢/空闲分区都会拖住全局水位,所以生产上要配 idle source 标记或超时。

Q7 下游消费实时聚合结果时,为什么必须支持 upsert/retract?

▸ 答案:窗口关闭后仍可能有迟到数据修正结果,或者上游重放导致同一窗口再次触发。如果下游只 append,同一窗口会出现多条记录,SUM 会把它们全部相加;支持 upsert(按窗口+维度主键覆盖)或 retract(先减旧值再加新值)才能保证最终结果唯一且正确。

关联