第4章 Flink DataStream API:DataStream基础、Source与Sink、Transformation算子详解

好,咱们进入Flink DataStream API的核心地带。说实话,很多初学者一上来就被各种算子搞晕了。我当年也是,看着map、flatMap、filter这些名字觉得简单,真到写业务逻辑时才发现坑不少。这一章,咱们就把DataStream的底裤扒清楚。

4.1 DataStream基础概念

DataStream是什么?说白了,它就是Flink里表示无限数据流的核心抽象。你从Kafka读进来的每一条消息,Socket传来的每一行日志,都会被封装成DataStream里的一个元素。

我习惯把DataStream想象成一条传送带。数据像零件一样源源不断流过来,我们在传送带旁边装各种机械臂(算子),对零件做加工、筛选、分流。这个比喻虽然糙,但理不糙。

核心要点:DataStream本身不存储数据,它只定义了对数据流的转换逻辑。Flink的运行时环境会把这个逻辑翻译成分布式任务去执行。

DataStream有三种基本类型:

  • DataStream[T]:最通用的流,元素类型为T
  • KeyedStream[T, K]:按key分过组的流,后面才能用window、aggregate等操作
  • ConnectedStreams[T1, T2]:两个流连接后的结果,用于合并不同结构的数据

嗯,这里要注意:KeyedStream不是凭空产生的,它必须由DataStream调用keyBy()得到。我见过有人直接在DataStream上调用window,结果编译报错——因为window只认KeyedStream。

4.2 Source:数据从哪来

Source就是数据源。Flink内置了丰富的Source实现,但实际生产中,90%的场景只用两种:Kafka Source和自定义Source。

Source类型 适用场景 并行度 注意事项
Socket Source 测试、调试 1 生产环境别用
Kafka Source 生产主流 可配置 注意offset提交策略
File Source 批处理、回溯 可配置 监控目录变化需配置
自定义Source 特殊协议 自己实现 记得实现checkpoint

来个Kafka Source的代码示例,这是最常用的写法:

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

// 设置Kafka参数
Properties kafkaProps = new Properties();
kafkaProps.setProperty("bootstrap.servers", "localhost:9092");
kafkaProps.setProperty("group.id", "flink-group");

// 创建Kafka Source
DataStream<String> stream = env
    .addSource(new FlinkKafkaConsumer<>(
        "input-topic",
        new SimpleStringSchema(),
        kafkaProps
    ));

我的经验:Kafka Source的并行度最好和Kafka分区数保持一致。我曾经设过并行度比分区数大,结果有并行度空闲,浪费资源。反过来设小了,又会造成数据倾斜。

自定义Source呢?我建议你实现SourceFunction接口,但要注意:必须实现cancel()方法,否则Flink无法正常停止你的Source。我之前有个项目,自定义Source没写cancel逻辑,每次重启都要kill进程,惨痛教训。

4.3 Sink:数据往哪去

Sink是数据流的终点。数据经过一系列变换后,最终要落到某个存储系统里。Flink的Sink设计很有意思——它不保证精确一次语义,除非你用了两阶段提交协议。

常用的Sink包括:

  • Kafka Sink:写回Kafka,常用于流式ETL
  • JDBC Sink:写入关系型数据库
  • File Sink:写入HDFS/S3,常用于批处理
  • 自定义Sink:实现SinkFunction

写个Kafka Sink的例子:

stream
    .addSink(new FlinkKafkaProducer<>(
        "localhost:9092",
        "output-topic",
        new SimpleStringSchema()
    ));

避坑指南:我曾经在生产环境遇到过Sink写入性能瓶颈。原因是默认的Kafka Producer缓冲区太小,导致频繁刷盘。解决方案是调大batch.size和linger.ms参数。记住:Sink的吞吐量往往决定了整个作业的吞吐量。

4.4 Transformation算子详解

算子才是DataStream的灵魂。Flink提供了几十种算子,但核心的其实就几个。咱们一个一个过。

4.4.1 map:一对一转换

map是最简单的算子。输入一个元素,输出一个元素。类型可以变,但数量不变。

DataStream<String> input = ...;
DataStream<Integer> output = input
    .map(new MapFunction<String, Integer>() {
        @Override
        public Integer map(String value) {
            return value.length();
        }
    });

你想想看,map适合什么场景?数据清洗、格式转换、字段提取。我有个项目用map把JSON字符串转成Java对象,一行代码搞定。

4.4.2 flatMap:一对多转换

flatMap和map的区别在于:一个输入可以产生0个、1个或多个输出。它用Collector来收集输出结果。

DataStream<String> lines = ...;
DataStream<String> words = lines
    .flatMap(new FlatMapFunction<String, String>() {
        @Override
        public void flatMap(String line, Collector<String> out) {
            for (String word : line.split(" ")) {
                out.collect(word);
            }
        }
    });

这个例子很经典——单词拆分。flatMap把一行文本拆成多个单词。注意看,out.collect()可以调用多次,也可以不调用。如果一行是空字符串,那就什么都不输出。

我的习惯:flatMap里一定要记得处理异常。如果解析失败,不要抛异常,而是用out.collect()输出一个默认值或者直接跳过。这样能保证作业不因为一条脏数据而挂掉。

4.4.3 filter:过滤算子

filter就是做条件判断。返回true保留,false丢弃。逻辑简单,但用好了能省不少资源。

DataStream<Integer> numbers = ...;
DataStream<Integer> evenNumbers = numbers
    .filter(new FilterFunction<Integer>() {
        @Override
        public boolean filter(Integer value) {
            return value % 2 == 0;
        }
    });

filter有个容易被忽略的点:它不会改变数据类型。输入是Integer,输出还是Integer。只是数量变少了。

4.4.4 keyBy:分组算子

keyBy是DataStream API里最重要的算子之一。它把数据按key分组,相同key的数据会被分到同一个分区。

DataStream<Event> events = ...;
KeyedStream<Event, String> keyed = events
    .keyBy(new KeySelector<Event, String>() {
        @Override
        public String getKey(Event event) {
            return event.getUserId();
        }
    });

keyBy之后,你才能做窗口聚合、状态操作。为什么?因为Flink需要知道哪些数据属于同一个分组,才能做增量计算。

避坑指南:keyBy的key选择非常关键。我曾经用了一个高基数的字段(比如用户ID)做keyBy,结果导致状态膨胀,Checkpoint超时。后来改用hash(userId) % 1000做key,问题解决了。记住:key的基数不要太大,也不要太小。太大导致状态爆炸,太小导致数据倾斜。

4.5 知识体系总览

为了让你更直观地理解DataStream API的整体结构,我画了一张图。这张图把Source、Transformation、Sink的串联关系展示得很清楚。

DataStream API 核心流程 Source map flatMap filter keyBy Sink 说明: Source负责读取数据源,Transformation对数据进行转换处理,Sink将结果写出。 keyBy是唯一改变数据分区的算子,它决定了后续聚合操作的并行度。

4.6 算子链与性能

最后聊个实战话题:算子链。Flink默认会把多个算子合并到一个Task里执行,减少网络开销。但有时候,你希望强制断开算子链,比如某个算子特别耗CPU,你想单独给它分配资源。

断开算子链的方法:

stream
    .map(...)
    .disableChaining()  // 断开算子链
    .filter(...)
    .startNewChain()    // 开启新链
    .keyBy(...)
    .window(...);

我的建议:不要滥用disableChaining()。每个断开都会增加一次网络序列化和反序列化开销。只有当你明确知道某个算子是瓶颈时,才考虑断开。我一般只在keyBy前后断开,因为keyBy涉及数据重分区,天然需要网络传输。