1. 从一个“数据管道工”的日常说起
第一次接触Flink是在一个实时报表项目里。当时业务方要求“订单数据从产生到看板展示,延迟不能超过3秒”,团队里有人提议用定时任务每分钟跑一次批处理,有人建议上消息队列加流式计算。最后我们选了Flink,原因很简单:它能把“流”和“批”用同一套代码逻辑处理掉,不用维护两套系统。
这个系列我打算把Flink从里到外讲透。第一篇先解决“它到底是什么、为什么需要它、核心概念怎么理解”这三个问题。如果你正在做实时数据处理、事件驱动应用,或者单纯想搞清楚“流计算”和“批处理”到底差在哪,这篇内容应该能帮你省下不少翻官方文档的时间。
Flink本质上是一个分布式流处理引擎,但它最特别的地方在于:把“流”当作一等公民,批处理只是流处理的一个特例。这个设计哲学决定了它和Spark Streaming、Storm这些框架的根本差异。下面我会从实际使用场景出发,把Flink的定位、架构、核心概念逐个拆开讲。
2. Flink到底解决了什么实际问题
2.1 传统批处理的“时间差”困境
假设你负责一个电商平台的运营看板。用户下单后,运营希望立刻看到GMV变化,风控希望立刻识别异常订单,推荐系统希望立刻根据用户行为调整策略。如果还用传统的T+1批处理,数据要等到凌晨跑完ETL才能用,黄花菜都凉了。
批处理的本质是攒一批数据再处理,优点是吞吐量大、资源利用率高,缺点是延迟高。流处理的本质是来一条处理一条,延迟低,但早期框架(比如Storm)在吞吐量和状态管理上做得不够好。Flink的切入点就是:用流处理的低延迟,做到批处理的吞吐量和一致性。
2.2 有状态计算:Flink真正的杀手锏
很多流处理框架只能做无状态的转换,比如过滤、映射。但实际业务里大量场景需要“记住之前的数据”。比如:
- 计算每个用户过去5分钟的点击次数
- 检测连续3笔交易金额递增的异常模式
- 对每个会话进行窗口聚合
这些都需要状态。Flink提供了托管状态(Managed State),把状态存储在内存或RocksDB里,通过检查点(Checkpoint)机制保证故障恢复后状态不丢。这一点比早期Storm的Trident或者Spark Streaming的DStream要优雅得多。
提示:如果你只需要简单的ETL过滤,不用Flink也能做。但一旦涉及窗口聚合、状态机、事件时间处理,Flink的优势会非常明显。
2.3 事件时间与乱序数据处理
现实世界的数据很少是准时到达的。用户手机信号不好,点击事件可能延迟几秒才上报;IoT设备网络抖动,传感器数据可能乱序。如果用处理时间(Processing Time)做窗口,结果会受数据到达顺序影响,统计不准。
Flink原生支持**事件时间(Event Time)语义,配合水位线(Watermark)**机制,可以容忍一定程度的乱序,等数据到齐了再触发计算。这个能力在实时风控、实时对账场景里几乎是刚需。
3. Flink架构里最值得关注的几个设计
3.1 JobManager与TaskManager的分工逻辑
Flink集群采用经典的主从架构:
| 角色 | 职责 | 类比 |
|---|---|---|
| JobManager | 接收作业、调度任务、协调检查点、故障恢复 | 项目经理 |
| TaskManager | 执行具体任务、管理内存和状态 | 干活的工程师 |
| ResourceManager | 管理计算资源(Slot) | 后勤 |
JobManager不干具体活,只负责“指挥”。TaskManager才是真正跑算子(Operator)的地方。一个TaskManager可以划分多个Slot,每个Slot能跑一个任务链(Task Chain)。这种设计让Flink既能跑在独立集群上,也能跑在Kubernetes、YARN上。
3.2 任务链与算子融合的取舍
Flink默认会把能合并的算子串成一条链,比如source -> map -> filter -> sink,如果并行度相同且没有 shuffle,就会合并成一个任务。这样做的好处是减少线程间切换和序列化开销。
但有时候你需要手动断开任务链,比如:
- 某个算子特别耗CPU,想单独分配资源
- 需要观察中间结果
- 不同算子需要不同的并行度
这时候可以用disableChaining()或者startNewChain()来控制。实际调优时,任务链的划分对性能影响很大,后面讲调优时会专门展开。
3.3 检查点机制:怎么做到“ Exactly-Once”
Flink的Exactly-Once语义靠分布式快照实现。JobManager定期往数据流里插入Barrier,Barrier随着数据流动,把算子状态“切”成前后两段。当所有算子都确认收到Barrier并完成状态快照后,这次Checkpoint就算成功。
如果某个TaskManager挂了,JobManager会从最近一次成功的Checkpoint恢复,把状态重新分发给存活的TaskManager,然后从Barrier位置重新消费数据。整个过程自动完成,不需要人工干预。
注意:Exactly-Once是针对Flink内部状态而言的。如果Sink端是外部系统(比如MySQL),还需要配合事务或者幂等写入才能端到端Exactly-Once。
4. 流、批、窗口:三个必须搞懂的基础概念
4.1 无界流与有界流的本质区别
Flink里所有数据都是流。区别在于:
- 无界流(Unbounded Stream):有开始没结束,比如Kafka里的实时日志。处理时只能等数据来,不能等“全部数据到齐”。
- 有界流(Bounded Stream):有开始有结束,比如读取一个文件。处理时可以等读完再算,所以能排序、能全局聚合。
Flink把批处理当作有界流的特例,用同一套运行时引擎处理。这意味着你写流处理的代码,稍微改改就能跑批数据,反之亦然。这个统一模型是Flink区别于其他框架的核心卖点。
4.2 窗口:把无限流切成有限块
窗口是流处理里最常用的操作。Flink支持多种窗口类型:
- 滚动窗口(Tumbling Window):固定大小,不重叠。比如每5分钟统计一次。
- 滑动窗口(Sliding Window):固定大小,可重叠。比如每1分钟统计过去5分钟的数据。
- 会话窗口(Session Window):按活动间隙划分。比如用户30秒没操作就认为会话结束。
- 全局窗口(Global Window):所有数据放一个窗口,需要自定义触发器。
窗口可以基于事件时间、处理时间或者摄入时间。实际项目里,事件时间窗口最常用,但需要处理好水位线延迟。
4.3 水位线:告诉系统“可以触发计算了”
水位线是一个时间戳,表示“事件时间小于等于这个值的数据都已经到了”。比如水位线是10:05:00,意味着10:05:00之前的数据不会再来了,窗口可以触发计算。
水位线的生成策略有两种:
- 周期性生成:每隔一段时间发一个,适合大多数场景。
- 断点式生成:根据特定事件触发,适合数据流有明确结束标记的场景。
如果水位线设置得太激进,可能丢数据;设置得太保守,结果输出延迟高。实际调优时,通常先观察数据乱序程度,再设置一个合理的outOfOrderness。
5. 从零搭建一个Flink作业的完整思路
5.1 环境准备:本地跑通再上集群
刚开始学Flink,不建议直接上集群。先在本地用IDE跑一个WordCount,把依赖、日志、检查点配置都摸一遍。
Maven依赖核心就几个:
<dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-streaming-java</artifactId> <version>1.17.0</version> </dependency> <dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-clients</artifactId> <version>1.17.0</version> </dependency>如果是Scala项目,把flink-streaming-java换成flink-streaming-scala。注意Flink版本和Scala版本要匹配,比如flink-streaming-scala_2.12。
5.2 代码骨架:Source、Transformation、Sink
一个最简的Flink作业长这样:
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); env.enableCheckpointing(5000); // 每5秒做一次检查点 DataStream<String> source = env.socketTextStream("localhost", 9999); DataStream<Tuple2<String, Integer>> counts = source .flatMap(new Tokenizer()) .keyBy(value -> value.f0) .sum(1); counts.print(); env.execute("WordCount");这里有几个关键点:
enableCheckpointing:生产环境必须开,本地测试可以关。keyBy:按key分组,这是有状态操作的前提。execute:Flink作业是懒执行的,不调用execute不会真正跑。
5.3 并行度设置:别让资源闲着,也别让资源打架
并行度决定了算子有多少个并行实例。设置方式有四种优先级:
- 算子级别:
map(...).setParallelism(4) - 执行环境级别:
env.setParallelism(4) - 客户端级别:提交作业时指定
- 集群级别:配置文件里指定
实际项目里,Source的并行度通常和Kafka分区数一致,中间算子的并行度根据CPU核数和数据量调整,Sink的并行度要考虑下游系统的承受能力。
经验:并行度不是越大越好。并行度太高会导致大量小文件、频繁网络传输、检查点变慢。一般从CPU核数的2-4倍开始试,再根据背压监控调整。
6. 新手最容易踩的几个坑
6.1 忘记设置事件时间语义
默认情况下,Flink用的是处理时间。如果你直接开窗口,结果会受数据到达顺序影响。正确做法是:
env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime);然后在Source后面指定时间戳和水位线:
DataStream<Event> withTimestamps = stream .assignTimestampsAndWatermarks( WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5)) .withTimestampAssigner((event, timestamp) -> event.getTimestamp()) );这个坑很隐蔽,因为代码能跑,结果看起来也“差不多”,但统计值就是不对。
6.2 状态太大导致检查点超时
如果状态存在内存里,数据量一大就会OOM。这时候需要配置RocksDB状态后端:
env.setStateBackend(new RocksDBStateBackend("hdfs:///flink/checkpoints"));RocksDB把状态存在磁盘上,内存只做缓存。代价是读写速度比纯内存慢,但能支撑TB级状态。
6.3 水位线延迟设置不合理
forBoundedOutOfOrderness的参数决定了容忍多久的乱序。设太小,迟到数据被丢弃;设太大,窗口触发延迟高。建议先统计一下数据乱序分布,取P99延迟作为参考值。
6.4 忘记处理空闲分区
如果Kafka某个分区长时间没数据,水位线不会推进,下游窗口永远不触发。解决办法是用withIdleness:
WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5)) .withIdleness(Duration.ofMinutes(1))这样超过1分钟没数据的分区会被标记为空闲,水位线可以继续推进。
7. 什么时候该用Flink,什么时候不该用
Flink很强,但不是银弹。根据我的经验,以下场景适合上Flink:
- 数据实时性要求高(秒级或亚秒级)
- 需要复杂事件处理(CEP)
- 有状态计算,且状态需要容错
- 流批一体,不想维护两套代码
以下场景可能不需要Flink:
- 数据量很小,单机脚本就能处理
- 延迟要求不高,分钟级批处理足够
- 团队没有流处理经验,维护成本太高
- 只是简单的ETL,Kafka Streams或Spark Structured Streaming更轻量
选型时不要只看技术指标,还要考虑团队的技术栈和运维能力。我见过不少团队为了“技术先进”上Flink,结果连基本的监控和调优都没做好,反而不如用熟悉的批处理稳。
8. 后续会展开的几个方向
这篇主要把Flink的定位和核心概念过了一遍。后面几篇我打算按这个顺序深入:
- 第二篇:DataStream API详解,包括Source、Sink、算子、分区策略
- 第三篇:状态管理与检查点机制,含RocksDB调优和端到端Exactly-Once
- 第四篇:时间语义与窗口实战,重点讲水位线对齐和迟到数据处理
- 第五篇:Flink SQL与Table API,怎么用SQL写流处理
- 第六篇:生产环境部署与调优,包括背压监控、资源规划、常见故障处理
如果你正在做实时数据平台,或者准备把批处理迁移到流处理,这个系列应该能帮你少走一些弯路。下一篇我会从DataStream API开始,把每个算子的使用场景和坑点都过一遍。