零基础学会flink,保姆级超详细教学

第 1 步:搭建开发环境(详细步骤)

1. 安装 Java 11

Flink 1.17 推荐使用 Java 11。

  • 下载:访问 Adoptium 选择 OpenJDK 11 (LTS),下载对应操作系统的安装包(Windows .msi,macOS .pkg,Linux 可参考包管理器安装)。
  • 安装:双击安装,记住安装路径(如 C:\Program Files\Eclipse Adoptium\jdk-11.0.20.8-hotspot)。
  • 配置环境变量
    • Windows:在“系统属性 → 高级 → 环境变量”中新建 JAVA_HOME,值为 JDK 安装路径;在 Path 中添加 %JAVA_HOME%\bin
    • macOS/Linux:编辑 ~/.bashrc~/.zshrc,添加:
      export JAVA_HOME=/path/to/jdk-11
      export PATH=$JAVA_HOME/bin:$PATH
      
  • 验证:打开终端/命令提示符,输入:
    java -version
    
    应显示 openjdk version "11.0.20" 等。

2. 安装 Maven

  • 下载:访问 Maven 官网 下载 Binary zip(如 apache-maven-3.9.4-bin.zip)。
  • 解压:解压到任意目录,如 C:\apache-maven-3.9.4/opt/maven
  • 配置环境变量
    • 新增 MAVEN_HOME 指向解压目录。
    • Path 中添加 %MAVEN_HOME%\bin(或 $MAVEN_HOME/bin)。
  • 验证
    mvn -version
    
    应显示 Maven 版本和 Java 版本。

3. 安装 IntelliJ IDEA

  • 下载社区版(免费):https://www.jetbrains.com/idea/download/
  • 安装后打开,选择默认设置即可。

4. 下载并启动 Flink 本地集群

  • 下载:访问 Flink 下载页面,选择 Flink 1.17.1,下载对应 Scala 2.12 的二进制包(如 flink-1.17.1-bin-scala_2.12.tgz)。
  • 解压:解压到例如 C:\flink/opt/flink
  • 启动本地集群
    • Windows:进入 Flink 目录,运行 bin\start-cluster.bat
    • Linux/macOS:运行 bin/start-cluster.sh
  • 验证:浏览器访问 http://localhost:8081,看到 Flink Web UI 即成功。
  • 停止集群(之后需要时):运行 bin\stop-cluster.batbin/stop-cluster.sh

第 2 步:编写并运行第一个 Flink 程序(WordCount)

我们将创建一个 Maven 项目,从 Socket 读取文本流,实时统计单词出现次数并打印到控制台。

1. 创建 Maven 项目

在 IntelliJ 中:

  1. File → New → Project...
  2. 选择 Maven,勾选 Create from archetype,选择 org.apache.maven.archetypes:maven-archetype-quickstart,点击 Next
  3. 输入 GroupId(如 com.example)、ArtifactId(如 flink-wordcount),点击 Next
  4. 选择 Maven 安装路径和 settings 文件(默认即可),点击 Finish

2. 添加 Flink 依赖

打开项目根目录下的 pom.xml,在 <dependencies> 标签内添加以下依赖:

<properties>
    <flink.version>1.17.1</flink.version>
    <maven.compiler.source>11</maven.compiler.source>
    <maven.compiler.target>11</maven.compiler.target>
</properties>

<dependencies>
    <!-- Flink 核心依赖 -->
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-streaming-java</artifactId>
        <version>${flink.version}</version>
    </dependency>
    <!-- Flink 客户端依赖(本地运行需要) -->
    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-clients</artifactId>
        <version>${flink.version}</version>
    </dependency>
    <!-- 日志依赖,避免运行时警告 -->
    <dependency>
        <groupId>org.slf4j</groupId>
        <artifactId>slf4j-simple</artifactId>
        <version>1.7.36</version>
    </dependency>
</dependencies>

保存后,IntelliJ 会自动下载依赖(或点击 Maven 工具栏的刷新按钮)。

3. 编写 WordCount 代码

删除默认生成的 App.java,新建一个 Java 类 SocketWordCount.java,代码如下:

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 SocketWordCount {

    public static void main(String[] args) throws Exception {
        // 1. 创建执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        // 设置并行度为1,方便本地调试
        env.setParallelism(1);

        // 2. 从 Socket 读取数据(需要先启动 nc -lk 9999)
        DataStream<String> textStream = env.socketTextStream("localhost", 9999);

        // 3. 切分单词、计数
        DataStream<Tuple2<String, Integer>> wordCounts = textStream
                .flatMap(new FlatMapFunction<String, Tuple2<String, Integer>>() {
                    @Override
                    public void flatMap(String line, Collector<Tuple2<String, Integer>> out) {
                        // 按空格切分
                        for (String word : line.toLowerCase().split("\\s+")) {
                            if (!word.isEmpty()) {
                                out.collect(new Tuple2<>(word, 1));
                            }
                        }
                    }
                })
                // 按单词分组
                .keyBy(value -> value.f0)
                // 求和
                .sum(1);

        // 4. 打印结果
        wordCounts.print();

        // 5. 启动作业(会一直运行)
        env.execute("Socket WordCount");
    }
}

代码解释

  • StreamExecutionEnvironment:Flink 流处理入口,自动识别本地环境或集群环境。
  • socketTextStream("localhost", 9999):创建一个 Source,从 TCP Socket 读取文本行。
  • flatMap:将每一行文本拆分成多个单词,并发射 (word, 1) 元组。
  • keyBy(value -> value.f0):按元组的第一个字段(单词)分组。
  • sum(1):对第二个字段(计数)求和。
  • print():将结果输出到控制台(标准输出)。
  • env.execute(...):触发作业执行,并指定作业名称。

4. 运行程序

  1. 在终端启动一个 Socket 服务,用于发送数据:
    • Windows:下载 netcat for Windows 后运行 nc -l -p 9999
    • Linux/macOS:直接运行 nc -lk 9999
  2. 回到 IntelliJ,右键运行 SocketWordCountmain 方法。
  3. nc 终端中输入一行文字,例如:
    hello flink hello world
    
  4. 观察 IntelliJ 控制台输出,会看到类似:
    (hello,1)
    (flink,1)
    (hello,2)
    (world,1)
    
    每次输入新行,计数会累加。

恭喜!你已经成功运行了第一个 Flink 流处理程序。

5. 常见问题排查

  • nc 命令找不到:Windows 可下载 netcat,或使用 ncat;也可临时改用 env.socketTextStream("localhost", 9999, "\n", 1) 并启动一个 Python socket 服务代替。
  • 端口被占用:换一个端口,并同步修改代码和 nc 命令。
  • 控制台无输出:确保 nc 先启动,且 Flink 程序在运行;检查防火墙是否拦截 localhost 连接。
  • 依赖下载慢:配置 Maven 阿里云镜像(修改 ~/.m2/settings.xml 添加 mirror)。

第 3 步

  1. 理解 DataStream API 的编程模型。
  2. 掌握 mapflatMapfilter 的使用场景和语法。
  3. 能够使用 Lambda 表达式和自定义函数类实现算子逻辑。
  4. 完成一个“实时订单数据清洗”的小练习。

一、DataStream API 简介

在 Flink 中,数据流被抽象为 DataStream<T>,其中 T 是流中元素的类型(如 StringInteger、自定义 POJO、元组等)。我们可以对 DataStream 应用各种 转换算子(Transformation) 来生成新的 DataStream,从而构建出完整的数据处理管道。

今天学习的三个算子都是 无状态的一对一/一对多转换

  • map:一对一转换,输入一个元素,输出一个元素(类型可以不同)。
  • flatMap:一对多转换,输入一个元素,输出零个、一个或多个元素。
  • filter:过滤,输入一个元素,根据条件决定是否保留(输出零个或一个)。

这些算子可以链式调用,形成清晰的流水线。


二、实战:实时订单数据清洗

场景描述

假设我们有一个电商系统,实时产生订单数据,格式为字符串:

订单ID,用户ID,商品名称,价格,数量,订单时间

例如:

1001,u001,手机,2999.0,1,2024-01-01 10:00:00
1002,u002,电脑,5999.0,2,2024-01-01 10:01:00
bad-data,u003,,,,
1003,u003,平板,1999.0,3,2024-01-01 10:02:00

我们需要编写一个 Flink 程序:

  1. 从集合中读取这些订单字符串。
  2. 使用 flatMap 将每一行字符串切分成多个字段,并过滤掉空行。
  3. 使用 filter 过滤掉非法数据(例如订单ID以"bad"开头,或价格为空)。
  4. 使用 map 将合法数据转换为一个自定义的 Order 对象。
  5. 打印清洗后的订单对象。

步骤 1:准备项目

你可以继续使用之前创建的 flink-wordcount 项目。在 src/main/java/com/example 下新建一个包,例如 com.example.datastream,并在其中创建 Java 类。

如果之前没有项目,请参考第 2 天的步骤创建一个新的 Maven 项目,并添加相同的 Flink 依赖。

步骤 2:定义订单 POJO 类

com.example.datastream 包下新建 Order.java

package com.example.datastream;

// 订单 POJO 类,必须有无参构造器和 public 字段或 getter/setter
public class Order {
    public String orderId;
    public String userId;
    public String product;
    public double price;
    public int quantity;
    public String orderTime;

    // 无参构造器(Flink 要求)
    public Order() {}

    public Order(String orderId, String userId, String product, double price, int quantity, String orderTime) {
        this.orderId = orderId;
        this.userId = userId;
        this.product = product;
        this.price = price;
        this.quantity = quantity;
        this.orderTime = orderTime;
    }

    @Override
    public String toString() {
        return "Order{" +
                "orderId='" + orderId + '\'' +
                ", userId='" + userId + '\'' +
                ", product='" + product + '\'' +
                ", price=" + price +
                ", quantity=" + quantity +
                ", orderTime='" + orderTime + '\'' +
                '}';
    }
}

注意:POJO 类必须有 public 的无参构造器,并且字段通常是 public 的或者有标准的 getter/setter(这里使用 public 字段简化)。

步骤 3:编写主程序

新建 OrderCleanJob.java,代码如下:

package com.example.datastream;

import org.apache.flink.api.common.functions.FilterFunction;
import org.apache.flink.api.common.functions.FlatMapFunction;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.util.Collector;

import java.util.Arrays;
import java.util.List;

public class OrderCleanJob {

    public static void main(String[] args) throws Exception {
        // 1. 创建执行环境
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1); // 本地调试方便

        // 2. 模拟数据源:从集合创建 DataStream<String>
        List<String> rawOrders = Arrays.asList(
                "1001,u001,手机,2999.0,1,2024-01-01 10:00:00",
                "1002,u002,电脑,5999.0,2,2024-01-01 10:01:00",
                "bad-data,u003,,,,",
                "",
                "1003,u003,平板,1999.0,3,2024-01-01 10:02:00"
        );
        DataStream<String> orderLines = env.fromCollection(rawOrders);

        // 3. 使用 flatMap 将每一行拆分为 Order 对象(或跳过非法行)
        DataStream<Order> orders = orderLines.flatMap(new OrderFlatMap());

        // 4. 使用 filter 过滤掉价格 <= 0 的订单(例如非法数据)
        DataStream<Order> validOrders = orders.filter(new FilterFunction<Order>() {
            @Override
            public boolean filter(Order order) throws Exception {
                return order.price > 0 && order.orderId != null && !order.orderId.startsWith("bad");
            }
        });

        // 5. 使用 map 将订单转换为更简洁的描述字符串
        DataStream<String> orderSummary = validOrders.map(new MapFunction<Order, String>() {
            @Override
            public String map(Order order) throws Exception {
                return "用户 " + order.userId + " 购买了 " + order.product +
                        ",总价:" + (order.price * order.quantity);
            }
        });

        // 6. 打印结果
        orderSummary.print();

        // 7. 执行作业
        env.execute("Order Clean Job");
    }

    // 自定义 FlatMapFunction,将每行字符串解析为 Order 对象
    public static class OrderFlatMap implements FlatMapFunction<String, Order> {
        @Override
        public void flatMap(String line, Collector<Order> out) throws Exception {
            // 忽略空行
            if (line == null || line.trim().isEmpty()) {
                return;
            }
            // 按逗号分割
            String[] fields = line.split(",");
            // 至少需要 6 个字段
            if (fields.length < 6) {
                return;
            }
            // 尝试解析价格和数量,失败则跳过
            try {
                double price = Double.parseDouble(fields[3].trim());
                int quantity = Integer.parseInt(fields[4].trim());
                Order order = new Order(
                        fields[0].trim(),
                        fields[1].trim(),
                        fields[2].trim(),
                        price,
                        quantity,
                        fields[5].trim()
                );
                out.collect(order);
            } catch (NumberFormatException e) {
                // 解析失败,忽略该条数据
                System.err.println("解析失败: " + line);
            }
        }
    }
}

步骤 4:运行程序

  1. 在 IntelliJ 中右键运行 OrderCleanJobmain 方法。
  2. 观察控制台输出,你会看到类似:
用户 u001 购买了 手机,总价:2999.0
用户 u002 购买了 电脑,总价:11998.0
用户 u003 购买了 平板,总价:5997.0
  1. 注意 bad-data 行和空行被过滤掉了,价格解析失败的行也被 flatMap 忽略了。

代码详解

1. 创建 DataStream
  • env.fromCollection(rawOrders):从 Java 集合创建一个有限的数据流。在流处理模式下,它会按顺序发送集合中的元素。本地调试时常用。
2. flatMap 算子
  • 作用:输入一个 String,输出零个或多个 Order
  • 实现方式:我们定义了一个 OrderFlatMap 类实现 FlatMapFunction<String, Order> 接口。
  • 核心方法flatMap(String line, Collector<Order> out),通过 out.collect(...) 发射数据。
  • 为什么要用 flatMap 而不是 map 因为一行可能被过滤掉(输出 0 个),也可能输出 1 个订单。map 只能输出恰好一个元素,不适合此场景。
3. filter 算子
  • 作用:保留满足条件的元素。
  • 实现方式:使用匿名内部类实现 FilterFunction<Order>,在 filter 方法中返回 true 保留,false 丢弃。
  • 这里我们保留了价格大于 0 且订单 ID 不以 “bad” 开头的订单。
4. map 算子
  • 作用:将 Order 对象转换为 String 摘要。
  • 实现方式:匿名内部类实现 MapFunction<Order, String>
  • 注意map 的输入和输出类型可以不同。

三、使用 Lambda 表达式简化代码

从 Java 8 开始,我们可以使用 Lambda 表达式让代码更简洁。但需要注意 Flink 的类型推断问题(稍后常见错误中说明)。

将上面的主程序改写为 Lambda 版本(替换步骤 3-5):

DataStream<Order> orders = orderLines.flatMap((String line, Collector<Order> out) -> {
    if (line.trim().isEmpty()) return;
    String[] fields = line.split(",");
    if (fields.length < 6) return;
    try {
        double price = Double.parseDouble(fields[3].trim());
        int quantity = Integer.parseInt(fields[4].trim());
        out.collect(new Order(fields[0].trim(), fields[1].trim(), fields[2].trim(),
                price, quantity, fields[5].trim()));
    } catch (NumberFormatException e) {
        // ignore
    }
}).returns(Order.class); // 必须指定返回类型,否则可能报错

DataStream<Order> validOrders = orders.filter(order -> order.price > 0 && !order.orderId.startsWith("bad"));

DataStream<String> orderSummary = validOrders.map(order ->
        "用户 " + order.userId + " 购买了 " + order.product + ",总价:" + (order.price * order.quantity)
);

注意:在使用 Lambda 时,如果算子输出的是 POJO 或元组等,Flink 可能无法自动推断类型,需要使用 .returns(...) 显式指定。建议初学者先使用自定义函数类(如上面的 OrderFlatMap),更易于理解且不易出错。


四、练习题

  1. 修改 OrderCleanJob:增加一个 filter,只保留购买数量大于等于 2 的订单。
  2. 使用 map 提取订单中的商品名称,并将商品名称转为大写后打印。
  3. 使用 flatMap 将一句话拆分为单词(已学过),但这次要求忽略长度小于 3 的单词(例如 “a”, “an”),并转换为小写。
  4. (选做)将 Order 类中的字段改为 private,并添加 getter/setter,观察 Flink 是否还能正常序列化(提示:POJO 规则)。

五、常见错误与解决方法

1. Lambda 类型擦除导致编译错误或运行时异常

错误信息org.apache.flink.api.common.functions.InvalidTypesException: The generic type parameters of 'MapFunction' are missing.
原因:使用 Lambda 表达式时,Java 编译器无法推断出完整的泛型类型,Flink 无法获取足够的类型信息。
解决

  • 在算子后调用 .returns(TypeInformation).returns(Class) 明确指定返回类型。
  • 或者改用匿名内部类 / 自定义函数类,显式写出泛型。

2. POJO 类不满足规则

错误信息... is not a valid POJO type... cannot be used as key
原因:Flink 要求 POJO 类必须有 public 的无参构造器,且字段是 public 或具有符合规范的 getter/setter。
解决:添加无参构造器,确保字段访问级别正确。

3. 在 flatMap 中忘记调用 out.collect() 导致无输出

检查:确认在需要输出数据的地方调用了 out.collect()

4. 数据源是有限集合时,程序运行结束后立即退出

现象:程序运行后打印完结果就退出了,没有持续运行。
原因env.fromCollection() 是有限流,所有数据发送完后作业结束。这是正常的。如果需要持续运行,应使用 socketTextStreamaddSource 连接无限数据源。


非常好!你已经掌握了基础转换算子,现在进入 第 4 天:分组与聚合算子。这是 Flink 中非常核心的内容,几乎所有有状态的实时计算(如实时统计、累加、TopN 等)都离不开分组和聚合。


第 4 步

  1. 理解 keyBy 的作用:将无限流逻辑上划分为多个 keyed stream。
  2. 掌握 keyBy 的三种指定 key 的方式。
  3. 熟练使用 summinmaxminBymaxBy 进行简单聚合。
  4. 掌握 reduce 实现自定义增量聚合。
  5. 完成一个“实时订单金额统计”的练习。

一、KeyedStream 与 keyBy

在 DataStream API 中,keyBy 是一种分组操作,它按照指定的 key 将数据流逻辑上划分为多个分区。相同 key 的数据会被发送到同一个并行实例(slot)上进行处理,从而保证后续的有状态操作(如聚合、状态存储)在逻辑上每个 key 独立。

重要keyBy 本身不产生任何计算,它只是为后续的聚合或状态操作提供分组上下文。keyBy 返回一个 KeyedStream<T, KEY>,其中 T 是元素类型,KEY 是 key 类型。

keyBy 的三种指定 key 的方式

假设我们有一个 Order POJO 类(如第 3 天定义),包含 userId 字段,我们想按用户 ID 分组。

方式 1:使用字段名(适用于 POJO 或 Tuple)
DataStream<Order> orders = ...;
KeyedStream<Order, String> keyed = orders.keyBy("userId");

注意:keyBy("字段名") 要求字段是 public 的,或者有标准的 getter 方法。Flink 会通过反射获取字段值。如果字段是 private 且没有 getter,会报错。

方式 2:使用字段位置(适用于 Tuple 类型)
DataStream<Tuple2<String, Integer>> tuples = ...;
KeyedStream<Tuple2<String, Integer>, String> keyed = tuples.keyBy(0); // 按第一个字段分组
方式 3:使用 KeySelector(最灵活,推荐)
KeyedStream<Order, String> keyed = orders.keyBy(order -> order.userId);

或使用匿名内部类:

KeyedStream<Order, String> keyed = orders.keyBy(new KeySelector<Order, String>() {
    @Override
    public String getKey(Order order) throws Exception {
        return order.userId;
    }
});

KeySelector 可以处理任意类型,包括复杂的嵌套字段,并且类型安全。建议优先使用 KeySelector


二、简单聚合算子

KeyedStream 上可以直接调用一些内置的聚合方法:summinmaxminBymaxBy。这些方法会返回一个 DataStream(注意不是 KeyedStream,因为聚合后的结果流不再按原始 key 分组,但每个 key 的结果会独立计算)。

1. sum(int positionOrField)

对指定字段求和。字段可以是位置(Tuple)或名称(POJO)。例如,我们有一个订单流,每个订单有 pricequantity,想计算每个用户的累计消费金额(单价*数量),可以先 map 成包含用户和总价的 Tuple,再 sum。

2. minminBy

  • min:返回指定字段的最小值,但其他字段可能不来自同一条记录(因为只更新最小值字段,其他字段保留第一次出现的值)。
  • minBy:返回指定字段最小值对应的整条记录,可以传入一个布尔参数 first,表示当有多个相同最小值时是否返回第一个。
  • 同样有 maxmaxBy

示例:找出每个用户购买的最便宜订单。

DataStream<Order> cheapestOrders = orders.keyBy(order -> order.userId)
        .minBy("price"); // 返回价格最低的订单对象

3. 简单聚合的实现原理

这些方法内部实际上是基于 AggregateFunction 的封装,或者维护状态来实现增量计算,不需要保存所有历史数据。它们只能用于 KeyedStream


三、reduce 算子:自定义增量聚合

reduce 是一个通用的聚合方法,它接收一个 ReduceFunction,输入两个相同类型的元素,返回一个相同类型的元素,表示合并后的结果。Flink 会使用这个函数将每个 key 的所有元素增量合并为一个最终结果。

适用场景:当内置的 sum/min 等无法满足需求时(比如需要同时求最大值和最小值,或者需要复杂计算),使用 reduce 实现自定义逻辑。

示例:统计每个用户的累计订单金额和订单数

我们创建一个中间类 UserStats,包含用户 ID、累计金额、订单数,然后使用 reduce 合并。

步骤 1:定义 UserStats 类
public class UserStats {
    public String userId;
    public double totalAmount;
    public int orderCount;

    public UserStats() {}

    public UserStats(String userId, double totalAmount, int orderCount) {
        this.userId = userId;
        this.totalAmount = totalAmount;
        this.orderCount = orderCount;
    }

    @Override
    public String toString() {
        return "UserStats{" +
                "userId='" + userId + '\'' +
                ", totalAmount=" + totalAmount +
                ", orderCount=" + orderCount +
                '}';
    }
}
步骤 2:在主程序中使用 reduce
DataStream<Order> orders = ...; // 假设已有订单流

DataStream<UserStats> userStats = orders
        .map(order -> new UserStats(order.userId, order.price * order.quantity, 1))
        .keyBy(stats -> stats.userId)
        .reduce(new ReduceFunction<UserStats>() {
            @Override
            public UserStats reduce(UserStats value1, UserStats value2) {
                // 合并两个中间结果
                return new UserStats(
                        value1.userId,
                        value1.totalAmount + value2.totalAmount,
                        value1.orderCount + value2.orderCount
                );
            }
        });

userStats.print();

解释

  • 先用 map 将每个订单转换为一个 UserStats 对象,金额 = 单价 * 数量,订单数 = 1。
  • 然后按 userId 分组。
  • reduce 接收两个 UserStats(可能是之前的部分聚合结果和新来的元素),返回合并后的新 UserStats
  • Flink 会为每个 key 保存一个当前聚合状态(一个 UserStats),新元素到来时与状态合并,更新状态,并输出新的结果(取决于下游是否接收每次更新的结果)。注意:reduce 每次合并都会产生一个新的结果并向下游发射(即每来一条数据,输出一次当前聚合值)。如果你希望只在窗口触发时输出,需要配合窗口使用(后续会讲)。

四、完整示例:实时订单金额统计

需求

从 Socket 读取订单字符串(格式同第 3 天),实时统计每个用户的累计消费金额和订单数,并在控制台打印每次更新后的结果。

代码实现

import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.functions.ReduceFunction;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;

public class UserAmountStatsJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        // 从 Socket 读取数据(需要先启动 nc -lk 9999)
        DataStream<String> lines = env.socketTextStream("localhost", 9999);

        DataStream<UserStats> stats = lines
                .map(new OrderLineToUserStats())
                .keyBy(userStats -> userStats.userId)
                .reduce(new ReduceFunction<UserStats>() {
                    @Override
                    public UserStats reduce(UserStats value1, UserStats value2) {
                        return new UserStats(
                                value1.userId,
                                value1.totalAmount + value2.totalAmount,
                                value1.orderCount + value2.orderCount
                        );
                    }
                });

        stats.print();

        env.execute("User Amount Stats");
    }

    // 将一行订单字符串转换为 UserStats 对象
    public static class OrderLineToUserStats implements MapFunction<String, UserStats> {
        @Override
        public UserStats map(String line) throws Exception {
            String[] fields = line.split(",");
            if (fields.length < 6) {
                // 非法数据,返回一个无效的 UserStats(或者可以抛异常,但这里简单处理)
                return new UserStats("unknown", 0.0, 0);
            }
            String userId = fields[1].trim();
            double price = Double.parseDouble(fields[3].trim());
            int quantity = Integer.parseInt(fields[4].trim());
            double amount = price * quantity;
            return new UserStats(userId, amount, 1);
        }
    }
}

运行步骤

  1. 启动 nc -lk 9999
  2. 运行程序。
  3. 在 nc 终端依次输入:
1001,u001,手机,2999.0,1,2024-01-01 10:00:00
1002,u002,电脑,5999.0,2,2024-01-01 10:01:00
1003,u001,平板,1999.0,3,2024-01-01 10:02:00
1004,u003,鼠标,99.0,5,2024-01-01 10:03:00
1005,u001,键盘,299.0,2,2024-01-01 10:04:00
  1. 观察控制台输出,会看到类似:
UserStats{userId='u001', totalAmount=2999.0, orderCount=1}
UserStats{userId='u002', totalAmount=11998.0, orderCount=2}
UserStats{userId='u001', totalAmount=8996.0, orderCount=2}
UserStats{userId='u003', totalAmount=495.0, orderCount=5}
UserStats{userId='u001', totalAmount=9594.0, orderCount=3}

注意 u001 的累计金额在每次新订单到达时都更新输出。


五、练习题

  1. 使用 sum 实现同样的功能:只统计每个用户的累计订单数(不用金额),使用 sum 方法。

    • 提示:将订单映射为 Tuple2<String, Integer>(用户ID, 1),然后 keyBy(0).sum(1)
  2. 使用 maxBy 找出每个用户购买的最大金额订单:输出该订单的完整信息。

    • 提示:直接使用 orders.keyBy(order -> order.userId).maxBy("price")(注意 Order 类中有 price 字段)。
  3. 使用 reduce 实现每个用户的累计消费金额,并同时记录最近一次购买的商品名称

    • 提示:扩展 UserStats 类,增加 lastProduct 字段;在 reduce 中更新该字段为后一条记录的商品名(需要注意谁是新来的)。
  4. 思考题reducesum 在输出行为上有什么区别?如果数据量很大,使用 reduce 会不会导致状态无限增长?为什么?


六、常见错误与解决方法

1. keyBy 字段名错误

错误信息InvalidTypesException: Field "xxx" is not a valid key
原因:POJO 中没有该字段,或者字段不是 public 且没有 getter。
解决:使用 KeySelector 代替字段名,确保返回正确的 key 值。

2. reduce 输出类型与输入类型不一致

错误信息The result type of the ReduceFunction must be the same as the input type
原因reduce 要求输入和输出类型完全相同。
解决:如果希望改变类型,可以在 reduce 后使用 map,或者使用 aggregate(后续会讲)。

3. 使用 Lambda 表达式作为 KeySelector 时类型推断问题

错误信息InvalidTypesException: The generic type parameters of 'KeySelector' are missing
解决:使用匿名内部类或显式指定 .returns(),或者使用 keyBy(order -> order.userId) 时注意 Flink 版本可能无法推断,建议改用方法引用或显式泛型:

orders.keyBy((KeySelector<Order, String>) order -> order.userId)

4. sum 方法对 POJO 字段无效

错误信息InvalidTypesException: Sum aggregation function cannot be applied to field "xxx" because it is not a numeric type
原因:字段不是数值类型。
解决:检查字段类型。


很好!你已经掌握了分组聚合,现在进入 第 5 天:多流转换算子。在实际业务中,我们经常需要合并多个数据流、将不同类型的数据流关联处理,或者将一个流拆分为多个支流。Flink 提供了 unionconnectsideOutput 等算子来完成这些任务。


第 5 步

  1. 理解 unionconnect 的区别与使用场景。
  2. 掌握 union 合并多个同类型流。
  3. 掌握 connect 连接两个不同类型流,并使用 CoMapFunctionCoFlatMapFunction 处理。
  4. 了解 sideOutput 侧输出流,实现数据分流。
  5. 完成一个“实时订单支付对账”的练习。

一、union:合并多个同类型流

union 可以将两个或多个 DataStream 合并成一个,要求所有流的元素类型必须相同。合并后的流包含所有输入流的元素,顺序不保证。

使用场景

  • 多个 Kafka topic 的数据合并后统一处理。
  • 多个数据源(如不同地区的订单流)合并后统计。

语法

DataStream<Order> orderStream1 = ...;
DataStream<Order> orderStream2 = ...;
DataStream<Order> allOrders = orderStream1.union(orderStream2);
// 也可以连续 union 多个
DataStream<Order> all = stream1.union(stream2).union(stream3);

示例

假设我们有两个订单流(例如来自不同的 Kafka 分区或不同的数据源),需要合并后统计总订单数。

DataStream<Order> orderStream1 = env.fromCollection(orders1);
DataStream<Order> orderStream2 = env.fromCollection(orders2);
DataStream<Order> allOrders = orderStream1.union(orderStream2);

注意union 要求所有流的元素类型完全一致,否则编译错误。如果类型不同,请使用 connect


二、connect:连接两个不同类型流

connect 用于连接两个类型可能不同的流,返回一个 ConnectedStreams。之后必须调用 map/flatMap/process 等操作,使用 CoMapFunctionCoFlatMapFunction 对两条流的数据分别处理,或使用 CoProcessFunction 进行更复杂的双流匹配。

使用场景

  • 实时对账:将订单流和支付流关联,找出未支付订单。
  • 维表关联:将实时数据流和配置流(广播流)关联。
  • 双流合并:将不同类型的日志流合并处理。

语法

DataStream<Order> orderStream = ...;
DataStream<Payment> paymentStream = ...;
ConnectedStreams<Order, Payment> connected = orderStream.connect(paymentStream);

// 使用 CoMapFunction
DataStream<String> result = connected.map(new CoMapFunction<Order, Payment, String>() {
    @Override
    public String map1(Order order) {   // 处理第一个流的元素
        return "订单: " + order.orderId;
    }
    @Override
    public String map2(Payment payment) { // 处理第二个流的元素
        return "支付: " + payment.paymentId;
    }
});

也可以使用 flatMapprocessCoProcessFunction 功能最强大,可以访问状态和定时器,适合实现双流 join 等复杂逻辑。

示例:简单双流合并

我们创建一个 Payment 类,然后连接订单流和支付流,将它们分别转换为字符串输出。

定义 Payment 类
public class Payment {
    public String paymentId;
    public String orderId;
    public double amount;
    public String payTime;

    public Payment() {}
    public Payment(String paymentId, String orderId, double amount, String payTime) {
        this.paymentId = paymentId;
        this.orderId = orderId;
        this.amount = amount;
        this.payTime = payTime;
    }
}
主程序
DataStream<Order> orders = ...;       // 订单流
DataStream<Payment> payments = ...;   // 支付流

ConnectedStreams<Order, Payment> connected = orders.connect(payments);

DataStream<String> messages = connected.map(new CoMapFunction<Order, Payment, String>() {
    @Override
    public String map1(Order order) {
        return "新订单: " + order.orderId + " 用户: " + order.userId;
    }
    @Override
    public String map2(Payment payment) {
        return "新支付: " + payment.paymentId + " 对应订单: " + payment.orderId;
    }
});

messages.print();

三、sideOutput:侧输出流

有时我们不想丢弃不符合主流条件的数据,而是希望将它们输出到另一个流中。sideOutput 允许我们定义一个或多个侧输出流,在处理过程中将特定数据发送到侧输出,而主流继续处理其他数据。

使用场景

  • 数据清洗:将合法数据发送到主流,非法数据发送到侧输出流单独处理。
  • 分流:根据数据类型将流拆分为多个子流(如将日志分为错误日志和访问日志)。
  • 迟到数据:窗口处理中,太迟的数据可以输出到侧输出流。

语法

  1. 定义侧输出标签:
OutputTag<Order> dirtyOrdersTag = new OutputTag<Order>("dirty-orders") {};
  1. ProcessFunctionFlatMapFunction 中使用 Context.output(tag, element) 发送到侧输出。
  2. 从主流中获取侧输出流:
DataStream<Order> dirtyOrders = mainStream.getSideOutput(dirtyOrdersTag);

示例:订单清洗分流

OrderCleanJob 中,我们之前用 flatMap 过滤非法数据。现在改为:合法订单进入主流,非法订单通过侧输出收集并打印。

OutputTag<String> dirtyTag = new OutputTag<String>("dirty-orders") {};

DataStream<Order> orders = orderLines.process(new ProcessFunction<String, Order>() {
    @Override
    public void processElement(String line, Context ctx, Collector<Order> out) throws Exception {
        try {
            String[] fields = line.split(",");
            if (fields.length < 6) {
                ctx.output(dirtyTag, "字段不足: " + line);
                return;
            }
            double price = Double.parseDouble(fields[3].trim());
            int quantity = Integer.parseInt(fields[4].trim());
            Order order = new Order(fields[0].trim(), fields[1].trim(), fields[2].trim(),
                    price, quantity, fields[5].trim());
            out.collect(order);
        } catch (Exception e) {
            ctx.output(dirtyTag, "解析失败: " + line);
        }
    }
});

DataStream<String> dirtyOrders = orders.getSideOutput(dirtyTag);
dirtyOrders.print(); // 打印脏数据
orders.print();      // 打印合法订单

四、完整示例:实时订单支付对账

需求

我们有两个输入流:

  • 订单流:订单ID,用户ID,商品,金额,时间
  • 支付流:支付ID,订单ID,支付金额,支付时间

我们需要实时监控哪些订单已经支付,哪些订单还未支付。这里使用 connectCoProcessFunction 实现简单的对账逻辑:为每个订单维护一个状态,当订单到达时记录为“未支付”,当支付到达时标记为“已支付”。同时,我们可以输出对账结果。

由于还没有学习状态管理,我们先用 CoProcessFunction 配合一个简单的 HashMap 状态来演示(实际项目中建议使用 Flink 的 Keyed State,后面会学)。此处为了教学,我们可以在 CoProcessFunction 中使用成员变量存储,但要注意并行度必须为 1,否则状态不共享。

代码实现

import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.co.CoProcessFunction;
import org.apache.flink.util.Collector;

public class OrderPaymentReconciliation {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1); // 必须为1,因为使用了非 keyed 的成员变量状态

        // 订单流
        DataStream<String> orders = env.fromElements(
                "1001,u001,手机,2999.0,2024-01-01 10:00:00",
                "1002,u002,电脑,5999.0,2024-01-01 10:01:00",
                "1003,u003,平板,1999.0,2024-01-01 10:02:00"
        );
        // 支付流
        DataStream<String> payments = env.fromElements(
                "p001,1001,2999.0,2024-01-01 10:05:00",
                "p002,1003,1999.0,2024-01-01 10:06:00"
        );

        // 将字符串解析为 POJO 或 Tuple
        DataStream<Order> orderStream = orders.map(line -> {
            String[] f = line.split(",");
            return new Order(f[0], f[1], f[2], Double.parseDouble(f[3]), 1, f[4]);
        });
        DataStream<Payment> paymentStream = payments.map(line -> {
            String[] f = line.split(",");
            return new Payment(f[0], f[1], Double.parseDouble(f[2]), f[3]);
        });

        // 连接两个流,按订单ID分组(keyBy 后才能使用 keyed state 或更好的并行)
        // 但为了简化,我们直接使用 connect 后 process,并行度1
        DataStream<String> reconciliation = orderStream.connect(paymentStream)
                .process(new CoProcessFunction<Order, Payment, String>() {
                    // 存储订单ID -> 是否已支付 (这里用成员变量,因为并行度1)
                    private java.util.HashMap<String, Boolean> orderPaid = new java.util.HashMap<>();

                    @Override
                    public void processElement1(Order order, Context ctx, Collector<String> out) throws Exception {
                        orderPaid.put(order.orderId, false);
                        out.collect("订单到达: " + order.orderId + ",当前状态:未支付");
                    }

                    @Override
                    public void processElement2(Payment payment, Context ctx, Collector<String> out) throws Exception {
                        if (orderPaid.containsKey(payment.orderId)) {
                            orderPaid.put(payment.orderId, true);
                            out.collect("支付到达: " + payment.paymentId + ",订单 " + payment.orderId + " 已支付");
                        } else {
                            out.collect("支付到达: " + payment.paymentId + ",但订单 " + payment.orderId + " 不存在!");
                        }
                    }
                });

        reconciliation.print();
        env.execute("Order Payment Reconciliation");
    }
}

运行结果

订单到达: 1001,当前状态:未支付
订单到达: 1002,当前状态:未支付
订单到达: 1003,当前状态:未支付
支付到达: p001,订单 1001 已支付
支付到达: p002,订单 1003 已支付

注意:这个例子没有使用 keyBy,因此状态是全局的,且并行度必须为 1。后续学习状态管理后,我们可以使用 keyByValueState 来正确实现可扩展的对账逻辑。


五、练习题

  1. 使用 union 合并三个订单流:创建三个 DataStream<Order>,分别来自不同的集合,然后用 union 合并,统计每个用户的订单数(使用 keyBy + sum)。
  2. 使用 connect 和 CoMapFunction:连接一个 DataStream<String>(单词)和一个 DataStream<Integer>(数字),将单词转为大写,将数字乘以 10,输出为字符串。
  3. 使用 sideOutput 完成数据分流:从 Socket 读取一行字符串,如果以 “ERROR” 开头,则输出到错误流;否则输出到正常流。最后分别打印两个流的内容。
  4. 思考connectunion 在哪些场景下不能互换?它们对元素类型有什么要求?

六、常见错误与解决方法

1. union 时类型不一致

错误信息The generic type of the unioned streams must be identical
解决:确保所有被 unionDataStream 泛型相同。

2. connect 后未调用 map/flatMap 等操作

错误信息ConnectedStreams 没有实际输出,作业可能不会执行。
解决connect 后必须紧跟一个处理函数,如 .map(new CoMapFunction...)

3. 在 CoProcessFunction 中使用成员变量导致状态丢失

现象:并行度大于 1 时,每个并行实例都维护自己的 HashMap,导致对账不准确。
解决:使用 Keyed State(后续会学)或确保并行度为 1(仅用于本地调试)。

4. 侧输出标签类型不匹配

错误信息OutputTag<X> cannot be cast to OutputTag<Y>
解决:确保定义侧输出标签时指定正确的泛型,并在 ctx.output(tag, element) 中传入相同类型的元素。


非常感谢你的反馈!你说得对,我在第5天中使用了 process 但没有详细解释,这是我的疏忽。在后续内容中,我会对每一个新出现的算子或概念都进行详细说明,确保你完全理解。

现在开始 第 6 天:时间语义与窗口(上)——事件时间、Watermark、滚动窗口。这一天的内容非常重要,是 Flink 区别于其他流处理框架的核心。我会尽量细致地讲解每一个概念和 API。


第 6 步

  1. 理解 Flink 中三种时间语义的区别。
  2. 深刻理解 Event Time 和 Watermark 的作用。
  3. 掌握如何为数据流分配时间戳和生成 Watermark。
  4. 掌握滚动窗口(Tumbling Window)的定义和使用。
  5. 能够使用 ProcessWindowFunction 处理窗口数据并理解 process 算子的作用。
  6. 完成一个“实时每分钟订单量统计”的练习。

一、为什么需要时间语义?

在流处理中,数据是持续不断产生的,我们通常需要对一段时间内的数据进行聚合,例如“统计每分钟的访问量”、“计算每5分钟的平均温度”。因此,我们需要定义“时间”的概念。但数据从产生到被 Flink 处理,存在不同的时间点:

  • 事件时间(Event Time):数据真实产生的时间,通常由数据本身携带(例如订单时间字段)。
  • 处理时间(Processing Time):数据被 Flink 处理时的机器系统时间。
  • 摄入时间(Ingestion Time):数据进入 Flink 集群的时间(介于事件时间和处理时间之间)。

为什么事件时间更重要?

因为事件时间能够准确反映业务发生的真实时间,不受网络延迟、系统负载等影响,计算结果具有确定性和可复现性。例如,统计某个用户在特定时间段的行为,就必须使用事件时间。但事件时间带来了乱序问题:由于网络延迟等原因,事件可能晚到,乱序到达 Flink。为了解决乱序,我们引入了 Watermark。


二、Watermark(水位线)详解

1. 什么是 Watermark?

Watermark 是 Flink 中处理乱序事件的机制。它是一个特殊的时间戳,表示“在这个时间戳之前的数据(理论上)都已经到达了”。当 Watermark 时间戳为 T 时,Flink 认为事件时间小于等于 T 的数据都已到齐,窗口就可以触发计算。

形象理解:有一个闹钟,当闹钟显示 10:00 时,就认为 10:00 之前的事件都到了,可以关门统计了。但可能还有迟到的数据,所以 Watermark 允许一定的延迟。

2. 如何生成 Watermark?

在 DataStream API 中,我们通过 assignTimestampsAndWatermarks 方法来分配时间戳和生成 Watermark。通常使用内置的 WatermarkStrategy

  • 单调递增时间戳:假设数据的时间戳是递增的,没有乱序。WatermarkStrategy.forMonotonousTimestamps()
  • 固定乱序延迟:允许一定程度的乱序。WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(5)) 表示允许数据最多迟到5秒,Watermark 等于当前最大事件时间减去5秒。

3. Watermark 的传递

Watermark 是随着数据流广播的,每个并行任务会维护一个当前的 Watermark,并取所有上游分区 Watermark 的最小值作为该任务的 Watermark(确保所有上游数据都已到齐)。


三、窗口(Window)概述

窗口是一种将无限流切分为有限数据块进行聚合的机制。Flink 支持多种窗口类型:

  • 滚动窗口(Tumbling Window):窗口大小固定,不重叠。例如每5分钟一个窗口。
  • 滑动窗口(Sliding Window):窗口大小固定,但有滑动步长,窗口可能重叠。例如每1分钟滑动一次,窗口大小为5分钟。
  • 会话窗口(Session Window):根据活动间隔动态合并窗口。
  • 全局窗口(Global Window):所有数据属于一个窗口,需要自定义触发器。

今天先学习滚动窗口。

窗口 API 的基本结构

对于 KeyedStream,可以使用 .window(...) 定义窗口;对于非 KeyedStream,使用 .windowAll(...)(不常用,性能差)。窗口定义后,需要指定窗口函数来处理窗口内的数据。常用窗口函数有:

  • 增量聚合reduceaggregate,来一条数据就聚合,状态小。
  • 全量聚合process(ProcessWindowFunction),窗口触发时一次性处理窗口内所有数据,可以访问窗口元数据(如窗口起始时间)。

四、process 算子到底是什么?

在 Flink 中,process 是一个非常底层的算子,它比 mapflatMap 更强大。process 可以访问以下内容:

  • 元素本身
  • 上下文(Context):可以获取当前的处理时间、Watermark、时间戳(事件时间),还可以注册定时器(Timer)
  • 状态(State):如果在 KeyedStream 上使用 process,可以访问 Keyed State(后面会学)
  • 输出:可以通过 Collector 输出零个或多个结果

process 的典型实现是继承 ProcessFunctionKeyedProcessFunctionCoProcessFunctionProcessWindowFunction 等。

在第5天中,我们使用 ProcessFunction 来实现侧输出,通过 ctx.output(tag, data) 将数据发送到侧输出流。这就是 process 的一个应用。

在窗口计算中,常用的全量聚合函数是 ProcessWindowFunction,它允许我们在窗口触发时获取窗口内所有元素以及窗口的起始和结束时间,并输出任意结果。


五、实战:实时每分钟订单量统计

需求

从 Socket 读取订单字符串(格式:订单ID,用户ID,商品,价格,数量,订单时间(ISO-8601格式)),使用事件时间,统计每分钟的订单数量,并输出窗口的起始时间、结束时间和订单数。

步骤 1:准备数据

我们将使用 Socket 手动输入数据,订单时间字段需要是 ISO-8601 格式(如 2024-01-01 10:00:00)。为了演示乱序和 Watermark,我们会故意输入乱序的数据。

步骤 2:定义订单 POJO 类

我们复用第 3 天的 Order 类,但增加一个 timestamp 字段(或者直接使用 orderTime 字符串解析为时间戳)。为方便,我们创建一个新类 OrderEvent,包含 orderIduserIdpricequantityeventTime(long 类型毫秒时间戳)。

public class OrderEvent {
    public String orderId;
    public String userId;
    public double price;
    public int quantity;
    public long eventTime; // 事件时间(毫秒)

    public OrderEvent() {}

    public OrderEvent(String orderId, String userId, double price, int quantity, long eventTime) {
        this.orderId = orderId;
        this.userId = userId;
        this.price = price;
        this.quantity = quantity;
        this.eventTime = eventTime;
    }
}

步骤 3:编写主程序

import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

public class MinuteOrderCountJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

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

        // 1. 将字符串解析为 OrderEvent 对象
        DataStream<OrderEvent> orderEvents = lines.map(new MapFunction<String, OrderEvent>() {
            @Override
            public OrderEvent map(String line) throws Exception {
                String[] fields = line.split(",");
                // 格式:orderId,userId,price,quantity,eventTime(string)
                String orderId = fields[0].trim();
                String userId = fields[1].trim();
                double price = Double.parseDouble(fields[2].trim());
                int quantity = Integer.parseInt(fields[3].trim());
                // 解析事件时间字符串为毫秒时间戳
                String timeStr = fields[4].trim();
                long eventTime = LocalDateTime.parse(timeStr, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
                        .toInstant(ZoneOffset.UTC).toEpochMilli();
                return new OrderEvent(orderId, userId, price, quantity, eventTime);
            }
        });

        // 2. 分配时间戳和 Watermark,允许 5 秒乱序
        DataStream<OrderEvent> withTimestamps = orderEvents.assignTimestampsAndWatermarks(
                WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                        .withTimestampAssigner((event, timestamp) -> event.eventTime)
        );

        // 3. 按用户ID分组(这里为了演示每分钟订单量,可以不分组,但窗口需要 keyed 或 windowAll)
        // 我们不分组,直接使用 windowAll,但 windowAll 并行度为1,不推荐生产使用,此处仅为教学
        DataStream<String> result = withTimestamps
                .windowAll(TumblingEventTimeWindows.of(Time.minutes(1)))
                .process(new ProcessWindowFunction<OrderEvent, String, TimeWindow>() {
                    @Override
                    public void process(Context context, Iterable<OrderEvent> elements, Collector<String> out) throws Exception {
                        TimeWindow window = context.window();
                        int count = 0;
                        for (OrderEvent event : elements) {
                            count++;
                        }
                        String result = "窗口 [" + window.getStart() + ", " + window.getEnd() + ") 订单数: " + count;
                        out.collect(result);
                    }
                });

        result.print();

        env.execute("Minute Order Count");
    }
}

代码详解

(1) 解析事件时间

我们使用 LocalDateTime.parse 将字符串转为时间戳,方便 Watermark 使用。

(2) assignTimestampsAndWatermarks
  • WatermarkStrategy.forBoundedOutOfOrderness(Duration.ofSeconds(5)):生成 Watermark 的策略,允许数据乱序最多5秒。Watermark = 当前最大事件时间 - 5秒。
  • .withTimestampAssigner((event, timestamp) -> event.eventTime):告诉 Flink 从 OrderEvent 的哪个字段提取事件时间。
  • 注意:Lambda 可能因类型推断问题报错,可以改为匿名内部类:
.withTimestampAssigner(new SerializableTimestampAssigner<OrderEvent>() {
    @Override
    public long extractTimestamp(OrderEvent element, long recordTimestamp) {
        return element.eventTime;
    }
})

但通常 Lambda 在指定了泛型(WatermarkStrategy.)后可以正常工作。

(3) windowAll
  • 由于我们没有使用 keyBy,所以使用 windowAll 将所有数据放入一个窗口。这在生产环境中不推荐(并行度只能为1),但为了简单演示窗口触发逻辑,这里使用它。如果你想要并行度 >1,应该先 keyBy 然后使用 .window。后续我们会练习 keyed 窗口。
(4) TumblingEventTimeWindows.of(Time.minutes(1))
  • 定义滚动事件时间窗口,大小为1分钟。窗口的边界是自然时间对齐的,即从 00:00:00 开始,每分钟一个窗口。例如数据时间 10:00:00 到 10:00:59 属于窗口 [10:00:00, 10:01:00)。
(5) process(new ProcessWindowFunction<...>)
  • ProcessWindowFunctionprocess 算子的一种实现,用于全量处理窗口数据。
  • 它的泛型参数:<输入类型, 输出类型, 窗口类型, KEY类型>,因为我们是 windowAll,所以 KEY 类型是 TimeWindow(实际是 TimeWindow,但这里没有 key,我们不用 KEY,直接写 TimeWindow 作为第三个参数即可?实际上 ProcessWindowFunction 的签名是 ProcessWindowFunction<IN, OUT, KEY, W extends Window>,所以第三个参数是 KEY 类型,我们这里没有 key,通常使用 TimeWindow 作为 KEY 类型占位,因为 windowAll 的窗口函数 KEY 是 TimeWindow?需要仔细检查:在 Flink 中,windowAll 使用的 ProcessWindowFunction 第三个泛型参数是 TimeWindow(表示窗口类型),而 KEY 类型是 Object 或不存在。准确说,windowAll 返回的是 AllWindowedStream,其 process 方法要求的 ProcessWindowFunction<IN, OUT, TimeWindow> 仅有三个泛型参数(没有 KEY)。但在我们的代码中写了四个泛型参数 <OrderEvent, String, TimeWindow>,第三个是窗口类型,第四个是 KEY 类型?实际编译可能会报错。

需要修正代码:ProcessWindowFunction<OrderEvent, String, TimeWindow> 是三个泛型参数(输入、输出、窗口类型),而不是四个。我上面写的是 <OrderEvent, String, TimeWindow> 三个,但后面代码里写成了 ProcessWindowFunction<OrderEvent, String, TimeWindow> 是正确的,没有 KEY。但我在上面代码中写的是 new ProcessWindowFunction<OrderEvent, String, TimeWindow>(),这是对的。

但在之前的文字中我提到四个泛型参数,那是错误的。实际 ProcessWindowFunction 有三个泛型参数:输入、输出、窗口类型。只有在 KeyedStream 的 .window().process() 中使用时,ProcessWindowFunction 需要四个泛型参数,因为多了 KEY 类型。在 windowAll 中,使用三个泛型参数。

我检查一下我提供的代码:ProcessWindowFunction<OrderEvent, String, TimeWindow> 是对的。为了不混淆,我会在教程中说明这一点。

(6) context.window()
  • ProcessWindowFunctionContext 参数可以获取窗口元数据,包括窗口的起始时间、结束时间等。
  • window.getStart()window.getEnd() 返回毫秒时间戳。

运行步骤

  1. 启动 nc -lk 9999
  2. 运行 Flink 程序。
  3. 在 nc 中依次输入以下订单(注意时间格式和乱序):
1001,u001,手机,2999.0,1,2024-01-01 10:00:10
1002,u002,电脑,5999.0,2,2024-01-01 10:00:30
1003,u003,平板,1999.0,3,2024-01-01 10:00:50
1004,u004,鼠标,99.0,5,2024-01-01 10:01:05   // 属于下一个窗口,但它的到来会推进 Watermark
1005,u005,键盘,299.0,2,2024-01-01 10:00:40   // 乱序数据,但仍在允许的5秒乱序内
  1. 观察控制台输出。由于 Watermark 延迟5秒,当 10:01:05 的数据到达时,Watermark 可能已经超过 10:01:00,所以第一个窗口 [10:00:00, 10:01:00) 会触发。输出的结果应该是该窗口内的订单数(共4个,因为 10:00:40 的乱序数据也在窗口关闭前到达)。

注意:如果一直没有新数据到达,Watermark 不会推进,窗口不会关闭。所以需要不断输入数据来触发窗口。


六、练习题

  1. 修改程序:使用 keyBy 按用户ID分组,统计每分钟每个用户的订单数,而不是全局订单数。要求窗口函数使用 ProcessWindowFunction 输出用户ID、窗口起止时间和订单数。
  2. 使用增量聚合:将窗口函数改为 reduceaggregate,实现相同的每分钟订单数统计(需要先将订单映射为 (1) 然后求和)。
  3. 实验不同乱序延迟:将 Watermark 的乱序延迟改为 10 秒,再次运行,观察窗口触发时机是否变化。
  4. 思考题:如果数据迟到时间超过了 Watermark 允许的乱序延迟,会发生什么?如何解决?(提示:Allowed Lateness 和侧输出,我们后续会学)

七、常见错误与解决方法

1. 窗口不触发

原因:忘记推进 Watermark,或 Watermark 没有超过窗口结束时间。需要持续输入数据,且数据的事件时间要超过窗口结束时间 + 乱序延迟。
解决:确保数据源持续产生数据,或使用 env.setStreamTimeCharacteristic(TimeCharacteristic.EventTime) ?在 Flink 1.12 之后默认就是 EventTime,不需要设置。

2. 类型推断错误

错误信息InvalidTypesException: The generic type parameters of 'ProcessWindowFunction' are missing
解决:使用匿名内部类而不是 Lambda,并显式写出泛型。

3. 时间戳解析不正确

错误信息DateTimeParseException
解决:确保时间字符串与格式完全匹配。

4. windowAll 导致性能差

提示:在实际项目中避免使用 windowAll,始终使用 keyBy 后的 .window


第 7 步

  1. 掌握滑动窗口(Sliding Window)的定义和使用场景。
  2. 掌握会话窗口(Session Window)的定义和使用场景。
  3. 理解增量聚合与全量聚合的区别,并能选择合适的窗口函数。
  4. 学会使用 aggregate 实现自定义增量聚合。
  5. 掌握迟到数据的处理:allowedLatenesssideOutputLateData
  6. 完成一个“实时每5分钟商品点击量统计”的练习,包含迟到数据处理。

一、滑动窗口(Sliding Window)

1. 概念

滑动窗口有两个参数:窗口长度(window size)滑动步长(slide interval)。窗口长度固定,但窗口之间可以重叠(当步长小于窗口长度时)。例如,窗口长度为5分钟,滑动步长为1分钟,那么每1分钟产生一个覆盖过去5分钟数据的窗口。

2. 使用场景

  • 实时监控最近N分钟的趋势(如最近5分钟的错误率)。
  • 移动平均计算。
  • 需要频繁输出但统计范围较长的指标。

3. API 定义

// 对于 KeyedStream
keyedStream.window(SlidingEventTimeWindows.of(Time.minutes(5), Time.minutes(1)));
// 对于非 Keyed 流(不推荐)
dataStream.windowAll(SlidingEventTimeWindows.of(Time.minutes(5), Time.minutes(1)));

4. 示例代码

统计每个用户最近5分钟内(每1分钟更新一次)的订单总金额。

DataStream<OrderEvent> orders = ...; // 已经分配时间戳和 Watermark

DataStream<String> result = orders
        .keyBy(order -> order.userId)
        .window(SlidingEventTimeWindows.of(Time.minutes(5), Time.minutes(1)))
        .process(new ProcessWindowFunction<OrderEvent, String, String, TimeWindow>() {
            @Override
            public void process(String userId, Context context, Iterable<OrderEvent> elements, Collector<String> out) throws Exception {
                TimeWindow window = context.window();
                double totalAmount = 0.0;
                for (OrderEvent e : elements) {
                    totalAmount += e.price * e.quantity;
                }
                String result = "用户 " + userId + " 窗口 [" + window.getStart() + ", " + window.getEnd() + ") 总金额: " + totalAmount;
                out.collect(result);
            }
        });

二、会话窗口(Session Window)

1. 概念

会话窗口没有固定的窗口长度,而是根据数据的活动间隔动态合并窗口。如果两个事件的时间差小于设定的 会话超时时间(session gap),它们属于同一个会话窗口;如果时间差超过 gap,则窗口关闭。

2. 使用场景

  • 用户行为分析:用户连续点击操作构成一个会话。
  • 设备在线/离线检测:两次心跳间隔超过阈值则视为新的会话。

3. API 定义

keyedStream.window(EventTimeSessionWindows.withGap(Time.minutes(5)));
// 动态 gap:根据元素自定义 gap
keyedStream.window(EventTimeSessionWindows.withDynamicGap((element) -> ...));

4. 示例代码

统计每个用户的会话数量(即用户连续活动的次数)。假设订单流代表用户活动,如果两个订单间隔超过5分钟,则视为一个新的会话。

DataStream<OrderEvent> orders = ...;

DataStream<String> result = orders
        .keyBy(order -> order.userId)
        .window(EventTimeSessionWindows.withGap(Time.minutes(5)))
        .process(new ProcessWindowFunction<OrderEvent, String, String, TimeWindow>() {
            @Override
            public void process(String userId, Context context, Iterable<OrderEvent> elements, Collector<String> out) throws Exception {
                TimeWindow window = context.window();
                int orderCount = 0;
                for (OrderEvent e : elements) {
                    orderCount++;
                }
                String result = "用户 " + userId + " 会话窗口 [" + window.getStart() + ", " + window.getEnd() + ") 订单数: " + orderCount;
                out.collect(result);
            }
        });

三、增量聚合 vs 全量聚合

特性增量聚合(reduce/aggregate全量聚合(process
计算方式每来一条数据立即更新状态,窗口触发时直接输出状态窗口触发时遍历所有数据计算
状态大小只保存一个聚合状态(如累加器),内存占用小需要保存窗口内所有元素,内存占用大
灵活性只能做可合并的聚合(如求和、最大值)可访问所有数据,进行复杂计算
典型应用求和、计数、平均值等TopN、中位数、输出窗口所有数据等

推荐:优先使用增量聚合,因为状态小、性能高。只有在确实需要访问全部数据时才使用全量聚合。还可以结合两者:使用 aggregate + ProcessWindowFunction,既能增量聚合,又能在窗口触发时访问窗口元数据(如窗口起止时间)。


四、aggregate 详解

aggregate 是一个比 reduce 更灵活的增量聚合函数,它使用一个 AggregateFunction,该函数有四个方法:

  1. createAccumulator():创建初始累加器。
  2. add(IN value, ACC accumulator):将新元素合并到累加器中。
  3. getResult(ACC accumulator):窗口触发时,从累加器提取最终结果。
  4. merge(ACC a, ACC b):合并两个累加器(用于会话窗口或并行合并)。

示例:统计每个用户每分钟的平均订单金额

我们需要一个累加器保存 (sum, count),最后计算平均值。

// 累加器类
public static class AvgAccumulator {
    public double sum = 0.0;
    public int count = 0;
}

DataStream<String> result = orders
        .keyBy(order -> order.userId)
        .window(TumblingEventTimeWindows.of(Time.minutes(1)))
        .aggregate(new AggregateFunction<OrderEvent, AvgAccumulator, String>() {
            @Override
            public AvgAccumulator createAccumulator() {
                return new AvgAccumulator();
            }

            @Override
            public AvgAccumulator add(OrderEvent value, AvgAccumulator accumulator) {
                accumulator.sum += value.price * value.quantity;
                accumulator.count++;
                return accumulator;
            }

            @Override
            public String getResult(AvgAccumulator accumulator) {
                double avg = accumulator.count == 0 ? 0.0 : accumulator.sum / accumulator.count;
                return "平均订单金额: " + avg;
            }

            @Override
            public AvgAccumulator merge(AvgAccumulator a, AvgAccumulator b) {
                AvgAccumulator merged = new AvgAccumulator();
                merged.sum = a.sum + b.sum;
                merged.count = a.count + b.count;
                return merged;
            }
        });

注意aggregate 的第三个泛型参数是输出类型,而不是 ACC。上面代码中 AggregateFunction<OrderEvent, AvgAccumulator, String> 表示输入 OrderEvent,累加器 AvgAccumulator,输出 String


五、迟到数据处理

在事件时间中,即使有了 Watermark,仍可能有数据晚于 Watermark 到达(例如网络严重延迟)。这些数据被称为迟到数据。Flink 提供了两种机制来处理迟到数据:

  1. allowedLateness(Time):允许窗口在 Watermark 超过窗口结束时间后,再等待一段时间才关闭。在这段等待期内,如果有迟到数据到达,窗口会重新计算并再次输出结果。
  2. sideOutputLateData(OutputTag):当窗口最终关闭(Watermark > 窗口结束时间 + allowedLateness)后,如果还有数据到达,这些数据会被发送到侧输出流,由用户自行处理。

使用方式

OutputTag<OrderEvent> lateOrdersTag = new OutputTag<OrderEvent>("late-orders") {};

DataStream<String> result = orders
        .keyBy(order -> order.userId)
        .window(TumblingEventTimeWindows.of(Time.minutes(1)))
        .allowedLateness(Time.minutes(2))   // 允许迟到2分钟
        .sideOutputLateData(lateOrdersTag)   // 超过2分钟的数据输出到侧输出
        .process(new ProcessWindowFunction<OrderEvent, String, String, TimeWindow>() {
            // ... 窗口计算逻辑
        });

// 获取迟到数据流
DataStream<OrderEvent> lateOrders = result.getSideOutput(lateOrdersTag);

注意allowedLatenesssideOutputLateData 只对事件时间窗口有效,处理时间窗口没有迟到的概念。


六、完整练习:实时每5分钟商品点击量统计(滑动窗口 + 迟到处理)

需求

数据源为商品点击日志,格式:商品ID,用户ID,点击时间(ISO-8601)。要求:

  • 使用滑动窗口,窗口长度5分钟,滑动步长1分钟,统计每个商品的点击量。
  • 允许数据迟到1分钟,超过1分钟的数据输出到侧输出流。
  • 窗口函数使用 ProcessWindowFunction 输出商品ID、窗口起止时间和点击量。

步骤 1:定义点击事件类

public class ClickEvent {
    public String productId;
    public String userId;
    public long eventTime;

    public ClickEvent() {}
    public ClickEvent(String productId, String userId, long eventTime) {
        this.productId = productId;
        this.userId = userId;
        this.eventTime = eventTime;
    }
}

步骤 2:编写主程序

import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.SlidingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
import org.apache.flink.util.OutputTag;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

public class SlidingClickCountJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

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

        // 1. 解析为 ClickEvent
        DataStream<ClickEvent> clicks = lines.map(new MapFunction<String, ClickEvent>() {
            @Override
            public ClickEvent map(String line) throws Exception {
                String[] f = line.split(",");
                String productId = f[0].trim();
                String userId = f[1].trim();
                long eventTime = LocalDateTime.parse(f[2].trim(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
                        .toInstant(ZoneOffset.UTC).toEpochMilli();
                return new ClickEvent(productId, userId, eventTime);
            }
        });

        // 2. 分配时间戳和 Watermark(允许5秒乱序)
        DataStream<ClickEvent> withTimestamps = clicks.assignTimestampsAndWatermarks(
                WatermarkStrategy.<ClickEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                        .withTimestampAssigner((event, timestamp) -> event.eventTime)
        );

        // 3. 定义侧输出标签
        OutputTag<ClickEvent> lateClicksTag = new OutputTag<ClickEvent>("late-clicks") {};

        // 4. 窗口统计
        DataStream<String> result = withTimestamps
                .keyBy(click -> click.productId)
                .window(SlidingEventTimeWindows.of(Time.minutes(5), Time.minutes(1)))
                .allowedLateness(Time.minutes(1))   // 允许迟到1分钟
                .sideOutputLateData(lateClicksTag)   // 超过1分钟的数据进入侧输出
                .process(new ProcessWindowFunction<ClickEvent, String, String, TimeWindow>() {
                    @Override
                    public void process(String productId, Context context, Iterable<ClickEvent> elements, Collector<String> out) throws Exception {
                        TimeWindow window = context.window();
                        int count = 0;
                        for (ClickEvent click : elements) {
                            count++;
                        }
                        String result = "商品 " + productId + " 窗口 [" + window.getStart() + ", " + window.getEnd() + ") 点击量: " + count;
                        out.collect(result);
                    }
                });

        // 5. 打印主流结果
        result.print();

        // 6. 打印迟到数据
        DataStream<ClickEvent> lateClicks = result.getSideOutput(lateClicksTag);
        lateClicks.map(click -> "迟到数据: " + click.productId + " 时间: " + click.eventTime).print();

        env.execute("Sliding Click Count");
    }
}

运行与观察

  1. 启动 nc -lk 9999
  2. 运行程序。
  3. 输入一些点击数据,故意让部分数据的事件时间比 Watermark 晚很多(例如晚到2分钟),观察:
    • 正常数据触发窗口计算。
    • 迟到但仍在 allowedLateness 内的数据会触发窗口重新计算(控制台会再次输出更新后的结果)。
    • 超过 allowedLateness 的数据会被打印为“迟到数据”。

七、练习题

  1. 改用 aggregate 实现点击量统计:将 ProcessWindowFunction 替换为 aggregate,使用一个简单的计数器作为累加器,输出结果字符串。
  2. 实现会话窗口统计:统计每个用户在5分钟会话内的点击次数(即相邻点击间隔不超过5分钟属于同一会话)。
  3. 实验迟到数据:调整 allowedLateness 和 Watermark 乱序延迟,观察不同设置下窗口触发和迟到数据的行为。
  4. 思考:如果使用 reduce 实现同样的点击量统计,累加器应该是什么类型?与 aggregate 有何区别?

八、常见错误与解决方法

1. 使用 allowedLateness 但窗口不重新触发

原因:窗口函数必须是全量聚合(如 ProcessWindowFunction)或可合并的增量聚合(aggregate),才能重新计算并输出。简单的 reduce/sum 也支持,但需要确保状态可更新。通常没问题。
检查:确保数据的事件时间确实在窗口结束时间之后但在 allowedLateness 内。

2. 侧输出流为空

原因:数据迟到时间没有超过 Watermark + allowedLateness,或者 Watermark 推进太慢。
解决:输入一条事件时间远早于当前 Watermark 的数据(例如比当前最大时间早10分钟)。

3. 类型推断错误

错误信息InvalidTypesException 或泛型参数缺失。
解决:使用匿名内部类,并显式写出所有泛型参数。

4. 会话窗口不关闭

原因:会话窗口需要 Watermark 超过会话结束时间 + gap 后才会关闭。如果数据流停止,窗口不会关闭。
解决:持续输入数据,且最后一条数据的时间至少比会话结束时间晚 gap 以上。


非常好,你已经完成了窗口进阶的学习!现在进入 第 8 天:状态管理与容错机制。这是 Flink 最核心、最强大的特性之一,也是实现有状态流处理(如去重、累加、模式识别)的基础。今天的内容会比较深入,但我会尽量详细解释每一个概念和 API。


第 8 步 状态和检查点

  1. 理解什么是“状态”,为什么需要状态。
  2. 掌握 Flink 中 Keyed State 的几种类型:ValueStateListStateMapState
  3. 学会在 KeyedProcessFunction 中使用状态实现有状态计算。
  4. 理解 Checkpoint 的作用和基本配置。
  5. 了解状态后端(State Backend)和状态 TTL。
  6. 完成一个“用户连续登录检测”的练习,使用 ValueState 和定时器。

一、什么是状态?

在流处理中,我们经常需要记录和更新一些中间结果,例如:

  • 统计每个用户的累计消费金额(需要保存每个用户的当前总额)。
  • 去重:判断某个用户今天是否已经访问过(需要记住已访问的用户)。
  • 模式识别:检测用户连续三次登录失败(需要保存最近两次的失败信息)。

这些需要被记住并在后续计算中使用的数据就是 状态(State)。没有状态的流处理是无状态的(如 map、filter),它们每次只处理单条数据,不依赖历史。

Flink 提供了强大的状态管理机制,允许我们在函数中定义和使用状态,并且能够自动进行状态的一致性保证和容错。


二、Keyed State 与 Operator State

Flink 的状态分为两类:

  1. Keyed State:只能用于 KeyedStream(即 keyBy 之后的流)。状态与每个 key 关联,每个 key 有自己独立的状态。例如,每个用户的累计金额状态。
  2. Operator State:用于非 Keyed 流,每个算子实例(并行任务)维护一份状态。例如,Kafka Source 的偏移量。

今天我们重点学习 Keyed State,因为它最常用。

Keyed State 的三种基本类型

状态类型说明典型用途
ValueState<T>保存一个值,可以更新和读取累计值、上次登录时间
ListState<T>保存一个列表,可以添加和遍历最近 N 条记录、待处理队列
MapState<UK, UV>保存键值对去重、字典

还有 ReducingState<T>AggregatingState<I, O>,它们基于 reduce/aggregate 自动聚合,我们后续再学。


三、如何在函数中使用 Keyed State

要使用 Keyed State,我们通常继承 KeyedProcessFunction(或其他 Keyed 函数,如 RichFlatMapFunction)。KeyedProcessFunction 提供了 processElement 方法,并可以通过 getRuntimeContext().getState(...) 获取状态对象。

使用步骤:

  1. 定义状态描述符ValueStateDescriptorListStateDescriptor 等。
  2. open() 方法中初始化状态(或者直接在字段声明处通过 getRuntimeContext().getState(...) 初始化,但推荐在 open() 中)。
  3. processElement 中使用状态:读取、更新状态。
  4. (可选)注册定时器,在 onTimer 中使用状态。

重要:状态的作用域

Keyed State 的作用域是当前 key。相同 key 的数据会进入同一个并行实例,并访问同一个状态实例。因此,你不需要担心不同 key 之间的状态互相干扰。


四、定时器(Timer)

KeyedProcessFunction 还可以注册定时器,在未来的某个时间触发。定时器常用于:

  • 超时检测(如用户30秒未支付则取消订单)。
  • 延迟操作。

注册定时器

ctx.timerService().registerProcessingTimeTimer(timestamp); // 基于处理时间
ctx.timerService().registerEventTimeTimer(timestamp);       // 基于事件时间(需要 Watermark)

定时器触发

重写 onTimer(long timestamp, OnTimerContext ctx, Collector<OUT> out) 方法,当定时器触发时执行。


五、实战:用户连续三次登录失败检测

需求

从 Socket 读取登录事件,格式:用户ID,登录结果(SUCCESS/FAIL),事件时间(ISO-8601)
要求:

  • 检测用户是否连续三次登录失败。
  • 如果连续三次失败,输出告警信息。
  • 如果用户登录成功,重置失败计数器。

实现思路

  • 使用 keyBy 按用户ID分组。
  • 使用 KeyedProcessFunction 维护一个 ValueState<Integer> 记录连续失败次数。
  • 当失败次数达到3时,输出告警并重置状态。
  • 如果登录成功,将失败次数清零。

步骤 1:定义登录事件类

public class LoginEvent {
    public String userId;
    public boolean success; // true=成功, false=失败
    public long eventTime;  // 事件时间(毫秒)

    public LoginEvent() {}

    public LoginEvent(String userId, boolean success, long eventTime) {
        this.userId = userId;
        this.success = success;
        this.eventTime = eventTime;
    }
}

步骤 2:编写主程序

import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

public class LoginFailDetectionJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

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

        // 1. 解析为 LoginEvent
        DataStream<LoginEvent> loginEvents = lines.map(new MapFunction<String, LoginEvent>() {
            @Override
            public LoginEvent map(String line) throws Exception {
                String[] f = line.split(",");
                String userId = f[0].trim();
                boolean success = f[1].trim().equalsIgnoreCase("SUCCESS");
                long eventTime = LocalDateTime.parse(f[2].trim(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
                        .toInstant(ZoneOffset.UTC).toEpochMilli();
                return new LoginEvent(userId, success, eventTime);
            }
        });

        // 2. 分配时间戳和 Watermark(允许5秒乱序)
        DataStream<LoginEvent> withTimestamps = loginEvents.assignTimestampsAndWatermarks(
                WatermarkStrategy.<LoginEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                        .withTimestampAssigner((event, timestamp) -> event.eventTime)
        );

        // 3. 按用户分组,使用 KeyedProcessFunction 检测连续失败
        DataStream<String> alerts = withTimestamps
                .keyBy(event -> event.userId)
                .process(new LoginFailDetector());

        alerts.print();

        env.execute("Login Fail Detection");
    }

    // 检测连续登录失败的 KeyedProcessFunction
    public static class LoginFailDetector extends KeyedProcessFunction<String, LoginEvent, String> {

        // 定义状态:连续失败次数
        private ValueState<Integer> failCount;

        @Override
        public void open(Configuration parameters) throws Exception {
            // 初始化状态
            ValueStateDescriptor<Integer> descriptor = new ValueStateDescriptor<>(
                    "failCount",   // 状态名称
                    Integer.class  // 状态类型
            );
            failCount = getRuntimeContext().getState(descriptor);
        }

        @Override
        public void processElement(LoginEvent event, Context ctx, Collector<String> out) throws Exception {
            // 获取当前失败次数
            Integer current = failCount.value();
            if (current == null) {
                current = 0;
            }

            if (!event.success) {
                // 登录失败,次数+1
                int newCount = current + 1;
                failCount.update(newCount);

                if (newCount >= 3) {
                    // 连续失败3次,输出告警,并重置状态
                    out.collect("用户 " + event.userId + " 连续登录失败 3 次,触发告警!");
                    failCount.update(0);
                } else {
                    out.collect("用户 " + event.userId + " 登录失败,当前连续失败次数: " + newCount);
                }
            } else {
                // 登录成功,重置状态
                failCount.update(0);
                out.collect("用户 " + event.userId + " 登录成功,失败计数器已重置");
            }
        }
    }
}

运行步骤

  1. 启动 nc -lk 9999
  2. 运行程序。
  3. 在 nc 中依次输入:
u001,FAIL,2024-01-01 10:00:01
u001,FAIL,2024-01-01 10:00:02
u001,FAIL,2024-01-01 10:00:03
u001,SUCCESS,2024-01-01 10:00:04
u001,FAIL,2024-01-01 10:00:05
u001,FAIL,2024-01-01 10:00:06
u001,FAIL,2024-01-01 10:00:07

观察输出:前两次失败不会告警,第三次失败触发告警;然后成功重置;后面三次失败再次触发告警。

代码详解

(1) KeyedProcessFunction 的泛型

KeyedProcessFunction<KEY, IN, OUT>

  • KEY:key 的类型(我们按 userId 分组,所以是 String)。
  • IN:输入元素类型(LoginEvent)。
  • OUT:输出元素类型(String)。
(2) 状态初始化

open() 方法中通过 getRuntimeContext().getState(descriptor) 获取状态对象。注意 open()RichFunction 的生命周期方法,在任务启动时调用一次。

(3) 状态读取与更新
  • failCount.value():读取当前值,如果没有初始化过,返回 null
  • failCount.update(newValue):更新状态值。
(4) 为什么不需要手动清理状态?

状态会随着 Checkpoint 进行持久化,但如果不清理,状态会一直存在。在本例中,我们在告警后手动重置状态为 0,避免无限增长。


六、状态 TTL(Time To Live)

对于某些状态,我们不希望它永久保存,比如缓存、短期统计。Flink 支持为状态设置 TTL,超过 TTL 未更新的状态会被自动清理。

设置 TTL 的方法

ValueStateDescriptor<Integer> descriptor = new ValueStateDescriptor<>("failCount", Integer.class);
StateTtlConfig ttlConfig = StateTtlConfig
        .newBuilder(Time.minutes(30))      // 状态有效期
        .setUpdateType(StateTtlConfig.UpdateType.OnCreateAndWrite) // 创建和写入时更新时间戳
        .setStateVisibility(StateTtlConfig.StateVisibility.NeverReturnExpired) // 过期状态不可见
        .build();
descriptor.enableTimeToLive(ttlConfig);

七、Checkpoint 与容错

1. 什么是 Checkpoint?

Checkpoint 是 Flink 的容错机制,它定期将所有算子的状态(包括 keyed state、source 的偏移量等)保存到持久化存储(如 HDFS、本地文件系统)。当作业失败时,可以从最近的 Checkpoint 恢复,实现精确一次(Exactly-Once)或至少一次(At-Least-Once)的语义。

2. 如何启用 Checkpoint?

在 Flink 程序中,可以显式配置 Checkpoint:

env.enableCheckpointing(10_000); // 每10秒执行一次 Checkpoint
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE); // 精确一次
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(5000); // 两个 Checkpoint 之间的最小间隔
env.getCheckpointConfig().setCheckpointTimeout(60000); // Checkpoint 超时时间
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1); // 最大并发 Checkpoint 数

在本地运行时,Checkpoint 默认保存在 JobManager 的内存中,重启后丢失。生产环境需要配置状态后端,如 RocksDB 或 HDFS。

3. 状态后端

状态后端决定了状态数据如何存储和管理。Flink 支持三种:

  • MemoryStateBackend:状态保存在 TaskManager 的内存中,适合小状态和本地调试。
  • FsStateBackend:状态保存在 TaskManager 内存中,Checkpoint 时写入文件系统(如 HDFS)。
  • RocksDBStateBackend:状态保存在 RocksDB(嵌入式数据库)中,支持大状态和增量 Checkpoint,生产环境推荐。

配置方法(在代码中):

// 使用 RocksDB 状态后端(需要引入 flink-statebackend-rocksdb 依赖)
env.setStateBackend(new EmbeddedRocksDBStateBackend());

八、练习题

  1. 使用 ListState 实现用户最近5次操作记录:从 Socket 读取操作事件(用户ID, 操作类型, 时间),为每个用户保存最近5次操作类型,并打印出来。
  2. 使用 MapState 实现用户去重:统计每个用户访问过的不同商品数量(商品ID去重)。
  3. 使用定时器实现超时检测:模拟用户下单事件,如果用户下单后30秒内没有支付,输出“订单超时未支付”。需要注册处理时间定时器,并在 onTimer 中输出告警。
  4. 为上面的登录失败检测程序添加状态 TTL:设置状态有效期为10分钟,观察过期后状态是否被清理。

九、常见错误与解决方法

1. 状态未初始化导致 NullPointerException

错误failCount.value() 返回 null 后直接使用。
解决:在使用前判空,例如 Integer current = failCount.value(); if (current == null) current = 0;

2. 在非 Keyed 流中使用 Keyed State

错误信息Keyed state can only be used on a 'keyed stream', i.e., after a 'keyBy()' operation.
解决:确保在 keyBy 之后使用 KeyedProcessFunctionRichFlatMapFunction

3. 忘记在 open() 中初始化状态

错误信息:状态对象为 null,调用方法报 NPE。
解决:在 open() 中通过 getRuntimeContext().getState() 初始化。

4. Checkpoint 失败

错误信息CheckpointExceptionCould not initialize state backend
解决:检查状态后端依赖是否添加,存储路径是否可写,内存是否充足。


你已经掌握了状态管理的基础,今天我们将深入探讨 Flink 的容错机制:Checkpoint 原理、Savepoint 的使用以及状态后端的选择与优化。这是生产环境中确保作业稳定运行和精确一次语义的关键。


第 9 步

  1. 理解 Checkpoint 的分布式快照原理(Barrier 对齐与非对齐)。
  2. 掌握 Checkpoint 的配置参数和重启策略。
  3. 理解 Savepoint 与 Checkpoint 的区别,并学会触发和恢复 Savepoint。
  4. 掌握三种状态后端的区别、使用场景和配置方法。
  5. 了解 RocksDB 状态后端的增量 Checkpoint 优势。
  6. 完成一个模拟故障并自动恢复的练习。

一、Checkpoint 深入原理

1. 什么是 Checkpoint?

Checkpoint 是 Flink 用于实现容错的机制。它定期将所有算子的状态(包括数据源的位置、用户自定义状态等)保存到持久化存储(如 HDFS、S3、本地文件系统)中。当作业失败时,可以从最近的 Checkpoint 恢复,从而实现**精确一次(Exactly-Once)至少一次(At-Least-Once)**的语义。

2. 分布式快照:Barrier 机制

Flink 使用 Chandy-Lamport 算法的变体来生成分布式快照。其核心是 Checkpoint Barrier(检查点屏障)。

  • 当 JobManager 触发 Checkpoint 时,会向所有 Source 算子注入一个 Barrier。
  • Barrier 随着数据流向下游传播,将数据流分隔为“当前 Checkpoint 之前的数据”和“之后的数据”。
  • 当算子收到所有输入流的 Barrier 后,会将其状态快照(异步)保存到状态后端,然后继续向下游发送 Barrier。
  • 当所有算子完成快照后,JobManager 确认 Checkpoint 完成。

Barrier 对齐:在多个输入流的算子中,Barrier 需要对齐,即等待所有输入的 Barrier 都到达,才能进行快照。这会导致短暂的数据等待,但能保证 Exactly-Once。

Barrier 非对齐(Flink 1.11+):允许 Barrier 超越数据,避免对齐造成的延迟,但会带来更复杂的处理,通常用于反压严重的情况。默认使用对齐。

3. 精确一次与至少一次

  • Exactly-Once:快照时保存输入位置和状态,恢复后数据不会重复也不会丢失。
  • At-Least-Once:可能重复处理数据,但不会丢失。通常需要 Sink 支持幂等或事务。

4. Checkpoint 配置参数详解

StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

// 启用 Checkpoint,间隔 10 秒
env.enableCheckpointing(10_000);

CheckpointConfig cpConfig = env.getCheckpointConfig();

// 设置语义为 Exactly-Once(默认)
cpConfig.setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);

// 两个 Checkpoint 之间的最小暂停时间,防止连续触发导致资源占用
cpConfig.setMinPauseBetweenCheckpoints(5_000);

// Checkpoint 超时时间(60秒)
cpConfig.setCheckpointTimeout(60_000);

// 最大并发 Checkpoint 数(默认 1,Exactly-Once 强制为 1)
cpConfig.setMaxConcurrentCheckpoints(1);

// 作业取消时是否保留 Checkpoint(建议保留,便于恢复)
cpConfig.setExternalizedCheckpointCleanup(
    CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION
);

// 容忍的 Checkpoint 失败次数(0 表示不允许失败)
cpConfig.setTolerableCheckpointFailureNumber(0);

5. 重启策略

当作业失败时,Flink 会根据重启策略自动重启。常用策略:

  • 固定延迟重启env.setRestartStrategy(RestartStrategies.fixedDelayRestart(3, Time.seconds(10)))(最多重启3次,每次间隔10秒)。
  • 失败率重启env.setRestartStrategy(RestartStrategies.failureRateRestart(3, Time.minutes(5), Time.seconds(10)))(5分钟内最多失败3次,每次间隔10秒)。
  • 不重启env.setRestartStrategy(RestartStrategies.noRestart())

二、Savepoint 详解

1. 什么是 Savepoint?

Savepoint 是用户手动触发的 Checkpoint,它保存了作业的完整状态,可以用于:

  • 作业升级(修改代码后从 Savepoint 恢复)。
  • 作业迁移(集群迁移)。
  • 作业回滚(恢复到历史版本)。

2. Checkpoint 与 Savepoint 的区别

特性CheckpointSavepoint
触发方式自动(由 Flink 管理)手动触发
生命周期作业失败后可能被清理用户手动管理,长期保存
格式针对状态后端优化统一格式,可移植性更好
用途故障恢复升级、迁移、调试

3. 如何触发 Savepoint?

  • 命令行flink savepoint <jobId> [savepointDirectory]
  • Web UI:在作业详情页点击“Trigger Savepoint”。

4. 如何从 Savepoint 恢复?

提交作业时指定 Savepoint 路径:

flink run -s <savepointPath> -c <mainClass> <jar>

三、状态后端(State Backend)

1. 三种状态后端

状态后端状态存储位置Checkpoint 存储位置适用场景
MemoryStateBackendTaskManager 内存JobManager 内存本地调试、小状态
FsStateBackendTaskManager 内存文件系统(HDFS/本地)中小状态,需要持久化
RocksDBStateBackendRocksDB(本地磁盘)文件系统大状态、生产环境

2. 配置状态后端

在代码中:

// 使用 FsStateBackend(需提供文件系统路径)
env.setStateBackend(new FsStateBackend("file:///tmp/flink/checkpoints"));

// 使用 RocksDBStateBackend(需引入依赖 flink-statebackend-rocksdb)
env.setStateBackend(new EmbeddedRocksDBStateBackend());

flink-conf.yaml 中全局配置:

state.backend: rocksdb
state.checkpoints.dir: file:///tmp/flink/checkpoints

3. RocksDB 增量 Checkpoint

RocksDBStateBackend 支持增量 Checkpoint,只上传自上次 Checkpoint 以来发生变化的 SST 文件,大大减少网络和存储开销。默认开启,可通过 flink-conf.yamlstate.backend.incremental: true 设置。

4. 状态后端选择建议

  • 状态大小 < 100MB,且不需要持久化:Memory。
  • 状态大小中等(几 GB 以内):Fs。
  • 状态大(数十 GB 以上)或需要增量 Checkpoint:RocksDB。

四、实战:模拟故障自动恢复

需求

编写一个从 Socket 读取数字并进行累加的程序,使用 ValueState 保存当前累加值。启用 Checkpoint(每5秒一次),设置固定延迟重启策略。然后手动停止作业(模拟故障),再重新提交,观察能否从 Checkpoint 恢复并继续累加。

步骤 1:编写程序

import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.restartstrategy.RestartStrategies;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.api.common.time.Time;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.CheckpointingMode;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.CheckpointConfig;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;
import org.apache.flink.util.Collector;

public class CheckpointRecoveryJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        // 启用 Checkpoint
        env.enableCheckpointing(5_000);
        env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);
        env.getCheckpointConfig().setMinPauseBetweenCheckpoints(2_000);
        env.getCheckpointConfig().setCheckpointTimeout(30_000);
        env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
        env.getCheckpointConfig().setExternalizedCheckpointCleanup(
                CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION);

        // 设置重启策略:最多重启3次,每次间隔5秒
        env.setRestartStrategy(RestartStrategies.fixedDelayRestart(3, Time.seconds(5)));

        // 设置状态后端为 FsStateBackend(本地文件系统)
        env.setStateBackend(new FsStateBackend("file:///tmp/flink/checkpoints"));

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

        DataStream<String> numbers = lines.map(new MapFunction<String, Integer>() {
            @Override
            public Integer map(String value) throws Exception {
                return Integer.parseInt(value.trim());
            }
        });

        // 使用一个固定 key 进行累加(这里 keyBy 使用常量,方便演示)
        DataStream<String> result = numbers
                .keyBy(num -> 1) // 所有数据属于同一个 key
                .process(new KeyedProcessFunction<Integer, Integer, String>() {
                    private ValueState<Integer> sumState;

                    @Override
                    public void open(Configuration parameters) throws Exception {
                        ValueStateDescriptor<Integer> descriptor = new ValueStateDescriptor<>(
                                "sum", Integer.class);
                        sumState = getRuntimeContext().getState(descriptor);
                    }

                    @Override
                    public void processElement(Integer value, Context ctx, Collector<String> out) throws Exception {
                        Integer currentSum = sumState.value();
                        if (currentSum == null) {
                            currentSum = 0;
                        }
                        int newSum = currentSum + value;
                        sumState.update(newSum);
                        out.collect("当前累加值: " + newSum);
                    }
                });

        result.print();

        env.execute("Checkpoint Recovery Demo");
    }
}

步骤 2:运行与测试

  1. 启动 nc -lk 9999
  2. 在 IDE 中运行程序,输入几个数字,观察累加值。
  3. 模拟故障:在 IDE 中直接停止程序(或关闭进程)。
  4. 重新运行程序(注意:因为程序里设置了 RETAIN_ON_CANCELLATION,Checkpoint 会保留在 /tmp/flink/checkpoints)。但 IDE 重启后,Flink 不会自动从 Checkpoint 恢复,因为我们是本地运行,需要手动从 Checkpoint 恢复(比较复杂,涉及命令行提交)。这里我们仅演示 Checkpoint 的保存和状态恢复能力,更实际的恢复操作将在部署章节学习。

注意:对于本地 IDE 运行,作业停止后 Checkpoint 可能不会自动保存(取决于 JVM 退出方式)。为了体验恢复,建议使用 flink run 提交到本地集群,然后通过 Web UI 或命令行停止作业并恢复。此处主要展示 Checkpoint 的配置方法,实际恢复步骤将在后续部署章节详细说明。


五、练习题

  1. 配置 Checkpoint 并查看日志:在本地运行一个简单的有状态程序,启用 Checkpoint,观察日志中 Checkpoint 完成的记录。
  2. 使用 Savepoint 进行作业升级
    • 编写一个简单的累加程序(如上),打包成 jar 并提交到本地 Flink 集群。
    • 触发 Savepoint,停止作业。
    • 修改代码(例如增加一个输出字段),重新打包。
    • 使用 flink run -s <savepointPath> 从 Savepoint 恢复新作业,观察状态是否延续。
  3. 比较状态后端:分别使用 MemoryStateBackend 和 RocksDBStateBackend 运行同一个有状态程序(需要引入 RocksDB 依赖),观察状态存储位置和性能差异。
  4. 思考:为什么 RocksDB 状态后端适合大状态?增量 Checkpoint 是如何减少网络传输的?

六、常见错误与解决方法

1. Checkpoint 失败

错误信息CheckpointException: Checkpoint expired before completing
原因:Checkpoint 超时,通常是因为状态太大或反压严重。
解决:增加 setCheckpointTimeout,优化代码减少状态大小,或调整并行度。

2. 无法保存到文件系统

错误Failed to create directory 或权限错误
解决:确保路径存在且可写,运行用户有权限。

3. 使用 RocksDB 状态后端时依赖缺失

错误ClassNotFoundException: EmbeddedRocksDBStateBackend
解决:在 pom.xml 中添加依赖:

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-statebackend-rocksdb</artifactId>
    <version>1.17.1</version>
</dependency>

4. 从 Savepoint 恢复时状态不匹配

错误IncompatibleStatefulFunctionException
原因:修改了算子的 UID 或状态类型。
解决:确保每个有状态算子设置固定的 UID(.uid("xxx")),并在升级时保持 UID 不变。


你已经掌握了状态管理和容错机制,现在进入 第 10 天:Table API & SQL。这是 Flink 中非常强大的功能,允许你使用熟悉的 SQL 或 Table API 进行流处理,大幅提升开发效率。今天的内容会结合 SQL 和 Table API,教你如何用声明式的方式处理实时数据。


第 10 步

  1. 理解动态表(Dynamic Table)的概念,以及 Table API/SQL 如何映射到流处理。
  2. 掌握 TableEnvironment 的创建和配置。
  3. 学会将 DataStream 转换为 Table,以及将 Table 转换为 DataStream。
  4. 掌握 SQL 窗口聚合(滚动、滑动、会话窗口)和时间属性的定义。
  5. 学会使用 SQL 进行双流 JOIN(常规 JOIN、时间区间 JOIN、Lookup Join)。
  6. 完成一个“实时订单金额统计(SQL 版)”和“订单与支付流 JOIN”的练习。

一、动态表与 Table API/SQL 简介

1. 动态表

在流处理中,数据是持续变化的。Table API/SQL 将无限流抽象为一张动态表:随着数据的到来,表的内容不断更新。你对动态表的查询(SQL)会持续运行,并输出结果表(也是动态的)。Flink 会自动将查询转换为流处理程序。

例如,你有一张订单表 orders,每来一条订单,表就新增一行。查询 SELECT user_id, COUNT(*) FROM orders GROUP BY user_id 会实时计算每个用户的订单数,并随着新数据到来不断更新结果。

2. Table API 与 SQL 的选择

  • SQL:对于熟悉 SQL 的用户来说更直观、简洁,适合快速开发。
  • Table API:是 Flink 提供的基于 Java/Scala 的声明式 API,与 SQL 等价,但支持更灵活的组合和类型安全。
  • 两者可以混合使用,最终都转换为相同的底层执行计划。

3. 所需依赖

pom.xml 中添加 Flink Table API 依赖:

<!-- Flink Table API & SQL -->
<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-table-api-java-bridge</artifactId>
    <version>1.17.1</version>
</dependency>
<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-table-planner-loader</artifactId>
    <version>1.17.1</version>
</dependency>
<!-- 如果需要使用 SQL 客户端或内置函数,可能还需要其他依赖,但基本足够 -->

二、TableEnvironment 与基本操作

1. 创建 TableEnvironment

import org.apache.flink.table.api.EnvironmentSettings;
import org.apache.flink.table.api.TableEnvironment;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;

// 创建流式 TableEnvironment
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

或者使用独立的 TableEnvironment:

EnvironmentSettings settings = EnvironmentSettings.inStreamingMode();
TableEnvironment tableEnv = TableEnvironment.create(settings);

StreamTableEnvironment 可以直接与 DataStream 互相转换,更常用。

2. 将 DataStream 转换为 Table

假设有一个 OrderEvent POJO:

DataStream<OrderEvent> orders = ...;
Table ordersTable = tableEnv.fromDataStream(orders);

3. 注册临时视图

为了在 SQL 中引用表,可以注册为临时视图:

tableEnv.createTemporaryView("orders", ordersTable);

4. 执行 SQL 查询并输出结果

Table resultTable = tableEnv.sqlQuery("SELECT userId, COUNT(*) AS cnt FROM orders GROUP BY userId");
DataStream<Row> resultStream = tableEnv.toDataStream(resultTable);
resultStream.print();

三、时间属性与事件时间

在 SQL 中,时间属性用于窗口和 JOIN。必须显式指定哪个字段是事件时间。

1. 从 DataStream 中提取事件时间

在将 DataStream 转换为 Table 时,可以添加时间属性:

Table ordersTable = tableEnv.fromDataStream(
    orders,
    Schema.newBuilder()
        .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(eventTime, 3)")  // 或使用 TIMESTAMP(3)
        .watermark("rowtime", "SOURCE_WATERMARK()")  // 使用 DataStream 中的 Watermark
        .build()
);

或者更简单的方法:先为 DataStream 分配时间戳和 Watermark,然后直接在 Table DDL 中定义时间属性。

2. 使用 DDL 定义表和连接器

在生产中,通常使用 SQL DDL 来定义源表和结果表,指定连接器(如 Kafka)和时间属性。今天我们主要使用 DataStream 转换,DDL 方式后续讲连接器时再深入。

3. 处理时间属性

如果使用处理时间,可以直接在 SQL 中使用 PROCTIME() 函数:

SELECT PROCTIME() AS proc_time, ... FROM ...

四、SQL 窗口聚合

1. 滚动窗口(Tumbling Window)

使用 TUMBLE 表值函数:

SELECT 
    userId,
    TUMBLE_START(rowtime, INTERVAL '1' MINUTE) AS window_start,
    TUMBLE_END(rowtime, INTERVAL '1' MINUTE) AS window_end,
    COUNT(*) AS cnt
FROM orders
GROUP BY userId, TUMBLE(rowtime, INTERVAL '1' MINUTE)

2. 滑动窗口(Sliding Window)

使用 HOP 表值函数:

SELECT 
    userId,
    HOP_START(rowtime, INTERVAL '1' MINUTE, INTERVAL '5' MINUTE) AS window_start,
    HOP_END(rowtime, INTERVAL '1' MINUTE, INTERVAL '5' MINUTE) AS window_end,
    COUNT(*) AS cnt
FROM orders
GROUP BY userId, HOP(rowtime, INTERVAL '1' MINUTE, INTERVAL '5' MINUTE)

3. 会话窗口(Session Window)

使用 SESSION 表值函数:

SELECT 
    userId,
    SESSION_START(rowtime, INTERVAL '5' MINUTE) AS session_start,
    SESSION_END(rowtime, INTERVAL '5' MINUTE) AS session_end,
    COUNT(*) AS cnt
FROM orders
GROUP BY userId, SESSION(rowtime, INTERVAL '5' MINUTE)

注意:窗口表值函数(TVF)需要 Flink 1.13+ 版本,我们使用的是 1.17,完全支持。


五、双流 JOIN

1. 常规 JOIN

适用于有界流或使用处理时间的流,会保留状态直到 JOIN 条件满足。对于无限流,需要小心状态增长。

SELECT o.orderId, p.paymentId
FROM orders o
JOIN payments p
ON o.orderId = p.orderId;

2. 时间区间 JOIN(Interval Join)

限制 JOIN 的时间范围,比如订单发生后 5 分钟内支付,则只保留 5 分钟内的状态。

SELECT o.orderId, p.paymentId
FROM orders o, payments p
WHERE o.orderId = p.orderId
AND p.payTime BETWEEN o.orderTime AND o.orderTime + INTERVAL '5' MINUTE;

3. Lookup Join(维表关联)

用于事实表与维表的关联,维表数据保存在外部系统(如 MySQL),Flink 会实时查询维表。这里暂时不涉及。


六、实战示例 1:实时订单金额统计(SQL 版)

需求

从 Socket 读取订单字符串,使用 SQL 统计每分钟每个用户的订单总金额。

步骤 1:准备 OrderEvent 类(包含 eventTime 字段)

复用之前的类,确保有 userIdpricequantityeventTime 字段。

步骤 2:编写主程序

import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.Schema;
import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
import org.apache.flink.types.Row;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

public class SqlOrderAmountJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

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

        // 1. 解析为 OrderEvent
        DataStream<OrderEvent> orders = lines.map(new MapFunction<String, OrderEvent>() {
            @Override
            public OrderEvent map(String line) throws Exception {
                String[] f = line.split(",");
                String orderId = f[0].trim();
                String userId = f[1].trim();
                double price = Double.parseDouble(f[2].trim());
                int quantity = Integer.parseInt(f[3].trim());
                long eventTime = LocalDateTime.parse(f[4].trim(), DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
                        .toInstant(ZoneOffset.UTC).toEpochMilli();
                return new OrderEvent(orderId, userId, price, quantity, eventTime);
            }
        });

        // 2. 分配时间戳和 Watermark
        DataStream<OrderEvent> withTimestamps = orders.assignTimestampsAndWatermarks(
                WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                        .withTimestampAssigner((event, timestamp) -> event.eventTime)
        );

        // 3. 将 DataStream 转换为 Table,并指定事件时间字段和 Watermark
        Table ordersTable = tableEnv.fromDataStream(
                withTimestamps,
                Schema.newBuilder()
                        .column("orderId", "STRING")
                        .column("userId", "STRING")
                        .column("price", "DOUBLE")
                        .column("quantity", "INT")
                        .column("eventTime", "BIGINT") // 或者直接使用 rowtime
                        .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(eventTime, 3)")
                        .watermark("rowtime", "SOURCE_WATERMARK()")
                        .build()
        );

        // 注册临时视图
        tableEnv.createTemporaryView("orders", ordersTable);

        // 4. 使用 SQL 查询每分钟每个用户的订单总金额
        Table resultTable = tableEnv.sqlQuery(
                "SELECT userId, " +
                        "TUMBLE_START(rowtime, INTERVAL '1' MINUTE) AS window_start, " +
                        "TUMBLE_END(rowtime, INTERVAL '1' MINUTE) AS window_end, " +
                        "SUM(price * quantity) AS total_amount " +
                        "FROM orders " +
                        "GROUP BY userId, TUMBLE(rowtime, INTERVAL '1' MINUTE)"
        );

        // 5. 转换回 DataStream 并打印
        DataStream<Row> resultStream = tableEnv.toDataStream(resultTable);
        resultStream.print();

        env.execute("SQL Order Amount Job");
    }
}

代码解释

  • Schema.newBuilder() 用于定义表的列和元数据。这里我们显式声明了列名和类型,并通过 columnByExpression 添加了一个计算列 rowtime,它由 eventTime 转换而来。
  • watermark("rowtime", "SOURCE_WATERMARK()") 表示使用 DataStream 中已经存在的 Watermark。
  • SQL 查询中使用了 TUMBLE 窗口函数,TUMBLE_STARTTUMBLE_END 获取窗口边界。
  • SUM(price * quantity) 计算总金额。
  • tableEnv.toDataStream(resultTable) 将结果 Table 转换为 DataStream,输出类型为 Row

注意Row 是 Flink 提供的通用行类型,字段按查询结果的顺序排列。在实际应用中,你可能希望转换为自定义 POJO,可以使用 tableEnv.toDataStream(resultTable, UserAmount.class) 但需要匹配字段名称和类型。


七、实战示例 2:订单与支付流 JOIN(Interval Join)

需求

从两个 Socket 分别读取订单流和支付流,使用 SQL 进行 Interval Join,找出在订单发生后5分钟内支付的订单。

步骤 1:定义 OrderEvent 和 PaymentEvent 类

public class OrderEvent {
    public String orderId;
    public String userId;
    public double price;
    public int quantity;
    public long eventTime; // 订单时间
    // 构造器、getter/setter 省略
}

public class PaymentEvent {
    public String paymentId;
    public String orderId;
    public double amount;
    public long payTime; // 支付时间
    // 构造器、getter/setter 省略
}

步骤 2:编写主程序

import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.table.api.Schema;
import org.apache.flink.table.api.Table;
import org.apache.flink.table.api.bridge.java.StreamTableEnvironment;
import org.apache.flink.types.Row;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

public class SqlOrderPaymentJoinJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        StreamTableEnvironment tableEnv = StreamTableEnvironment.create(env);

        // 订单流 Socket(端口 9999)
        DataStream<String> orderLines = env.socketTextStream("localhost", 9999);
        DataStream<OrderEvent> orders = orderLines.map(new MapFunction<String, OrderEvent>() {
            @Override
            public OrderEvent map(String line) throws Exception {
                String[] f = line.split(",");
                return new OrderEvent(f[0], f[1], Double.parseDouble(f[2]), Integer.parseInt(f[3]),
                        LocalDateTime.parse(f[4], DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
                                .toInstant(ZoneOffset.UTC).toEpochMilli());
            }
        });

        // 支付流 Socket(端口 9998)
        DataStream<String> paymentLines = env.socketTextStream("localhost", 9998);
        DataStream<PaymentEvent> payments = paymentLines.map(new MapFunction<String, PaymentEvent>() {
            @Override
            public PaymentEvent map(String line) throws Exception {
                String[] f = line.split(",");
                return new PaymentEvent(f[0], f[1], Double.parseDouble(f[2]),
                        LocalDateTime.parse(f[3], DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))
                                .toInstant(ZoneOffset.UTC).toEpochMilli());
            }
        });

        // 分配时间戳和 Watermark
        DataStream<OrderEvent> ordersWithTs = orders.assignTimestampsAndWatermarks(
                WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                        .withTimestampAssigner((event, timestamp) -> event.eventTime));
        DataStream<PaymentEvent> paymentsWithTs = payments.assignTimestampsAndWatermarks(
                WatermarkStrategy.<PaymentEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                        .withTimestampAssigner((event, timestamp) -> event.payTime));

        // 转换为 Table
        Table ordersTable = tableEnv.fromDataStream(ordersWithTs,
                Schema.newBuilder()
                        .column("orderId", "STRING")
                        .column("userId", "STRING")
                        .column("price", "DOUBLE")
                        .column("quantity", "INT")
                        .column("eventTime", "BIGINT")
                        .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(eventTime, 3)")
                        .watermark("rowtime", "SOURCE_WATERMARK()")
                        .build());

        Table paymentsTable = tableEnv.fromDataStream(paymentsWithTs,
                Schema.newBuilder()
                        .column("paymentId", "STRING")
                        .column("orderId", "STRING")
                        .column("amount", "DOUBLE")
                        .column("payTime", "BIGINT")
                        .columnByExpression("rowtime", "TO_TIMESTAMP_LTZ(payTime, 3)")
                        .watermark("rowtime", "SOURCE_WATERMARK()")
                        .build());

        tableEnv.createTemporaryView("orders", ordersTable);
        tableEnv.createTemporaryView("payments", paymentsTable);

        // SQL: 时间区间 JOIN,订单后 5 分钟内支付
        Table resultTable = tableEnv.sqlQuery(
                "SELECT o.orderId, o.userId, p.paymentId, p.amount, " +
                        "o.rowtime AS order_time, p.rowtime AS pay_time " +
                        "FROM orders o, payments p " +
                        "WHERE o.orderId = p.orderId " +
                        "AND p.rowtime BETWEEN o.rowtime AND o.rowtime + INTERVAL '5' MINUTE"
        );

        DataStream<Row> resultStream = tableEnv.toDataStream(resultTable);
        resultStream.print();

        env.execute("SQL Order Payment Join");
    }
}

运行步骤

  1. 启动两个 Socket 服务:
    • 终端1:nc -lk 9999(订单流)
    • 终端2:nc -lk 9998(支付流)
  2. 运行程序。
  3. 在订单流输入:
1001,u001,手机,2999.0,1,2024-01-01 10:00:00
1002,u002,电脑,5999.0,2,2024-01-01 10:01:00
  1. 在支付流输入:
p001,1001,2999.0,2024-01-01 10:03:00
p002,1002,5999.0,2024-01-01 10:10:00

第一笔支付在订单后 3 分钟内,会输出匹配结果;第二笔支付在订单后 9 分钟,超过 5 分钟,不会被匹配(直到 Watermark 推进后状态清理)。

观察输出,只有第一个订单和支付被 JOIN 上。


八、练习题

  1. 使用 SQL 滑动窗口统计:统计每个用户最近 5 分钟(每 1 分钟滑动)的订单总金额。
  2. 使用 Table API 实现相同功能:将上面的 SQL 查询改为使用 Table API 编写(提示:使用 tableEnv.fromDataStream 后调用 .window(Tumble.over(lit(1).minutes()).on($("rowtime")).as("w")) 等)。
  3. 使用会话窗口统计:统计每个用户每 5 分钟会话内的订单数。
  4. 思考:Interval JOIN 在什么情况下会导致状态无限增长?如何避免?

九、常见错误与解决方法

1. 缺少 Table API 依赖

错误ClassNotFoundException: org.apache.flink.table.api.bridge.java.StreamTableEnvironment
解决:在 pom.xml 中添加 flink-table-api-java-bridgeflink-table-planner-loader 依赖。

2. SQL 中时间字段类型不匹配

错误Cannot apply 'TUMBLE' to arguments of type ...
解决:确保 rowtime 列的类型是 TIMESTAMP(3)TIMESTAMP_LTZ(3)。使用 TO_TIMESTAMP_LTZ(eventTime, 3) 转换。

3. Watermark 不生效

错误:窗口不触发
解决:检查是否在 Schema 中设置了 .watermark("rowtime", "SOURCE_WATERMARK()"),并且 DataStream 已经分配了 Watermark。

4. Table 转 DataStream 时类型问题

错误Cannot find a matching method for toDataStream
解决:确保查询结果的类型与目标类型匹配。如果使用 Row,通常没问题;如果转换为自定义 POJO,确保字段名称和类型对应。


很好,你已经完成了 Table API & SQL 的学习,现在进入 第 11 天:连接器(Connectors)实战——Kafka 与 Flink CDC。连接器是 Flink 与外部系统交互的桥梁,掌握它们可以构建完整的实时数据管道。今天将重点学习最常用的 Kafka 连接器和 Flink CDC,并提供详细的配置和代码示例。


第 11 步

  1. 掌握 Kafka Source 和 Sink 的使用,包括连接配置、消费位置、Exactly-Once 语义。
  2. 理解 Flink CDC 的原理,能够使用 MySQL CDC 实时捕获数据库变更。
  3. 完成一个“Kafka → Flink → Kafka”的实时清洗管道,并验证 Exactly-Once。
  4. 完成一个使用 Flink CDC 读取 MySQL binlog 并打印的示例。
  5. 了解其他连接器(JDBC、FileSystem、Elasticsearch)的基本用法。

一、Kafka 连接器

1. 添加依赖

pom.xml 中添加 Kafka 连接器依赖(Flink 1.17 使用新的 Kafka Connector):

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-connector-kafka</artifactId>
    <version>3.0.0-1.17</version>
</dependency>

注意:Flink 1.17 对应的 Kafka Connector 版本是 3.0.0-1.17,支持 Kafka 2.x/3.x。如果你使用旧版 API(flink-connector-kafka_2.12),需要引入不同的依赖,但官方推荐使用新 API。

2. Kafka Source

基本用法(DataStream API)

在 Flink 1.12+ 中,推荐使用 KafkaSource 构建器:

import org.apache.flink.connector.kafka.source.KafkaSource;
import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
import org.apache.flink.api.common.serialization.SimpleStringSchema;

KafkaSource<String> source = KafkaSource.<String>builder()
        .setBootstrapServers("localhost:9092")
        .setTopics("input-topic")
        .setGroupId("flink-consumer")
        .setStartingOffsets(OffsetsInitializer.earliest()) // 从最早开始消费
        .setValueOnlyDeserializer(new SimpleStringSchema())
        .build();

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

参数说明

  • setBootstrapServers:Kafka 集群地址。
  • setTopics:一个或多个主题。
  • setGroupId:消费者组 ID。
  • setStartingOffsets:消费起始位置,常用:
    • OffsetsInitializer.earliest():从最早偏移量开始。
    • OffsetsInitializer.latest():从最新偏移量开始。
    • OffsetsInitializer.committedOffsets():从消费者组提交的偏移量开始(默认)。
  • setValueOnlyDeserializer:反序列化器,这里使用简单字符串。
  • env.fromSource(source, WatermarkStrategy.noWatermarks(), "Kafka Source"):将 Source 添加到环境,第三个参数是 Source 名称。
使用 SQL DDL 定义 Kafka 表

Table API 中,可以直接通过 SQL DDL 创建 Kafka 表:

CREATE TABLE kafka_input (
    `message` STRING
) WITH (
    'connector' = 'kafka',
    'topic' = 'input-topic',
    'properties.bootstrap.servers' = 'localhost:9092',
    'properties.group.id' = 'flink-sql-consumer',
    'scan.startup.mode' = 'earliest-offset',
    'format' = 'raw'
)

3. Kafka Sink

基本用法(DataStream API)
import org.apache.flink.connector.kafka.sink.KafkaSink;
import org.apache.flink.connector.kafka.sink.KafkaRecordSerializationSchema;
import org.apache.flink.api.common.serialization.SimpleStringSchema;

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

dataStream.sinkTo(sink);

参数说明

  • setRecordSerializer:定义如何序列化记录,包括 topic 和 value serializer。也可以使用 setTopicSelector 动态选择 topic。
  • setDeliverGuarantee:传递保证,可选:
    • DeliveryGuarantee.NONE:无保证(可能丢失)。
    • DeliveryGuarantee.AT_LEAST_ONCE:至少一次。
    • DeliveryGuarantee.EXACTLY_ONCE:精确一次(需要 Kafka 事务,且 Kafka 版本 >= 0.11)。
  • 使用 sinkTo 方法添加 Sink。
SQL DDL 定义 Kafka Sink 表
CREATE TABLE kafka_output (
    `message` STRING
) WITH (
    'connector' = 'kafka',
    'topic' = 'output-topic',
    'properties.bootstrap.servers' = 'localhost:9092',
    'format' = 'raw',
    'sink.partitioner' = 'default'
)

4. Exactly-Once 语义配置

要实现端到端 Exactly-Once,需要:

  • Checkpoint 开启env.enableCheckpointing(10_000)
  • Kafka Source:使用 KafkaSource,它会在 Checkpoint 时保存 offset 并提交到 Kafka(或由 Flink 管理)。
  • Kafka Sink:设置 DeliveryGuarantee.EXACTLY_ONCE,使用 Kafka 事务。需要确保 Kafka 集群配置 transaction.timeout.ms 大于 Checkpoint 间隔,且 max.transaction.timeout.ms 足够大(默认 15 分钟)。

注意:Exactly-Once 会增加一定的开销,如果业务允许 At-Least-Once(配合幂等 Sink),可以选择 AT_LEAST_ONCE

5. 完整示例:Kafka → Flink → Kafka 实时清洗

假设我们有一个 Kafka 主题 raw-orders,数据格式为 订单ID,用户ID,商品,价格,数量,时间。需要过滤掉价格 <= 0 的非法数据,并将合法数据写入另一个主题 clean-orders

import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.serialization.SimpleStringSchema;
import org.apache.flink.connector.kafka.source.KafkaSource;
import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
import org.apache.flink.connector.kafka.sink.KafkaSink;
import org.apache.flink.connector.kafka.sink.KafkaRecordSerializationSchema;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.api.common.functions.FilterFunction;

public class KafkaToKafkaJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        env.enableCheckpointing(10_000); // 开启 Checkpoint 以实现 Exactly-Once

        // Kafka Source
        KafkaSource<String> source = KafkaSource.<String>builder()
                .setBootstrapServers("localhost:9092")
                .setTopics("raw-orders")
                .setGroupId("flink-cleaner")
                .setStartingOffsets(OffsetsInitializer.earliest())
                .setValueOnlyDeserializer(new SimpleStringSchema())
                .build();

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

        // 过滤非法数据:价格必须 > 0
        DataStream<String> cleanStream = rawStream.filter(new FilterFunction<String>() {
            @Override
            public boolean filter(String line) throws Exception {
                String[] fields = line.split(",");
                if (fields.length < 4) return false;
                try {
                    double price = Double.parseDouble(fields[3].trim());
                    return price > 0;
                } catch (NumberFormatException e) {
                    return false;
                }
            }
        });

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

        cleanStream.sinkTo(sink);

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

运行前提:本地或远程 Kafka 集群已启动,并且创建了主题 raw-ordersclean-orders。可以用命令行创建:

kafka-topics.sh --create --topic raw-orders --bootstrap-server localhost:9092 --partitions 1 --replication-factor 1
kafka-topics.sh --create --topic clean-orders --bootstrap-server localhost:9092 --partitions 1 --replication-factor 1

二、Flink CDC

1. 什么是 CDC?

CDC(Change Data Capture)是捕获数据库变更数据的技术。Flink CDC 基于 Debezium,能够实时读取 MySQL、PostgreSQL 等数据库的 binlog,将变更事件流式输出到 Flink 中。它简化了实时数据同步和实时数仓的建设。

2. 添加依赖

pom.xml 中添加 Flink CDC 依赖(以 MySQL 为例):

<dependency>
    <groupId>com.ververica</groupId>
    <artifactId>flink-connector-mysql-cdc</artifactId>
    <version>2.4.0</version>
</dependency>

注意:Flink CDC 2.x 版本对应 Flink 1.13+,我们使用 2.4.0 与 Flink 1.17 兼容。

3. MySQL CDC Source

使用 DataStream API
import com.ververica.cdc.connectors.mysql.source.MySqlSource;
import com.ververica.cdc.connectors.mysql.table.StartupOptions;
import com.ververica.cdc.debezium.JsonDebeziumDeserializationSchema;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;

MySqlSource<String> mySqlSource = MySqlSource.<String>builder()
        .hostname("localhost")
        .port(3306)
        .databaseList("mydb")               // 监听数据库
        .tableList("mydb.orders")           // 监听表
        .username("flink")
        .password("flink")
        .deserializer(new JsonDebeziumDeserializationSchema()) // 将变更数据转为 JSON 字符串
        .startupOptions(StartupOptions.initial()) // 从初始位置开始(首次全量+后续增量)
        .build();

DataStream<String> cdcStream = env.fromSource(mySqlSource, WatermarkStrategy.noWatermarks(), "MySQL CDC Source");
cdcStream.print();
使用 SQL DDL
CREATE TABLE orders_cdc (
    order_id INT,
    user_id STRING,
    product STRING,
    price DECIMAL(10,2),
    PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
    'connector' = 'mysql-cdc',
    'hostname' = 'localhost',
    'port' = '3306',
    'username' = 'flink',
    'password' = 'flink',
    'database-name' = 'mydb',
    'table-name' = 'orders'
);

然后可以像查询普通表一样查询 orders_cdc,获取实时变更。

4. 完整示例:实时同步 MySQL 变更并打印

下面给出一个 DataStream API 的完整程序,读取 MySQL 表变更并输出。

import com.ververica.cdc.connectors.mysql.source.MySqlSource;
import com.ververica.cdc.connectors.mysql.table.StartupOptions;
import com.ververica.cdc.debezium.JsonDebeziumDeserializationSchema;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;

public class MySqlCdcJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);

        MySqlSource<String> mySqlSource = MySqlSource.<String>builder()
                .hostname("localhost")
                .port(3306)
                .databaseList("mydb")
                .tableList("mydb.orders")
                .username("flink")
                .password("flink")
                .deserializer(new JsonDebeziumDeserializationSchema())
                .startupOptions(StartupOptions.initial())
                .build();

        DataStream<String> cdcStream = env.fromSource(mySqlSource, WatermarkStrategy.noWatermarks(), "MySQL CDC Source");
        cdcStream.print();

        env.execute("MySQL CDC Job");
    }
}

准备工作

  • 安装 MySQL,创建数据库 mydb,表 orders,并创建用户 flink 授予 SELECT, RELOAD, SHOW DATABASES, REPLICATION SLAVE, REPLICATION CLIENT 权限。
  • 开启 MySQL binlog(my.cnf 中设置 log-bin=mysql-binbinlog-format=ROW)。
  • 运行程序,然后在 MySQL 中执行插入、更新、删除操作,观察控制台输出 JSON 格式的变更事件。

三、其他连接器简介

1. JDBC 连接器

用于读写关系型数据库。常用作维表 Join 或结果表(支持 upsert)。

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-connector-jdbc</artifactId>
    <version>3.1.0-1.17</version>
</dependency>

2. FileSystem 连接器

用于读写文件(如 CSV、Parquet)。常用于批处理或流式写入到文件系统。

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-connector-files</artifactId>
    <version>1.17.1</version>
</dependency>

3. Elasticsearch 连接器

用于将数据写入 Elasticsearch,常用于实时搜索或日志分析。

<dependency>
    <groupId>org.apache.flink</groupId>
    <artifactId>flink-connector-elasticsearch7</artifactId>
    <version>3.0.1-1.17</version>
</dependency>

由于篇幅限制,这里不展开。后续需要时我们可以详细讲解。


四、练习题

  1. 搭建本地 Kafka 环境(如果还没有),使用 Docker 快速启动 Kafka:

    docker run -d --name kafka -p 9092:9092 -e ALLOW_PLAINTEXT_LISTENER=yes bitnami/kafka:latest
    

    然后创建两个 topic raw-ordersclean-orders,运行上面的 Kafka 清洗程序,使用 Kafka 命令行工具发送数据并消费结果,验证清洗效果。

  2. 使用 SQL DDL 方式实现 Kafka 清洗:在 Table API 中定义 Kafka Source 和 Sink 表,使用 SQL 进行过滤(如 SELECT * FROM raw_orders WHERE price > 0),并插入到 clean_orders 表。

  3. 使用 Flink CDC 读取 MySQL 变更:按照上面的步骤准备 MySQL,运行 CDC 程序,插入几条数据并更新一条,观察输出。尝试使用 SQL DDL 方式创建 CDC 表并进行简单查询。

  4. 思考:Exactly-Once 语义在 Kafka 到 Kafka 管道中是如何实现的?如果 Kafka 集群不支持事务,会有什么影响?


五、常见错误与解决方法

1. Kafka 连接失败

错误信息Connection refusedTimeout expired
解决:检查 Kafka 地址和端口是否正确,确保 Kafka 已启动。使用 telnet localhost 9092 测试连通性。

2. 找不到 Kafka 连接器类

错误ClassNotFoundException: org.apache.flink.connector.kafka.source.KafkaSource
解决:确认添加了 flink-connector-kafka 依赖,版本与 Flink 匹配。

3. Exactly-Once 配置后作业失败

错误TransactionTimeoutExceptionProducerFencedException
解决:调整 Kafka 的 transaction.timeout.ms 参数(例如设置为 60000),并确保 max.transaction.timeout.ms 足够大。同时 Checkpoint 间隔应小于事务超时时间。

4. Flink CDC 连接 MySQL 失败

错误Access deniedCould not find first log file name
解决:检查 MySQL 用户权限,确保具有 REPLICATION 权限;检查 binlog 是否开启;检查主机和端口。

5. CDC 输出 JSON 格式,但想使用 POJO

可以使用 JsonDebeziumDeserializationSchema 输出 JSON 字符串,然后通过 map 解析;或者使用 DebeziumDeserializationSchema 自定义反序列化,直接得到 POJO。


你已经掌握了 Flink 的核心开发技能,现在需要学习如何将作业部署到生产环境并进行监控。第 12 天:部署、监控与初步调优 将带你了解 Flink 在不同环境下的部署方式,以及如何通过 Web UI、REST API、Prometheus 监控作业状态。同时,你会学会识别反压和调整并行度。


第 12 步 部署模式

  1. 掌握 Flink 的三种部署模式:Standalone、YARN、Kubernetes。
  2. 学会在 Standalone 集群上提交作业,并查看 Web UI 和日志。
  3. 了解 Flink 的 REST API 和常用监控指标。
  4. 学会配置 Prometheus + Grafana 监控 Flink 作业。
  5. 理解反压的产生与定位方法,掌握并行度和内存调优的基本思路。
  6. 完成一个在 Standalone 集群上提交作业并查看监控的练习。

一、Flink 部署模式概览

部署模式资源管理适用场景
StandaloneFlink 自带资源管理器开发测试、独立集群
YARNHadoop YARN已有 Hadoop 生态的大数据平台
KubernetesK8s 集群云原生环境,弹性伸缩
Local(本地)单个 JVM开发调试,已在前面使用

二、Standalone 集群部署与作业提交

1. 集群架构

  • JobManager:接收作业提交,调度任务,协调 Checkpoint。
  • TaskManager:执行具体任务,管理 Slot 资源。
  • Web UI:默认端口 8081,提供作业监控界面。

2. 环境准备

  • 确保 Java 11 已安装。
  • 下载 Flink 1.17.1 二进制包(如果还没有),解压到目标目录。
  • 配置 flink-conf.yaml(可选,默认即可用于测试)。

3. 启动 Standalone 集群

进入 Flink 目录,执行:

# 启动 JobManager 和 TaskManager
./bin/start-cluster.sh

验证:

  • 浏览器访问 http://localhost:8081,应看到 Flink Web UI。
  • 查看进程:jps 命令应包含 StandaloneSessionClusterEntrypoint(JobManager)和 TaskManagerRunner

4. 提交作业

将之前编写的作业打成可执行 JAR 包。例如,我们使用第11天的 KafkaToKafkaJob 或第6天的 MinuteOrderCountJob。确保已打包为包含依赖的 fat JAR(可使用 Maven Shade Plugin 或 Spring Boot Maven Plugin)。

打包方法(Maven Shade Plugin):
pom.xml 中添加:

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-shade-plugin</artifactId>
            <version>3.4.1</version>
            <executions>
                <execution>
                    <phase>package</phase>
                    <goals><goal>shade</goal></goals>
                    <configuration>
                        <artifactSet>
                            <excludes>
                                <exclude>org.apache.flink:force-shading</exclude>
                                <exclude>com.google.code.findbugs:jsr305</exclude>
                                <exclude>org.slf4j:*</exclude>
                                <exclude>log4j:*</exclude>
                            </excludes>
                        </artifactSet>
                        <filters>
                            <filter>
                                <artifact>*:*</artifact>
                                <excludes>
                                    <exclude>META-INF/*.SF</exclude>
                                    <exclude>META-INF/*.DSA</exclude>
                                    <exclude>META-INF/*.RSA</exclude>
                                </excludes>
                            </filter>
                        </filters>
                        <transformers>
                            <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
                                <mainClass>com.example.YourMainClass</mainClass>
                            </transformer>
                        </transformers>
                    </configuration>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

然后执行 mvn clean package,得到 target/your-artifact-1.0-SNAPSHOT.jar

提交作业:

./bin/flink run -c com.example.MinuteOrderCountJob target/your-artifact.jar

-c 指定主类,如果不指定且 JAR 的 MANIFEST 中有 Main-Class,可以省略。

5. 查看作业与日志

  • Web UI:在 Running Jobs 中点击作业,查看任务拓扑、状态、Checkpoint 统计、反压等。
  • 日志:TaskManager 和 JobManager 的日志默认在 flink-<version>/log/ 目录下,文件名为 flink-<user>-standalonesession-<host>.logflink-<user>-taskexecutor-<host>.log
  • 使用命令行停止作业./bin/flink cancel <jobId>,可从 Web UI 或 ./bin/flink list 获取 jobId。

三、YARN 模式部署

1. 模式分类

  • Session Mode:预先启动一个 YARN 应用,之后在该应用中提交多个 Flink 作业。资源预先分配,作业间共享。
  • Per-Job Mode:每个作业启动一个独立的 YARN 应用,作业结束后释放资源。
  • Application Mode:与 Per-Job 类似,但 main() 方法在集群中执行,减少客户端负担。

2. 环境要求

  • Hadoop YARN 集群已搭建(版本 2.6+ 或 3.x)。
  • 配置 HADOOP_CLASSPATH 环境变量,使 Flink 能找到 Hadoop 类路径。
  • 可将 Flink 部署包上传到 HDFS 或本地,作业 JAR 也需要可访问。

3. 提交作业示例(Session Mode)

启动一个 YARN Session:

./bin/yarn-session.sh -n 2 -tm 1024 -s 2 -d

参数:-n TaskManager 数量,-tm 每个 TaskManager 内存(MB),-s 每个 TaskManager 的 Slot 数,-d 后台运行。

启动后,命令行会输出 JobManager 地址(或通过 YARN 资源管理器查看)。提交作业:

./bin/flink run -m yarn-cluster -yn 2 -yjm 1024 -ytm 1024 -c com.example.YourMainClass your.jar

或者连接到 Session:./bin/flink run -m <jobmanager-host>:<port> -c ...

4. 注意事项

  • YARN 模式需要正确设置 HADOOP_CONF_DIRHADOOP_CLASSPATH,否则报错。
  • 生产环境建议使用 Per-Job 或 Application 模式,资源隔离更好。

四、Kubernetes 部署

1. 模式

  • Native Kubernetes:Flink 使用 K8s API 启动 Pod 作为 JobManager 和 TaskManager。
  • Flink Kubernetes Operator:更高级的声明式管理。

2. 环境要求

  • 已配置 K8s 集群,kubectl 可用。
  • 有相应的 ServiceAccount 权限(创建 Pod、Service 等)。

3. 提交作业示例(Application 模式)

./bin/flink run-application \
    -t kubernetes-application \
    -Dkubernetes.cluster-id=my-flink-cluster \
    -Dkubernetes.container.image=flink:1.17.1-scala_2.12 \
    -c com.example.YourMainClass \
    local:///opt/flink/usrlib/your.jar

需要将 JAR 预先放入镜像或使用 -Dkubernetes.jobmanager.service-account=flink 指定账号。

4. 常用配置

  • kubernetes.namespace:部署的命名空间。
  • kubernetes.container.image:Flink 镜像。
  • kubernetes.service-account:使用的 ServiceAccount。
  • kubernetes.jobmanager.replicas:高可用配置(多副本)。

五、监控与指标

1. Flink Web UI

提供以下关键信息:

  • 作业概览:状态、持续时间、重启次数。
  • 任务拓扑:每个算子的并行度、背压状态、处理速率。
  • Checkpoint:最近 Checkpoint 的时间、大小、失败原因。
  • 异常:作业失败时的异常堆栈。

2. REST API

Flink 提供 REST API,可用于自动化监控。常用端点:

  • GET /jobs:列出所有作业。
  • GET /jobs/<jobid>:作业详情。
  • GET /jobs/<jobid>/metrics?get=...:获取指标(如 numRestarts, lastCheckpointSize, 0.numRecordsInPerSecond 等)。
  • GET /taskmanagers:查看 TaskManager 列表。

示例:获取作业指标

curl http://localhost:8081/jobs/<jobid>/metrics?get=numRestarts,lastCheckpointDuration,lastCheckpointSize

3. Prometheus + Grafana

Flink 支持将指标导出到 Prometheus。

  • 步骤 1:在 flink-conf.yaml 中添加:
    metrics.reporter.prom.class: org.apache.flink.metrics.prometheus.PrometheusReporter
    metrics.reporter.prom.port: 9249
    
    重启集群后,每个 TaskManager 和 JobManager 都会在 9249 端口暴露 Prometheus 格式的指标。
  • 步骤 2:配置 Prometheus 服务发现,抓取这些端口。
  • 步骤 3:在 Grafana 中导入 Flink Dashboard(已有社区模板),监控吞吐量、延迟、Checkpoint 等。

4. 关键监控指标

  • 吞吐量numRecordsInPerSecond / numRecordsOutPerSecond
  • 延迟end-to-end latency(需要自定义 Histogram)
  • Checkpoint 时间lastCheckpointDuration
  • 状态大小lastCheckpointSize
  • JVM 内存Status.JVM.Memory.Heap.Used
  • 背压:Web UI 中显示 High/Low,或通过指标 task_busy_time 等判断。

六、反压与调优初步

1. 反压(Backpressure)

当数据流入速度大于下游处理速度时,下游会通过 TCP 背压使上游减慢,表现为 TaskManager 的 Buffer Pool 耗尽。Web UI 中算子颜色变红表示高反压。

定位方法

  • 在 Web UI 中查看任务拓扑,点击算子,查看 Backpressure 标签页(需要采样)。
  • 如果某个算子的输入缓冲区经常满载,说明该算子处理能力不足。
  • 分析是否因为热点 key 导致倾斜,或者函数计算复杂度过高。

解决办法

  • 增大该算子的并行度。
  • 优化代码(如使用更高效的数据结构、减少状态访问)。
  • 调整网络缓冲区(taskmanager.network.memory.buffer-debloat.enabled 等)。
  • 使用异步 I/O 或缓存减少外部调用。

2. 并行度设置

  • 默认并行度:parallelism.defaultflink-conf.yaml 中配置。
  • 算子级并行度:.setParallelism(n)env.setParallelism(n)
  • 原则:并行度上限为集群总 Slot 数,通常设置为数据源分区数的倍数。需通过压测调整。

3. 内存调优

  • TaskManager 内存模型:总内存 = 堆内 + 堆外 + 网络缓冲 + 托管内存(Managed Memory,用于 RocksDB 等)。
  • 关键参数:
    • taskmanager.memory.process.size:TaskManager 进程总内存。
    • taskmanager.memory.managed.size:托管内存大小,RocksDB 状态后端需要足够大。
    • taskmanager.memory.network.min / max:网络缓冲内存。
  • 如果使用 RocksDB,建议将 taskmanager.memory.managed.size 设置为总内存的 50% 左右。

七、实战练习:在 Standalone 集群上提交作业并监控

任务

  1. 启动本地 Standalone 集群。
  2. 将第6天或第7天的窗口统计程序打包,提交到集群运行。
  3. 通过 Web UI 查看作业拓扑、Checkpoint 统计、反压情况。
  4. 使用 REST API 查询作业指标(如吞吐量、Checkpoint 大小)。
  5. (可选)配置 Prometheus 导出,并用 curl 查看指标。

步骤提示

  • 打包时注意排除 flink 自身的依赖,使用 Shade Plugin 的 provided 作用域(若使用 Maven,可将 Flink 依赖设为 <scope>provided</scope>)。
  • 提交后,Web UI 中 Running Jobs 会出现作业,点击进入查看。
  • 如果你使用 Socket 数据源,需要保持 nc 运行并向集群发送数据,作业才能持续运行。
  • REST API 示例:curl http://localhost:8081/jobs/<jobid> 获取作业状态。

八、常见问题与解决方法

1. 提交 JAR 时 ClassNotFoundException

原因:缺少依赖或 JAR 不是 fat JAR。
解决:确保使用 Maven Shade 打包,并将 Flink 依赖设置为 provided(如果集群已有 Flink 类库),或者使用 -C 参数添加额外 classpath。

2. YARN 模式报错 HADOOP_CLASSPATH 未设置

解决:在 ~/.bashrc 中添加:

export HADOOP_CLASSPATH=$(hadoop classpath)

或设置 HADOOP_CONF_DIR 指向 Hadoop 配置目录。

3. Kubernetes 部署权限不足

错误ForbiddenServiceAccount not found
解决:创建 ServiceAccount 并授予 RBAC 权限,例如:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: flink
  namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: flink-role-binding
subjects:
- kind: ServiceAccount
  name: flink
  namespace: default
roleRef:
  kind: ClusterRole
  name: edit
  apiGroup: rbac.authorization.k8s.io

4. Web UI 反压显示 High,但作业没报错

分析:可能只是某个算子处理慢。先看是否有数据倾斜,再考虑增加并行度。

5. Prometheus 指标无法访问

检查:是否在 flink-conf.yaml 正确配置了 reporter,端口是否被防火墙拦截,TaskManager 是否重启。


第13 步:综合项目实战——实时订单统计大屏

这是一个综合项目,将整合你之前学到的 Kafka、DataStream API、窗口、状态、连接器等知识,模拟真实生产环境中的实时统计场景。

我们将构建一个实时订单统计管道:Kafka → Flink → Redis,并最终通过 Redis 查看统计结果。为了降低环境复杂度,你可以使用 Docker 快速启动 Kafka 和 Redis,或者使用本地安装的服务。


项目目标

  • 从 Kafka 实时读取订单数据(模拟数据生成器产生)。
  • 使用 Flink 进行实时聚合:
    • 每分钟订单量(Tumbling Window)
    • 每分钟销售额(Tumbling Window)
    • 每个省份的累计订单量(Keyed State + 滚动窗口或持续更新)
  • 将聚合结果写入 Redis,供大屏展示或查询。
  • 提供简单的数据查看方式(redis-cli 或 Python 脚本)。

一、环境准备

1. 启动 Kafka 和 Redis(使用 Docker)

如果你已安装 Docker,执行以下命令快速启动:

# 启动 Kafka(需要 ZooKeeper,可以使用 bitnami 镜像内置)
docker run -d --name kafka \
  -p 9092:9092 \
  -e ALLOW_PLAINTEXT_LISTENER=yes \
  -e KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181 \
  --link zookeeper \
  bitnami/kafka:latest

# 启动 ZooKeeper(如果上面未自动启动)
docker run -d --name zookeeper \
  -p 2181:2181 \
  -e ALLOW_ANONYMOUS_LOGIN=yes \
  bitnami/zookeeper:latest

或者使用 docker-compose.yml(推荐):

version: '3'
services:
  zookeeper:
    image: bitnami/zookeeper:latest
    ports:
      - "2181:2181"
    environment:
      - ALLOW_ANONYMOUS_LOGIN=yes
  kafka:
    image: bitnami/kafka:latest
    ports:
      - "9092:9092"
    environment:
      - KAFKA_CFG_ZOOKEEPER_CONNECT=zookeeper:2181
      - ALLOW_PLAINTEXT_LISTENER=yes
    depends_on:
      - zookeeper
  redis:
    image: redis:latest
    ports:
      - "6379:6379"

保存为 docker-compose.yml,在目录下运行 docker-compose up -d

2. 创建 Kafka Topic

进入 Kafka 容器创建两个主题:

docker exec -it kafka kafka-topics.sh --create --topic orders --bootstrap-server localhost:9092 --partitions 1 --replication-factor 1
docker exec -it kafka kafka-topics.sh --create --topic order-stats --bootstrap-server localhost:9092 --partitions 1 --replication-factor 1

(第二个 topic 用于输出统计结果,可选,也可以直接写入 Redis)

3. 准备 Redis 客户端

可以使用 redis-cli 命令行工具(Docker 容器内已包含)或安装本地 Redis 客户端。查看结果时使用 docker exec -it redis redis-cli


二、数据生成器

我们需要一个程序持续向 Kafka 的 orders topic 发送模拟订单数据。数据格式为 CSV 字符串:

订单ID,用户ID,省份,商品ID,价格,数量,订单时间(ISO-8601)

例如:1001,u001,广东,P001,2999.0,1,2024-01-01 10:00:00

编写一个简单的 Java 程序(或 Python 脚本)作为数据生成器。这里提供 Java 版本,使用 Kafka 生产者。

1. 添加 Kafka 依赖

在现有项目中(或新建一个 Maven 项目),添加 Kafka 客户端依赖:

<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>3.5.0</version>
</dependency>

2. 编写数据生成器

import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.common.serialization.StringSerializer;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Properties;
import java.util.Random;
import java.util.UUID;

public class OrderDataGenerator {
    private static final String[] PROVINCES = {"广东", "江苏", "浙江", "山东", "河南", "四川", "湖北", "湖南"};
    private static final String[] PRODUCTS = {"P001", "P002", "P003", "P004", "P005"};

    public static void main(String[] args) throws InterruptedException {
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

        KafkaProducer<String, String> producer = new KafkaProducer<>(props);
        Random random = new Random();
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

        while (true) {
            String orderId = "O" + UUID.randomUUID().toString().substring(0, 8);
            String userId = "u" + (random.nextInt(100) + 1);
            String province = PROVINCES[random.nextInt(PROVINCES.length)];
            String productId = PRODUCTS[random.nextInt(PRODUCTS.length)];
            double price = 10 + random.nextDouble() * 1000;
            int quantity = 1 + random.nextInt(5);
            String eventTime = LocalDateTime.now().format(formatter); // 使用当前时间

            String record = String.format("%s,%s,%s,%s,%.2f,%d,%s",
                    orderId, userId, province, productId, price, quantity, eventTime);

            producer.send(new ProducerRecord<>("orders", null, record));
            System.out.println("Sent: " + record);

            Thread.sleep(1000); // 每秒发送一条,可根据需要调整
        }
    }
}

运行:在 IDE 中运行,或在命令行编译运行。保持程序持续运行,产生数据流。


三、Flink 作业开发

现在编写 Flink 程序,从 Kafka 读取订单数据,进行实时聚合,并将结果写入 Redis。

1. 添加依赖

pom.xml 中添加所需依赖:

  • Flink Kafka Connector(同第11天)

  • Redis Connector(Flink 官方支持)

    <dependency>
        <groupId>org.apache.flink</groupId>
        <artifactId>flink-connector-redis</artifactId>
        <version>1.17.1</version>
    </dependency>
    

    注意:flink-connector-redis 是社区维护,如果不可用,可以使用自定义 Redis Sink(通过 Jedis 客户端)。这里我们使用自定义 Redis Sink,因为更灵活且不需要额外连接器。

  • Redis 客户端 Jedis

    <dependency>
        <groupId>redis.clients</groupId>
        <artifactId>jedis</artifactId>
        <version>4.4.3</version>
    </dependency>
    
  • 其他基础依赖(Flink core、Kafka connector 等)已经包含。

2. 设计聚合逻辑

  • 每分钟订单量:使用 TumblingEventTimeWindows 窗口,按窗口统计订单数量。
  • 每分钟销售额:窗口内对 price * quantity 求和。
  • 每个省份累计订单量:使用 KeyedState 维护每个省份的累计订单数(或者使用 keyBy(province).process 实时更新)。

为了简化,我们使用两个独立的处理流:

  • 流1:基于窗口的统计,输出到 Redis(键:minute_stats,值为 JSON 格式)。
  • 流2:基于 Keyed State 的省份累计统计,持续更新到 Redis(键:province_stats,值为 Hash 或 String)。

3. 实现自定义 Redis Sink

由于 Flink 官方 Redis Connector 在 1.17 可能不包含在发布包中,我们实现一个简单的 RichSinkFunction,使用 Jedis 写入 Redis。

import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.sink.RichSinkFunction;
import redis.clients.jedis.Jedis;

public class RedisSink extends RichSinkFunction<String> {
    private transient Jedis jedis;
    private final String redisHost;
    private final int redisPort;

    public RedisSink(String redisHost, int redisPort) {
        this.redisHost = redisHost;
        this.redisPort = redisPort;
    }

    @Override
    public void open(Configuration parameters) {
        jedis = new Jedis(redisHost, redisPort);
    }

    @Override
    public void invoke(String value, Context context) {
        // value 格式:"key:value"
        String[] parts = value.split(":", 2);
        if (parts.length == 2) {
            jedis.set(parts[0], parts[1]);
        }
    }

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

4. 编写 Flink 主程序

import org.apache.flink.api.common.eventtime.WatermarkStrategy;
import org.apache.flink.api.common.functions.MapFunction;
import org.apache.flink.api.common.functions.ReduceFunction;
import org.apache.flink.api.common.serialization.SimpleStringSchema;
import org.apache.flink.connector.kafka.source.KafkaSource;
import org.apache.flink.connector.kafka.source.enumerator.initializer.OffsetsInitializer;
import org.apache.flink.streaming.api.datastream.DataStream;
import org.apache.flink.streaming.api.datastream.KeyedStream;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.windowing.ProcessWindowFunction;
import org.apache.flink.streaming.api.windowing.assigners.TumblingEventTimeWindows;
import org.apache.flink.streaming.api.windowing.time.Time;
import org.apache.flink.streaming.api.windowing.windows.TimeWindow;
import org.apache.flink.util.Collector;
import org.apache.flink.util.OutputTag;
import org.apache.flink.api.common.functions.FilterFunction;
import org.apache.flink.api.common.state.ValueState;
import org.apache.flink.api.common.state.ValueStateDescriptor;
import org.apache.flink.streaming.api.functions.KeyedProcessFunction;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;

public class RealTimeOrderStatsJob {

    public static void main(String[] args) throws Exception {
        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        env.setParallelism(1);
        env.enableCheckpointing(10_000); // 开启 Checkpoint 保证容错

        // 1. Kafka Source
        KafkaSource<String> source = KafkaSource.<String>builder()
                .setBootstrapServers("localhost:9092")
                .setTopics("orders")
                .setGroupId("flink-order-stats")
                .setStartingOffsets(OffsetsInitializer.latest()) // 从最新开始,避免处理历史数据;也可用 earliest
                .setValueOnlyDeserializer(new SimpleStringSchema())
                .build();

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

        // 2. 解析订单
        DataStream<OrderEvent> orders = orderLines
                .filter(line -> line.trim().length() > 0)
                .map(new OrderParser());

        // 3. 分配时间戳和 Watermark(订单时间字段 eventTime)
        DataStream<OrderEvent> withTimestamps = orders.assignTimestampsAndWatermarks(
                WatermarkStrategy.<OrderEvent>forBoundedOutOfOrderness(Duration.ofSeconds(5))
                        .withTimestampAssigner((event, timestamp) -> event.eventTime)
        );

        // 4. 分支1:每分钟订单量和销售额(窗口统计)
        DataStream<String> minuteStats = withTimestamps
                .map(order -> new Tuple2<>(1, order.price * order.quantity)) // 标记为(数量,金额) 但需要区分字段
                // 实际上我们需要分开统计,使用 Tuple2<Long, Double> 累加
                .keyBy(value -> "all") // 全局统计,使用固定 key
                .window(TumblingEventTimeWindows.of(Time.minutes(1)))
                .aggregate(new OrderAggregate(), new MinuteWindowResult());
        // 注意:上面代码有类型问题,需要重新设计。我们改用两个独立聚合:
        // 统计订单量:map to (1, 0)  sum
        // 统计销售额:map to (0, amount) sum
        // 然后 union 再写入 redis
        // 为了清晰,我使用两个独立流分别处理

        // 实际代码中,我们分别处理订单量和销售额,然后合并写入 redis
        DataStream<String> orderCountPerMinute = withTimestamps
                .map(order -> new Tuple2<>("count", 1))
                .keyBy(t -> t.f0)
                .window(TumblingEventTimeWindows.of(Time.minutes(1)))
                .sum(1)
                .map(t -> "minute_count:" + t.f1);

        DataStream<String> salesPerMinute = withTimestamps
                .map(order -> new Tuple2<>("sales", order.price * order.quantity))
                .keyBy(t -> t.f0)
                .window(TumblingEventTimeWindows.of(Time.minutes(1)))
                .sum(1)
                .map(t -> "minute_sales:" + t.f1);

        // 5. 分支2:每个省份累计订单量(Keyed State)
        DataStream<String> provinceStats = withTimestamps
                .keyBy(order -> order.province)
                .process(new ProvinceOrderCounter());

        // 6. 合并所有结果流,写入 Redis
        DataStream<String> redisOutput = orderCountPerMinute
                .union(salesPerMinute)
                .union(provinceStats);

        redisOutput.addSink(new RedisSink("localhost", 6379));

        // 7. 打印到控制台(便于调试)
        redisOutput.print();

        env.execute("Real-time Order Stats");
    }

    // 订单解析器
    public static class OrderParser implements MapFunction<String, OrderEvent> {
        private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
        @Override
        public OrderEvent map(String line) throws Exception {
            String[] f = line.split(",");
            if (f.length < 7) {
                return new OrderEvent(); // 过滤掉非法数据
            }
            OrderEvent order = new OrderEvent();
            order.orderId = f[0].trim();
            order.userId = f[1].trim();
            order.province = f[2].trim();
            order.productId = f[3].trim();
            order.price = Double.parseDouble(f[4].trim());
            order.quantity = Integer.parseInt(f[5].trim());
            order.eventTime = LocalDateTime.parse(f[6].trim(), formatter)
                    .toInstant(ZoneOffset.UTC).toEpochMilli();
            return order;
        }
    }

    // 订单事件 POJO
    public static class OrderEvent {
        public String orderId;
        public String userId;
        public String province;
        public String productId;
        public double price;
        public int quantity;
        public long eventTime;

        public OrderEvent() {}
    }

    // 省份累计订单计数器
    public static class ProvinceOrderCounter extends KeyedProcessFunction<String, OrderEvent, String> {
        private ValueState<Long> orderCount;

        @Override
        public void open(Configuration parameters) throws Exception {
            ValueStateDescriptor<Long> descriptor = new ValueStateDescriptor<>(
                    "province-order-count", Long.class);
            orderCount = getRuntimeContext().getState(descriptor);
        }

        @Override
        public void processElement(OrderEvent order, Context ctx, Collector<String> out) throws Exception {
            Long count = orderCount.value();
            if (count == null) count = 0L;
            count += 1;
            orderCount.update(count);
            // 输出格式:province_stats:省份:订单数,存入 Redis,使用 Hash 结构更方便,这里简化为 key 拼接
            out.collect("province_count:" + order.province + ":" + count);
        }
    }

    // 注意:上面的 union 要求类型相同,都是 String,可以 union。
}

代码说明

  • 订单解析使用 MapFunction,忽略非法行(返回空对象,但后续可以过滤)。
  • Watermark 分配使用事件时间(订单时间字段)。
  • 每分钟订单量:使用固定 key 分组后,应用滚动窗口,用 sum 聚合计数。由于 sum 只返回数值,我们需要将结果转换为 Redis 键值对字符串。
  • 每分钟销售额:类似,用 sum 聚合金额。
  • 省份累计订单量:使用 KeyedProcessFunction,按省份分组,维护 ValueState<Long> 累计订单数,每次更新后输出。
  • 三个输出流通过 union 合并(注意类型必须一致,这里都是 String),然后写入 Redis 并打印。
  • Redis Sink 接收格式为 key:value 的字符串,解析后存入 Redis。

改进:上述 unionorderCountPerMinutesalesPerMinute 的 key 固定为 minute_countminute_sales,它们会不断覆盖之前的值(因为每分钟统计结果只保留最新一次)。实际需求可能是保留最近一段时间,但 Redis 单键覆盖也能反映最新值。如果需要历史,可以使用 Hash 或者时间戳作为 key 的一部分,例如 minute_count:2024-01-01 10:00。可自行扩展。

5. 运行 Flink 作业

  • 在 IDE 中运行 RealTimeOrderStatsJob,或者打包提交到集群(参考第12天)。
  • 确保 Kafka 和 Redis 已启动,数据生成器正在发送数据。
  • 启动 Flink 作业后,可以在控制台看到输出。

6. 查看 Redis 结果

在终端执行:

docker exec -it redis redis-cli
# 查看所有键
KEYS *
# 获取某个省份累计订单量
GET province_count:广东
# 获取最新一分钟订单量
GET minute_count
# 获取最新一分钟销售额
GET minute_sales

输出示例:

1) "province_count:广东"
2) "minute_count"
3) "minute_sales"
4) "province_count:江苏"
...

四、扩展与优化

1. 使用 Flink SQL 简化开发

你可以使用 Table API/SQL 完成相同的统计,代码更简洁。例如:

CREATE TABLE orders (
    order_id STRING,
    user_id STRING,
    province STRING,
    product_id STRING,
    price DOUBLE,
    quantity INT,
    event_time TIMESTAMP(3),
    WATERMARK FOR event_time AS event_time - INTERVAL '5' SECOND
) WITH (
    'connector' = 'kafka',
    'topic' = 'orders',
    'properties.bootstrap.servers' = 'localhost:9092',
    'format' = 'csv'
);

CREATE TABLE redis_sink (
    `key` STRING,
    `value` STRING
) WITH (
    'connector' = 'redis',
    'host' = 'localhost',
    'port' = '6379'
);

INSERT INTO redis_sink
SELECT 'minute_count', CAST(COUNT(*) AS STRING)
FROM orders
GROUP BY TUMBLE(event_time, INTERVAL '1' MINUTE);

这需要引入相应的连接器依赖,但能大幅减少代码量。

2. 将结果输出到 Kafka

你也可以将统计结果写回 Kafka 的 order-stats 主题,供下游消费。修改 Sink 为 Kafka Sink 即可。

3. 加入维表关联(省份名称映射)

如果订单中只有省份代码,可以通过 Flink CDC 读取 MySQL 中的省份映射表,使用 Lookup Join 进行关联。

4. 端到端 Exactly-Once

在 Kafka Source 和 Redis Sink 之间实现 Exactly-Once 需要 Redis 支持事务(如 Redis Transactions + WATCH),或使用幂等写入。由于 Redis 写入通常用于实时展示,可以接受 At-Least-Once。


五、常见问题与解决

1. Kafka 连接超时

检查 Kafka 是否启动,localhost:9092 是否可达。使用 telnet localhost 9092 测试。

2. 订单数据时间戳问题

数据生成器使用当前时间,如果 Flink 作业晚于数据启动,Watermark 可能无法推进,导致窗口不触发。确保数据生成器和 Flink 作业同时运行,或使用 OffsetsInitializer.latest() 忽略历史数据。

3. Redis 写入乱码

确保 Redis Sink 中字符串格式正确,split(":") 分隔符与数据一致。

4. 省份累计状态丢失

由于使用 Keyed State,在故障恢复时会从 Checkpoint 恢复,不会丢失。确保 Checkpoint 启用并配置了持久化状态后端(如 RocksDB 或 FsStateBackend)。

5. 窗口结果不更新

如果使用 TumblingEventTimeWindows,需要 Watermark 超过窗口结束时间才会触发。数据生成器持续产生数据,事件时间接近当前时间,窗口会正常关闭。


恭喜你完成了 Flink 零基础到实战的学习路线! 你已经具备了独立开发 Flink 实时计算应用的能力。接下来你可以:

  • 深入阅读官方文档,了解更多高级特性(如 CEP、异步 I/O、状态序列化)。
  • 尝试将项目部署到 YARN 或 Kubernetes 环境。
  • 学习 Flink 内核源码,理解实现原理。
  • 参与开源社区或实际工作项目。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值