做流计算的人一定绕不开一个问题:Flink 号称能做到“真正的流处理”,而其中最硬核、也最容易被误解的点,就是它提供的数据处理语义。这篇是系列第二十五篇,正好轮到“一致性级别”这个硬骨头。我不打算只把概念念一遍,而是想从底层实现到实际调参,把我自己的理解完整地写出来。你要是能跟着走一遍,后面不管是做技术方案还是排查线上数据问题,都会通透很多。
Flink 里的一致性级别,说人话就是回答“一条数据被处理了多少次”。可能是不管丢不丢、能处理多少算多少;也可能是保证不丢,但扛不住重复;再往上还有一个人人想要的理想状态——不丢不重,每条数据只生效一次。不少人以为 Flink 默认就是精确一次,所以把线上重复数据全都怪到 Flink 头上。实际上这里头的分别得掰开揉碎讲清楚,尤其是端到端、状态、链路通道这三层各有各的语义要求,配错是会出大问题的。
这篇文章适合两类人:一类是刚看完 Flink 基础 API、想深入理解内部机制的读者;另一类就是已经跑生产环境的同学,尤其是被重复数据、状态异常、检查点失败折腾过的人。下面我把里面的门道一层层拆开,尽量不堆概念,带点实操视角。
1. 一致性级别到底在讲谁的“一致性”
1.1 三张面孔:端到端、状态、通道
很多人一说一致性,直觉就认为是“结果不要重复”。但在 Flink 里,一致性起码有三层含义,分别发生在不同的位置上。
第一层是状态一致性。Flink 的算子在处理数据时可以保存状态,比如累加器、去重集合、窗口聚合中间结果。状态的一致性,指的是当作业从故障中恢复后,状态能不能回到一个和“数据实际进度”严格对齐的时点。如果状态落后或超前于数据,聚合结果就会错乱,这类问题你看日志都不一定能发现。
第二层是通道一致性。指数据从上游算子流向下游算子的过程中,阻塞、缓冲、持久化会不会造成数据重复或者丢失。Flink 内部的数据交换是 pipelined 的,天然不带事务属性,所以实现精确一次不能靠“把每条消息带上事务标记”,只能靠快照机制,这个后面详细讲。
第三层是端到端一致性,也最符合业务直觉。从数据源(比如某个分布式消息队列)读数据,经过 Flink 计算,最终写入目标系统,这整条链路上数据一共被消费、被提交了几次。想让端到端做到精确一次,光靠 Flink 自己根本不够,源端要支持重放,汇端要支持幂等写入或事务提交,缺一个环节都白搭。
这三层不是三选一,而是全都要对上。Flink 内部可以保证自己的状态和通道精确一次,但源端重复发送、汇端重复写入一样会导致业务数据出错。这就是我常说的“没有全链路的配合,就没有端到端的精确一次”。
1.2 三种语义:最多一次、至少一次、精确一次
Flink 给三种语义起了官方名词,对应AT_MOST_ONCE、AT_LEAST_ONCE、EXACTLY_ONCE,在代码里就是CheckpointingMode枚举。
先看AT_MOST_ONCE,它字面意思是“最多一次”。Spark Streaming 第一代微批模型、简单的 socket source、某些丢消息也不在乎的链路经常是这个表现。数据最多被处理一次,如果处理过程中出了故障,那就丢数据,不补、不重发。这种模式的优点是快,不用记录进度,不用对齐,但也因为不保证可靠性,在金融、订单、交易这类场景里基本不敢用。
AT_LEAST_ONCE是“至少一次”。一个流处理引擎发生故障后会重启,重启后要从最近的一个一致点重放数据。但重放过程中,上游有部分数据其实已经处理过并发出去了,你无法精确地只重放“没处理的那一部分”,所以必然会出现重复。如果你在下游做了去重或者写成幂等操作,那业务层面可以容忍重复,实际上很多实时仓库、日志采集链路就是这么处理的。
EXACTLY_ONCE才是大家津津乐道的“精确一次”。但这个措辞有迷惑性——它不是说“每条数据刚好只在内存里过了一遍”,而是指从故障恢复后,整个计算状态和数据汇的写入结果,表现得像每条数据只被处理过一次。Flink 用分布式快照加回放机制保证状态层面精确一次,汇端还得用事务或幂等兜住,才能让最终结果不重不漏。理解这点,后面检查点原理就好懂了。
要说生活类比,我觉得很像寄快递。最多一次像没有回执的普通包裹,丢了就丢了;至少一次像快递员敲门没人应就把包裹重新塞进门缝,可能放了两份;精确一次则像有签收登记,只有签收成功后任务才算完成,而且全程只有一条签收记录。
2. 实现精确一次的核心:分布式快照与 Barrier 机制
2.1 从 Chandy-Lamport 快照算法说起
Flink 的检查点本质上是分布式快照,核心思想源自 Chandy-Lamport 算法。那这个算法干了一件什么事?在一个分布式系统里,多个节点同时处理消息,你想让所有节点在同一时刻把自己的状态记下来,且这个“同一时刻”还要逻辑一致,怎么办?算法引入了“标记消息”,从发起方开始,顺着数据流转发给所有节点。每个节点收到标记后,把当时的状态保存下来,然后继续往下游传标记。等所有节点都完成了状态保存,一个全局一致快照就形成了。
Flink 把这个“标记消息”叫做Barrier,中文常叫“屏障流”。它是插在正常数据流里的一种特殊控制流,夹在历史数据和新数据之间。JobManager里的检查点协调器CheckpointCoordinator定期向每个Source任务下发一个带有编号的 barrier,然后这个 barrier 会跟着数据流和普通数据一起,从上游流到下游。
正因为有了 barrier,我们才能把“所有算子状态保存的进度”统一到同一条逻辑时刻上。比如 barrierN代表“在这条 barrier 之前的所有数据都已经反映在对应状态里了,barrier 之后的数据还没开始算”。每个算子在收到 barrierN的那一刻冻结当前状态并写入持久化存储,那么这个状态就是一个“一致性快照”。
2.2 Barrier 对齐与不对齐的真相
虽然 barrier 是跟着数据往下游走的,但问题来了:一个算子上游往往有多个并行子任务,每个子任务收到的 barrier 可能不以相同速率到达。假设有 A、B 两条上游流,A 的 barrier 已经到了,B 的 barrier 还在路上,这时该算子能不能立刻做快照?
能,但不安全。因为如果只基于 A 流的新数据和 B 流的旧数据混合计算做快照,那这个状态对应的数据进度根本对不齐。为了保证一致性,Flink 默认采用Barrier 对齐:先到的 barrier 会被“堵在门口”,算子继续处理还没到 barrier 的那条流的旧数据,同时把已经过了 barrier 的那条流的新数据缓存到缓冲区里,直到所有输入流的 barrier 都到位,这时才一起触发状态快照。
Barrier 对齐保证了快照的强一致性,但付出的代价是延迟和缓冲。如果某条流一直有积压或者反压,barrier 迟迟不齐,作业就可能出现“网上堵车”一样的连锁反应。所以 Flink 后来加入了不对齐检查点(Unaligned Checkpoint):barrier 不需要等待对齐,直接“插队”穿过算子,快照时把当前算子缓冲区里的所有数据也一并纳入快照。这样避免了对齐等待,但快照体积会变大,因为要保存掉在途数据。
说穿了,这是一个“强一致 vs 快照体积 vs 延迟”的三角取舍。生产环境里哪种情况选哪种,我放到第四章实操配置里细讲。
2.3 一个检查点的完整生命周期
我把一次检查点从触发到完成的完整过程走一遍,你就能理解后面那些参数到底在调什么。
第一步,由CheckpointCoordinator定期向所有SourceTask广播新建检查点指令,并给这次检查点发一个全局递增的编号,比如N。
第二步,每个SourceTask收到指令后,把当前消费位置(比如读到了消息队列的哪个 offset)作为快照的一部分保存下来,然后往自己所有下游输出里注入编号为N的 barrier。
第三步,下游算子收到 barrier 后,按上一节的对齐规则处理,然后把自身的状态和缓存在途数据写入持久化存储(可以是JobManager的堆内存、HDFS,或者外部状态后端),写完后向下游继续转发 barrier,并向协调器上报“我这个算子已完成快照”。
第四步,当所有算子都完成快照上报,协调器标记检查点N成功。旧的检查点可以保留也可以清理,具体看配置。
发生故障恢复时,Flink 找到最近一个成功完成的检查点N,把每个任务的状态恢复到快照时点,然后把 Source 的位置重置到当时记录的消费位点,重新消费并重放N之后的所有数据。由于状态和数据进度都对齐到同一个时点,重放的结果不会把已经算过的数据再算一遍,也不会丢掉还没算的数据,这就是 Flink 内部精确一次的底层逻辑。
我在实际项目里观察到一个容易被忽略的细节:恢复用的是“最近成功完成的检查点”,而不是“最近请求触发的检查点”。如果检查点一直失败,看似触发了成百上千次,真正能兜底的一个都没有,作业一挂就全丢了。监控检查点成功率比监控“是否有检查点”重要得多。
3. 端到端精确一次:Source、Flink、Sink 的三方合谋
3.1 源端必须能按位点重放
Flink 内部精确一次再厉害,如果源端不带重放能力,恢复时也无法把数据“倒回去”。拿常见的分布式消息队列举例,消息队列通过 offset 机制管理消费进度,Flink 的Source任务会把消费过的 offset 写进检查点,故障恢复时按这个 offset 重新拉取数据。但这里有一个前提:消息队列的数据要有足够的保留期。如果保留期太短,检查点在几天前,而数据已经被清理了,恢复时根本拉不到历史数据,只能从最新位置继续,之前那几天就是黑洞。
真实场景里,不少同学把 Kafka、Pulsar 这类系统的保留时间设成 1 天甚至更短,结果作业偶尔回退一次就丢一堆数据。可用的做法是把保留时间调到检查点间隔的数倍以上,留足冗余。或者干脆用“可重放的存储 + 定期清理快照”的组合,保证任何状态下都能从快照位置继续消费。
源端还有一类坑是:数据源明明支持回放,但用户在 Source 自定义实现里没用统一位点,而是用了随机读、随机 offset 之类的逻辑,那样一到恢复就乱套。Flink 官方提供的各种连接器之所以安全,就是因为它们把位点管理和检查点机制焊死在一起。自己写 Source 时千万要记得:一个能配合检查点的 Source,必须满足“给定一个位点,我能从这个位点无副作用地把数据重新读一遍”。
3.2 汇端的两条路:幂等写入与两阶段提交
哪怕 Flink 内部状态精确一次,外部系统不在乎你重不重放,照样写重复数据。比如你把结果直接写进 MySQL,没有唯一键约束,恢复时重放一段数据,数据库里就多几条记录。所以汇端必须配合,两条典型路线分别适合不同类型的外部系统。
第一条路是幂等写入。汇端天然支持按照业务键去重,或者配置了唯一索引,重复写同一个 key 不会产生新的记录,而是覆盖、忽略或者告警。典型的如写 Redis 这种键值存储,用SET覆盖即可;写 HBase 可以用行键唯一性来兜底;写 MySQL 就建唯一索引并配合INSERT ... ON DUPLICATE KEY UPDATE。幂等写入实现简单,不需要 Flink 参与事务协议,延迟也低,但前提是业务数据里有稳定且语义正确的键。
第二条路是两阶段提交。Flink 提供了一整套TwoPhaseCommitSinkFunction的框架,由汇端连接器实现“预提交—提交—中止”三个步骤。平时数据写入外部系统的临时区,不直接暴露给业务;当 Flink 检查点成功时,才触发提交,让临时数据变成可见数据。如果作业在提交前崩溃,外部系统可以回滚临时区,恢复后重新从检查点继续,数据不会重复。
这条思路是不是听着很熟悉?是的,和数据库事务里的两阶段提交几乎同构。区别在于,Flink 的提交动作是由检查点驱动的。检查点成功这个事件,既代表 Flink 内部状态已经就位,也代表“该把外部 Sink 的事务一起提交了”。
3.3 关于两阶段提交的一个简化视角
如果你想向同事解释 Flink 汇端精确一次,不用扯太深,可以这么说:Flink 把你要写的数据先存在一个“暂存区”,然后定期拍照(检查点)。等照片拍好了,就对外宣布——可以提交了。如果拍照失败,那就把暂存区里的东西全部作废,等重来一遍。
这里面真正对技术选型有影响的是:外部系统必须支持事务性写入,或者至少支持“写入后可以撤销”。关系型数据库天然支持,但像 Elasticsearch、ClickHouse 这类系统,早期版本缺乏强一致事务,做两阶段提交要么依赖额外组件,要么就退回幂等写入。生产环境里,我看到很多团队为了追求“字面上的精确一次”,硬要把不支持事务的系统凹成两阶段提交,最后维护成本极高,得不偿失。
所以在方案设计阶段,其实不是先问“你要精确一次还是至少一次”,而是先问“你的下游系统支持什么协议”。系统支持的,才谈得上精确一次;系统不支持的,只能靠幂等或上下游协调去兜。
4. 实操配置与择时调优:把语义变成参数
4.1 检查点配置的标准动作
Flink 代码里开启检查点很直接,核心就是一句话加几个调优参数。我通常用一个专门的配置类来集中管理,避免散落在作业代码里难以维护。
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment(); // 开启检查点,间隔 30 秒,语义设为精确一次 env.enableCheckpointing(30_000, CheckpointingMode.EXACTLY_ONCE); CheckpointConfig config = env.getCheckpointConfig(); config.setMinPauseBetweenCheckpoints(5_000); config.setCheckpointTimeout(60_000); config.setMaxConcurrentCheckpoints(1); config.enableExternalizedCheckpoints( CheckpointConfig.ExternalizedCheckpointCleanup.RETAIN_ON_CANCELLATION); config.setTolerableCheckpointFailureNumber(3);我来逐个解释这些参数背后的原因。
enableCheckpointing的第一个参数是检查点间隔。间隔太短,状态落盘频繁,给存储和网络带来压力;间隔太长,故障恢复时需要重放的数据太多,恢复耗时会成倍增长。经验上,从一个快照间隔到下一个快照间隔之间,最好能在 5 到 6 分钟内完成恢复回放,所以间隔设在 30 秒到 2 分钟之间比较常见,具体要看状态大小和回放速度。
setMinPauseBetweenCheckpoints表示“两个检查点之间至少要隔多久”,作用是防止前一个检查点还没完成,后一个检查点又触发,形成“检查点风暴”。这个值我通常设为检查点间隔的十分之一到三分之一,实测能把 JobManager 的负担压下来。
setCheckpointTimeout是单次检查点的整体超时。如果一次检查点超过这个时间还没完成,直接取消这次。注意这和上面那个 pause 是两个维度:timeout 防止一个慢检查点拖死后续所有检查点,pause 防止检查点之间贴得太紧。
setMaxConcurrentCheckpoints(1)在很多文章里被误读为“只能有一个检查点同时进行”,其实是说同一时刻最多允许几个检查点处于进行中。正常场景写 1 就好,毕竟多并发检查点会增加协调和数据落盘压力,收益非常有限。
RETAIN_ON_CANCELLATION的意思是,作业正常取消时保留外部化的检查点数据。这样如果只是停作业升级代码,重启时可以从旧检查点恢复,不用重新算历史数据。反之如果用DELETE_ON_CANCELLATION,一停就删,那只能从头重跑。
4.2 对齐模式的取舍参数
刚才讲了 Barrier 对齐和不对齐两种模式。Flink 从 1.11 开始支持在配置层面自由选择。相关参数主要是这两个:
config.enableUnalignedCheckpoints(true); config.setAlignedCheckpointsTimeout(Duration.ofSeconds(10));如果直接enableUnalignedCheckpoints(true),会强制所有检查点都不对齐。不太推荐全局这么干,因为不对齐的快照要额外保存所有流经算子的在途数据,快照体积可能大一个量级。
更精细的做法是设置一个“对齐超时”:默认先尝试对齐,如果到了超时时间 barrier 还没收齐,自动切换成不对齐模式。这个参数非常实用,等于是给“正常时的强一致”和“异常时的可用性”之间加了一个熔断。我通常在反压经常超过 10 秒的作业里启用它,实测能显著减少检查点卡死的情况。
4.3 状态后端选型与快照性能
状态一致性要落地,终究取决于状态后端性能。Flink 三种常用状态后端是 memory、filesystem 和 RocksDB。生产环境里我基本只在极小的调试作业里用 memory,正经作业要么 filesystem 要么 RocksDB。
- Filesystem 后端:直接把状态存到分布式文件系统,适合状态不大(几百 MB 以内)的场景。检查点生成时把全量状态序列化写入文件,一口价,过程简单。
- RocksDB 后端:以嵌入式 KV 数据库保存状态,适合超大状态(几十 GB 到 TB 级别)。配合增量检查点,每次只写发生变化的数据,能极大减少快照耗时。代价是需要关注磁盘 IO 和本地恢复时的数据加载时间。
我的一个通用参考配置:
RocksDBStateBackend backend = new RocksDBStateBackend("hdfs:///flink/checkpoints", true); backend.setNumberOfTransferThreads(4); env.setStateBackend(backend);true表示开启增量检查点。若状态总量比较大、变更频率又可控,增量模式的收益非常明显。我在一个累计状态约 300GB 的作业上做过对比:全量检查点一次要 20 多分钟,增量检查点只要 1 分多钟,恢复时间也从 10 分钟压缩到 3 分钟内。代价是状态文件之间形成依赖关系,定期做一次全量检查点可以避免增量链过长,这个可以通过类似“每 N 次增量后强制全量”的自定义策略来实现。
4.4 一个可复用的推荐配置组合
我不太喜欢把参数写死,因为不同作业的数据量、状态模型、下游系统差异太大。但可以提供一份经过几次实战验证的基准配置,你在此基础上按自己场景微调:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 检查点间隔 | 30s-120s | 状态大或恢复耗时长选大值 |
| 最小间隔 | 5s-15s | 防止检查点风暴 |
| 检查点超时 | 60s-180s | 超过即放弃本次 |
| 并发检查点数 | 1 | 不要大于 1 |
| 对齐超时 | 10s-30s | 超时后自动切不对齐 |
| 失败容忍次数 | 3 | 连续失败 3 次后作业才失败 |
| 外部化清理策略 | RETAIN_ON_CANCELLATION | 升级代码可恢复 |
这个表格不是金科玉律,但作为起步值是够稳的。落到不同作业,再用监控数据微调,比如如果检查点频繁接近超时,说明状态后端或磁盘是瓶颈,先扩并行度或优化状态结构,而不是盲目加大超时数字。
5. 常见问题排查与避坑实录
5.1 真的重复了,先别怪 Flink
很多同事一看到下游出现重复数据,第一反应就是“Flink 的精确一次是不是吹牛”。实际上我先检查汇端写入是不是幂等。如果汇端不是幂等写入,也不支持事务提交,那端到端重复根本就是符合规律的。比如明明没有唯一键的 MySQL 表,还期望精确一次,那属于需求和安全设计不匹配。
排查口径我建议固化下来:先看 Flink 检查点是否成功,再看源端是否可以重放,最后看汇端的幂等性。三步按顺序来,很多问题能快速定位。印象很深的一次,某个作业在恢复后向关系库重复插入了上千条数据,最后排查发现根因是汇端连接器把“若干条消息打包成一个事务”,预提交已经成功,但真正的提交因为网络抖动延迟,重复执行了一次。后来给写入 SQL 加上唯一键后,重复数据直接变成了更新,问题迎刃而解。
5.2 检查点失败但作业还在跑,别被假象骗了
Flink 作业允许“容忍一定数量的检查点失败”,这意味着某个检查点失败了,作业不一定马上挂,而是继续跑,并尝试下一次检查点。如果接下来连续失败超过配置的容忍次数,作业才真正失败。隐患就在这里:检查点失败原因如果不去看,作业仿佛在正常运行,但真正需要恢复时发现最近成功的检查点已经是很久以前的了,一恢复就要重算一大段历史数据。
再有,检查点失败通常伴随反压、频繁 GC、状态后端写入超时等问题,日志里会有一堆“Checkpoint expired”或者“Checkpoint was declined”。排查时不要只盯表面,要去看为什么状态落盘变慢。用监控把检查点开始到结束的耗时曲线拉出来,我一般优先看是否出现明显的波动尖峰,尖峰对应的往往是 GC 停顿或者磁盘 IO 抖动。
5.3 Barrier 对齐卡死导致的反压连锁
这是生产环境非常常见的隐形坑:某个下游算子处理慢,数据排成长队,barrier 在队列里“挤不过去”,各个输入流的 barrier 迟迟不能对齐。检查点一直失败,而失败会让任务重启,重启又带来更多数据重放,进一步加重反压,形成恶性循环。
解决办法有两个层面。第一层面是降低对齐压力,启用setAlignedCheckpointsTimeout,让超时后走不对齐快照。第二层面是整改反压来源,比如增加并行度、优化算子逻辑、为事件时间窗口设置合理的空闲等待。不对齐检查和反压修复,一个治标,一个治本,两个都要做。只调检查点参数不改作业,长期来看还是会堵。
5.4 外部系统事务超时导致的“幽灵数据”
用两阶段提交对接外部系统时,最常见的坑是事务挂起。Flink 预提交完成后,如果在最终提交阶段外部系统超时,Flink 会认为这次提交失败,但外部系统的事务实际上可能已经生效。于是出现一种“半提交”的幽灵数据:业务侧看到一部分数据已经存在,另一部分没有,整个系统并不处于一致状态。
遇到这种问题,第一选型是确认外部系统事务超时时间上限是否大于 Flink 检查点超时。比如检查点超时是 60 秒,外部事务最大空闲时间却只有 30 秒,那预提交后事务很容易被外部系统提前回滚。建议把外部系统的案件超时时间调大,并且把 Flink 检查点超时控制在外部系统事务安全期内。必要时可以在汇端对 key 做幂等约束,作为两阶段提交失效时的兜底方案。
5.5 恢复时间太长,业务等不起怎么办
一个大规模作业故障恢复可能要几分钟甚至十几分钟,业务侧往往等不了。这里有几个务实手段。
加快恢复的第一步是本地状态恢复。RocksDB 后端支持从本地磁盘直接加载,而不是从远程文件系统全量拉取,前提是各 TaskManager 节点的状态目录还在。重启后如果节点没变,加载会很快;节点变了也无妨,自动退回到远程加载。开启本地恢复的方式不复杂,但注意重启前不要清理本地工作目录。
第二步是合理设置并发恢复。Flink 在 1.13 之后支持增量检查点和恢复的迭代优化,可以让各任务并发下载各自快照,减少总恢复时间。
最后一步是避免过度调大检查点间隔。间隔越大,重放的数据量越大,恢复时间越长。这个我踩过坑:为了减少检查点对状态后端的压力,把一个作业的间隔调到 5 分钟,结果一次故障恢复硬是用了 20 多分钟,线上告警不断。后来把间隔降到 45 秒,恢复时间降到 6 分钟,整体反而更稳。
结束语:关于一致性级别,我的几点最终体会
我在实际项目里体会最深的一点是:一致性级别不是“跟着感觉选”,而是“跟着系统能力选”。Flink 自己把内部精确一次做得再漂亮,源端不可重放,汇端不幂等,好戏也唱不完。真正要做的是在方案设计的一开始,就把数据源、Flink、目标系统的能力边界画清楚,然后才谈得上配置参数。
另一个深刻的感受是:精确一次容易让人产生“万无一失”的错觉,但它只是计算语义层面的保证,不包含业务逻辑本身对重复数据的容忍度。比如业务侧存在并发修改同一行的场景,即使 Flink 精确一次,下游数据库自己也可能出现冲突和更新丢失。做实时系统的,一定要学会区分“计算语义”和“数据正确性”,这是两回事。
最后给一个实用建议:在你的作业监控面板上,一定要同时放三个指标——检查点完成的个数、最近一次成功检查点的年龄、检查点平均耗时。只要第三个长期超标或者第二个持续增长,不管作业跑得多欢,都到了该动手调优的时候。优先级永远是把检查点调稳,再去谈各种花哨的语义级别。