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 选型建议:什么时候选什么

说了这么多,到底怎么选?我总结了一个决策树,你可以参考:

实时计算引擎选型决策树 实时计算需求 延迟 < 100ms? 延迟 > 1s 可接受? 状态复杂? 批流一体? Flink Spark Streaming Flink(批流一体) 选型建议说明: • 延迟敏感 + 状态复杂 → Flink(毫秒级延迟,原生状态管理) • 延迟容忍 + 简单ETL → Spark Streaming(生态丰富,学习成本低) • 批流一体需求 → Flink(统一API,一套代码跑批和流) • 已有Spark技术栈 → 可先用Spark Streaming,后续迁移Flink

我个人的经验是:新项目直接上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是当前最好的选择。