3. Flink DataStream API 基础:从入门到动手

好,咱们正式开始接触 Flink 的核心编程接口——DataStream API。说实话,我刚开始学 Flink 的时候,面对一堆抽象概念也有点懵。但后来我发现,只要抓住三个关键点,一切就清晰了:数据从哪来、数据怎么变、数据去哪了。这一章,我们就先搞定前两个。

本章核心脉络:DataStream API 概述 → Source 算子(集合/文件/Kafka)→ Transformation 算子(map/flatMap/filter/keyBy)。

DataStream API 核心三要素 Source 算子 数据从哪来? Transformation 数据怎么变? Sink 算子 数据去哪了? 集合 文件 Kafka map flatMap filter keyBy 💡 学习建议 先掌握 Source 和 Transformation,Sink 会在后续章节展开。

3.1 DataStream API 是什么?

DataStream API,说白了就是 Flink 提供的一套 Java/Scala 接口,让你能像操作集合一样处理无界数据流。你想想看,传统的数据处理都是批量的——数据攒够了再算。但实时场景下,数据是源源不断来的,没法等。DataStream API 就是解决这个问题的。

我个人习惯把 DataStream 理解成一个「流动的集合」。它跟 Java 的 List 很像,但有两个本质区别:

  • 元素是动态到达的——你不知道下一秒会来什么数据
  • 操作是延迟执行的——你定义好转换逻辑,数据到了才触发计算

我的经验:刚开始写 Flink 程序时,最容易犯的错误是把 DataStream 当成普通集合去调试。记住,stream.map(...) 只是定义了一个操作,不会立即执行。你需要调用 env.execute() 才会真正跑起来。

3.2 Source 算子:数据从哪来?

Source 是 DataStream 的起点。Flink 提供了丰富的内置 Source,我们重点讲三种最常见的。

3.2.1 从集合读取

这是学习阶段最常用的方式。适合写 Demo 和单元测试。

// 从 List 创建 DataStream
DataStream<String> stream = env.fromElements(
    "hello flink",
    "hello world",
    "flink streaming"
);

// 或者从集合创建
List<Integer> data = Arrays.asList(1, 2, 3, 4, 5);
DataStream<Integer> numStream = env.fromCollection(data);

嗯,这里要注意:fromElements 和 fromCollection 在 Flink 1.12 之后被标记为过时了,官方推荐用 fromSource。但作为学习入门,它们依然是最直观的。

3.2.2 从文件读取

生产环境中,文件 Source 常用于批处理或回溯历史数据。

// 读取文本文件,每行一条记录
DataStream<String> lineStream = env.readTextFile("hdfs:///data/input.log");

// 读取 CSV 文件(需要自定义解析)
DataStream<MyRecord> csvStream = env
    .readFile(new CsvInputFormat<MyRecord>(path) { ... }, 
              "hdfs:///data/records.csv");

我曾经踩过的坑:文件 Source 默认是有界流,读完后作业就结束了。如果你想让作业持续监听文件变化,需要设置 FileProcessingMode.PROCESS_CONTINUOUSLY。但说实话,生产环境很少这么干——文件追加的场景更适合用 Kafka。

3.2.3 从 Kafka 读取(重点)

这才是实时流处理的标配。Kafka Source 是 Flink 最常用的 Source 之一。

Properties props = new Properties();
props.setProperty("bootstrap.servers", "localhost:9092");
props.setProperty("group.id", "flink-consumer");

DataStream<String> kafkaStream = env
    .addSource(new FlinkKafkaConsumer<>(
        "input-topic",          // 主题名
        new SimpleStringSchema(), // 反序列化器
        props                   // 消费者配置
    ));

这里有几个关键参数我建议你记住:

参数 作用 我的建议
bootstrap.servers Kafka 集群地址 至少写两个 broker,防止单点
group.id 消费者组 ID 不同作业用不同 group.id
enable.auto.commit 是否自动提交 offset 建议设为 false,用 Flink 的 Checkpoint 管理

避坑指南:Kafka 的 offset 提交和 Flink 的 Checkpoint 是两套机制。我曾经在一个项目里同时开启了自动提交和 Checkpoint,结果重启后数据重复消费了。记住:用 Flink 消费 Kafka,让 Checkpoint 管理 offset,把 enable.auto.commit 关掉。

3.3 Transformation 算子:数据怎么变?

数据进来了,接下来就是各种转换。我挑四个最常用的算子来讲,它们覆盖了 80% 的日常场景。

3.3.1 map:一对一转换

map 是最简单的算子——输入一条,输出一条。就像流水线上的工人,每个零件经过他,他做一次加工。

// 将字符串转为大写
DataStream<String> upperStream = stream
    .map(new MapFunction<String, String>() {
        @Override
        public String map(String value) {
            return value.toUpperCase();
        }
    });

// Lambda 写法(Java 8+)
DataStream<String> upperStream = stream
    .map(value -> value.toUpperCase());

我个人强烈推荐用 Lambda 写法,代码简洁很多。但要注意:如果逻辑复杂(超过 3 行),还是单独写一个类吧,方便单元测试。

3.3.2 flatMap:一对多转换

flatMap 跟 map 的区别在于:输入一条,可以输出零条、一条或多条。典型的场景就是分词。

// 将一行文本拆成多个单词
DataStream<String> wordStream = lineStream
    .flatMap(new FlatMapFunction<String, String>() {
        @Override
        public void flatMap(String line, Collector<String> out) {
            String[] words = line.split(" ");
            for (String word : words) {
                out.collect(word);  // 每条单词都发射出去
            }
        }
    });

你看,flatMap 多了一个 Collector 参数。你调用 out.collect() 的次数决定了输出多少条。一次都不调用,那就没输出——这可以用来做过滤。

我的经验:flatMap 其实可以替代 map 和 filter 的组合。当你的逻辑既要做转换又要做过滤时,用 flatMap 一把搞定,代码更紧凑。

3.3.3 filter:过滤算子

filter 就是做筛选的。返回 true 保留,false 丢弃。

// 只保留长度大于 5 的单词
DataStream<String> longWords = wordStream
    .filter(value -> value.length() > 5);

// 或者过滤掉空字符串
DataStream<String> nonEmpty = wordStream
    .filter(value -> !value.isEmpty());

嗯,这里有个小细节:filter 不会改变数据类型,它只是决定哪些元素能通过。所以输入输出类型是一样的。

3.3.4 keyBy:分组算子(核心)

keyBy 是 Flink 里最重要的算子之一。它把数据按照某个 key 分组,后续的聚合操作(比如 sum、reduce、window)都是基于分组后的子流进行的。

// 按照单词分组
DataStream<Tuple2<String, Integer>> wordCounts = wordStream
    .map(word -> Tuple2.of(word, 1))
    .keyBy(value -> value.f0)   // 按单词分组
    .sum(1);                     // 对第二个字段求和

你想想看,没有 keyBy,sum 就是对全局求和。有了 keyBy,每个 key 都有自己的状态,各自独立累加。这就是 WordCount 的核心逻辑。

我曾经踩过的坑:keyBy 之后的数据类型会变成 KeyedStream,不再是 DataStream。有些算子(比如 map)不能直接在 KeyedStream 上调用,需要先 sum、reduce 或者 window 之后才能转回来。我刚开始时经常搞混,编译报错才反应过来。

3.4 完整示例:一个简单的 WordCount

把上面学的串起来,写一个完整的 Flink 程序:

public class WordCount {
    public static void main(String[] args) throws Exception {
        // 1. 创建执行环境
        StreamExecutionEnvironment env = 
            StreamExecutionEnvironment.getExecutionEnvironment();
        
        // 2. Source:从 socket 读取(方便测试)
        DataStream<String> text = env.socketTextStream("localhost", 9999);
        
        // 3. Transformation
        DataStream<Tuple2<String, Integer>> counts = text
            .flatMap((String line, Collector<String> out) -> {
                for (String word : line.split(" ")) {
                    out.collect(word);
                }
            })
            .returns(Types.STRING)  // Lambda 需要指定返回类型
            .map(word -> Tuple2.of(word, 1))
            .returns(Types.TUPLE(Types.STRING, Types.INT))
            .keyBy(value -> value.f0)
            .sum(1);
        
        // 4. Sink:打印到控制台
        counts.print();
        
        // 5. 启动执行
        env.execute("Socket WordCount");
    }
}

注意:Lambda 表达式在 Flink 中有时会丢失类型信息,需要用 .returns() 显式指定。这是 Java 泛型擦除导致的。我建议你在 IDE 里写代码时,如果遇到 returns 相关的编译错误,直接加上 .returns(...) 就好。