[Flink/高可用/容错] Flink Checkpoint/Savepoint 机制

1 概述:Flink Checkpoint 机制

  • 前情提要
  • Checkpoint 是什么?为什么需要它?
  • 它的底层原理(基于 Chandy-Lamport 算法)
  • 如何在 Flink 1.15 中开启和配置 Checkpoint(关键代码示例)
  • Exactly-Once 和 At-Least-Once 语义的区别与实现思路

1.1 Checkpoint 是什么?为什么需要?

D:/flink-checkpoints/
└── [JOB_ID]/                     # 每个任务唯一的 ID
    └── [JOB_Startup_ID]/         # 启动时分配的启动ID(华为云DLI)
        ├── chk-1/                # 第1次 Checkpoint 的文件夹
        │   ├── _metadata         # 元数据(最重要的文件,记录了状态指针)
        │   └── [UUID]-data       # 实际的状态数据文件
        ├── chk-2/
        └── ...

背景:流处理中的容错问题

  • Flink 是一个 有状态的流处理引擎。这意味着你的算子(如 map、filter、keyed window)可能会保存中间状态(比如窗口聚合值、用户行为计数等)。

一旦作业失败(比如机器宕机、网络中断),这些状态就可能【丢失】,导致结果错误重复

  • Checkpoint 就是 Flink 用来实现容错的核心机制 —— 它会定期将整个作业的状态快照”保存到【持久化存储】(如 HDFS、S3、本地磁盘等)中。

当作业失败重启时,可以从最近一次成功的 Checkpoint 恢复状态,从而保证 结果的一致性和正确性

1.2 Checkpoint 的核心原理(Chandy-Lamport 算法)

  • Flink 的 Checkpoint 机制基于 分布式快照算法 Chandy-Lamport,其核心思想:

在不停止数据流的前提下,对【整个分布式系统】做【一致性的状态快照】。

  • 推荐文献

算法概述

  • Spark 的 Structured Streaming 的 Continuous Processing Mode 的容错处理使用了分布式快照(Distributed Snapshot)算法 Chandy-Lamport 算法,那么分布式快照算法可以用来解决什么问题呢?

A snapshot algorithm is used to create a consistent snapshot of the global state of a distributed system. Due to the lack of globally shared memory and a global clock, this isn’t trivially possible.
简单来说就是: 用来在缺乏类似全局时钟或者全局时钟不可靠的【分布式系统】中来确定一种【全局状态】。

那么,【分布式快照算法】应用到【流式系统】中就是确定一个 GlobalSnapshot,错误处理的时候各个节点根据上一次的 Global Snapshot 来恢复。

下面就介绍一下在流式系统中广泛使用分布式快照算法:Chandy-Lamport 算法

Flink 使用的是 Chandy-Lamport 的改进算法。

  • Chandy-Lamport 算法以2个作者的名字命名,没错,其中 Lamport 就是分布式系统领域无人不晓的 Leslie Lamport,著名的一致性算法 Paxos 的作者。

算法的论文于 1985 年发表,《Distributed Snapshots: Determining Global States of a Distributed System》,提到这篇论文,不得不提一下这篇论文的由来,洗个澡的时间想出来的。
The distributed snapshot algorithm described here came about when I visited Chandy, who was then at the University of Texas in Austin. He posed the problem to me over dinner, but we had both had too much wine to think about it right then. The next morning, in the shower, I came up with the solution. When I arrived at Chandy’s office, he was waiting for me with the same solution. I consider the algorithm to be a straightforward application of the basic ideas from Time, Clocks and the Ordering of Events in a Distributed System.

正如 Lamport 所述,算法的思想非常的 straight forward,在描述算法之前需要先介绍一下 Global Snapshot。

Global Snapshot

  • Global Snapshot,我们也可以理解为 Global State,中文可以叫做全局状态,在系统做 Failure Recovery 的时候非常有用,也是广泛应用在分布式系统,更多是分布式计算系统中的一种容错处理理论基础

  • Chandy-Lamport 算法中,为了定义分布式系统的全局状态,我们先将分布式系统简化成【有限个进程】和进程之间的 channel 组成,也就是一个有向图

  • 【节点】是进程,【边】是 channel

因为是分布式系统,也就是说,这些进程是运行在不同的物理机器上的。

那么,一个分布式系统的全局状态就是有进程的状态channel 中的 message组成,这个也是【分布式快照算法】需要记录的。
因为是【有向图】,所以每个进程对应着两类 channel: input channel, output channel。同时假设 Channel 是一个容量无限大的 FIFO 队列,收到的 message 都是有序且无重复的。

  • Chandy-Lamport 分布式快照算法通过记录每个进程的 local state 和它的 input channel 中有序的 message,我们可以认为这是一个局部快照。那么【全局快照】就可以通过将所有的进程的【局部快照】合并起来得到。

Chandy-Lamport 算法

那么我们基于上面假设的分布式系统模型来看一下 Chandy-Lamport 算法具体的工作流程是什么样的。主要包括下面三个部分:

  1. Initiating a snapshot: 也就是开始创建 snapshot,可以由系统中的任意一个进程发起
  2. Propagating a snapshot: 系统中其他进程开始逐个创建 snapshot 的过程

Propagating : 增殖、传播之义

  1. Terminating a snapshot: 算法结束条件
Initiating a snapshot
  • 进程 Pi 发起:
  • 记录自己的进程状态
  • 同时生产一个标识信息 marker,marker 和进程通信的 message 不同
  • 将 marker 信息通过 output channel 发送给系统里面的其他进程
  • 开始记录所有 input channel 接收到的 message
Propagating a snapshot

对于进程 Pj 从 input channel Ckj 接收到 marker 信息:

  • 如果 Pj 还没有记录自己的进程状态,则

    • Pj 记录自己的进程状态
    • 同时将 channel Ckj 置为空
    • 向 output channel 发送 marker 信息
  • 否则

    • 记录其他 channel 在收到 marker 之前的 channel 中收到所有 message

所以,这里的 marker 其实是充当一个分隔符,分隔进程做 local snapshot (记录进程状态)的 message。

比如 Pj 做完 local snapshot 之后 Ckj 中发送过来的 message 为 [a,b,c,marker,x,y,z] 那么 a, b, c 就是进程 Pk 做 local snapshot 前的数据,Pj 对于这部分数据需要记录下来,比如记录在 log 里面。而 marker 后面 message 正常处理掉就可以了。

Terminating a snapshot
  • 所有的进程都收到 marker 信息并且记录下自己的状态和 channel 的状态(包含的 message)

例子

假设系统中包含两个进程 P1 和 P2 ,P1 进程状态包括三个变量 X1,Y1 和 Z1 , P2 进程包括三个变量 X2,Y2 和 Z2。初始状态如下。

image

由 P1 发起全局 Snapshot 记录,P1 先记录本身的进程状态,然后向 P2 发送 marker 信息。在 marker 信息到达 P2 之前,P2 向 P1 发送 message: M。

image

P2 收到 P1 发送过来的 marker 信息之后,记录自己的状态。然后 P1 收到 P2 之前发送过来的 message: M。

对于 P1 来说,从 P2 channel 发送过来的信息相当于是 [M, marker],由于 P1 已经做了 local snapshot,所以 P1 需要记录 message M。

image

那么全局 Snapshot 就相当于下图中的蓝色部分。

image

算法总结

  • Chandy-Lamport 算法通过抽象分布式系统模型描述了一种简单直接但是非常有效的分布式快照算法。

讨论 Chandy-Lamport 算法一定要注意算法的几个前提:网络可靠、消息有序

  • Spark 的 Structured Streaming 虽然在官方博客中披露使用的 Chandy-Lamport 算法来做 Failover 处理,但是并没有更细节的披露。

  • 相比之下 Flink 在 2015 发布了一篇论文 Lightweight asynchronous snapshots for distributed dataflows 更适合在工程上实现,而且已经应用在了 Flink 项目中。

核心思想是在 input source 端插入 barrier 来替代 Chandy-Lamport 算法中的 Marker,通过控制 barrier 的同步来实现 snapshot 的备份和 exactly-once 语义。
如果看过 Spark Streaming 那篇论文,对于这个地方很明显的一个疑问就是如何处理 straggler(分布式系统中运行明显慢于其他节点的节点),答案是无法处理。有时候不得不承认,在大多数情况下,所谓系统架构都是在做 trade-off。

推荐文献

  • Chandy K M, Lamport L. Distributed snapshots: Determining global states of distributed systems[J]. ACM Transactions on Computer Systems (TOCS), 1985, 3(1): 63-75.
  • Carbone P, Fóra G, Ewen S, et al. Lightweight asynchronous snapshots for distributed dataflows[J]. arXiv preprint arXiv:1506.08603, 2015.
  • Time, Clocks and the Ordering of Events in a Distributed System
  • Leslie Lamport Homepage
  • tele.informatik.uni-freiburg.de
  • people.cs.umass.edu/~ar
  • cs.princeton.edu/course

简单解释: 分布式快照(Chandy-Lamport算法)

  • Barrier(屏障):Flink 在数据流中插入特殊的标记(称为 barrier),用于触发和协调 Checkpoint。
  • 异步快照:算子在接收到 barrier 后,会异步地将当前状态写入外部存储,不影响主数据流处理。
  • 对齐(Alignment):在 Exactly-Once 模式下,算子会暂停处理某些输入通道的数据,直到所有通道的 barrier 都到达(防止状态不一致)。
  1. JobManager 触发一次 Checkpoint(比如每 5 秒一次)。

  2. Source 算子插入一个 barrier 到所有输出流中,并开始快照自己的状态(如 Kafka offset)。

  3. Barrier 随数据流向下传递:

    • 当一个算子从所有输入通道都收到同一个 Checkpoint ID 的 barrier 后,它才执行自己的状态快照。
    • 在等待期间,来自未收到 barrier 的通道的数据会被 缓存(对齐)
  4. 所有算子完成快照后,JobManager 收到确认,Checkpoint 成功。

  5. 如果作业失败,Flink 会从最近一次成功的 Checkpoint 恢复所有算子的状态,并从对应的 source offset 重新消费。

这样就能保证:即使故障,也不会丢数据,也不会重复处理(Exactly-Once)

以下是一个典型的 Flink 1.15 作业配置示例(使用 DataStream API):

CheckpointExample

import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.CheckpointingMode;

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

        // 1. 开启 checkpoint,每 5000 毫秒(5秒)做一次
        env.enableCheckpointing(5000);

        // 2. 设置 checkpoint 模式:Exactly-Once(默认)
        env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);

        // 3. 设置 checkpoint 超时时间(默认 10 分钟,这里设为 1 分钟)
        env.getCheckpointConfig().setCheckpointTimeout(60000);

        // 4. 允许同时进行的 checkpoint 数量(默认 1)
        env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);

        // 5. 设置两次 checkpoint 之间的最小间隔(避免太频繁)
        env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);

        // 6. 外部化 checkpoint(作业取消后保留)
        env.getCheckpointConfig().setExternalizedCheckpointCleanup(
            ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION
        );

        // 7. 设置 checkpoint 存储路径(必须配置)
        env.setStateBackend(new EmbeddedRocksDBStateBackend());
        env.getCheckpointConfig().setCheckpointStorage("hdfs://namenode:9000/flink/checkpoints");

        // ... 添加你的 source、transform、sink ...

        env.execute("My Flink Job with Checkpoint");
    }
}

补充说明

  • State Backend:Flink 支持 Memory、FsStateBackend(已弃用)、RocksDBStateBackend。Flink 1.15 推荐使用 EmbeddedRocksDBStateBackend(原 RocksDBStateBackend)。
  • Checkpoint Storage:指定快照保存位置(HDFS/S3/本地文件系统等)。
  • Externalized Checkpoint:如果作业被手动取消,默认会删除 checkpoint。设置 RETAIN_ON_CANCELLATION 策略————可保留,方便后续从该点恢复。

1.4 Exactly-Once vs At-Least-Once 语义

1.4.1 语义定义

语义 含义
At-Least-Once 数据 至少处理一次,可能重复(但不会丢失)
Exactly-Once 数据 精确处理一次,既不丢失也不重复

注意:这里的“一次”是指 端到端(end-to-end) 的效果,不仅包括 Flink 内部,还包括 source 和 sink。

✅ Exactly-Once 实现要点:

  1. Source 端可重放:如 Kafka,能根据 offset 重新消费。
  2. Barrier 对齐:算子等待所有输入流的 barrier 到达后再快照,确保状态一致性。
  3. Sink 端幂等或事务写入
    • 幂等写入:多次写相同 key 不影响结果(如覆盖写)。
    • 事务写入:Flink 提供 TwoPhaseCommitSinkFunction(两阶段提交),配合支持事务的外部系统(如 Kafka 0.11+、HBase)。

⚠️ At-Least-Once 实现要点:

  • 关闭 barrier 对齐:算子一收到任意输入流的 barrier 就快照,不等其他流。
  • 快照更快,延迟更低,但可能因未对齐导致状态包含“未来”数据,重启后重复处理。

在 Flink 中切换:

env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.AT_LEAST_ONCE);

1.4.3 端到端 Exactly-Once 需要全链路支持

Flink 内部做到 Exactly-Once ≠ 整个系统 Exactly-Once!

必须满足:

  • Source:可重放 + 记录 offset(如 Kafka)
  • Flink:开启 Exactly-Once Checkpoint
  • Sink:支持幂等写 或 两阶段提交(如 Kafka Producer with transaction)
  • 例如,使用 Flink 写入 Kafka 实现端到端 Exactly-Once:
FlinkKafkaProducer<String> kafkaSink = new FlinkKafkaProducer<>(
    "my-topic",
    new SimpleStringSchema(),
    properties,
    FlinkKafkaProducer.Semantic.EXACTLY_ONCE  // 启用事务
);

1.4.4 经典案例

  • 例如:Flink 作业中,Source = Mysql CDC + Kafka,作业逻辑:涉及状态后端存储,Sink:Kafka,如何保证整个作业的Exactly-Once语义?

要在一个 Flink 作业中实现 端到端的 Exactly-Once 语义(End-to-End Exactly-Once),当你的作业结构如下:

  • Source:MySQL CDC(通过 Debezium + Flink CDC connector)
  • 中间处理:涉及状态(如 KeyedProcessFunction、Window 等),使用状态后端(如 RocksDB)
  • Sink:写入 Kafka

那么你需要从 Source → Flink 内部 → Sink 全链路协同配合,才能真正实现 Exactly-Once。下面我们将逐层拆解关键要点,并给出 Flink 1.15+ 的配置建议和代码示例

Exactly-Once 的三大支柱

组件 要求
Source 可重放 + 支持 checkpoint 记录 offset / binlog position
Flink 引擎 开启 Exactly-Once Checkpoint + 状态后端持久化
Sink 幂等写入 或 两阶段提交(2PC)

只有三者都满足,才能实现 端到端 Exactly-Once

各组件详解与配置

Source:MySQL CDC(Debezium)

Flink CDC 2.x(基于 Debezium)天然支持 Exactly-Once,因为:

  • 它将 binlog 位点(binlog filename + position + GTID) 作为 source 的状态
  • 这些位点会随 Checkpoint 一起持久化到状态后端
  • 作业恢复时,从上次成功 checkpoint 的位点继续读取

无需额外配置,只要开启 Checkpoint 即可。

注意:确保 MySQL 开启了 binlog(ROW 格式),且 Flink CDC connector 版本 ≥ 2.0。

MySqlSource<String> mySqlSource = MySqlSource.<String>builder()
    .hostname("localhost")
    .port(3306)
    .databaseList("mydb")
    .tableList("mydb.users")
    .username("flinkuser")
    .password("flinkpw")
    .deserializer(new JsonDebeziumDeserializationSchema()) // 输出 JSON 字符串
    .build();

env.fromSource(mySqlSource, WatermarkStrategy.noWatermarks(), "MySQL CDC Source");

⚠️ 不要混用 CDC 和普通 JDBC Source!CDC 才能保证顺序和可重放。

  • Flink CDC 中的 MySqlSource(来自 flink-cdc-connectors 项目)在 Flink 1.15 + flink-cdc 2.x 及以上版本中,默认**就是 Exactly-Once 语义,但前提是 你开启了 Checkpoint
  1. MySqlSource Exactly-Once 的实现依赖 Checkpoint

MySqlSource 本身 不主动做持久化,它的 Exactly-Once 能力是 通过 Flink 的 Checkpoint 机制实现的

  • 它将 binlog 的读取位置(如 filename、position、GTID set) 作为 算子状态(operator state)
  • 当 Flink 触发 Checkpoint 时,这个状态会被 快照并持久化到状态后端
  • 作业失败重启时,从最近一次成功的 Checkpoint 恢复该状态,从精确的 binlog 位点继续消费

所以:只要开启 Checkpoint,MySqlSource 就天然支持 Exactly-Once;如果不开启 Checkpoint,则无法保证任何一致性语义(可能重复或丢失)。

  1. 无需额外配置语义模式

与 Kafka Source/Sink 不同,MySqlSource 没有 Semantic 参数(如 EXACTLY_ONCE / AT_LEAST_ONCE),因为:

  • MySQL binlog 是 有序、可重放、带精确位点的日志
  • Debezium 引擎(底层)按顺序读取事件
  • Flink CDC connector 将位点作为状态参与 Checkpoint

因此,只要用的是 Flink CDC 2.0+ 的 MySqlSource + 开启了 Checkpoint,就默认是 Exactly-Once

  1. 官方文档佐证

根据 Flink CDC 官方文档(v2.3+)
“The MySQL CDC source reads the snapshot of the database first and then reads the binlog continuously. The offset of binlog is checkpointed with Flink’s checkpointing mechanism, which ensures exactly-once processing semantics.”
翻译: “MySQL CDC Source 先读取数据库快照,然后持续读取 binlog。binlog 的偏移量通过 Flink 的 checkpoint 机制进行保存,从而确保 Exactly-Once 处理语义。”

  • 注意事项

虽然 MySqlSource 默认支持 Exactly-Once,但仍需满足以下条件:

条件 说明
✅ 开启 Checkpoint env.enableCheckpointing(...) 必须调用
✅ 使用可靠的状态后端 EmbeddedRocksDBStateBackend + HDFS(推荐)/S3(推荐)/本地磁盘(不推荐)
✅ MySQL 配置正确 binlog_format=ROW, binlog_row_image=FULL, 开启 GTID(推荐)
✅ 不手动跳过快照阶段 默认先全量再增量,全量阶段也参与 checkpoint
  • 结论

MySqlSource<String> 在开启 Checkpoint 的前提下,默认就是 Exactly-Once 语义,无需额外设置。
你只需要确保:

env.enableCheckpointing(30_000); // 开启即可

其余由 Flink CDC 和 Checkpoint 机制自动保证。

  • 必须开启 Exactly-Once Checkpoint,并配置可靠的状态后端(如 HDFS/S3 上的 RocksDB)。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();

// 1. 开启 checkpoint(建议 10~60 秒)
env.enableCheckpointing(30_000);

// 2. Exactly-Once 模式(默认,但显式写出更安全)
env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE);

// 3. 设置 checkpoint 超时 & 并发
env.getCheckpointConfig().setCheckpointTimeout(5 * 60_000); // 5分钟
env.getCheckpointConfig().setMaxConcurrentCheckpoints(1);
env.getCheckpointConfig().setMinPauseBetweenCheckpoints(500);

// 4. 外部化 checkpoint(作业取消后保留)
env.getCheckpointConfig().setExternalizedCheckpointCleanup(
    ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION
);

// 5. 状态后端:RocksDB + 分布式文件系统
env.setStateBackend(new EmbeddedRocksDBStateBackend());
env.getCheckpointConfig().setCheckpointStorage("hdfs://namenode:9000/flink/checkpoints");

✅ 此配置确保:

  • 所有算子状态(包括 CDC source 的 binlog 位点)被定期快照
  • 故障恢复时从一致的全局状态重启
Sink:Kafka(关键!)
  • 这是最容易出错的地方。普通 Kafka Producer 是 At-Least-Once

要实现 Exactly-Once,必须使用 Kafka 事务(Transactional Producer),Flink 提供了封装:

正确方式:使用 FlinkKafkaProducer with Semantic.EXACTLY_ONCE
Properties kafkaProps = new Properties();
kafkaProps.setProperty("bootstrap.servers", "kafka:9092");
kafkaProps.setProperty("transaction.timeout.ms", "900000"); // 必须 >= checkpoint interval

FlinkKafkaProducer<String> kafkaSink = new FlinkKafkaProducer<>(
    "output-topic",
    (KafkaSerializationSchema<String>) (element, timestamp) -> 
        new ProducerRecord<>("output-topic", element.getBytes(StandardCharsets.UTF_8)),
    kafkaProps,
    FlinkKafkaProducer.Semantic.EXACTLY_ONCE  // 关键!启用事务
);

// 必须设置 Kafka producer 的 transactional.id 前缀(Flink 自动管理)
kafkaProps.setProperty("transactional.id", "flink-1.15-job-1"); // 实际由 Flink 控制,此处可不设

stream.addSink(kafkaSink);
原理说明
  • Flink 为每个 Kafka sink task 分配唯一的 transactional.id
  • 每次 checkpoint 时,执行 pre-commit(flush 数据但不 commit)
  • 所有 task 的 pre-commit 成功,JobManager 发出 notifyCheckpointComplete
  • Sink 收到通知后 正式 commit 事务
  • 如果 checkpoint 失败,事务自动 abort,数据不会写入 Kafka

💡 这就是 两阶段提交(2PC) 在 Flink 中的实现。

注意事项
  1. Kafka 版本 ≥ 0.11(支持事务)

  2. transaction.timeout.ms ≥ checkpoint interval(否则 Kafka 会自动 abort 事务)

    • 例如 checkpoint 30s → transaction.timeout.ms 至少设为 600000(10分钟更安全)
  3. 不要手动调用 producer.flush()close()

全链路 Exactly-Once 验证逻辑

场景 行为
作业正常运行 数据从 MySQL → Flink → Kafka,每条只写一次
Flink TaskManager 挂掉 从最近 checkpoint 恢复: - CDC 从保存的 binlog 位点重放 - 状态恢复 - Kafka 未 commit 的事务自动回滚
Kafka 写入中途失败 事务未 commit,数据对 consumer 不可见
作业手动取消 若配置 RETAIN_ON_CANCELLATION,可从该 checkpoint 恢复

常见误区

错误做法 后果
使用 KafkaProducer 手动写 sink 无法参与 checkpoint,只能 At-Least-Once
Kafka sink 用 Semantic.AT_LEAST_ONCE 可能重复写入
未设置 transaction.timeout.ms Kafka 自动 abort 事务,导致数据丢失
状态后端用 MemoryStateBackend 故障后状态丢失,无法恢复

总结:配置清单

必须做到

  1. Source:使用 Flink CDC 2.x(基于 Debezium)
  2. Checkpoint
    • 开启,模式为 EXACTLY_ONCE
    • 存储到 HDFS/S3
    • 外部化保留
  3. State BackendEmbeddedRocksDBStateBackend
  4. Sink
    • 使用 FlinkKafkaProducer
    • Semantic.EXACTLY_ONCE
    • 配置足够大的 transaction.timeout.ms

只有这样,你的 MySQL → Flink → Kafka 链路才能真正实现 端到端 Exactly-Once

1.5 综合案例

样例数据流

  • /dataset/device-signals.txt
[
  { "D1" : { "offset":  1, "bms_soc" :  331, "IC_totalMileage" :  45.78 } }
  , { "D2" : { "offset":  2, "bms_soc" :  332, "IC_totalMileage" :  45.79 } }
  , { "D3" : { "offset":  3, "bms_soc" :  333, "IC_totalMileage" :  45.19 } }
  , { "D4" : { "offset":  4, "bms_soc" :  334, "IC_totalMileage" :  45.29 } }
  , { "D5" : { "offset":  5, "bms_soc" :  335, "IC_totalMileage" :  45.39 } }
  , { "D6" : { "offset":  6, "bms_soc" :  316, "IC_totalMileage" :  45.49 } }
  , { "D7" : { "offset":  7, "bms_soc" :  337, "IC_totalMileage" :  45.69 } }
  , { "D8" : { "offset":  8, "bms_soc" :  318, "IC_totalMileage" :  45.49 } }
  , { "D9" : { "offset":  9, "bms_soc" :  339, "IC_totalMileage" :  45.69 } }
]

FlinkExactlyOnceDemo: 基于指定的 checkpoint/savepoint 恢复,并及时持久化处理进度到 checkpoint/savepoint

package com.johnnyzen.flinkapi.demo.flink.exactlyonce;

import com.johnnyzen.flinkapi.utils.FlinkJobUtils;
import com.johnnyzen.flinkapi.utils.FileUtils;
import com.alibaba.fastjson2.JSON;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.apache.flink.api.common.JobID;
import org.apache.flink.api.common.state.ListState;
import org.apache.flink.api.common.state.ListStateDescriptor;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.configuration.PipelineOptions;
import org.apache.flink.configuration.PipelineOptionsInternal;
import org.apache.flink.runtime.state.FunctionInitializationContext;
import org.apache.flink.runtime.state.FunctionSnapshotContext;
import org.apache.flink.streaming.api.checkpoint.CheckpointedFunction;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.functions.source.SourceFunction;

import java.io.File;
import java.io.InputStream;
import java.lang.reflect.Type;
import java.util.*;

@Slf4j
public class FlinkExactlyOnceDemo {
    public static String DEVICES_SIGNALS_RECOVERABLE_SOURCE_UID = "Devices_Signals_Recoverable_Source";
    public static boolean FIRST_RUNNING = true;//可自己调整,以模拟: 第1次运行,正常读取全量数据,且在处理过程中将主动报错; 非第1次运行,将不报错

    public static void main(String[] args) throws Exception {
        // 获取配置对象
        Configuration conf = new Configuration();
        // 指定从哪个具体的 Checkpoint 目录恢复 (可选步骤)
        //conf.setString("execution.savepoint.path", "file:///E:/tmp/checkpoint/[JOB_ID]/chk-5");
        //conf.setString("execution.savepoint.path", "file:///E:/tmp/checkpoint/9866061bb8a60fea0dd2a67785e8ec5b/chk-255.bak");

        /**
         * 核心:设置自定义Job ID(关键步骤)
         * 关键代码:手动设置一个 32 位的十六进制字符串作为 JobID
         * 注意:必须是 32 位合法的十六进制字符
         */
        String customJobInstanceName = "12345_DemoJob";
        byte [] customJobInstanceNameMd5Bytes = FlinkJobUtils.convertTo16BytesByMD5(customJobInstanceName);//不定长的字符串转换为定长的 16 字节(128 bit)
        JobID customJobId = JobID.fromByteArray( customJobInstanceNameMd5Bytes); //JobID.fromHexString(customJobIdStr);//// 方式A:直接指定字符串转换为JobID(推荐,与已有checkpoint目录的{JOB_ID}完全匹配)
        //JobID customJobId = new JobID(UUID.randomUUID(), UUID.randomUUID());//方式B:若需生成新的自定义Job ID(用于新作业绑定自定义目录)
        conf.setString( PipelineOptionsInternal.PIPELINE_FIXED_JOB_ID, customJobId.toHexString() );//9866061bb8a60fea0dd2a67785e8ec5b

        StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(conf);; //StreamExecutionEnvironment.getExecutionEnvironment();

        // 1. 关键配置:每 n 秒做一次 Checkpoint
        env.enableCheckpointing(4000);
        /**
         * 指定 Checkpoint 存储位置 (默认: 内存中)
         * @note 检查点的文件路径:
         *  E:\tmp\checkpoints\9866061bb8a60fea0dd2a67785e8ec5b\
         *      /chk-14
         *      /shared
         *      /taskowned
         */
        env.getCheckpointConfig().setCheckpointStorage("file:///E:/tmp/checkpoint/");
        env.getCheckpointConfig().setTolerableCheckpointFailureNumber(3); //设置可容忍的失败次数

        env
            .addSource(new DevicesSignalsRecoverableSource()).uid( DEVICES_SIGNALS_RECOVERABLE_SOURCE_UID )
            .map(device -> "Processed: " + device)
            .print();

        env.execute(customJobInstanceName);
    }

    /**
     * 自定义 Source,实现 CheckpointedFunction 接口以支持状态保存
     */
    public static class DevicesSignalsRecoverableSource implements SourceFunction< Map<String, Object> >, CheckpointedFunction { // CheckpointedFunction 接口 : 实现 Exactly-Once 的核心
        public final static String OFFSET_PARAM = "offset";
        public final static String DEVICE_STATE_NAME = "device-state";
        //checkpointedState - 这是 Flink 提供的算子状态(Operator State)。它与普通的 Java List 不同,它是“受管理的”,意味着 Flink 会负责将其持久化到 HDFS 或 S3 等状态后端,并在重启时自动拉取
        private ListState<String> checkpointedState;
        private List<Map<String, Object>> remainingDevicesSignals = new ArrayList<>();//剩余本批次待处理的所有设备信号数据
        //当前处理进度状态
        private List<Map<String, Object>> currentHandleProcessDeviceSignals = new ArrayList<>();//仅记录当前处理进展的设备信号数据 (仅1个元素)
        private volatile boolean isRunning = true;

        // 任务逻辑执行入口
        @Override
        public void run(SourceContext< Map<String, Object> > ctx) throws Exception {
            for (Map<String, Object> deviceSignals : remainingDevicesSignals) {//模拟:从一个数据流Source中持续读取来源数据
                if (!isRunning) break;

                // 模拟处理耗时
                Thread.sleep(1000);

                // 模拟异常:处理到 D{X} 时崩溃
                if( deviceSignals != null && deviceSignals.size() != 0 ){//实际上, size 必为 1
                    //Map<String, Object> deviceSignalsValues = (Map<String, Object>) deviceSignals.get( "D1" );
                    Map<String, Object> deviceSignalsValues = (Map<String, Object>) ( deviceSignals.entrySet().iterator().next().getValue( ) );
                    Integer offset = (Integer) deviceSignalsValues.get( OFFSET_PARAM );//模拟: 获取消息队列的消息偏移量
                    if ( FIRST_RUNNING && offset.equals(6) ) { // deviceSignals.containsKey("D6")
                        throw new RuntimeException("Oops! Handle device signals failed!offset: " + offset + ", deviceSignals:" + JSON.toJSONString(deviceSignals) );
                    }
                }

                //在 run 方法中发送数据时使用同步锁。这确保了“发送数据”和“记录进度”在 Checkpoint 触发时是原子性的,从而避免数据丢失或重复发送(Exactly-Once 的保障)。
                synchronized (ctx.getCheckpointLock()) {//getCheckpointLock() 属于旧版本(SourceFunction 时代)的锁机制
                    ctx.collect( deviceSignals );
                    // 模拟从待处理列表中移除(实际逻辑会根据状态快照更新)
                    //...

                    //仅记录当前处理进度(offset)
                    List<Map<String, Object>> currentDeviceSignals = new ArrayList<>();
                    currentDeviceSignals.add( deviceSignals );
                    currentHandleProcessDeviceSignals = currentDeviceSignals;
                }
            }
        }

        @Override
        public void cancel() {
            isRunning = false;
            log.info("任务取消");
        }

        // --- 状态管理核心代码 ---

        // 制作快照:Checkpoint 触发时执行

        /**
         * @note
         *   1. Flink 处理状态快照时的“两代安全保障机制”:
         *       getCheckpointLock() 属于旧版本(SourceFunction 时代)的锁机制;
         *       而 snapshotState() 是现代版本(CheckpointedFunction 接口)的生命周期回调;
         *   2. 它们之间的联系在于:确保在制作快照的那一瞬间,数据处理逻辑必须停下来,保证“状态”和“数据流位点”的一致性
         *   3. 核心联系:原子性 (Atomicity) - 无论使用哪种方式,Flink 的核心目标是防止以下情况发生:
         *       脏读/写:正在执行 ctx.collect(data) 发送数据并更新内存变量时,Checkpoint 线程突然介入保存状态。如果不加控制,保存的状态可能只更新了一半。
         *   4. 具体运作机制对比
         *       ctx.getCheckpointLock() —— “主动加锁”
         *       在传统的 SourceFunction 中,Flink 的 Checkpoint 线程和 run() 方法的数据产生线程是异步的。为了保证 Exactly-once,你需要手动同步。
         *       4.1 执行逻辑:当 Checkpoint 触发器尝试制作快照时,它会去申请这把锁。
         *       4.2 你的责任:在 SourceFunction 的 run() 方法中,你必须包裹这个锁
         *       // 旧版 Source 模式
         *       @Override
         *       public void run(SourceContext<String> ctx) {
         *           while (isRunning) {
         *               synchronized (ctx.getCheckpointLock()) {
         *                   // 只有拿到这把锁,才发送数据并更新本地变量
         *                   ctx.collect(data);
         *                   this.offset++;
         *               }
         *               // 锁释放后,Checkpoint 线程才有机会切进来执行快照
         *           }
         *       }
         *   5. snapshotState() —— “被动回调”
         *       当你实现 CheckpointedFunction 接口时,snapshotState 是由 Flink 框架控制的回调方法。
         *       1) 联系点:在 Flink 内部,当框架准备调用你的 snapshotState 之前,它实际上已经通过 Mailbox 机制(新架构)或 锁机制(旧架构)确保了 run() 方法中的逻辑处于暂停状态。
         *       2) 状态保存:在 snapshotState 执行期间,你可以放心地将内存变量(如 remainingDevices)同步到 ListState 中,因为此时数据处理是绝对静止的。
         *   6. 演变:从“同步锁”到“单线程邮箱”
         *       在 Flink 1.10 之后,Flink 引入了 Mailbox Model (邮箱模型),这是 getCheckpointLock() 逐渐被边缘化的原因:
         *       1) 旧架构 (Locking):多线程竞争。run() 线程和 Checkpoint 线程抢同一把锁。在高吞吐下,频繁抢锁会严重影响性能。
         *       2) 新架构 (Mailbox):单线程处理。数据处理、Checkpoint 任务、Timer 任务都被封装成一个个“邮件(Action)”,放在一个队列里,由一个【主线程】循环执行。
         *           联系:当 Checkpoint 邮件(Action)被处理时,它会自然而然地阻塞住后续的数据处理邮件。此时调用 snapshotState(),天生就是线程安全的,不再需要显式的 synchronized。
         *   7. 小结:
         *       如果你写的是老式的 SourceFunction,必须用 getCheckpointLock() 包裹 collect()。
         *       如果你写的是通用的算子逻辑,你应该关注如何在 snapshotState() 里把内存数据持久化到 ListState 等状态后端对象中
         * @param context
         * @throws Exception
         */
        @Override
        public void snapshotState(FunctionSnapshotContext context) throws Exception {//snapshotState() 是现代版本(CheckpointedFunction 接口)的生命周期回调
            checkpointedState.clear();
            // 将当前尚未处理完的进度存入 Checkpoint
            //for (Map<String, Object> deviceSignals : remainingDevicesSignals) {
            for (Map<String, Object> deviceSignals : currentHandleProcessDeviceSignals) {
                checkpointedState.add( JSON.toJSONString( deviceSignals ) );
            }
            //log.info("快照成功,remainingDevicesSignals: " + JSON.toJSONString( remainingDevicesSignals ));
            log.info("快照成功,currentHandleProcessDeviceSignals: " + JSON.toJSONString( currentHandleProcessDeviceSignals ));
        }

        // 初始化/恢复状态:作业启动或重启时执行
        @Override
        public void initializeState(FunctionInitializationContext context) throws Exception {
            ListStateDescriptor<String> stateDescriptor = new ListStateDescriptor<>(DEVICE_STATE_NAME, Types.STRING);
            checkpointedState = context.getOperatorStateStore().getListState(stateDescriptor);

            // 如果是从 Checkpoint 恢复
            int flag = 0;
            Integer latestOffset = null;
            if ( context.isRestored() ) {
                flag = 1;
                for (String deviceSignalsJson : checkpointedState.get()) {
                    //remainingDevicesSignals.add( JSON.parseObject( deviceSignalsJson ) );
                    Map<String, Object> deviceSignals = JSON.parseObject( deviceSignalsJson );
                    Map<String, Object> deviceSignalsValues = (Map<String, Object>) ( deviceSignals.entrySet().iterator().next().getValue( ) );
                    latestOffset = (Integer) deviceSignalsValues.get( OFFSET_PARAM );//eg(chk-255.bak): 5
                    currentHandleProcessDeviceSignals.add( deviceSignals );
                }
                remainingDevicesSignals.addAll( getDevicesSignalsDataset( latestOffset + 1) );//从指定的上一次失败的偏移量+1处开始拉取数据
                //log.info("成功从 Checkpoint 恢复,剩余处理任务: " + JSON.toJSONString(remainingDevicesSignals));
                log.info("成功从 Checkpoint 恢复,latestOffset:{}, currentHandleProcessDeviceSignals: {}, remainingDevicesSignals:{}"
                    , latestOffset, JSON.toJSONString(currentHandleProcessDeviceSignals), JSON.toJSONString(remainingDevicesSignals)
                );
            } else {
                // 第一次运行,初始化所有数据 到 remainingDevicesSignals
                remainingDevicesSignals.addAll( getDevicesSignalsDataset( null ) );
            }
            //log.info("状态初始化完成,flag: {}, remainingDevicesSignals: {}", flag, JSON.toJSONString(remainingDevicesSignals));
            log.info("状态初始化完成,flag: {}, latestOffset:{}, currentHandleProcessDeviceSignals: {}", flag, latestOffset, JSON.toJSONString(currentHandleProcessDeviceSignals));
        }

        /**
         * 模拟从消息队列等数据源中获取流式数据
         * @param offset
         * @return
         */
        @SneakyThrows
        private static List<Map<String, Object>> getDevicesSignalsDataset(Integer offset){
            InputStream deviceSignalsDatasetJsonResourceStream = DevicesSignalsRecoverableSource.class
                    .getClassLoader().getResourceAsStream("dataset/device-signals.json".replace("/", File.separator) );
            String deviceSignalsDatasetJson = FileUtils.readFile2Str( deviceSignalsDatasetJsonResourceStream );

            List< Map<String, Object > > deviceSignalsDataset = JSON.parseArray( deviceSignalsDatasetJson, (Type) Map.class);
            if(offset == null){//全量加载
                return deviceSignalsDataset;
            } else {
                List< Map<String, Object > > deviceSignalsDataset2 = new ArrayList<>();
                deviceSignalsDataset.stream().forEach(deviceSignals -> {
                    Map<String, Object> deviceSignalsValues = (Map<String, Object>) ( deviceSignals.entrySet().iterator().next().getValue( ) );
                    Integer dataElementOffset = (Integer) deviceSignalsValues.get( OFFSET_PARAM );//模拟: 获取消息队列的消息偏移量
                    if( dataElementOffset >= offset){
                        deviceSignalsDataset2.add( deviceSignals );
                    }
                });
                return deviceSignalsDataset2;
            }
        }
    }
}
  • FlinkJobUtils
package com.johnnyzen.flinkapi.utils;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;


public class FlinkJobUtils {

    /**
     * 将不定长字符串转换为16字节(MD5哈希算法)
     * @param jobName 不定长作业名称
     * @return 16字节数组
     * @throws NoSuchAlgorithmException 哈希算法不存在异常
     */
    public static byte[] convertTo16BytesByMD5(String jobName) throws NoSuchAlgorithmException {
        // 1. 获取MD5消息摘要实例(MD5固定生成128位=16字节哈希值)
        MessageDigest md5 = MessageDigest.getInstance("MD5");

        // 2. 将字符串转换为字节数组(指定字符集,避免编码不一致问题)
        byte[] jobNameBytes = jobName.getBytes(StandardCharsets.UTF_8);

        // 3. 计算哈希值,直接返回16字节结果
        return md5.digest(jobNameBytes);
    }

    public static void main(String[] args) {
        try {
            // 测试不定长字符串
            String[] testJobNames = {"453_HelloJob", "1234_TestJob_LongName123456", "ShortJob"};
            for (String jobName : testJobNames) {
                byte[] result = convertTo16BytesByMD5(jobName);
                System.out.printf("作业名称:%s,转换后字节数组长度:%d%n", jobName, result.length);
            }
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
        }
    }
}

CheckpointReader: 读取 指定的 checkpoint 的状态信息

package com.johnnyzen.flinkapi.demo.flink.exactlyonce;

import lombok.extern.slf4j.Slf4j;
import org.apache.flink.api.common.typeinfo.Types;
import org.apache.flink.api.java.ExecutionEnvironment;
import org.apache.flink.runtime.state.hashmap.HashMapStateBackend;
import org.apache.flink.state.api.ExistingSavepoint;
import org.apache.flink.state.api.Savepoint;

/**
 * @description ...
 * @dependency
 *   1. 如果你想通过 Java 程序打开 _metadata 文件并读取出 D1, D2... 这些设备 已持久化到 checkpoint/savepoint 的状态信息,你需要引入:
 *     <dependency><groupId>org.apache.flink</groupId><artifactId>flink-state-processor-api</artifactId><version>${flink.version}</version></dependency>
 * @refrence-doc
 * @gpt-promt
 */
@Slf4j
public class CheckpointReader {
    public static void main(String[] args) throws Exception {
        //StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
        ExecutionEnvironment env = ExecutionEnvironment.getExecutionEnvironment();//创建批处理执行环境 (DataSet API)

        // 1. 指定 Checkpoint/Savepoint 的路径
        //String checkpointPath = "file:///E:/tmp/checkpoint/9866061bb8a60fea0dd2a67785e8ec5b/chk-107.bak";//从内的 _metadata 文件读取
        String checkpointPath = "file:///E:/tmp/checkpoint/9866061bb8a60fea0dd2a67785e8ec5b/chk-255.bak";//从内的 _metadata 文件读取

        // 2. 加载状态(这里以读取我们之前 Demo 中的 ListState 为例)
        ExistingSavepoint savepoint = Savepoint.load(env, checkpointPath, new HashMapStateBackend());//Savepoint.load 仅支持 env 为 ExecutionEnvironment

        // 3. 读取特定算子的状态
        // "device-reader-source" 是你在代码里通过 .uid() 指定的 ID
        savepoint.readListState( // 从 savepoint 读取 ListState 状态
            FlinkExactlyOnceDemo.DEVICES_SIGNALS_RECOVERABLE_SOURCE_UID //参数 1: Operator ID (即你在代码中 .uid("xxx") 设置的值)
            , FlinkExactlyOnceDemo.DevicesSignalsRecoverableSource.DEVICE_STATE_NAME // 参数 2: State Name (即 ListStateDescriptor 中定义的名字)
            , Types.STRING // 参数 3: 状态中数据的类型 , 如: TypeInformation<String> = Types.STRING
        )
        .map( input -> {
            log.info( "input: {}", input );//eg(chk-255.bak): input: {"D5":{"offset":5,"bms_soc":335,"IC_totalMileage":45.39}} (仅此1条,提供上一次 checkpoint 完成时的数据流处理进度)
            return input;
        })
        .print();

        /**
         * 注意:DataSet API 的 print() 会直接触发执行,不需要手动再调用 env.execute(),否则报错:
         *   Exception in thread "main" java.lang.RuntimeException: No new data sinks have been defined since the last execution. The last execution refers to the latest call to 'execute()', 'count()', 'collect()', or 'print()'.
         *       at org.apache.flink.api.java.ExecutionEnvironment.createProgramPlan(ExecutionEnvironment.java:1165)
         *       ...
         */
        //env.execute("ReadingStateJob");
    }
}
  • 读取 KafkaSource 的 checkpoint
        //要读取 KafkaSource 的 Checkpoint 状态信息 (topic / partition / offset)
        savepoint.readListState(
            "DeviceSignals-BigdataKafka-KafkaSource" // 算子 ID: 必须是你在代码中给 KafkaSource 设置的 .uid("kafka-source") , uid 错误时将直接报错: Savepoint does not contain state with operator uid xxxx
            , "SourceReaderState" // 状态名称: FLIP-27 标准下,Source 算子的状态名统一固定为 "SourceReaderState"
            //, BytePrimitiveArraySerializer.INSTANCE // 序列化器: Kafka 的 Split 信息是以字节数组存储的,需要特殊处理 ; Flink 1.15 内部通常先存为 byte[]
            , PrimitiveArrayTypeInfo.BYTE_PRIMITIVE_ARRAY_TYPE_INFO // 正确的写法
        ).map( bytes -> {
            // 使用 Kafka 指定的序列化器将字节转回对象 | org.apache.flink.connector.kafka.source.split.KafkaPartitionSplitSerializer.deserialize
            KafkaPartitionSplitSerializer serializer = new KafkaPartitionSplitSerializer();
            //在 Flink 的 Source 框架(FLIP-27)中,版本号的管理遵循一套严格的机制。
            //在 Flink 1.15 中,KafkaPartitionSplitSerializer 的版本号通常是 0 ; 0 代表版本号 ; 版本号错误时可能会导致反序列化的结果错误
            KafkaPartitionSplit kafkaPartitionSplit = serializer.deserialize(serializer.getVersion(), bytes);
            return String.format("KafkaPartitionSplit: topic=%s, partition=%d, offset=%d",
                kafkaPartitionSplit.getTopic(),
                kafkaPartitionSplit.getPartition(),
                kafkaPartitionSplit.getStartingOffset()
            );
        }).print();

K 最佳实践

FlinkJobUtils#enableCheckpoint

  • FlinkJobUtils
/**
 * 启用 Checkpoint
 * 默认:开启 checkpoint (关联的自定义配置项: "state.backend" / "checkpoint.dir" / "checkpoint.interval" / "checkpoint.min.pause.interval" / "checkpoint.timeout" ) 
 */
public static void enableCheckpoint(String jobName, StreamExecutionEnvironment env, ParameterTool paraTool) throws IOException {
    StateBackend stateBackend = null;
    if (paraTool.get("checkpoint.dir") != null && "rocksdb".equals(paraTool.get("state.backend"))) {
        stateBackend = new RocksDBStateBackend( paraTool.get("checkpoint.dir") + "/" + jobName, true ); //true 即 enableIncrementCheckpointing = true; 或: new EmbeddedRocksDBStateBackend();
    } else if (paraTool.get("checkpoint.dir") != null) {
        stateBackend = new FsStateBackend(paraTool.get("checkpoint.dir") + "/" + jobName);
    } else {
        stateBackend = new MemoryStateBackend();
    }

    //设置 checkpoint 存储路径、及所依赖的状态后端(必须配置)
    env.setStateBackend((StateBackend)stateBackend); // 设置状态后端
    //env.getCheckpointConfig().setCheckpointStorage("hdfs://namenode:9000/flink/checkpoints"); //(可选代码行)

    env.enableCheckpointing( paraTool.getLong("checkpoint.interval", 300000L), CheckpointingMode.EXACTLY_ONCE); // 开启 checkpoint,每 XXXX 毫秒做一次 + 设置 checkpoint 模式:Exactly-Once(默认)
    //env.getCheckpointConfig().setCheckpointingMode(CheckpointingMode.EXACTLY_ONCE); // 设置 checkpoint 模式:Exactly-Once(默认)
    env.getCheckpointConfig().setMinPauseBetweenCheckpoints(paraTool.getLong("checkpoint.min.pause.interval", 60000L)); // 设置两次 checkpoint 之间的最小间隔(避免太频繁)
    env.getCheckpointConfig().setCheckpointTimeout( paraTool.getLong("checkpoint.timeout", 60000L) );// 设置 checkpoint 超时时间(默认 10 分钟,这里设为 1 分钟)
    env.getCheckpointConfig().setMaxConcurrentCheckpoints(1); // 允许同时进行的 checkpoint 数量(默认 1)
    env.getCheckpointConfig().enableExternalizedCheckpoints(ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION); // 外部化 checkpoint(策略 = 作业取消后保留)
    env.getCheckpointConfig().enableUnalignedCheckpoints(); // 启用-非对齐检查点
}
  • enableUnalignedCheckpoints() : Flink新特性之非对齐检查点 (unaligned checkpoint)

Flink 1.11版本引入的非对齐检查点Unaligned Checkpoints)是为了解决高反压场景下的检查点超时问题而设计的。
非对齐检查点允许Barrier越过排队数据直接传递,将阻塞数据纳入【状态快照】,从而实现【反压环境】下作业的【稳定性】。

  • 核心原理: 非对齐检查点允许Barrier越过排队数据直接传递,将阻塞数据纳入状态快照,实现反压环境下稳定的检查点。
  • 优势: 非对齐检查点在反压场景下成功率提升显著,但会带来30-50%的状态大小增加。
  • 最佳实践: 设置超时阈值 (如30秒)实现对齐/非对齐智能切换,配合增量检查点和缓冲区优化,可在电商大促等高峰场景将检查点成功率从30%提升至98%以上。

非对齐检查点已成为高反压、大数据量场景下Flink作业的必备技术。

  • Use Demo in Flink 业务程序
//enable checkpoint
if( "false".equals(jobParameterTool.get(FlinkJobConstants.CHECKPOINT_ENABLE_PARAM) ) ){// 自定义配置项: "checkpoint.enable"

} else {//默认:开启 checkpoint (关联的自定义配置项: "state.backend" / "checkpoint.dir" / "checkpoint.interval" / "checkpoint.min.pause.interval" / "checkpoint.timeout" ) 
    FlinkUtils.enableCheckpoint( flinkJobConfig.getJobName() , executionEnvironment, parameterTool);
}

Z FAQ for 检查点/保存点机制

试验1

配置 主流的 mysql cdc 的 startoptions = initial

停止 Job 时:
    主动勾选:保存到保存点

停止后:
    保存点: test-flink-runtime/jobs/savepoint/230445/2025-08-08_16-52-18/savepoint-c7115a-4294a042693e/_metadata

启动时
    主动勾选:从保存点恢复

启动后
    各个算子 发送的记录数、接受的记录数:会从 0 开始重新计数( 即使源有数据,只要启动后没有新增的、或 update,就应该会一直为 0 ),而非从停止前的数据开始计数。
    保存点会被自动删除掉

试验2

配置 主流的 mysql cdc 的 startoptions = initial

停止 Job 时,主动勾选:保存到保存点

保存点:
    test-flink-runtime/jobs/savepoint/230445/2025-08-08_16-52-18/savepoint-c7115a-4294a042693e/_metadata

启动时
    不勾选:从保存点恢复

启动后:
    各个算子 发送的记录数、接受的记录数:会从 0 开始重新计数(会从0开始消费数据,只要源有数据,就不会一直为0),而非从停止前的数据开始计数。
    保存点会被自动删除掉

试验3

配置 主流的 mysql cdc 的 startoptions = latest

停止 Job 时:
    主动勾选:保存到保存点

停止后:
    保存点: test-flink-runtime/jobs/savepoint/230445/2025-08-08_16-52-18/savepoint-c7115a-4294a042693e/_metadata

启动时
    主动勾选:从保存点恢复

启动后
    各个算子 发送的记录数、接受的记录数:会从 0 开始重新计数( 即使源有数据,只要启动后没有新增的、或 update,就应该会一直为 0 ),而非从停止前的数据开始计数。
    保存点会被自动删除掉

试验4

配置 主流的 mysql cdc 的 startoptions = latest

停止 Job 时,主动勾选:保存到保存点

保存点:
    test-flink-runtime/jobs/savepoint/230445/2025-08-08_16-52-18/savepoint-c7115a-4294a042693e/_metadata

启动时
    不勾选:从保存点恢复

启动后:
    各个算子 发送的记录数、接受的记录数:会从 0 开始重新计数(即使源有数据,只要启动后没有新增的、或 update,就应该会一直为 0),而非从停止前的数据开始计数。
    保存点会被自动删除掉

试验5

配置 主流的 mysql cdc 的 startoptions = initial

停止 Job 时,不主动勾选:保存到保存点

保存点:
    --

启动时
    [X,没有此选项] 从保存点恢复

启动后:
    各个算子 发送的记录数、接受的记录数:会从 0 开始重新计数(会从0开始消费数据,只要源有数据,就不会一直为0),而非从停止前的数据开始计数。
    保存点会被自动删除掉

试验6

配置 主流的 mysql cdc 的 startoptions = latest

停止 Job 时,不主动勾选:保存到保存点

保存点:
    --

启动时
    [X,没有此选项] 从保存点恢复

启动后:
    各个算子 发送的记录数、接受的记录数:会从 0 开始重新计数(即使源有数据,只要启动后没有新增的、或 update,就应该会一直为 0),而非从停止前的数据开始计数。
    保存点会被自动删除掉
  • Flink中的检查点用于【容错恢复】,而【保存点】则用于【手动维护和升级】,二者在触发方式和使用场景上有显著区别。

  • 检查点(Checkpoint)

  • 定义:检查点是Flink实现容错机制的核心功能,能够周期性地生成作业状态的快照,并将其持久化存储。当Flink程序意外崩溃时,可以从最近的检查点恢复状态,确保作业的准确性和一致性。
  • 触发方式:检查点是自动触发的,通常由Flink的TaskManager定期执行,用户无需干预。可以通过配置设置检查点的时间间隔和模式。
  • 存储格式:检查点的存储格式较轻量级,支持增量存储,适合频繁生成以降低恢复时间。
  • 使用场景:主要用于处理意外失败后的自动恢复,确保作业的连续性和数据的准确性。
  • 保存点(Savepoint)
  • 定义:保存点是通过检查点机制创建的,表示作业状态的完整快照,通常用于手动干预和维护。
  • 触发方式:保存点是用户手动触发的,适用于需要进行作业升级、迁移或A/B测试等场景。用户可以根据需要选择何时创建保存点。
  • 存储格式:保存点以二进制形式存储所有状态数据和元数据,执行速度较慢,但支持在代码升级或并行度变化后恢复。
  • 使用场景:适用于需要人工干预的场景,如作业升级、迁移或进行A/B测试时,确保状态的完整性和可恢复性。
  • 总结
  • 容错 vs 维护:检查点侧重于容错,确保在意外失败后能够快速恢复;而保存点则侧重于维护,允许用户在进行手动操作时保存状态并恢复。
  • 自动 vs 手动:检查点是自动生成的,频率较高;保存点则是手动触发的,适合特定的维护需求。

通过理解这两者的区别,用户可以更有效地管理Flink作业的状态和恢复策略。

Q: 以 MySQL cdc 或kafka 为source的flink作业未开启checkpoint,或启动时以无状态启动,可能会存在什么问题?

对于以 MySQL CDC 或 Kafka 为数据源的 Flink 作业,如果未开启 Checkpoint 或者以无状态方式启动,将会面临严重的数据可靠性和一致性问题。这两种情况本质上都导致作业无法记录和恢复数据处理的进度。

具体风险分析有:

🔁 无法保证数据一致性与精确一次处理

Flink 的容错机制核心在于 Checkpoint。它通过定期记录作业的全局状态(包括消费位点、聚合结果等)来实现故障恢复。

  • 未开启 Checkpoint:意味着作业失去了“自动存档”功能。一旦作业失败重启,它无法知道之前处理到了哪里,只能从数据源的初始位置(如 Kafka 的 earliest offset 或 MySQL binlog 的起点)重新开始读取。
  • 无状态启动:相当于强制作业忽略所有历史快照,从一个全新的起点开始运行。

这两种情况都会导致 At-Least-Once 语义,即数据会被重复处理。对于需要精确统计或幂等性不强的业务逻辑,这会造成严重的数据错误。

📊 具体场景分析

场景一:Source 为 MySQL CDC

MySQL CDC 作业通过读取数据库的 binlog 日志来捕获数据变更。Checkpoint 对于此场景至关重要。

  • 核心问题:全量阶段重复与数据错乱
    • 全量读取阶段:Flink CDC 在首次启动时,会先读取表的全量快照。如果在此阶段未开启 Checkpoint 或以无状态启动,一旦作业失败重启,它将重新执行全量读取。这不仅会再次消耗大量数据库资源,还会向下游发送重复的全量数据,导致下游数据膨胀或覆盖。
    • 增量读取阶段:CDC 作业依赖 Checkpoint 来记录当前读取到的 binlog 位置(filename + position)或 GTID。没有 Checkpoint,作业重启后无法恢复到故障前的精确位置,只能从 binlog 的某个默认起点(如 earliest)开始读取。这会导致大量历史变更被重复发送,或者如果配置不当,甚至可能丢失故障期间产生的部分数据变更。

场景二:Source 为 Kafka

Kafka 作为 Flink 的常见数据源,其消费位点(offset)的管理高度依赖 Checkpoint。

  • 核心问题:Offset 提交与数据重复
    • 自动提交失效:Flink Kafka Consumer 默认会禁用 Kafka 客户端的自动提交(enable.auto.commit=false),因为它依赖 Checkpoint 来以更可靠的方式提交 offset。如果未开启 Checkpoint,Flink 就无法将消费位点持久化。
    • 数据重复风暴:当作业失败重启时,由于没有有效的 Checkpoint 来恢复 offset,消费者将根据 auto.offset.reset 策略(默认为 latest 或 earliest)重新开始消费。这通常意味着成千上万条消息会被重复处理,造成下游数据重复。
    • 监控指标误导:Kafka 的消费延迟(Lag)监控可能会出现剧烈波动。因为 Flink 只有在 Checkpoint 成功时才会提交 offset,不开启 Checkpoint 会导致 Kafka 服务端认为消费者已经消费了数据(如果依赖其他机制提交),但实际上 Flink 重启后又重新消费了,造成数据处理逻辑与监控状态不一致。

📌 总结

总而言之,对于任何需要高可靠性和精确处理语义的生产环境作业,尤其是基于 CDC 或消息队列的流处理任务,必须开启并正确配置 Checkpoint

场景 未开启 Checkpoint / 无状态启动的后果
MySQL CDC 全量阶段重复读取,增量阶段重复处理历史变更或丢失数据,导致数据错乱。
Kafka 无法提交消费位点,作业重启后大量消息被重复处理,导致数据重复。

最佳实践建议

  1. 务必开启 Checkpoint,并根据业务需求设置合理的间隔(如 5-10 秒)。
  2. 使用 RocksDBStateBackend 以支持大状态和增量 Checkpoint,降低对作业性能的影响。
  3. 配置 Exactly-Once 语义,确保端到端的数据一致性。
  4. 避免使用无状态启动,除非是全新的作业或明确需要丢弃历史状态的特殊情况。

Q: Flink中为算子配置 uid 和 name 分别有什么作用和区别?

问题描述

  • Flink中为算子配置 uid 和 name 分别有什么作用和区别?
DataStream<Tuple2<AlarmRecord, DimXxxCode>> xxxBackendAlarmRecordsWithDimXxxCodeDataStream = xxxBackendAlarmRecordsMysqlCdcDataStream
	.map( new DimXxxCodeMapFunction() )
	/**
	 * name
	 * 1. 用于给算子一个可读的名称,方便在 WEB UI 或日志中识别
	 * 2. name 的设置或调整,不影响作业的执行逻辑,仅是提高程序可维护性————便于监控和调试。
	 * 3. 允许重复(但不建议这么做)
	 */
	.name( "Map-BigdataRedis-DimXxxCode" )
	/**
	 * uid: 用户定义的算子唯一标识符
	 * 1. 在作业重启或版本升级时保持状态的一致性,其影响状态恢复和作业的稳定性,不建议随意变更
	 * 2. 当修改作业拓扑时,如果没有显式设置uid,flink会自动生成uid,这可能导致状态无法正确修复(因: flink 通过 uid 来关联状态)
	 * 3. 要求唯一、且稳定
	 */
	.uid( "Map-BigdataRedis-DimXxxCode" );
FlinkJobUtils.setParallelism(jobParameterTool, xxxBackendAlarmRecordsWithDimXxxCodeDataStream, DwdAlarmRecordsRiConstants.Parallel.MAP_DIM_Xxx_CODE_PARAM, null);

问题分析

  • name
    1. 用于给算子一个可读的名称,方便在 WEB UI 或日志中识别
    1. name 的设置或调整,不影响作业的执行逻辑,仅是提高程序可维护性————便于监控和调试。
    1. 允许重复(但不建议这么做)
  • uid: 用户定义的算子唯一标识符
    1. 在作业重启或版本升级时保持状态的一致性,其影响状态恢复和作业的稳定性,不建议随意变更
    1. 当修改作业拓扑时,如果没有显式设置uid,flink会自动生成uid,这可能导致状态无法正确修复(因: flink 通过 uid 来关联状态)
    1. 要求唯一、且稳定

参考文献

Q:基于保存点恢复并启动Flink时报错:Caused by: java.lang.IllegalArgumentException: Key group 1 is not in KeyGroupRange{startKeyGroup=64, endKeyGroup=127}. Unless you're directly using low level state access APIs, this is most likely caused by non-deterministic shuffle key (hashCode and equals implementation).

问题描述

2025-08-18 15:02:59,138 WARN  org.apache.flink.runtime.taskmanager.Task                    1132 [Filter -> Map -> Filter -> Map-BigdataRedis-DimFaultCode -> Map-xxxBackendMysql-deviceRemoteConfigVersion -> Map-BigdataRedis-Dimdevice (2/2)#4]  - Filter -> Map -> Filter -> Map-BigdataRedis-DimFaultCode -> Map-xxxBackendMysql-deviceRemoteConfigVersion -> Map-BigdataRedis-Dimdevice (2/2)#4 (ca3a4e1bbeabcfb04b904447536f614a) switched from RUNNING to FAILED with failure cause:
java.lang.RuntimeException: Exception occurred while setting the current key context.
	at org.apache.flink.streaming.api.operators.StreamOperatorStateHandler.setCurrentKey(StreamOperatorStateHandler.java:480) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.api.operators.AbstractStreamOperator.setCurrentKey(AbstractStreamOperator.java:549) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.api.operators.AbstractStreamOperator.setKeyContextElement(AbstractStreamOperator.java:544) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.api.operators.AbstractStreamOperator.setKeyContextElement1(AbstractStreamOperator.java:531) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.api.operators.OneInputStreamOperator.setKeyContextElement(OneInputStreamOperator.java:36) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.runtime.tasks.OneInputStreamTask$StreamTaskNetworkOutput.emitRecord(OneInputStreamTask.java:232) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.runtime.io.AbstractStreamTaskNetworkInput.processElement(AbstractStreamTaskNetworkInput.java:134) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.runtime.io.AbstractStreamTaskNetworkInput.emitNext(AbstractStreamTaskNetworkInput.java:105) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.runtime.io.StreamOneInputProcessor.processInput(StreamOneInputProcessor.java:65) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.runtime.tasks.StreamTask.processInput(StreamTask.java:539) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.runtime.tasks.mailbox.MailboxProcessor.runMailboxLoop(MailboxProcessor.java:216) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.runtime.tasks.StreamTask.runMailboxLoop(StreamTask.java:829) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.runtime.tasks.StreamTask.invoke(StreamTask.java:778) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.runtime.taskmanager.Task.runWithSystemExitMonitoring(Task.java:958) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.runtime.taskmanager.Task.restoreAndInvoke(Task.java:937) [flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.runtime.taskmanager.Task.doRun(Task.java:751) [flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.runtime.taskmanager.Task.run(Task.java:573) [flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at java.lang.Thread.run(Thread.java:750) [?:1.8.0_422]
Caused by: java.lang.IllegalArgumentException: Key group 58 is not in KeyGroupRange{startKeyGroup=64, endKeyGroup=127}. Unless you're directly using low level state access APIs, this is most likely caused by non-deterministic shuffle key (hashCode and equals implementation).
	at org.apache.flink.runtime.state.KeyGroupRangeOffsets.newIllegalKeyGroupException(KeyGroupRangeOffsets.java:37) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.runtime.state.heap.InternalKeyContextImpl.setCurrentKeyGroupIndex(InternalKeyContextImpl.java:77) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.runtime.state.AbstractKeyedStateBackend.setCurrentKey(AbstractKeyedStateBackend.java:194) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	at org.apache.flink.streaming.api.operators.StreamOperatorStateHandler.setCurrentKey(StreamOperatorStateHandler.java:478) ~[flink-dist-1.15.0-h0.cbu.dli.330.20241021.r34.jar:1.15.0-h0.cbu.dli.330.20241021.r34]
	... 17 more
2025-08-18 15:02:59,139 WARN  org.apache.flink.runtime.taskmanager.Task                    1139 [Filter -> Map -> Filter -> Map-BigdataRedis-DimFaultCode -> Map-xxxBackendMysql-deviceRemoteConfigVersion -> Map-BigdataRedis-Dimdevice (2/2)#4]  - Call stack:
    at java.lang.Thread.getStackTrace(Thread.java:1564)
    at org.apache.flink.runtime.taskmanager.Task.transitionState(Task.java:1139)
    at org.apache.flink.runtime.taskmanager.Task.doRun(Task.java:801)
    at org.apache.flink.runtime.taskmanager.Task.run(Task.java:573)
    at java.lang.Thread.run(Thread.java:750)

2025-08-18 15:02:59,139 WARN  org.apache.flink.runtime.taskmanager.Task                    1139 [Filter -> Map -> Filter -> Map-BigdataRedis-DimFaultCode -> Map-xxxBackendMysql-deviceRemoteConfigVersion -> Map-BigdataRedis-Dimdevice (1/2)#4]  - Call stack:
    at java.lang.Thread.getStackTrace(Thread.java:1564)
    at org.apache.flink.runtime.taskmanager.Task.transitionState(Task.java:1139)
    at org.apache.flink.runtime.taskmanager.Task.doRun(Task.java:801)
    at org.apache.flink.runtime.taskmanager.Task.run(Task.java:573)
    at java.lang.Thread.run(Thread.java:750)
...

这个异常:

Caused by: java.lang.IllegalArgumentException: Key group 1 is not in KeyGroupRange{startKeyGroup=64, endKeyGroup=127}. Unless you're directly using low-level state access APIs, this is most likely caused by non-deterministic shuffle key (hashCode and equals implementation).

是 Flink 在恢复状态时(如重启、从 checkpoint/savepoint 恢复)常见的报错,核心原因是 key 的 hashCodeequals 方法不合法或不一致,导致 key 被错误地分配到不属于它的 key group。


问题本质

Flink 使用 key group 来分片 ·keyed state·。每个 key 会被映射到一个 key group(通过 KeyGroupRangeAssignment.assignKeyToParallelOperator()),而这个映射依赖于 key.hashCode()

如果:

  • hashCode() 不稳定(比如依赖可变字段或随机值);
  • equals()hashCode() 不遵循 Java 规范(即两个 equal 的对象 hashCode 不一致);
  • 或者你在恢复时使用了不同的 key 类型(比如类结构变了);

就会导致 key 被错误地分配到不属于它的 key group,从而触发这个异常。


如何排查

1. 检查 key 的类型

确认你使用的 key(比如 POJO、Tuple、String、Long 等)是否:

  • 实现了 hashCode()equals()
  • 这两个方法是稳定的(不依赖可变字段、时间戳、随机数等);
  • 满足 Java 规范:equal 的对象必须有相同的 hashCode

2. 是否有使用自定义 key

如果你用了自定义类作为 key:

public class MyKey {
    public String id;
    public long timestamp;

    @Override
    public boolean equals(Object o) { ... }

    @Override
    public int hashCode() { ... }
}

确保 hashCode()equals()基于不可变字段实现的。

3. 是否修改过 key 类型或结构

如果你从 checkpoint/savepoint 恢复,但期间修改了 key 类的结构(比如加了字段、改了 hashCode() 实现),也会导致这个错误。


解决方案

✅ 方案一:修复 key 的 hashCode()equals()

确保 key 的 hashCode()equals() 满足以下要求:

  • 不变性:多次调用 hashCode() 返回相同值;
  • 一致性:equals() == truehashCode() 必须相同;
  • 不依赖可变字段或外部状态。

示例修复:

@Override
public int hashCode() {
    return Objects.hash(id); // 只使用不可变字段
}

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    MyKey myKey = (MyKey) o;
    return Objects.equals(id, myKey.id);
}

✅ 方案二:清除状态,重新启动

如果你已经修复了 key 的问题,但旧 checkpoint/savepoint 中保存的状态仍有问题,可以尝试:

  • 丢弃旧 checkpoint/savepoint,重新启动作业;
  • 或者使用 --allowNonRestoredState(仅当你确定状态不重要);
  • 或者使用 state processor API 修复状态(高级用法)。

注意事项

如果你使用的是 Flink SQL,key 是由 SQL 的 GROUP BYPRIMARY KEY 自动生成的,通常不会有问题。但如果你用了 UDTFUDAF 或自定义 connector,也可能引入不合法 key。


简单总结

这个错误 99% 是因为 key 的 hashCode()equals() 不合法或不一致,导致 key group 分配错误。修复 key 的实现,或清除旧状态重新启动作业即可。

如果你能贴出你用的 key 类型或相关代码,我可以帮你具体看看哪里有问题。

参考文献

Caused by: java.lang.IllegalArgumentException: Key group 45 is not in KeyGroupRange{startKeyGroup=0, endKeyGroup=42}.
        at org.apache.flink.runtime.state.KeyGroupRangeOffsets.computeKeyGroupIndex(KeyGroupRangeOffsets.java:142)
        at org.apache.flink.runtime.state.KeyGroupRangeOffsets.setKeyGroupOffset(KeyGroupRangeOffsets.java:104)
        at org.apache.flink.contrib.streaming.state.RocksDBKeyedStateBackend$RocksDBFullSnapshotOperation.writeKVStateData(RocksDBKeyedStateBackend.java:664)
        at org.apache.flink.contrib.streaming.state.RocksDBKeyedStateBackend$RocksDBFullSnapshotOperation.writeDBSnapshot(RocksDBKeyedStateBackend.java:521)
        at org.apache.flink.contrib.streaming.state.RocksDBKeyedStateBackend$3.performOperation(RocksDBKeyedStateBackend.java:417)
        at org.apache.flink.contrib.streaming.state.RocksDBKeyedStateBackend$3.performOperation(RocksDBKeyedStateBackend.java:399)
        at org.apache.flink.runtime.io.async.AbstractAsyncIOCallable.call(AbstractAsyncIOCallable.java:72)
        at java.util.concurrent.FutureTask.run(FutureTask.java:266)
        at org.apache.flink.util.FutureUtil.runIfNotDoneAndGet(FutureUtil.java:40)
        at org.apache.flink.streaming.runtime.tasks.StreamTask$AsyncCheckpointRunnable.run(StreamTask.java:897)
        ... 5 more
    [CIRCULAR REFERENCE:java.lang.IllegalArgumentException: Key group 45 is not in KeyGroupRange{startKeyGroup=0, endKeyGroup=42}.]


问题是我的数据对象(PO​​JO)具有可变的哈希码。具体来说,哈希代码包含枚举。例如,如果我有一个汽车流,其哈希码由汽车年份和汽车类型(枚举)组成,如下所示。

Car {
   private final CarType carType;
   private final int carYear

   public long hashCode() {
     int result = 17;
     result = 31 * result + carYear;
     result = 31 * result + carType.hasCode();  <---- This is mutable!
   }
}

枚举的hashCode本质上是Object.hashCode()(取决于内存地址)。随后,一台机器(或进程)上的hashCode将与另一台机器(或进程)上的hashCode不同。这也解释了为什么我只在分布式环境中运行而不是本地运行时才遇到此问题。

为了解决这个问题,我将hashCode()更改为不可变的。做String.hashCode()的性能很差,所以我可能需要对其进行优化。但是下面对Car的定义将解决此问题。

Car {
   private final CarType carType;
   private final int carYear

   public long hashCode() {
     int result = 17;
     result = 31 * result + carYear;
     result = 31 * result + carType.name().hasCode();  <---- This is IMMUTABLE!
   }
}

Q: 给Flink初步入门的工程师关于检查点/保存点方面的建议?

项目 建议
是否开启 Checkpoint? 必须开! 否则状态无法恢复
默认用哪种语义? Exactly-Once(除非对延迟极度敏感)
Checkpoint 间隔设多少? 通常 10s~60s,太短影响性能,太长丢失数据多
状态太大怎么办? 使用 RocksDBStateBackend(支持增量 checkpoint)
作业失败怎么恢复? 从 Web UI 或命令行指定 checkpoint 路径重启

既然你对 Barrier (检查点分界线) 感兴趣,这正是理解 Flink 如何在“不停止程序”的情况下实现全局一致性快照的关键。

如果把 Flink 的数据流比作一条流水线,那么 Checkpoint 如果采用“暂停一切”的方式(Stop-the-world),性能会极差。Flink 采用了 Chandy-Lamport 算法 的变体,通过 Barrier 实现了非阻塞的快照

1. Barrier 究竟是什么?

  • Checkpoint/Savepoint Barrier 是一种特殊的控制事件。它由 JobManager 产生,注入到 Source 算子中,并随着数据流一起向下游流动。
  • 它是分界线:它将数据流在时间上划分为两个部分。Barrier 之前的数据属于“本次 Checkpoint”;Barrier 之后的数据属于“下次 Checkpoint”。
  • 它是无形的信号:普通算子看到数据就处理,看到 Barrier 就触发“【保存状态】”的动作。

2. 核心机制:Barrier 对齐 (Barrier Alignment)

这是 Exactly-once 的核心。当一个算子有多个输入流(比如两个 Source 关联)时,过程如下:

  1. 到达第一个 Barrier:算子收到流 A 的 Barrier,但流 B 的 Barrier 还没到。
  2. 阻塞流 A:算子会停止处理流 A 之后的数据(缓存起来),继续处理流 B 的数据。
  3. 对齐成功:当流 B 的 Barrier 也到达时,算子知道:“OK,现在两个流都已经处理完该 Checkpoint 之前的所有数据了。”
  4. 快照与向下广播:算子执行 snapshotState(),然后将 Barrier 向下游所有节点发送。
  5. 释放阻塞:开始处理刚才缓存的流 A 的数据,以及流 B 的后续数据。

3. 为什么需要对齐?(Exactly-once vs At-least-once)

  • Exactly-once (精确一次):必须对齐。如果不阻塞流 A,流 A 属于下个 Checkpoint 的数据就会被提前处理并计入当前状态。如果此时发生崩溃,重启后流 A 会重放,这些数据就会被处理两次
  • At-least-once (至少一次):不需要对齐(非对齐 Checkpoint)。算子收到第一个 Barrier 就不管三七二十一直接做快照。这样速度快,但会导致状态中包含了一部分“未来”的数据,重启后会产生重复。

4. 关键代码层面的体现

在 Flink 源码或高级自定义算子中,Barrier 的流动会触发 AbstractStreamOperatorsnapshotState 方法。

对于之前("综合案例")写的 Demo:

  1. Source 端:当 JobManager 告诉 Source 要做 Checkpoint 时,Source 停止发送数据,执行 snapshotState(保存进度),然后发送一个 Barrier 给下游。
  2. Map 端:Map 收到 Barrier,知道 Source 已经把 Barrier 之前的数据发完了。Map 执行自己的 snapshotState,再把 Barrier 发给 Sink。
  3. Sink 端:Sink 收到所有上游的 Barrier 后,执行快照。当所有算子都确认快照完成后,JobManager 宣布:本次 Checkpoint 成功!

5. 总结

步骤 动作 目的
注入 JobManager 向 Source 注入 Barrier 标记快照开始
对齐 下游算子等待所有输入端的 Barrier 保证数据位点一致性
持久化 调用 snapshotState() 将内存状态写入 HDFS/磁盘
提交 算子向 JM 汇报完成 确认快照有效

这就是 Flink 强大一致性的来源。至此,你已经从“如何写代码”学到了“底层如何运行”。
为了检验学习成果,可尝试挑战一下:手动模拟一个失败场景,并观察 Flink 是如何利用刚才讲的这些机制从本地文件恢复数据的.

  • 关键信息:
Checkpoint Counts Triggered: 10576 In Progress: 0 Completed: 10576 Failed: 0 Restored: 6
...
Checkpoint Detail:Path: obs://xxx-flink-runtime/jobs/checkpoint/XXXTEST_XXXXXEventTrigger/22447eb336f64bb57cd04618a551b0d9/chk-11231 Discarded: - Checkpoint Type: unaligned checkpoint
...

image

对于Flink初级数据开发人员来说,学会看 Flink Web UI 的 Checkpoint 统计面板是排查线上问题的第一步。你提供的这组数据展示了一个非常健康且运行时间较长的任务。

1. 指标逐项解析

指标名称 当前值 深度解读
Triggered 10576 自任务启动以来,总共尝试触发的 Checkpoint 次数。 这个数字很大说明你的任务已经稳定运行了很久(例如:如果是 1 分钟一次,这已经运行了约 7 天)。
In Progress 0 当前正在进行(尚未完成)的 Checkpoint 数量。 正常情况下应为 0 或 1。如果这个数字长期大于 0,说明 Checkpoint 制作速度跟不上触发速度,产生了堆积。
Completed 10576 成功完成并持久化到状态后端的次数。 你的 Completed 等于 Triggered,说明成功率 100%,状态后端(如 HDFS/S3)响应非常及时,网络无拥塞。
Failed 0 失败的次数。 如果此项不为 0,通常是因为:1. 状态过大导致超时;2. 网络闪断;3. 磁盘空间不足。
Restored 6 恢复次数。 这代表任务由于异常报错或手动重启,从之前的 Checkpoint 恢复并继续运行了 6 次。这证明了你的 Exactly-Once 机制生效了 6 次。

2. 隐藏在数据背后的信息

为什么会有 Restored: 6

  • 虽然你的 Failed 是 0,但 Restored 是 6。这意味着:
    • 任务可能经历了 6 次失败重启(比如 TaskManager 挂了、或者是你手动停止后带状态重启)。
    • 关键点Failed 指的是“制作快照的过程失败”,而 Restored 指的是“由于 Job 失败,从快照恢复”。两者是不同的概念。

健康检查:

你的 Triggered == CompletedFailed == 0。这说明:

  1. Source 位点记录准确:每次 Barrier 流过都很顺畅。
  2. 状态后端压力极小:存储介质能完美处理快照写入。
  3. Barrier 对齐顺畅:没有严重的背压(Backpressure),数据流速均匀。

3. 下一步:点进 History 详情页看什么?

  • 当你点击 History 选项卡时,应该重点关注单次 Checkpoint 的以下三个参数:
  • Checkpointed Data Size
    这是增量上传到状态后端的数据量。如果突然暴增,说明业务量激增或逻辑有变。
  • End to End Duration
    从触发到完成的总耗时。如果这个时间接近你的 Checkpoint 间隔时间,任务就会变得非常危险(容易触发拥塞)。
  • Alignment Duration(对齐耗时)
    这是衡量反压的关键指标。如果这个值很高,说明某个上游算子处理太慢,导致 Barrier 在等待对齐。

💡 职业建议

作为一个正在学习 Checkpoint 的开发人员,你可以尝试故意断开数据库连接在 Map 算子中手动抛出 RuntimeException

此时你会观察到:

  1. Failed 可能会增加 1(如果崩溃发生在快照期间)。
  2. 任务自动重启后,Restored 会从 6 变成 7。
  3. 你的 remainingDevices 列表会从上一个成功的快照位置继续,而不是从头开始。
  • 在 Flink + Kafka + Database 的全链路架构中,实现 Exactly-Once(精确一次) 语义的核心在于将 Flink 的 Checkpoint 机制 与 外部系统的 事务能力(如 Kafka 的幂等性与事务回滚、数据库的 ACID 事务)通过 两阶段提交(2PC) 协议进行“握手”。

以下是 Gemni AI 对该流程落地的详细拆解:

1. 核心组件的角色分配

要实现全链路 Exactly-Once,链路中的每一环都必须给力:

  • Source (Kafka): 记录偏移量(Offset),支持从特定位置重放。
  • Flink Engine: 充当 协调者(Coordinator),通过 Checkpoint 驱动全局事务。
  • Sink (Kafka/DB): 充当 参与者(Participant),必须支持事务(开始、预提交、提交/回滚)。

2. 两阶段提交(2PC)的具体流程

Flink 的 TwoPhaseCommitSinkFunction 抽象了这一过程,具体分为以下阶段:

第一阶段:预提交 (Pre-commit)

  1. 屏障对齐: JobManager 向 Source 发送 Checkpoint Barrier。Source 收到后,持久化当前的 Offset。
  2. 数据下发: Barrier 随着数据流向下游 Sink。在 Barrier 到达之前,Sink 正常接收数据。
  3. 开启事务: Sink 在接收到数据时,会开启一个外部系统的事务(如 BEGIN TRANSACTION)。
  4. 写入但不提交:
  • Kafka Sink: 调用 producer.beginTransaction(),数据写入 Kafka 但标记为 "Uncommitted"。
  • DB Sink: 数据写入数据库事务缓冲区,但不执行 COMMIT
  1. 状态快照: 当 Barrier 到达 Sink 时,Sink 固定当前事务 ID,将其写入 Flink 状态(State),并向 JobManager 返回 ACK

第二阶段:正式提交 (Commit)

  1. 决策发出: 当 JobManager 收集到所有算子的 ACK 后,确认本次 Checkpoint 成功,并向所有 Sink 发送“成功确认”。
  2. 执行 Commit:
  • Kafka Sink: 调用 producer.commitTransaction(),数据对消费者变为可见。
  • DB Sink: 执行真正的数据库 COMMIT 操作。
  1. 异常回滚: 如果 Checkpoint 失败,JobManager 通知 Sink 回滚事务(ABORT),Sink 清理掉预提交的数据。

3. 不同 Sink 的落地差异

虽然理论一致,但在数据库和 Kafka 上的具体实现略有不同:

特性 Kafka Sink (TwoPhaseCommit) Database Sink (JDBC)
底层支持 依赖 Kafka 幂等性与事务 ID 依赖数据库自身的 Connection 事务
超时挑战 需要 transaction.timeout.ms > Checkpoint 间隔 需要处理长连接失效或数据库锁问题
消费者配置 必须设置 isolation.level=read_committed 默认提交后即见

4. 落地中的关键隐患(避坑指南)

注意: 仅仅用了 2PC 并不代表万事大吉。

  • Kafka 事务超时: 如果 Flink 重启时间超过了 Kafka 的事务超时时间,那些处于“预提交”状态的事务会被 Kafka 强制终止,导致数据丢失。
  • 数据库连接管理: 在 2PC 过程中,数据库连接必须保持开启直到 Checkpoint 完成。如果连接中途断开,预提交的内容会回滚。
  • 幂等性的兜底: 2PC 增加了延迟(数据必须等 Checkpoint 完成才可见)。如果业务对延迟敏感,通常建议使用 “幂等写入 + 快速 Checkpoint”(如 INSERT ON DUPLICATE KEY UPDATE)作为替代方案。

5. 总结

Flink-Kafka-Database 链路中,2PC 的落地本质上是 将外部系统的事务生命周期绑定到 Flink 的 Checkpoint 周期上

  • Checkpoint 开始 = 开启外部事务。
  • Checkpoint Barrier 到达 Sink = 预提交。
  • Checkpoint 完成通知 = 正式 Commit。
  • 针对这三种 Sink,虽然都遵循两阶段提交(2PC)的逻辑,但在实际落地时,由于系统底层架构的差异,它们的实现手段侧重点完全不同。

以下是具体的落地对比:

1. Kafka Sink (最标准的 2PC)

Flink 官方提供的 KafkaSink 是两阶段提交的教科书级实现。

  • 实现机制: 利用 Kafka 0.11+ 引入的事务型 Producer

  • 落地过程:

  • 预提交: Flink 开启 Kafka 事务,数据正常发送,但带有 uncommitted 标记。

  • 提交: Checkpoint 完成后,调用 producer.commitTransaction()

  • 关键配置:

  • 下游消费: 消费者必须设置 isolation.level = read_committed,否则会读到预提交但最终可能回滚的数据。

  • 超时设置: Kafka 的 transaction.timeout.ms 必须大于 Flink 的 Checkpoint 间隔 + 最大延迟,否则事务会被 Kafka 强制关闭。

  • 代码示例

Kafka Sink (官方原生支持)
Flink 1.14+ 推荐使用 KafkaSink 构建器,它天然支持两阶段提交。

KafkaSink<String> sink = KafkaSink.<String>builder()
    .setBootstrapServers("localhost:9092")
    .setRecordSerializer(KafkaRecordSerializationSchema.builder()
        .setTopic("target-topic")
        .setValueSerializationSchema(new SimpleStringSchema())
        .build()
    )
    // 关键配置:开启 EXACTLY_ONCE 语义
    .setDeliveryGuarantee(DeliveryGuarantee.EXACTLY_ONCE)
    // 必须设置唯一的事务 ID 前缀
    .setTransactionalIdPrefix("flink-kafka-2pc-")
    .build();

stream.sinkTo(sink);

注意: 消费者端必须配置 isolation.level=read_committed 才能体现 2PC 的效果。

2. MySQL Sink (插件化的事务控制)

对于 MySQL 等传统 RDBMS,落地 2PC 依赖于 JDBC 事务连接

  • 实现机制: 通过 TwoPhaseCommitSinkFunction 包装 JDBC 连接。

  • 落地过程:

  • 预提交: 关闭自动提交 connection.setAutoCommit(false)。在 Checkpoint 触发前执行 INSERT 操作(此时数据在数据库 Undo Log 中,处于锁定状态但未生效)。

  • 提交: 收到通知后执行 connection.commit()

  • 挑战:

  • 连接开销: 事务长期不提交会占用数据库连接和行锁,高并发下容易拖垮 MySQL。

  • 僵尸事务: 如果 Flink Job 崩溃且重启时间过长,MySQL 可能因超时自动断开连接并回滚,导致 2PC 失效。

  • 优化建议: 生产环境更推荐使用 幂等写入 (ON DUPLICATE KEY UPDATE),配合 Checkpoint 失败重试,比 2PC 更轻量稳健。

  • 代码示例

MySQL Sink (基于 JDBC 事务)
对于 MySQL,通常使用 JdbcSinkexactlyOnceSink 方法。它要求数据库驱动支持 XA 事务(分布式事务)或者在单个连接内控制事务。

stream.addSink(
    JdbcSink.exactlyOnceSink(
        "INSERT INTO users (name, age) VALUES (?, ?)",
        (ps, user) -> {
            ps.setString(1, user.name);
            ps.setInt(2, user.age);
        },
        JdbcExecutionOptions.builder()
            .withMaxRetries(3)
            .build(),
        // 关键配置:JDBC 事务选项
        JdbcExactlyOnceOptions.builder()
            // 推荐使用 XA 事务以保证跨 Checkpoint 的安全性
            .withTransactionPerConnection(true)
            .build(),
        new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
            .withUrl("jdbc:mysql://localhost:3306/db")
            .withDriverName("com.mysql.cj.jdbc.Driver")
            .withUser("root")
            .withPassword("password")
            .build()
    )
);

3. ClickHouse Sink (最特殊的实现)

ClickHouse 的情况最为特殊,因为它对事务支持较弱,且由于其 LSM-Tree 架构,频繁的小批次写入会产生过多的 Data Part,导致 Too many parts 错误。

  • 实现机制: 通常不直接使用官方底层事务,而是通过 临时表(Temporary Tables)本地文件预写 模拟 2PC。

  • 落地过程:

  • 预提交: 每一个 Checkpoint 周期内,将数据写入一个临时分区或临时表。

  • 提交: Checkpoint 成功后,通过 ATTACH PARTITIONRENAME TABLE 原子性地将临时数据移动到正式表。

  • 现实妥协:

  • 大多数 ClickHouse Sink 采用 “攒批 + 幂等” 逻辑。即:在内存攒一批数据(通常由 Checkpoint 触发写入),并利用 ClickHouse 的 ReplacingMergeTree 引擎根据主键进行去重,实现最终一致性,而非严格意义上的实时 2PC。

  • 代码示例

ClickHouse Sink (幂等写入 + 攒批)
由于 ClickHouse 对 2PC 支持不佳(XA 事务极慢),社区主流方案是利用其 ReplacingMergeTree 引擎实现“伪 Exactly-Once”。代码上重点在于 攒批写入。

// 使用第三方库(如 clickhouse-jdbc 配合 Flink JDBC Sink)
stream.addSink(
    JdbcSink.sink(
        "INSERT INTO ck_table (id, sign, version) VALUES (?, ?, ?)",
        (ps, row) -> {
            ps.setLong(1, row.id);
            ps.setInt(2, 1); // 状态标记
            ps.setLong(3, System.currentTimeMillis()); // 版本号用于 ReplacingMergeTree 去重
        },
        // ClickHouse 必须攒批,否则会报 Too many parts 错误
        JdbcExecutionOptions.builder()
            .withBatchSize(5000)      // 5000条刷一次
            .withBatchIntervalMs(2000) // 或2秒刷一次
            .build(),
        new JdbcConnectionOptions.JdbcConnectionOptionsBuilder()
            .withUrl("jdbc:clickhouse://localhost:8123/default")
            .withDriverName("com.clickhouse.jdbc.ClickHouseDriver")
            .build()
    )
);

逻辑: 即使 Checkpoint 失败重跑,相同 id 的数据再次写入,ClickHouse 的 ReplacingMergeTree 会根据 version 合并重复行。

  • 实现一个基于 TwoPhaseCommitSinkFunction 的自定义 JDBC Sink 是理解 Flink 分布式事务最好的方式。

其核心思想是:将数据库连接(Connection)对象作为事务状态(Transaction State)在 Checkpoint 之间传递。

import org.apache.flink.api.common.ExecutionConfig;
import org.apache.flink.api.common.typeutils.base.VoidSerializer;
import org.apache.flink.api.java.typeutils.runtime.kryo.KryoSerializer;
import org.apache.flink.streaming.api.functions.sink.TwoPhaseCommitSinkFunction;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

/**
 * 自定义支持 2PC 的 JDBC Sink
 * Input: 写入的数据类型
 * Transaction: 事务上下文(这里包含 Connection)
 * Context: 辅助上下文(通常为 Void)
 */
public class MyTwoPhaseJdbcSink extends TwoPhaseCommitSinkFunction<String, MyTwoPhaseJdbcSink.ConnectionHolder, Void> {

    public MyTwoPhaseJdbcSink() {
        // 传入事务状态序列化器,ConnectionHolder 建议使用 Kryo 或自定义
        super(new KryoSerializer<>(ConnectionHolder.class, new ExecutionConfig()), VoidSerializer.INSTANCE);
    }

    // 事务持有类,方便在状态中流转
    public static class ConnectionHolder {
        transient Connection connection;
        String transactionId; // 可选:用于追踪

        public ConnectionHolder(Connection connection) {
            this.connection = connection;
        }
    }

    // --- 1. 开启事务 ---
    @Override
    protected ConnectionHolder beginTransaction() throws Exception {
        Class.forName("com.mysql.cj.jdbc.Driver");
        Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/db", "user", "password");
        // 关键:关闭自动提交,开启事务
        conn.setAutoCommit(false);
        System.out.println("开始事务: " + conn);
        return new ConnectionHolder(conn);
    }

    // --- 2. 正常写入 (预提交阶段的操作) ---
    @Override
    protected void invoke(ConnectionHolder transaction, String value, Context context) throws Exception {
        Connection conn = transaction.connection;
        try (PreparedStatement ps = conn.prepareStatement("INSERT INTO test_table (data) VALUES (?)")) {
            ps.setString(1, value);
            ps.executeUpdate();
            // 注意:此处千万不要调用 commit()
        }
    }

    // --- 3. 预提交 (Pre-commit) ---
    @Override
    protected void preCommit(ConnectionHolder transaction) throws Exception {
        // 对于 JDBC 而言,invoke 里的 executeUpdate 已经是预提交到数据库的 Buffer 了
        // 只需要确保数据已经发送给数据库即可
        System.out.println("预提交完成: " + transaction.connection);
    }

    // --- 4. 正式提交 (Commit) ---
    @Override
    protected void commit(ConnectionHolder transaction) {
        try {
            transaction.connection.commit();
            transaction.connection.close();
            System.out.println("事务已正式提交");
        } catch (SQLException e) {
            throw new RuntimeException("提交事务失败", e);
        }
    }

    // --- 5. 回滚 (Abort) ---
    @Override
    protected void abort(ConnectionHolder transaction) {
        try {
            transaction.connection.rollback();
            transaction.connection.close();
            System.out.println("事务已回滚");
        } catch (SQLException e) {
            // 生产环境下需要更严谨的日志记录
        }
    }
}

关键细节说明

  1. 事务状态持久化: Flink 会将 beginTransaction() 返回的对象(ConnectionHolder)保存在 Checkpoint 中。如果 Job 崩溃,Flink 重启后会从状态中恢复这个对象并调用 commitabort
  2. 序列化挑战: Connection 对象本身是不可序列化的(带有 transient 关键字)。在发生故障恢复时,旧的 Connection 已经断开,无法直接“恢复”。
  • 在 MySQL 中: 这种标准的 2PC 只能处理运行中的故障。如果整个进程重启,旧的未提交连接会因断开而导致数据库自动回滚。
  • 严格 2PC: 真正的 Exactly-Once 往往需要数据库支持 XA 事务。在恢复时,通过 transactionId 重新关联未完成的分布式事务。
  1. 超时控制: 必须确保数据库的 innodb_lock_wait_timeout 足够长,否则 Flink Checkpoint 还没做完,数据库端就因为行锁超时把事务给断开了。

生产建议

除非有极强的学术或特殊定制需求,否则在生产中:

  • MySQL: 建议直接使用 Flink 官方提供的 JdbcSink.exactlyOnceSink(...)
  • ClickHouse: 放弃 2PC 吧,老老实实用主键去重逻辑(ReplacingMergeTree),那才是它的正确打开方式。
  • 感兴趣的话,还可深入了解一下 XA 事务(分布式事务)在 Flink 中的具体实现。

三者对比总结

特性 Kafka MySQL ClickHouse
原子性支持 原生事务消息 JDBC 事务管理 分区原子替换/幂等引擎
副作用 增加消费端延迟 长期占用锁和连接 产生大量小 Part (需攒批)
Exactly-Once 难度 低 (官方原生支持) 中 (需自处理断开重连) 高 (通常靠去重引擎绕过)
推荐方案 事务型 Producer 幂等写入 (Upsert) 攒批写入 + ReplacingMergeTree
方案 核心类/机制 容错手段 性能影响
Kafka KafkaSink (2PC) 事务 Producer 中 (等待 CP 完成数据才可见)
MySQL JdbcExactlyOnceOptions XA 事务 / Connection 事务 高 (锁资源占用较久)
ClickHouse JdbcSink + 幂等引擎 攒批 + 主键去重 低 (最接近实时写入)
  • 如果对延迟极其敏感,ClickHouse 的幂等方案通常优于 MySQL 的 2PC 方案。
  • 实现全链路 Exactly-Once 的最大风险往往不在“正常提交”时,而是在 “作业失败重启后的事务恢复”

P FAQ for 检查点/保存点机制(面试版)

在 Flink 面试中,CheckpointSavepoint 是必考的核心基础。
面试官通常会从原理机制配置调优容错恢复以及两者区别这几个维度来考察。

这个问题考察的是对底层原理的理解。

  • 回答要点
    1. 核心机制:基于 Chandy-Lamport 算法的分布式快照。
    2. Barrier 对齐:Flink 通过 JobManager 周期性地向数据流中注入 Barrier。当算子收到 Barrier 时,会暂停处理新数据,等待所有输入流的 Barrier 都到达(对齐),然后异步将当前状态快照写入持久化存储。
    3. 两阶段提交(2PC):对于 Sink 端(如 Kafka),Flink 使用 TwoPhaseCommitSinkFunction。在 Checkpoint 时先执行预提交(Pre-commit),等所有算子确认后,再统一提交事务(Commit)。这样即使故障重启,未提交的数据对下游不可见,从而保证了 Exactly-Once。

Q: Checkpoint 和 Savepoint 有什么区别?

这是最经典的对比题,需要从触发方式、用途和存储格式三个维度回答。

  • 回答要点
维度 Checkpoint Savepoint
触发方式 自动触发,由系统配置决定。 手动触发,由用户通过命令操作。
主要用途 容错,用于机器故障后的自动恢复。 维护,用于版本升级、修改并行度、迁移作业。
存储结构 轻量级,增量格式(RocksDB),不保证可移植性。 自包含格式,全量元数据,保证跨版本/并行度兼容。
生命周期 由系统自动清理(跟随作业生命周期)。 由用户手动创建和删除,作业取消后依然存在。

考察对数据流和控制流的细节掌握。

  • 回答要点
    1. 注入:JobManager 定时向 Source 算子注入 Checkpoint Barrier。
    2. 快照:Source 收到 Barrier 后,将自己的状态(如 Kafka offset)快照到 StateBackend。
    3. 广播:Source 将 Barrier 广播给下游算子。
    4. 对齐与快照:中间算子等待所有输入流的 Barrier 到达(对齐),然后对自己的状态做快照,并继续向下游广播 Barrier。
    5. 确认:当所有 Sink 算子完成快照并通知 JobManager 后,本次 Checkpoint 完成。如果有一个失败,整个 Checkpoint 作废。

Q: 什么是 Barrier 对齐?如果不进行对齐会有什么后果?

考察对 Exactly-Once 和 At-Least-Once 的理解。

  • 回答要点
    • 定义:Barrier 对齐是指多流输入的算子必须等待所有输入流的 Barrier 都到达,才能进行状态快照。
    • 后果:如果不进行对齐(即 At-Least-Once 模式),算子可能在快照时已经处理了属于下一个 Checkpoint 周期的数据。当作业恢复时,这部分数据会被重新处理,导致重复计算。对齐机制是为了防止状态中混入“未来”的数据,从而保证精确一次。

考察实战经验,最好能说出关键代码参数。

  • 回答要点
    主要通过 StreamExecutionEnvironment 进行配置:
    • enableCheckpointing(5000):设置间隔(如 5秒)。
    • setCheckpointingMode(EXACTLY_ONCE):设置语义模式。
    • setCheckpointTimeout(60000):设置超时时间,防止卡住。
    • setMinPauseBetweenCheckpoints(3000):设置两次 Checkpoint 之间的最小间隔,防止太频繁。
    • setTolerableCheckpointFailureNumber(3):设置可容忍的失败次数。

Q: 什么情况下 Checkpoint 会失败?如何排查?

考察故障排查能力。

  • 回答要点
    • 原因
      • 状态过大/超时:状态数据太多,写入 HDFS/S3 耗时超过 CheckpointTimeout
      • 背压(Backpressure):下游处理慢,Barrier 无法及时传递。
      • 资源瓶颈:网络延迟、磁盘 IO 慢、Full GC 频繁。
      • 代码异常:状态序列化失败或用户逻辑抛出异常。
    • 排查:查看 WebUI 的 Checkpoint 详情,看是哪个算子耗时最长(通常是瓶颈),或者看日志是否有序列化错误。

Q: 作业升级(修改代码)时,如何保证状态的兼容性?

考察对 UID 和状态恢复的理解。

  • 回答要点
    • UID 固化:Flink 默认根据算子顺序生成 UID,一旦代码增删算子,UID 会变,导致状态无法恢复。因此,必须在代码中为有状态的算子手动设置稳定的 UIDuid("my-unique-id"))。
    • 允许丢失状态:如果新代码删除了某个有状态算子,启动时需要加上 --allowNonRestoredState(或 -n)参数,否则作业会启动失败。

Q: 什么是 Unaligned Checkpoints(非对齐快照)?

考察对 Flink 新特性的了解(Flink 1.11+)。

  • 回答要点
    • 背景:在严重背压的情况下,Barrier 对齐会导致 Checkpoint 时间很长甚至超时。
    • 机制:Flink 1.11 引入了 Unaligned Checkpoints。允许算子在收到 Barrier 时,将未对齐之前积压的数据(in-flight data) 也一并写入快照。
    • 优点:极大缩短 Checkpoint 时间,避免因背压导致的超时。
    • 代价:可能会导致少量数据重复(取决于 Sink 的实现),且快照文件会变大。

考察恢复流程。

  • 回答要点
    1. 重启策略:Flink 会根据配置的重启策略(如固定延迟重启、失败率重启)尝试重启作业。
    2. 定位元数据:TaskManager 从配置的 StateBackend(如 HDFS)下载最近一次成功的 Checkpoint 元数据文件。
    3. 状态恢复:根据元数据中的路径,将状态数据(如 RocksDB SST 文件)恢复到本地。
    4. 重置位点:Source 算子根据恢复的状态,重置消费位点(如 Kafka offset)。
    5. 继续处理:作业从该 Checkpoint 点继续消费数据,仿佛从未中断过。

Q: 为什么 Savepoint 通常比 Checkpoint 大?

考察对存储格式的理解。

  • 回答要点
    • 增量 vs 全量:Checkpoint(特别是使用 RocksDBStateBackend 时)支持增量快照,只记录变化的部分,因此体积小、速度快。而 Savepoint 为了保证可移植性和兼容性,必须是自包含的全量快照,包含完整的元数据和状态信息,不依赖之前的任何历史文件,所以体积较大。

Y 推荐文献

X 参考文献

posted @ 2025-08-08 17:59  千千寰宇  阅读(102)  评论(0)    收藏  举报