3. Flink 编程模型:DataStream API 基础、Source、Transformation、Sink

好,咱们进入第三章。这一章是 Flink 开发的核心——DataStream API。说白了,你写 Flink 作业,大部分时间就是在跟这三个东西打交道:Source(数据从哪来)、Transformation(数据怎么变)、Sink(数据往哪去)。

我记得刚带团队做实时数仓那会儿,有个同事把 Source 和 Sink 的并行度配反了,结果数据全堵在算子链上,背压飙到 99%。排查了半天,最后发现是 API 理解不透彻。所以这一章,咱们把这三个环节彻底掰开揉碎。

3.1 DataStream API 的核心抽象

Flink 的 DataStream API,本质上就是一个有状态、无界、并行的数据流处理引擎。你想想看,你定义好 Source,然后连一串 Transformation,最后接到 Sink,Flink 就帮你把整个 DAG(有向无环图)跑起来。

核心就三个概念:

  • DataStream:代表一个并行流,数据在里面流动。
  • 算子(Operator):对流的操作,比如 map、filter、keyBy。
  • 并行度(Parallelism):每个算子可以有几个线程同时跑。

一句话总结:DataStream API 让你用声明式的方式描述“数据怎么流”,Flink 负责“怎么高效地流”。

3.2 Source:数据从哪来

Source 是数据流的起点。Flink 内置了很多 Source,但实际项目中,我用的最多的就三种:Kafka Source、文件 Source、自定义 Source。

3.2.1 内置 Source 快速上手

先看个最简单的——从 socket 读数据。这常用于测试:

DataStream<String> stream = env.socketTextStream("localhost", 9999);

但生产环境千万别这么干。我曾经见过有人把 socket 当生产 Source 用,结果网络抖动一次,整个作业重启,数据全丢了。

真正生产级的是 Kafka Source。Flink 1.12 之后推荐用新的 KafkaSource(基于 Source API V2):

KafkaSource<String> source = KafkaSource.<String>builder()
    .setBootstrapServers("localhost:9092")
    .setTopics("input-topic")
    .setGroupId("my-group")
    .setStartingOffsets(OffsetsInitializer.earliest())
    .setValueOnlyDeserializer(SimpleStringSchema.class)
    .build();

DataStream<String> stream = env.fromSource(source, WatermarkStrategy.noWatermarks(), "Kafka Source");

我的习惯:Kafka Source 的并行度最好和 Kafka 分区数保持一致。多了浪费资源,少了会加剧分区倾斜。

3.2.2 自定义 Source

当内置 Source 满足不了你时,就得自己写。比如对接 MQTT、Pulsar 或者某个老旧的消息中间件。

实现一个自定义 Source 有两种方式:

  • 实现 SourceFunction 接口(旧 API,已标记为 @PublicEvolving,不推荐新项目用)
  • 实现 Source 接口(新 API,Flink 1.12+ 推荐)

我拿新 API 举个例子。假设你要从某个 HTTP 接口轮询数据:

public class HttpSource implements Source<String, HttpSourceSplit, HttpSourceEnumState> {
    @Override
    public void run(SourceReaderContext ctx) throws Exception {
        // 轮询逻辑
        while (isRunning) {
            String data = httpClient.get("http://api.example.com/data");
            ctx.sendSourceEvent(data);
            Thread.sleep(1000);
        }
    }
}

避坑指南:我曾经写过一个自定义 Source,忘了在 checkpoint 时保存偏移量。结果作业重启后,数据从 0 开始重读,下游 Kafka 直接炸了。记住:自定义 Source 一定要实现 CheckpointedFunction 接口,否则 Exactly-Once 就是空话。

3.3 Transformation:数据怎么变

Transformation 是 DataStream API 的灵魂。Flink 提供了几十种算子,但常用的就十来个。我按使用频率排个序:

算子 用途 并行度影响
map 一对一转换 不变
flatMap 一对多转换 不变
filter 过滤数据 不变
keyBy 按 key 分区 变为 hash 分区
reduce 增量聚合 按 key 分组
window 开窗计算 按 key 分组
connect 连接两个流 取最大值

3.3.1 基础算子实战

先看 map 和 flatMap。map 是一对一,flatMap 是一对多。比如你从 Kafka 读到 JSON 字符串,想解析成 Java 对象:

DataStream<UserEvent> events = stream
    .map(json -> JsonUtil.parse(json, UserEvent.class));

flatMap 常用于拆分。比如一条日志里包含多条事件:

DataStream<Event> events = stream
    .flatMap((String line, Collector<Event> out) -> {
        for (String part : line.split("\\|")) {
            out.collect(parseEvent(part));
        }
    });

3.3.2 keyBy 与分区

keyBy 是 Flink 里最重要的算子之一。它把数据按 key 哈希到不同分区,后续的 window、reduce 才能按组计算。

DataStream<UserEvent> keyedStream = events
    .keyBy(UserEvent::getUserId);

这里有个坑:keyBy 之后的数据分布是哈希均匀的,但如果你 key 的基数很小(比如只有 0 和 1),就会产生数据倾斜。我在做用户行为分析时就遇到过,某个 key 的数据量是其他 key 的 100 倍,导致那个子任务一直跑,其他子任务闲着。

我的建议:如果 key 基数小,可以考虑加盐(salted key),比如 key + "_" + (random % N),然后在 reduce 里再去掉盐。

3.3.3 窗口与聚合

窗口是实时计算的精髓。Flink 支持三种窗口:

  • Tumbling Window:固定大小,不重叠。比如每 5 分钟统计一次。
  • Sliding Window:固定大小,可重叠。比如每 1 分钟统计过去 5 分钟的数据。
  • Session Window:按不活动间隔划分。比如用户连续操作,超过 30 秒没动作就结束窗口。

来个实际例子:统计每个用户每 5 分钟的点击次数。

DataStream<Tuple2<String, Long>> result = keyedStream
    .window(TumblingProcessingTimeWindows.of(Time.minutes(5)))
    .aggregate(new AggregateFunction<UserEvent, Long, Long>() {
        @Override
        public Long createAccumulator() { return 0L; }
        @Override
        public Long add(UserEvent value, Long acc) { return acc + 1; }
        @Override
        public Long getResult(Long acc) { return acc; }
        @Override
        public Long merge(Long a, Long b) { return a + b; }
    });

注意:窗口的触发时间取决于 watermark。如果你用 ProcessingTime,那窗口在机器时间到达时触发。如果你用 EventTime,那必须等 watermark 推进到窗口结束时间。我见过有人用 EventTime 但没设 watermark,结果窗口永远不触发。

3.4 Sink:数据往哪去

Sink 是数据流的终点。Flink 内置了 Kafka Sink、JDBC Sink、File Sink 等。但生产环境里,我几乎只用两种:Kafka Sink 和 自定义 Sink。

3.4.1 Kafka Sink

Kafka Sink 是最常用的,因为它天然支持 Exactly-Once 语义(配合两阶段提交)。

KafkaSink<String> sink = KafkaSink.<String>builder()
    .setBootstrapServers("localhost:9092")
    .setRecordSerializer(KafkaRecordSerializationSchema.builder()
        .setTopic("output-topic")
        .setValueSerializationSchema(new SimpleStringSchema())
        .build()
    )
    .setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)
    .build();

stream.sinkTo(sink);

关键点:setDeliveryGuarantee 有三个选项:NONE(至少一次)、AT_LEAST_ONCE(至少一次)、EXACTLY_ONCE(精确一次)。EXACTLY_ONCE 需要配合 checkpoint 和事务,性能会差一些,但数据一致性最高。

3.4.2 自定义 Sink

当你要写入 Redis、Elasticsearch 或者某个自研存储时,就得自己写 Sink。实现方式有两种:

  • 实现 SinkFunction 接口(旧 API)
  • 实现 Sink 接口(新 API,推荐)

我拿写入 Redis 举个例子。用新 API 实现一个简单的 Redis Sink:

public class RedisSink implements Sink<String, Void, Void> {
    private transient Jedis jedis;

    @Override
    public void open() {
        jedis = new Jedis("localhost", 6379);
    }

    @Override
    public void write(String value, Context ctx) {
        jedis.set("key", value);
    }

    @Override
    public void close() {
        if (jedis != null) jedis.close();
    }
}

我的经验:自定义 Sink 一定要处理好连接池和重试。我曾经有个 Sink 没做重试,Redis 集群抖动一次,整个 Flink 作业就挂了。后来加了指数退避重试,稳如老狗。

3.5 完整示例:从 Kafka 到 Kafka 的 ETL

把上面三个环节串起来,就是一个完整的实时 ETL 作业。假设我们要从 Kafka 读 JSON 日志,解析后过滤掉无效数据,再写回 Kafka:

public class KafkaToKafkaETL {
    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.enableCheckpointing(5000);

        // Source
        KafkaSource<String> source = KafkaSource.<String>builder()
            .setBootstrapServers("localhost:9092")
            .setTopics("input-topic")
            .setGroupId("etl-group")
            .setStartingOffsets(OffsetsInitializer.earliest())
            .setValueOnlyDeserializer(SimpleStringSchema.class)
            .build();

        DataStream<String> stream = env.fromSource(source, WatermarkStrategy.noWatermarks(), "Kafka Source");

        // Transformation
        DataStream<String> result = stream
            .map(json -> JsonUtil.parse(json, LogEvent.class))
            .filter(event -> event.getLevel() != null)
            .map(event -> JsonUtil.toJson(event));

        // Sink
        KafkaSink<String> sink = KafkaSink.<String>builder()
            .setBootstrapServers("localhost:9092")
            .setRecordSerializer(KafkaRecordSerializationSchema.builder()
                .setTopic("output-topic")
                .setValueSerializationSchema(new SimpleStringSchema())
                .build()
            )
            .setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)
            .build();

        result.sinkTo(sink);

        env.execute("Kafka to Kafka ETL");
    }
}

这个例子虽然简单,但涵盖了 DataStream API 的完整流程。你想想看,Source 读数据,Transformation 处理数据,Sink 写数据——Flink 帮你把这三件事串成一个有状态的 DAG,自动容错、自动恢复。

3.6 本章知识体系

下面这张图把 DataStream API 的核心流程画出来了。Source、Transformation、Sink 三个环节,加上并行度和 checkpoint,构成了 Flink 实时作业的骨架。

DataStream API 核心流程 Source Kafka / 文件 / 自定义 Transformation map / filter / keyBy / window Sink Kafka / Redis / 自定义 每个算子都可以独立设置并行度,Flink 自动处理数据分发 Checkpoint 保证 Source 偏移量 + Transformation 状态 + Sink 事务的一致性 有状态、无界、并行的数据流处理引擎

嗯,这一章的内容就到这。Source、Transformation、Sink 这三个环节,是 Flink 开发的基石。你只要把这三块搞透了,后面学 ProcessFunction、状态后端、容错机制都会轻松很多。