4、实时计算引擎:Flink核心概念与选型
聊到实时特征计算,引擎选型是绕不开的坎。我这些年经手过不少实时项目,从早期的Storm到后来的Spark Streaming,再到现在的Flink,可以说踩坑无数。今天咱们就重点拆解Flink的核心概念,再跟Spark Streaming做个对比,最后给出我的选型建议。
4.1 Flink核心概念:DataStream、Operator、Window
Flink的设计哲学,说白了就是「一切皆流」。它把数据看作无界流,然后通过一系列操作把它变成有界、可计算的状态。
4.1.1 DataStream:流的核心抽象
DataStream是Flink里最基础的数据结构。它代表一个持续不断的数据流,你可以对它做各种转换操作。
核心要点:DataStream是不可变的,每次操作都会生成一个新的DataStream。这一点跟Spark的RDD很像,但Flink是真正的逐条处理,不是微批次。
我记得刚接触Flink时,有个同事问:「DataStream跟Java的Stream有啥区别?」其实区别大了。Flink的DataStream是分布式的,可以跨节点并行处理,而且自带容错机制。Java的Stream只是单机内存里的集合操作。
// 一个简单的DataStream示例
DataStream<String> sourceStream = env.socketTextStream("localhost", 9999);
DataStream<Event> parsedStream = sourceStream
.map(line -> parseEvent(line))
.filter(event -> event.getType() != null);
4.1.2 Operator:算子链与并行度
Operator就是你对DataStream做的各种操作。Flink的算子分为三类:Source(数据源)、Transformation(转换)、Sink(输出)。
这里有个坑,我刚开始做项目时踩过。Flink默认会把相邻的算子合并成Operator Chain,目的是减少网络传输和序列化开销。但有时候你希望单独调某个算子的并行度,就得手动禁用Chain。
避坑指南:我曾经在一个高吞吐场景下,发现某个map算子成了瓶颈。排查了半天,才发现它跟前面的source chain在一起,并行度被source限制了。后来用.disableChaining()单独设置并行度,吞吐直接翻倍。
// 手动控制算子链
DataStream<Event> stream = env
.addSource(new FlinkKafkaConsumer<>(...))
.disableChaining() // 断开chain
.map(new MyMapper()).setParallelism(10)
.keyBy(event -> event.getUserId())
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.process(new MyProcessFunction());
4.1.3 Window:流计算的核心机制
无界流怎么算聚合?答案就是Window。Flink把无限的数据切分成有限的「桶」,在每个桶里做计算。
Flink支持三种窗口类型:
| 窗口类型 | 说明 | 适用场景 |
|---|---|---|
| Tumbling Window | 固定大小,不重叠 | 每分钟PV统计 |
| Sliding Window | 固定大小,可重叠 | 最近5分钟滑动UV |
| Session Window | 基于活动间隙 | 用户行为会话 |
嗯,这里要注意时间语义。Flink支持Event Time、Processing Time和Ingestion Time。我强烈建议用Event Time,因为它能处理乱序数据。Processing Time虽然简单,但结果不稳定,一旦数据延迟就全乱套了。
个人经验:用Event Time时,一定要设置Watermark。Watermark就像闹钟,告诉Flink「到这个时间点了,该触发窗口计算了」。我习惯设置forBoundedOutOfOrderness(Duration.ofSeconds(5)),允许5秒的乱序。
// Event Time + Watermark 配置
DataStream<Event> withTimestamps = stream
.assignTimestampsAndWatermarks(
WatermarkStrategy.<Event>forBoundedOutOfOrderness(Duration.ofSeconds(5))
.withTimestampAssigner((event, timestamp) -> event.getEventTime())
);
DataStream<UserAction> result = withTimestamps
.keyBy(event -> event.getUserId())
.window(TumblingEventTimeWindows.of(Time.minutes(1)))
.aggregate(new MyAggregateFunction());
4.2 Flink vs Spark Streaming:核心差异
这两个引擎经常被拿来对比。我两个都用过,说说我的真实感受。
4.2.1 处理模型:逐条 vs 微批次
这是最根本的区别。Flink是真正的逐条处理,数据来一条处理一条。Spark Streaming是微批次,把数据攒成小批次再处理。
| 维度 | Flink | Spark Streaming |
|---|---|---|
| 处理模型 | 逐条处理(真流) | 微批次(伪流) |
| 延迟 | 毫秒级 | 秒级(批次间隔) |
| 状态管理 | 原生支持,强一致 | 依赖外部存储 |
| Exactly-Once | 端到端保证 | 需要额外配置 |
| 背压机制 | 天然支持 | 需要手动调优 |
你想想看,微批次有个天然问题:批次间隔内数据要等。如果批次设成1秒,那延迟至少1秒。Flink是来一条处理一条,延迟可以做到几十毫秒。
4.2.2 状态管理与容错
Flink的状态管理是我最喜欢的功能。它内置了Keyed State和Operator State,而且通过Checkpoint实现一致性快照。我在项目中做过测试,100万key的状态恢复,Flink只需要几秒钟。
Spark Streaming的状态管理就比较弱了。它依赖updateStateByKey或mapWithState,但状态是存在内存里的,一旦挂掉就丢了。要持久化状态,得自己对接外部存储,比如Redis或HBase。
我的建议:如果你的场景需要维护大量状态(比如用户画像、实时特征),选Flink。如果只是简单的ETL或聚合,Spark Streaming也够用。
4.2.3 背压处理
背压是个容易被忽略的点。Flink的背压是自动的,它通过Credit-based流控机制,让下游告诉上游「我吃不下了,你慢点发」。整个过程对用户透明。
Spark Streaming的背压需要手动配置backpressure.enabled,而且效果有限。我遇到过几次Spark Streaming因为背压导致OOM,后来不得不加机器、调参数,折腾了好久。
4.3 选型建议:什么时候选什么
说了这么多,到底怎么选?我总结了一个决策树,你可以参考:
我个人的经验是:新项目直接上Flink。原因很简单,Flink代表了实时计算的未来方向。Spark Streaming虽然生态好,但微批次的架构天花板很明显。
避坑指南:我曾经在一个项目里用Spark Streaming做实时特征,结果状态越来越大,GC频繁,最后不得不迁移到Flink。迁移过程痛苦不说,还丢了几天数据。所以我的建议是:如果确定要做实时特征,一开始就选Flink。
4.4 小结
Flink的核心概念其实不复杂:DataStream是流,Operator是操作,Window是切分。理解这三者的关系,你就掌握了Flink的骨架。
跟Spark Streaming对比,Flink在延迟、状态管理、背压处理上都有明显优势。但Spark Streaming也有自己的场景:如果你团队全是Spark技术栈,或者只是做简单的流式ETL,用Spark Streaming也没问题。
选型没有银弹,关键看你的业务场景。但如果你问我个人意见——实时特征计算,Flink是当前最好的选择。