3. 实时流处理基础:Flink 核心概念与 WordCount 实战

好,咱们进入第三章。这一章是硬骨头,也是地基。

实时特征计算,说白了就是跟数据流打交道。而 Flink,是目前业界处理数据流最主流的引擎之一。我这些年做实时架构,从 Storm 到 Spark Streaming 再到 Flink,踩过的坑不少。最后为什么选择 Flink?因为它把「流」这件事想得最透彻。

这一章,我会带你从零搭建 Flink 环境,然后亲手跑一个 WordCount。别小看 WordCount,它背后藏着 Flink 最核心的抽象——DataStream 和 Operator。搞懂了这两个,后面讲实时特征计算,你就知道数据是怎么在管道里流动、怎么被加工的了。

3.1 为什么是 Flink?

先聊个题外话。我记得 2018 年有个项目,需要做毫秒级的用户点击流特征提取。当时团队用的是 Spark Streaming,但微批处理的延迟始终降不下来。后来换成 Flink,延迟直接降到几十毫秒。那次之后,我对 Flink 的「真·流处理」能力有了切身体会。

Flink 的核心优势,我总结为三点:

  • 真正的流处理:每条数据到达即处理,不是攒一批再算。延迟低到亚秒级。
  • 精确一次语义:在故障恢复时,数据不会丢也不会重复。做金融风控特征时,这个特性救过我。
  • 强大的状态管理:算子可以记住历史数据,比如计算滑动窗口内的用户行为次数,Flink 帮你管好状态。

你想想看,如果特征计算不准,后面的模型推理全是错的。所以选 Flink,就是选确定性。

3.2 Flink 核心概念:DataStream 与 Operator

这两个概念,是理解 Flink 的钥匙。

3.2.1 DataStream:数据流的抽象

DataStream 是什么?说白了,它就是一条无限流动的数据序列。每条数据是一个事件,比如一次点击、一条日志、一个传感器读数。

在代码里,DataStream 是一个泛型类。你可以把它想象成一个管道,数据从源头进来,经过各种算子处理,最后输出到某个地方。

举个例子:

// 从 socket 读取数据流
DataStream<String> textStream = env.socketTextStream("localhost", 9999);

这里的 textStream 就是一个 DataStream,里面的每个元素是一行字符串。

我个人习惯把 DataStream 比作「传送带」。传送带上的每个包裹就是一条数据。你可以在传送带上做各种操作——拆包、贴标签、分拣。这些操作,就是 Operator。

3.2.2 Operator:数据流的转换操作

Operator 是对 DataStream 进行转换的函数。Flink 提供了丰富的算子,我挑几个最常用的:

算子 作用 输入 → 输出
map 一对一转换 1 条 → 1 条
flatMap 一对多转换 1 条 → 0 或多条
filter 过滤 保留符合条件的
keyBy 按 key 分组 DataStream → KeyedStream
window 开窗口 KeyedStream → WindowedStream
sum/reduce 聚合 分组后做累加或归约

嗯,这里要注意:keyBy 之后的数据流变成了 KeyedStream,它和普通的 DataStream 不一样。KeyedStream 可以理解为「按 key 分好桶的流」,后续的窗口、聚合操作都基于这些桶进行。

避坑指南: 我曾经在线上环境犯过一个低级错误——在 keyBy 之前用了 map 做状态操作。结果状态没有按 key 隔离,数据全乱了。记住:需要按 key 维护状态的操作,一定要放在 keyBy 之后。

3.3 Flink 环境搭建

光说不练假把式。咱们动手搭个环境。

3.3.1 本地开发环境

我建议你直接用 IntelliJ IDEA + Maven。Flink 的 Java API 最成熟,虽然现在也有 PyFlink,但做生产级特征计算,Java/Scala 还是主流。

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>

版本号我用的 1.17,目前比较稳定。如果你用更新的版本,注意检查 API 兼容性。

3.3.2 启动本地集群(可选)

如果你只是想跑个 demo,用 IDE 直接运行就行,Flink 会自动启动一个嵌入式集群。但如果你想体验完整的提交流程,可以下载 Flink 压缩包,解压后运行:

# 启动集群
./bin/start-cluster.sh

# 访问 Web UI
http://localhost:8081

Web UI 是个好东西。我调试任务时,经常盯着 UI 看算子延迟、反压情况。这些信息对调优很有帮助。

3.4 WordCount 实战

好,重头戏来了。咱们写一个实时 WordCount,从 socket 读取文本,实时统计单词出现次数。

3.4.1 完整代码

import org.apache.flink.api.common.functions.FlatMapFunction;
import org.apache.flink.api.java.tuple.Tuple2;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.util.Collector;

public class StreamingWordCount {
    public static void main(String[] args) throws Exception {
        // 1. 创建执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

        // 2. 读取数据源:socket 文本流
        DataStream<String> textStream = env.socketTextStream("localhost", 9999);

        // 3. 转换:切分单词,映射为 (word, 1)
        DataStream<Tuple2<String, Integer>> wordCounts = textStream
                .flatMap(new Tokenizer())          // 切分
                .keyBy(value -> value.f0)          // 按单词分组
                .sum(1);                           // 累加计数

        // 4. 输出
        wordCounts.print();

        // 5. 启动任务
        env.execute("Streaming WordCount");
    }

    // 自定义 FlatMapFunction
    public static class Tokenizer implements FlatMapFunction<String, Tuple2<String, Integer>> {
        @Override
        public void flatMap(String value, Collector<Tuple2<String, Integer>> out) {
            // 按空格切分
            String[] words = value.toLowerCase().split("\\W+");
            for (String word : words) {
                if (word.length() > 0) {
                    out.collect(new Tuple2<>(word, 1));
                }
            }
        }
    }
}

3.4.2 运行步骤

  1. 在终端启动一个 socket 数据源:nc -lk 9999
  2. 在 IDE 中运行 StreamingWordCount 的 main 方法
  3. 在终端输入一些英文句子,比如 hello world hello flink
  4. 观察 IDE 控制台输出:(hello,2) (world,1) (flink,1)

你会发现,每次输入新句子,结果都会实时更新。这就是流处理的魅力——数据一到,结果就出。

关键点解析:
  • flatMap:把一行文本拆成多个单词,每个单词发射一个 (word, 1) 元组。
  • keyBy:按单词分组,相同单词的数据进入同一个分区。
  • sum(1):对元组的第二个字段(索引 1)做累加。
  • print():将结果输出到标准输出,每个并行子任务一个线程。

3.5 数据流图:从源头到输出

为了让你更直观地理解数据是怎么流动的,我画了一张图。这张图展示了 WordCount 任务中,数据从 socket 进入,经过各个算子,最终输出的完整路径。

Flink WordCount 数据流图 Flink WordCount 数据流图 Socket 数据源 flatMap 切分 keyBy 分组 sum 聚合 print 输出 说明: 数据从 Socket 进入 → flatMap 拆成单词 → keyBy 按单词分组 → sum 累加计数 → print 输出到控制台。

这张图里,每个矩形代表一个算子,箭头代表数据流动方向。你可以看到,数据从左边进来,经过四个算子的处理,最后从右边输出。这就是 Flink 任务的基本执行模型——一个有向无环图(DAG)。

3.6 常见问题与避坑

最后,分享几个我实际踩过的坑:

坑 1:并行度设置不当导致数据倾斜

我曾经有个特征计算任务,某个 key 的数据量特别大,导致那个子任务处理不过来,其他子任务却闲着。解决办法:对 key 加盐(salted key),把热点 key 拆成多个子 key,计算完再合并。

坑 2:状态后端选择错误

Flink 的状态后端有 MemoryStateBackend、FsStateBackend、RocksDBStateBackend。如果状态量很大(比如超过 1GB),千万别用 Memory,否则 OOM 是迟早的事。我一般用 RocksDB,虽然读写慢一点,但稳定。

小技巧: 调试时可以在 flatMap 或 map 里加个 System.out.println,打印当前处理的数据。虽然不优雅,但排查问题很管用。

好了,这一章的内容就到这儿。DataStream 和 Operator 是 Flink 的基石,WordCount 是入门的第一道坎。搞懂了这些,后面讲实时特征计算,你就能跟上节奏了。