核心概念与设计哲学:统一数据模型、统一计算引擎、事件时间与处理时间、一致性语义
好,咱们正式开始聊流批一体的核心设计。这一章,我打算把几个最基础、也最容易混淆的概念掰开揉碎讲清楚。说白了,流批一体不是凭空冒出来的,它背后有一套完整的设计哲学在支撑。
我个人习惯,在动手做架构之前,先把这些概念对齐。不然团队里你说你的流,我说我的批,最后出来的东西肯定四不像。咱们一个一个来。
统一数据模型:Row 与 Table
先问个问题:在 Flink 的世界里,流和表到底是什么关系?
很多人觉得,流就是源源不断的数据,表就是静态的数据快照。这个理解没错,但不够本质。在 Flink 的设计里,流是表的动态视图,表是流的静态快照。你想想看,是不是这个理?
Flink 用 Table 和 Row 这两个核心类型来统一表达数据。不管数据是从 Kafka 来的,还是从 HDFS 来的,最终都能映射成一张表,或者一行行记录。
核心要点:动态表(Dynamic Table)是流批一体的基石。批处理看到的是表在某个时间点的完整快照,流处理看到的是表随时间变化的 changelog 流。
我记得在做一个实时数仓项目时,业务方坚持要用 SQL 查实时数据,但又想看到历史全量。当时我就用 Flink 的 Table API 把 Kafka 流注册成动态表,再跟 HBase 里的维表做 join。嗯,效果还不错。这里的关键是,你得理解 Row 就是一条条具体的数据载体,而 Table 是它的 schema 约束。
| 概念 | 批处理视角 | 流处理视角 |
|---|---|---|
| Table | 有界数据集,固定 schema | 无界数据流,schema 随时间演化 |
| Row | 数据集中的一条记录 | 事件流中的一个事件 |
| 动态表 | 不适用 | append、retract、upsert 三种 changelog 模式 |
避坑指南:我曾经在定义动态表的 schema 时,忽略了时间字段的精度问题。结果流处理跑起来,watermark 一直不触发,窗口数据死活不输出。后来排查了半天,才发现是 timestamp 精度没对齐。所以,schema 定义一定要严谨,尤其是时间字段。
统一计算引擎:SQL 与 DataStream
Flink 提供了两套 API:一套是声明式的 SQL,一套是命令式的 DataStream API。很多人纠结到底该用哪个。我的建议是:能用 SQL 解决的,别手写 DataStream。
为什么?因为 SQL 有优化器。你写一条 SQL,Flink 的优化器会帮你做谓词下推、投影裁剪、join 重排。这些优化,你用手写 DataStream 来实现,累死不说,还容易出错。
但 DataStream API 也不是没用。当业务逻辑复杂到 SQL 表达不了,或者你需要精细控制状态和定时器时,DataStream 就是你的王牌。
// SQL 方式:简洁明了
Table result = tableEnv.sqlQuery(
"SELECT userId, COUNT(*) AS cnt " +
"FROM clicks " +
"GROUP BY TUMBLE(proctime, INTERVAL '1' MINUTE), userId"
);
// DataStream 方式:灵活可控
DataStream<Click> clicks = env.addSource(kafkaSource);
DataStream<Count> result = clicks
.keyBy(Click::getUserId)
.window(TumblingProcessingTimeWindows.of(Time.minutes(1)))
.aggregate(new CountAggregate());
你看,同样的逻辑,SQL 三行搞定,DataStream 要写一堆。但如果你要做复杂的自定义窗口策略,比如根据数据内容动态调整窗口大小,那 SQL 就无能为力了,得上 DataStream。
注意:不要以为 SQL 是万能的。我见过有人硬要用 SQL 实现复杂的多流关联,结果 SQL 写了几百行,性能一塌糊涂。这时候,拆成几个 DataStream 算子,反而更清晰高效。
事件时间与处理时间
这是流处理里最容易踩坑的地方。我刚开始接触时也迷糊过。
- 事件时间(Event Time):数据产生的时间,通常由业务系统打戳。比如用户点击网页的时间。
- 处理时间(Processing Time):数据到达 Flink 算子的时间。比如 Flink 收到这条点击记录的时间。
- 摄入时间(Ingestion Time):数据进入 Flink 源算子的时间。介于两者之间,但用得不多。
为什么事件时间这么重要?因为数据可能会延迟、乱序。如果你用处理时间做窗口统计,那结果会随着数据到达顺序的变化而变化,不可复现。而事件时间,只要数据本身的时间戳不变,无论你重跑多少次,结果都是一致的。
我记得有一次做实时大屏,业务方说数据对不上。排查下来,发现是上游 Kafka 数据有延迟,但窗口用的是处理时间。结果本该属于 10:00 的数据,因为延迟到了 10:05 才被处理,被算进了 10:05 的窗口。后来改成事件时间 + watermark,问题就解决了。
| 特性 | 事件时间 | 处理时间 |
|---|---|---|
| 确定性 | 确定,可复现 | 不确定,依赖数据到达顺序 |
| 延迟处理 | 需要 watermark 和 allowed lateness | 天然低延迟 |
| 适用场景 | 对结果准确性要求高的场景(如金融、对账) | 对实时性要求极高、允许一定误差的场景(如监控告警) |
| 实现复杂度 | 较高,需要处理乱序和延迟 | 较低,开箱即用 |
我的经验:能用事件时间,就别用处理时间。虽然事件时间需要处理 watermark、allowed lateness 这些概念,但换来的是结果的正确性和可复现性。这在生产环境里,比那点延迟重要得多。
一致性语义:Exactly-Once
聊到一致性,就绕不开 Exactly-Once。这个词被炒得很热,但真正理解它的人不多。
Exactly-Once 不是说每条数据只被处理一次,而是说每条数据对最终结果的影响只发生一次。即使系统发生故障、重启、重试,最终结果也不会被重复计算或遗漏。
Flink 实现 Exactly-Once 的核心机制是:检查点(Checkpoint) + 两阶段提交(Two-Phase Commit)。
- Checkpoint:定期保存算子的状态快照。故障时从最近的 checkpoint 恢复。
- 两阶段提交:在 checkpoint 完成时,将数据写入外部系统(如 Kafka、HDFS)的事务中。只有 checkpoint 成功,事务才提交。
这里有个关键点:端到端的 Exactly-Once。Flink 内部的状态可以保证 Exactly-Once,但数据要写到外部系统,就需要外部系统支持事务或幂等写入。
举个例子:Flink 写 Kafka,需要 Kafka 支持事务(Kafka 0.11+)。Flink 写 HDFS,需要 HDFS 支持 rename 操作。如果外部系统不支持,那 Flink 只能保证 At-Least-Once,也就是至少一次。
我曾经踩过一个坑:用 Flink 写 Elasticsearch,ES 不支持事务。结果任务重启后,ES 里出现了重复数据。后来我改成了幂等写入,用业务主键去重,才算解决了问题。所以,不要迷信 Exactly-Once 这个词,要看你的上下游系统是否支持。
这张图把咱们这一章的核心概念串起来了。你看,统一数据模型在最上层,下面是统一计算引擎,再下面是时间语义,最下面是一致性语义。它们层层递进,共同构成了流批一体的设计哲学。
最后提醒一句:一致性语义不是银弹。Exactly-Once 会带来额外的性能开销和延迟。如果你的业务场景允许少量重复(比如监控告警),用 At-Least-Once 反而更高效。别为了追求理论上的完美,牺牲了实际的生产效率。