ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Flink架构与集群部署实战:从角色分工到反压排查与高可用配置

Flink架构与集群部署实战:从角色分工到反压排查与高可用配置

第一次把Flink作业从测试环境搬上生产集群时,我遇到的不是作业报错,而是另一种更磨人的问题:作业能提交、能运行、偶尔也有输出,可只要吞吐量一上来,整条链路就陷入反压,Checkpoint反复超时,最后连Source都被堵死。查代码查了三天,最后发现根子不在业务逻辑,而在架构和部署的理解上——集群的角色如何分工、slot怎么分配、Checkpoint对齐时到底发生了什么,这些没想明白,代码写得再对也没用。

这篇文章就是冲着“Flink架构与集群部署”这个主题来的。不讲空泛的概念,只把我自己在生产环境里一路踩过来的经验摊开讲:从作业提交后的第一轮调度,到状态与容错的底层逻辑,再到部署模式选型、内存配置推演、高可用设计、排障链路和上线前检查清单。无论你是第一次搭集群,还是已经跑了一堆作业但总被诡异的性能问题折磨,这篇都值得慢慢看。

1. 集群里每个角色都在忙什么:从一次作业提交到输出结果的全过程

很多人看Flink架构图,记住的只有JobManager和TaskManager两个词。但真正把一个作业提交到集群之后,参与演出的一共有四类角色:客户端、Dispatcher、ResourceManager、以及由JobManager和TaskExecutor组成的执行体系。理顺它们之间的协作关系,是理解集群部署的第一步。

1.1 一份代码是怎么变成一套分布式执行的

我们在main方法里写好的DataStream API,并不是直接分发到集群上跑的。整个过程要先经过三层图的转换:

  • StreamGraph:纯粹的逻辑图,描述Source、各种算子和Sink之间的前后关系。
  • JobGraph:经过算子链合并和shuffle优化后的执行计划图,此时Source和相邻算子可能已合并成一个执行节点。
  • ExecutionGraph:在JobGraph基础上为每个节点分配并行子任务,形成真正能被调度执行的物理图。

这个转换发生在哪里,取决于部署模式。在Session模式下,main方法在客户端跑完,生成的JobGraph提交给Dispatcher。在Application模式下,main方法直接跑到集群的JobManager里,客户端提交完jar就可以断线离开。很多生产事故都源于对这一步不理解——以为客户端窗口一关作业就没了,其实在正确的模式下,作业的生命周期根本不由客户端决定。

1.2 Dispatcher和ResourceManager:容易被忽略的幕后角色

Dispatcher接收提交请求,给每个作业创建一个JobManager实例,而这个JobManager在Flink里更准确的叫法是JobMaster。每个作业一个JobMaster,它负责调度这个作业的ExecutionGraph、触发Checkpoint、处理作业内所有TaskExecutor上报的状态。

ResourceManager则是集群级的资源管家,负责从TaskExecutor手里收集slot、把slot分配给各作业的JobMaster。你可以把整个集群理解成一家餐厅:JobManager是前厅经理,负责盯住每一桌客人的用餐节奏;TaskManager是后厨灶台;slot就是灶台上的灶眼。ResourceManager不炒菜,它只决定哪个灶眼给哪桌用。

弄清楚这个分工之后,很多困惑就迎刃而解了。比如Session模式下多个作业共享一个ResourceManager和一套TaskManager,一个作业写炸了内存,其他作业的slot也会跟着遭殃。这个问题我在后面部署模式章节会展开讲。

1.3 TaskExecutor里的slot到底是个什么概念

TaskManager的一个关键配置是slot数量,但它并不等于能跑的并行任务总数。真正决定并行上限的是并行度,而slot提供的是运行环境。每个slot能跑一个子任务,但Flink有个重要的机制叫slot共享组——默认情况下所有算子属于同一个共享组,所以source、map、window、sink这一个链路的所有子任务可以挤进同一个slot。

这也解释了为什么并行度10的作业,并不需要10个map、10个sink各自占满几十个slot。实际上一个有10个并行子任务的作业,每个slot里会跑一条完整的Source到Sink链。理解了这一点,你才能合理估算一个TaskManager到底能扛多少作业,而不是看着slot数量就觉得资源满了。

2. Checkpoint与状态后端:数据不丢的秘密藏在哪

部署Flink集群,绕不开状态和容错。Flink的核心卖点是有状态流处理,而状态能不能在故障之后恢复,全靠Checkpoint这套机制。我见过不少团队,作业跑得欢,一重启发现状态全丢了,才想起来压根没配Checkpoint存储。这种事故其实完全可以避免。

2.1 Barrier对齐:为什么恰好一次要付出代价

Checkpoint的底层原理是分布式快照,算法上叫作Chandy-Lamport。Flink把这个分布式快照的标记物叫Barrier,它像漂流在数据河流里的浮标,由Source算子周期性地注入数据流。

当一个算子收到所有输入通道的Barrier后,它会把当前的状态做一次本地快照,然后向上下游广播这个Barrier已经完成。这里有个关键动作叫Barrier对齐:为了做出一致性快照,算子必须先等慢的那个通道的Barrier到了,才能开始本地快照。也就是说,在等待对齐的这段时间里,先到的数据会被临时缓冲,给网络和内存带来额外压力。

这正是Checkpoint会引发反压的根源之一:一边数据在流动,一边Barrier在等待对齐,缓冲区一旦被填满,背压就从下游往上游蔓延。所以生产环境里见到“Checkpoint耗时突然变长、同时背压指标飘红”,往往不是两个独立问题,而是一个问题的两面。

2.2 状态后端怎么选:HashMap还是RocksDB

Flink 1.13之后,状态后端统一成了两种:HashMapStateBackend和EmbeddedRocksDBStateBackend。以前那种按内存/文件系统区分的旧分类已经废弃,别再看老教程配过时的参数。

选择的核心依据就三个:状态规模、快照方式、GC压力。

维度HashMapStateBackendEmbeddedRocksDBStateBackend
状态存放位置TaskManager堆内存本地RocksDB(磁盘/内存外)
适合的状态规模小,单任务几GB以内大,几十GB甚至TB级
访问速度快相对慢,需序列化
增量Checkpoint不支持支持
对GC影响大状态容易Full GC状态在托管内存,GC压力小

简单总结我的选型习惯:状态小、对吞吐要求极高、又不介意大堆GC的,用HashMap;状态大到堆内存装不下,或者Checkpoint时间要求稳定可预期的,直接上RocksDB加增量Checkpoint。我们线上一个几十GB状态的作业,配RocksDB加增量快照,一次Checkpoint能压到几十秒内完成;同期另一个团队用堆后端存同类状态,每次快照都在遍历全量数据,Checkpoint经常超时失败,最后也只能迁回RocksDB。

这里多说一句:RocksDB的状态是存在TaskManager的托管内存里的,所以配内存时要算好managed memory的占比,给它留足空间,否则RocksDB会往堆外挤,间接引发容器OOM。这个参数我在第4章会仔细算给你看。

3. Session、Per-job还是Application:部署模式关乎的不仅是启动方式

部署Flink集群,第一步就是选择部署模式。很多人只把它当成一个启动命令的区别,但模式选错,后面运维就是持续受苦。这一章我把三种方式的隔离性、启动速度、运维成本和坑讲清楚。

3.1 三张面孔:隔离性、启动速度与运维成本

Session模式是最容易上手的:预先启动一套长期运行的集群,所有作业通过客户端提交到这个共享集群上。启动快、迭代方便,开发环境用起来很舒服。但代价也明显——作业之间共享TaskManager,资源隔离几乎为零,一个作业内存泄漏可能拖垮整批作业。

Per-job模式是早期YARN上主推的隔离方案:每个作业拉起一套独立的Flink集群,JobManager、TaskManager都按作业单独分配。资源隔离好,但每次提交都要经历完整的集群启动过程,并且早期版本客户端和集群生命周期绑定得比较紧,客户端一断作业就悬了。

Application模式是Flink 1.11之后的主推方向。它的特点是main方法在集群的JobManager上运行,客户端提交完jar就能退出,作业生命周期独立;同时为每个作业维护单独的JobManager和资源。这既保留了Per-job的资源隔离,又解决了客户端断线问题。

模式集群生命周期资源隔离客户端断线影响适合场景
Session长期共享弱不影响已提交作业开发调试、短任务多
Per-job作业级强早期版本有影响有严格隔离要求的场景
Application作业级强基本不影响生产环境主流选择

3.2 我为什么在绝大多数生产场景推Application模式

从运维视角看,Application模式最直观的好处是“一个作业一套进程,职责分明”。作业A内存写爆了,作业B还能稳稳地跑,排查问题也不用在共享集群里猜是谁干的。加上YARN和Kubernetes都能很好地管理这种作业级集群,资源用完自动释放,非常契合容器化环境。

但Application模式也有约束。它要求把所有依赖打进一个jar包里,Flink自带的connector还好说,如果用了自研的UDF依赖,就得提前做好依赖打包和去重,否则集群里ClassLoader一打架,半夜爬起来看报错的就是你。

还要注意一点:无论选哪种模式,都不建议在同一个集群里同时混跑要求不同隔离级别的作业。我接过一个环境,Session和Application混着用,结果Session上一个作业的GC把同一批TaskManager吃干净,Application模式作业也排不上slot,整个链路全堵在一起。隔离这件事,最好从部署设计上就一刀切干净。

4. TaskManager内存与并行度:一个业务场景的完整配置推演

集群部署里最容易出问题的,是内存参数。Flink 1.10之后重构了内存模型,老教程里的那些heap参数基本作废。如果按旧版配置去部署新版,轻则性能不佳,重则容器直接OOM。

4.1 从并行度倒推集群规模

先看一个具体场景:某日志清洗与聚合作业,峰值每秒25万条记录,目标端到端延迟5秒以内,需要RocksDB保存若干聚合状态,大约5GB,设Checkpoint间隔1分钟。

第一步定并行度。Source并行度通常跟Kafka分区数对齐,这里假设12个分区,所以Source并行度设为12。下游清洗算子如果逻辑简单,可以和Source合并在一个算子链里,最终链路并行度取12。

第二步定slot数量。假定每个TaskManager开4个slot,那么三个TaskManager就够12个slot,正好塞下并行度12的整条链路。

第三步算内存。TaskManager的进程总内存可以这样拆:任务堆内存用在算子逻辑上,RocksDB状态用托管内存,网络交换用网络内存,最后还要留出JVM自身开销。

4.2 最容易调错的几个内存参数

在新版Flink中,我建议直接定义进程总内存,再分配各个子部分的比例。几个关键参数如下:

jobmanager.memory.process.size: 2048m taskmanager.memory.process.size: 12288m taskmanager.numberOfTaskSlots: 4 taskmanager.memory.task.heap.size: 6144m taskmanager.memory.managed.size: 3072m taskmanager.memory.network.fraction: 0.1 taskmanager.memory.jvm-overhead.fraction: 0.1

这里最容易犯的错有三个。

第一个是把taskmanager.memory.process.size设得过大,正好等于容器内存上限,结果把JVM Overhead和系统内存挤掉,容器直接被健康检查杀掉。容器内存一定要留至少1到2GB给系统层,别卡着上限配。

第二个是direct memory(即堆外内存)没概念。网络层和RocksDB都吃直接内存,默认的JVM Overhead不够时,会频繁触发堆外内存耗尽,而且不容易从普通日志里看出来。遇到莫名其妙的OutOfMemoryError,先查网络缓冲和RocksDB用量。

第三个是并行度的内存叠加没有好好算。比如一个TaskManager配6个slot,RocksDB状态总共5GB,每个slot分摊的状态量其实不到1GB,但如果你把托管内存规模按整个状态总量去定,反而浪费资源。内存规划一定要以“单个slot的资源需求”乘以slot数量来算,而不是拿整个作业的状态总量直接除以任务数。

4.3 一个可以直接套用的配置模板

给一个经过线上验证的中小规模配置。假设作业并行度12、RocksDB状态5GB左右、每台TaskManager 4个slot,容器内存给16GB:

jobmanager.memory.process.size: 2048m taskmanager.memory.process.size: 15360m taskmanager.numberOfTaskSlots: 4 taskmanager.memory.managed.fraction: 0.35 taskmanager.memory.network.fraction: 0.1 taskmanager.memory.jvm-overhead.fraction: 0.15 state.backend: rocksdb state.checkpoints.dir: hdfs:///flink-checkpoints state.savepoints.dir: hdfs:///flink-savepoints execution.checkpointing.interval: 60s execution.checkpointing.timeout: 5min

注意execution.checkpointing.interval和execution.checkpointing.timeout是作业级别的参数,也可以写在代码里。这里的思路是:进程总内存15GB,托管内存占35%(约5GB多),网络占10%,JVM额外开销15%,剩下给任务堆和框架。这样RocksDB有充足托管空间,网络也不容易出现缓冲耗尽。以后部署新作业时,先把这套模板跑通,再按实际情况微调。

5. 高可用部署:JobManager挂了之后,恢复链路到底怎么走

单机部署的Flink集群,JobManager就是单点。生产环境里JobManager挂了,作业就彻底断掉了。所以高可用不是可选项,而是部署的基础项。

5.1 两种HA模式的选择

Flink HA目前主流两种:基于ZooKeeper的HA和基于Kubernetes的HA。

  • ZooKeeper HA:多个JobManager进程注册到ZooKeeper,ZooKeeper负责leader选举。适合裸机、虚拟机或自建YARN集群。
  • Kubernetes HA:利用Kubernetes的ConfigMap和leader election机制,不需要额外部署ZooKeeper。适合容器化环境,运维更省心。

选型的核心逻辑很简单:集群本身是不是Kubernetes管理的。如果是,用Kubernetes HA少一套依赖;如果不是,就用ZooKeeper,一样稳定。

5.2 从JobManager挂掉到作业恢复,中间到底发生了什么

很多人以为HA配好之后,JobManager挂了会瞬间恢复,作业无损继续。其实恢复链路没那么简单:

新leader选出来后,它要做三件事:读取持久化在分布式存储里的作业元数据、从最新一次Checkpoint恢复作业状态、重新拉起TaskManager并把作业状态分发下去。

这三步里,最耗时的通常是最后一步。状态总量几十GB时,从Checkpoint恢复加task分发可能要分钟级。所以HA能保证的是“最终恢复”,不是“秒级无损切换”。对这个有预期,做SLO才不会拍脑袋。

5.3 高可用部署里最容易踩的暗坑

暗坑一:只给JobManager配了HA,没给Checkpoint配持久化存储。JobManager恢复后发现元数据有,但状态根本没有落盘,一切回到原点。

暗坑二:jobmanager的元数据目录和checkpoint目录写在本地磁盘上。JobManager换一台机器,目录没了,依然等于没配HA。

暗坑三:多JobManager之间时钟偏移。ZooKeeper选主需要时间,各节点时钟偏差过大时,可能出现一次作业被两个JobManager同时操作的混乱状态。时钟同步这件事,在分布式部署里永远不要觉得理所当然。

我的习惯是:高可用部署完,一定要主动做一次故障演练。直接kill掉主JobManager,观察作业多久能从最新Checkpoint恢复,把这些时间记录成基线。没有演练过的高可用,只是心理安慰而已。

6. 一次生产故障排查:反压和Checkpoint超时是如何互相放大的

这一章分享一次真实排查过程,场景很典型:一个夜间聚合作业运行了快两周,某天凌晨开始,整体吞吐量从稳定的高位突然掉了近一半,端到端延迟从2秒飙升到15秒以上。

6.1 从背压指标切入,别急着改代码

我打开Flink Web UI,先在作业详情里看BackPressure标签。结果显示中游聚合算子的多个子任务处于HIGH状态。如果只看这一层,第一反应往往是“聚合算子太慢,加资源”。但再加资源之前,我先点开了Checkpoint页签。

Checkpoint面板上,最近几次Complete的耗时都在3分钟以上,而这个作业的Checkpoint间隔只配了60秒。也就是说,上一轮Checkpoint还没完成,下一轮已经开始了,状态一直处于超时重试的恶性循环里。反压和Checkpoint超时在这里是互相放大的关系:

  • 某个算子处理变慢,数据在缓冲区堆积,Barrier无法快速流动到所有通道;
  • Barrier对齐等待时间拉长,Checkpoint持续超时;
  • 为了完成Checkpoint,系统不断重试,额外的快照开销进一步占用处理资源;
  • 资源被占用,数据处理更慢,背压更严重。

所以面对这类现象,一定要同时看背压和Checkpoint,不能只看一个指标就下结论。

6.2 顺着热点数据往下挖

确认Checkpoint超时后,我在Web UI上打开该算子的子任务列表,发现其中一个子任务的numRecordsIn明显高于其他子任务,数据倾斜特征很明显。进一步翻日志,看到某个业务key的聚合量在凌晨剧增,几乎独占了一个子任务。

到这里根因清楚了:热点key导致的倾斜,让单个子任务成为整个链路的瓶颈。Barrier在那个子任务里迟迟处理不完,拖累了所有并行子任务的Checkpoint,反压又从聚合算子一层层传回Source。

修复方案分了短期和长期两步:

  • 短期:先给作业临时增加并行度,同时把瓶颈算子的并行度单独调大,缓解单点压力;
  • 长期:改造聚合逻辑,引入两阶段聚合,先按附加随机维度进行局部聚合,再合并成完整结果,从根源上打散热点key。

顺带记录一个经验:UDF里尽量避免在热点key上直接做大窗口聚合。如果没法避免,就给key加盐,或者让热点数据走单独的旁路处理,千万别把所有流量压在一个子任务上。

6.3 修复之后的验证,别只看作业恢复

调整完成后,我在Web UI上确认三件事:背压状态全部回到OK或LOW、Checkpoint的End to End Duration回落到分钟级以内、currentInputWatermark跟上实际到达水位。

这里特别提醒:作业恢复运行不代表问题解决了。数据倾斜这种问题往往会换个时间点再次出现,所以我在原逻辑里加了一条热点key告警,当单个key的聚合量超过阈值时,主动推送通知,而不是等作业吞吐量崩了再人工介入。

7. 部署收尾:上线前用这份检查清单替你做一遍体检

部署完一套Flink集群,作业也跑起来了,这时候最容易松懈。结合我个人长期运维的经验,列几个上线前一定不能跳过的检查项。

7.1 必须盯住的四个核心指标

端到端延迟和Watermark延迟。面向时间窗口的作业,重点看currentInputWatermark和当前处理时间的差距,差距持续拉大说明数据处理慢于数据进入速度。

Checkpoint的完成时长和失败次数。这是最直观的稳定性信号,连续失败超过三次就应该告警。

TaskManager的堆内存和GC情况。频繁Full GC会直接拖垮吞吐量,也是很多模糊问题的根源。

背压状态。Web UI的BackPressure页面不用多说,但建议为它单独配一个监控看板,一旦出现持续HIGH状态就触发告警。

监控落地的方案,首选Prometheus加Grafana,Flink的MetricsReporter有现成的Prometheus实现,把metrics.reporter.prom.factory.class配置好即可。日志方面,作业运行日志和JobManager日志要分开采集,权限也要分开管理。

7.2 发布过程里的几件小事

依赖冲突是最常见的事故源。每次新作业上线,jar包里的Flink依赖、Hadoop依赖、用户自定义依赖可能互相覆盖。建议统一使用一个依赖管理基线,并且在上线前用冲突检查插件扫一遍。

Checkpoint和Savepoint目录要放到对象存储或分布式文件系统上,绝不能放本地盘。再有就是版本升级,横向大版本跃迁前先跑一遍兼容性验证,确认状态后端和SQL语法没有不兼容项,再逐步灰度。

还有一些细节容易被忽略:容器里允许的文件句柄数和网络缓冲要按集群规模调大;尽量避免在作业运行时手动执行cancel加resume,如果需要升级,用Savepoint做平滑切换。这些看起来不起眼,真出事时每一条都能救你一次。

坦白说,Flink架构与集群部署这条路,没有一套配置能适用于所有场景。我现在的做法是:每上线一个新环境,先在低峰期把一个有状态的作业放进去跑,观察它在真实链路下的Checkpoint间隔、背压水位和恢复耗时,记录成基线,再逐步放开流量。集群进入平稳期后,最怕的不是突发故障,而是所有指标都“看着还行”,其实暗流涌动。把上面这几个维度盯住,Flink集群出问题的概率会小很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进