bg7.flink
批引擎(Spark)处理的是有界数据:一批数据到齐了(比如昨天全天的订单落到了存储里),调度系统触发一个作业,把整批读进来算完、结果落盘。这套模式的延迟下限由调度周期决定——常见是小时级或天级(T+1)。
自问:那我把批作业的调度间隔调到 1 分钟一次,不就"准实时"了吗?
自答:不行,而且代价很大。每次批作业都要从头读一遍窗口内的全量数据、重新启动一整套 DAG 调度与 shuffle,启动开销(资源申请、任务分发)本身就可能是秒级到分钟级。间隔越短,重复计算和启动开销占比越高,延迟很难压到秒级以下;而且跨批次的"累计状态"(比如"从今天 0 点到现在的累计 GMV")在纯批模型里没有天然的承载方式,每次都得重算历史。
真实场景要的是低延迟、连续计算:实时风控要在刷单发生的几百毫秒内拦截;实时大屏要看到秒级更新的 GMV;实时数仓要让下游随时查到最新指标。这些场景里,数据是无界的——它没有"到齐"的那一刻,而是像 Kafka topic 一样源源不断地流进来。这就是流处理要解决的问题。
流处理不是 Flink 发明的。理解 Flink 的设计,得先看它的前辈踩了什么坑:
- Apache Storm(早期流处理代表):真正做到了逐条处理、低延迟,但它基本是无状态的,容错语义只能做到 at-least-once(至少一次)——失败重放会导致同一条数据被处理多次,下游看到重复结果。要做"累计计数"这类有状态计算,得自己接外部存储,又慢又难保证一致。
- Spark Streaming(微批 micro-batch):它的思路是"用批模拟流"——把流切成一个个很小的时间片(比如 1 秒一批),每片当成一个小批作业跑。好处是复用了 Spark 成熟的批引擎和容错(RDD 血缘重算);坏处是延迟下限被批间隔卡死(压不到批间隔以下),而且它天然按"数据到达的时间片"切分,难以正确处理乱序和事件时间(后面会讲这有多要命)。
一句话:Storm 有低延迟但没有强一致的状态;Spark Streaming 有强一致但延迟受微批限制、且时间语义先天不足。
Apache Flink 的定位是:真正的逐条流处理(而非微批)、事件驱动、原生有状态、且提供 exactly-once(恰好一次)状态语义。它把前面两条路的优点合到一起——既低延迟,又有强一致的状态。
代价(这是本篇后半段要展开的):
- 状态管理变复杂:引擎要长期、可靠地维护每个算子的中间状态,还要能高效快照与恢复。
- 必须直面乱序:一旦按"业务发生时间"算,就要处理"晚到的数据",这引入了 watermark、迟到处理等一整套机制。
- 运维成本上升:checkpoint 调优、状态后端选型、反压排查——这些都是批作业里不存在的问题。
本篇的目标,就是把这三项代价背后的机制讲清楚。
一句话定义:Flink 是一个分布式的、有状态的流处理引擎(stateful stream processing engine),它以"数据流 + 算子上的状态"为核心抽象,对无界和有界数据都用同一套 runtime 处理。
拆开两个关键词:
- 有界流(bounded stream):有明确起止的有限数据集——这正是第 05 篇批处理的对象。
- 无界流(unbounded stream):持续到达、没有终点的数据流——比如一个 Kafka topic。
流批一体的核心思想就一句话:批是流的一个特例——批就是"有界流"。既然二者只是"有没有终点"的区别,那就用同一套引擎、同一套 API/SQL 来处理,只在执行策略上因"有界/无界"而不同。这解决的痛点是:过去实时链路用一套引擎(如 Storm)、离线链路用另一套(如 Spark),同一段业务逻辑要写两遍、两套结果还经常对不上(这就是所谓的 Lambda 架构痛点)。流批一体让"一套代码/SQL,既能跑实时也能跑离线回溯"。
注意区分两个层面:计算层的流批一体(Flink 一套引擎跑两种模式)和存储层的流批一体(用 Iceberg / Paimon 这类既支持流读流写、又支持批读的湖格式当统一存储)——后者是第 08 篇「湖」的主题,本篇只讲计算层。
前面讲的都是"语义层"。但你提交一段 Flink SQL 之后,集群里到底有哪些进程、谁在协调谁、你的 SQL 变成了什么在哪里执行?这一节把运行时骨架讲清楚——它也是后面 checkpoint、反压、状态后端这些机制的物理载体。
Flink 集群运行时只有两类长期进程,外加一个提交用的客户端:
flowchart TB
CLIENT["Client(客户端)<br/>提交作业 · 构建 JobGraph<br/>(Application 模式下 main() 改在集群侧执行)"]
subgraph JM["JobManager(控制面,至少 1 个;HA = 1 leader + N standby)"]
DISP["Dispatcher<br/>REST 提交入口 · 托管 Web UI<br/>每个作业启动一个 JobMaster"]
RM["ResourceManager<br/>管理 task slot(资源调度单元)<br/>按部署环境有 YARN / K8s / Standalone 实现"]
JMS["JobMaster<br/>管理单个 JobGraph 的执行<br/>调度任务 · 响应失败 · 协调 checkpoint"]
end
subgraph TM1["TaskManager #1(JVM 进程 / 又称 worker)"]
S1["slot 1"]
S2["slot 2"]
end
subgraph TM2["TaskManager #2(JVM 进程)"]
S3["slot 1"]
S4["slot 2"]
end
CLIENT -- "提交 JobGraph" --> DISP
DISP --> JMS
JMS -- "申请 slot" --> RM
RM -- "分配 slot" --> TM1
RM -- "分配 slot" --> TM2
JMS -- "部署 / 取消 subtask · 触发 checkpoint" --> TM1
JMS -- "部署 / 取消 subtask · 触发 checkpoint" --> TM2
TM1 <-- "数据流交换(网络)" --> TM2
JobManager(主控进程) 负责协调分布式执行:调度任务、响应失败、协调 checkpoint。官方把它拆成三个组件:
| 组件 | 职责 | 关键点 |
|---|---|---|
| Dispatcher | 提供 REST 提交接口,为每个提交的作业启动一个 JobMaster;并托管 Flink Web UI | 你在 Web UI 上看反压指标,看的就是这个进程暴露的界面 |
| ResourceManager | 负责 task slot 的申请与分配——"slot 是 Flink 集群里资源调度的单元" | 按部署环境有 YARN / Kubernetes / Standalone 等实现;Standalone 下它只能分配已有 TaskManager 的 slot,不能新拉起 TaskManager |
| JobMaster | 管理单个 JobGraph 的执行,每个并发运行的作业各有一个 | Checkpoint Coordinator 就在这一层——它周期性通知 source 插入 barrier |
注意这是 Flink 自己的 ResourceManager,和 YARN ResourceManager 不是一个东西:Flink 的 RM 管的是"作业内部的 slot",YARN 的 RM 管的是"集群里的容器"。二者是上下层关系——Flink RM 向 YARN/K8s 要容器,再把容器里的 slot 分给作业。术语撞名,别混。
TaskManager(工作进程,又称 worker):一个 JVM 进程,真正执行 dataflow 里的 task,并"缓冲与交换数据流"。一个 TaskManager 内可以用多个线程并发跑多个 subtask。集群里至少要有一个。
对照 Spark:角色分工上 JobManager ≈ Driver、TaskManager ≈ Executor——都是"一个协调者 + 一堆工作进程"。但执行模型根本不同:Spark 的 Executor 是按 stage 领取一批批 task、算完就结束;Flink 的 TaskManager 上跑的是长期常驻的算子,作业一启动就把整条 pipeline 铺开、然后持续接收数据。这个差异是"批 vs 流"在物理层的体现,也是为什么 Flink 能做到毫秒级延迟而不必每批重启调度。
Task Slot:资源调度的最小单位
定义:task slot 是 TaskManager 上固定的一份资源子集,也是 Flink 里资源调度的最小单位。一个 TaskManager 有几个 slot,就表示它能并发处理几个 task。
关键细节(最容易误解的点):slot 隔离的只有内存,没有 CPU。官方原话:"no CPU isolation happens here; currently slots only separate the managed memory of tasks." 一个配了 3 个 slot 的 TaskManager,每个 slot 拿到该 TaskManager 1/3 的托管内存(managed memory),但它们共享同一份 CPU(以及同一个 JVM、同一批 TCP 连接和心跳)。
自问:既然一个 slot 只是"1/N 的内存",那我把 slot 数调大,不就能跑更多任务了吗?
自答:能跑更多,但不等于更快。因为没有 CPU 隔离,slot 开太多只会让同一个 JVM 里的线程争抢 CPU、GC 压力上升,并把每个 slot 的托管内存切得更碎(更容易触发状态溢写)。经验法则(社区常见建议,非官方硬性规定):slot 数取与该机器 CPU 核数同量级。真实值应结合作业是 CPU 密集还是状态密集来定。
slot sharing(槽位共享,默认开启):官方原话是——Flink 允许同一个作业中不同 task 的 subtask 共享同一个 slot。也就是说,一个 slot 里可以塞下这个作业一整条 pipeline(source → map → window → sink 各取一个并行实例)。
两个好处(官方给出的理由,附因果):
- 集群只需要"作业中最高并行度"那么多 slot,不用把各算子的 task 数逐一累加。例:source 并行度 4、window 并行度 4,不共享要 8 个 slot,共享后 4 个就够。
- 资源利用率更高:不共享的话,轻量的
source/mapsubtask 会和重量级的windowsubtask 占用同样多的资源;共享后,轻重任务混在一个 slot 里,把资源摊匀。
并行度 = 2 的作业:source → map → keyBy/window → sink
【关闭 slot sharing】需要 8 个 slot,轻任务白占资源
slot1[source-1] slot2[source-2] slot3[map-1] slot4[map-2]
slot5[win-1] slot6[win-2] slot7[sink-1] slot8[sink-2]
【默认 slot sharing】只需 2 个 slot(= 最高并行度)
slot1[ source-1 → map-1 → win-1 → sink-1 ] ← 一整条 pipeline
slot2[ source-2 → map-2 → win-2 → sink-2 ]
并行度(parallelism):一个算子被拆成多少个并行实例。每个实例叫一个 subtask(子任务),各自处理数据的一个分区——这直接对应第 02 篇的"分区"思想。
operator chaining(算子链):Flink 会把多个算子的 subtask 串联成一个 task,由单个线程执行。官方给出的理由是:"it reduces the overhead of thread-to-thread handover and buffering, and increases overall throughput while decreasing latency."
因果链:两个算子若不 chain,数据从算子 A 到算子 B 要经过"序列化 → 写入网络/本地缓冲区 → 线程切换 → 反序列化";chain 之后,B 就是 A 所在线程里的一次普通方法调用,上述开销全部省掉。代价:chain 在一起的算子共用一个线程,无法分别扩缩容,且一个算子慢会直接卡住整条链;所以调试性能瓶颈时,有时会显式关闭 chaining,好在 Web UI 上看清是哪个算子慢(chaining 可配置)。
逻辑 dataflow(并行度 2,sink 并行度 1):
source ──▶ map ──▶ keyBy/window ──▶ sink
物理执行(chain 后):
TaskManager slot1: [ source-1 → map-1 ] ──┐ ← 前后并行度相同且是 forward 连接 → chain 成 1 个 task,单线程
├─▶ shuffle(keyBy 按 key 重分区,跨网络)
TaskManager slot2: [ source-2 → map-2 ] ──┘
│
▼
slot1: [ window-1 ] ─┐
slot2: [ window-2 ] ─┴──▶ [ sink-1 ](并行度 1)
什么时候 chain 不起来?典型情形:前后算子并行度不同、连接方式不是 forward(如
keyBy触发的重分区)、或被显式禁用。上图里map → window因为有 keyBy 重分区,就断在这里——这个"断点"和第 05 篇 Spark 的 stage 边界由 shuffle 划分是同一个道理(注:两者机制不同,Spark 是批的 stage 调度,Flink 是常驻算子间的数据交换,此处仅类比"重分区处必然断开")。
你写的一段 Flink SQL,要经过几层"图"的转换才落到机器上执行:
flowchart LR
SQL["你的 Flink SQL / DataStream 代码"]
SG["StreamGraph<br/>(客户端侧最初的算子拓扑)"]
JG["JobGraph<br/>算子=JobVertex<br/>中间结果=IntermediateDataSet<br/>operator chaining 在此完成"]
EG["ExecutionGraph<br/>JobGraph 的并行版本<br/>每个 JobVertex → N 个 ExecutionVertex"]
PHY["物理执行<br/>subtask 部署到各 TaskManager 的 slot 上"]
SQL -- "SQL 解析 / 优化" --> SG
SG -- "算子合并(chain)" --> JG
JG -- "提交给 JobManager" --> EG
EG -- "JobMaster 调度" --> PHY
- JobGraph:提交给 JobManager 的形态,官方定义为"由算子(
JobVertex)和中间结果(IntermediateDataSet)构成的数据流表示",算子上带着并行度和代码,还捎带运行所需的依赖库。 - ExecutionGraph:JobManager 把 JobGraph 转换成的并行版本。官方举的例子最直观:"一个并行度为 100 的算子,对应 1 个 JobVertex 和 100 个 ExecutionVertex。" 每个
ExecutionVertex追踪一个 subtask 的执行状态;失败重试的每一次尝试记录为一个Execution对象——这就是"作业失败后能精确知道该重启哪些 subtask"的数据结构基础。
对照第 05 篇 Spark:Spark 也有"逻辑计划 → 物理计划"的转换(Catalyst)。相同点是都要把用户写的声明式 SQL 降解成可调度的并行执行单元;不同点是 Spark 的物理计划按 stage 分批调度、算完一批调下一批,而 Flink 的 ExecutionGraph 一次性把所有 subtask 全部部署并常驻,数据流持续穿过它们。
说明:官方
job_scheduling页只详述了 JobGraph 与 ExecutionGraph 两层;StreamGraph 是客户端在生成 JobGraph 之前的算子拓扑表示(chaining 尚未合并),这一层在官方 concepts 页面着墨较少,如需精确定义请查阅对应版本源码或文档。
同一套架构可以用三种方式部署,区别在集群生命周期和资源隔离度。三者容易混,逐维度对比:
| 维度 | Session 模式 | Application 模式 | Per-Job 模式(已废弃) |
|---|---|---|---|
| 集群生命周期 | 预先存在的长期集群,可接收多个作业;作业结束后集群仍在,直到手动停止 | 每个应用一个专属集群,生命周期与应用绑定 | 每提交一个作业拉起一个集群,作业结束即销毁 |
main() 在哪跑 |
客户端 | 集群上(由 ApplicationClusterEntryPoint 调用) |
客户端 |
| 隔离性 | 差:作业共享集群,一个 TaskManager 挂掉会让其上有 task 的所有作业失败;JobManager 致命错误影响全部作业 | 好:ResourceManager 与 Dispatcher 只服务单个应用 | 好:JobManager 致命错误只影响该作业 |
| 启动开销 | 小(省去申请资源与拉起 TaskManager 的时间) | 较大 | 较大 |
| 适用场景 | 短作业、交互式查询——启动快是主要收益 | 生产上的长期作业,当前推荐方式 | — |
| 状态 | 可用 | 可用(推荐) | 仅 YARN 支持,自 Flink 1.15 起废弃;移除进度见 FLINK-26000,官方建议用 Application 模式替代 |
这三种模式跑在什么之上(YARN / Kubernetes / Standalone)、容器怎么申请到——那是资源调度的范畴,留到后文展开。本篇只需知道:Flink 的 ResourceManager 会向底层的 YARN/K8s 要资源,再切成 slot 分给作业。
- checkpoint:由 JobMaster 里的 Checkpoint Coordinator 周期性触发,barrier 沿着 3.4 里 ExecutionGraph 铺开的 subtask 拓扑流动,各 TaskManager 把状态异步写到外部存储。
- state backend:状态就存在 TaskManager 进程里——HashMapStateBackend 存在它的 JVM 堆上,EmbeddedRocksDBStateBackend 存在它的本地磁盘上。所以"状态膨胀"撑爆的是 TaskManager,不是 JobManager。
- 反压:发生在 subtask 之间的数据交换通道上,通过 Dispatcher 托管的 Web UI 逐算子观测。
- watermark:随数据在 subtask 之间流动,下游取所有上游通道的最小值推进——"空闲源"问题正源于此。
流计算里"时间"有三种,分不清就会算错:
| 时间语义 | 定义 | 由谁决定 |
|---|---|---|
| Event time(事件时间) | 事件在业务系统里真实发生的时刻(如订单下单时刻),通常是数据里的一个字段 | 数据本身携带 |
| Processing time(处理时间) | 事件被某个算子处理到的那一刻的机器墙上时钟 | 算子所在机器的系统时钟 |
| Ingestion time(摄入时间) | 事件进入 Flink source 的时刻 | source 算子赋予 |
自问:处理时间最简单(直接读机器时钟,不用管数据里有没有时间戳),为什么还要费劲用事件时间?
自答:因为数据几乎必然乱序到达。想象订单事件经过 App → 网关 → Kafka → Flink,不同订单走不同网络路径、Kafka 有多个分区并行,于是"业务上先发生的订单"未必先到达 Flink。如果你按处理时间开窗——比如"每 10 秒的订单量"——那么一条业务上属于
00:09的订单,如果因为网络抖动00:15才到,就会被错误地算进[00:10, 00:20)这个窗口。结果依赖机器什么时候恰好处理到它,不可复现、也不正确。而事件时间是数据自带的、确定的,用它开窗,无论数据早到晚到,结果都一致且正确——代价就是:引擎必须回答一个难题——"我怎么知道某个时间点的数据是不是都到齐了?"这个难题的答案,就是 watermark。
Ingestion time 介于两者之间(比处理时间稳定,但仍不反映业务真实时间,乱序问题依旧),实践中用得少。绝大多数正确性敏感的场景都用 event time。
精确定义:watermark 是一条插入在数据流中的特殊标记,它携带一个时间戳 t,含义是一个断言——"事件时间 ≤ t 的数据,(基本上)都已经到达了,后续不应再出现事件时间 ≤ t 的数据"。它是一个单调不减(monotonically non-decreasing)的逻辑时钟,由 source 或专门的算子生成,随数据一起向下游流动。
注意:watermark 不是数据,而是关于"事件时间进展到哪了"的声明。
它怎么生成? 最常用的是"固定乱序上界(bounded out-of-orderness)"策略:
watermark = (到目前为止观察到的最大事件时间) - (允许的最大乱序时长)
在 Flink SQL 里,你就是用 DDL 里那行 WATERMARK FOR ... AS ... 声明它的。比如 WATERMARK FOR order_time AS order_time - INTERVAL '2' SECOND 表示"我容忍最多 2 秒的乱序"。
因果链:watermark 如何界定"窗口何时可关"?
一个事件时间窗口 [start, end),并不会因为墙上时钟到了 end 就关闭——它要等 watermark ≥ end。逻辑是:
- 窗口把落在
[start, end)的事件不断累积进状态; - 当 watermark 推进到
≥ end,引擎判定"事件时间到end为止的数据都到齐了",于是触发窗口计算、输出结果、清理该窗口占用的状态; - 此后再来一条本该属于这个窗口、但事件时间
< end的数据,就是迟到数据(late data)。
迟到数据默认怎么办?代价是什么?
- 默认:被丢弃(结果里就是少算了它)。
- allowed lateness(允许迟到):这是 DataStream API 里的显式机制——窗口在 watermark ≥ end 时首次触发后,不立刻清状态,而是再保留
allowedLateness时长;这段时间内到达的迟到数据可以触发窗口重算并更新输出(late fire);直到 watermark 越过end + allowedLateness才真正清状态,再迟到的才彻底丢弃(或转到 side output 单独处理)。 - 在 Flink SQL 里,窗口默认同样丢弃迟到数据;是否/如何输出迟到更新受
table.exec.emit.late-fire.*等配置与版本影响,行为不如 DataStream API 直观。具体配置与版本以官方文档为准(可用volcano-docsskill 或 Flink 官方文档核实)。
这里是核心权衡(必须理解):watermark 的乱序容忍时长 = 延迟 vs 完整性的旋钮。
- 容忍时长太小 → watermark 推进快、窗口早关早出结果(低延迟),但大量本该纳入的乱序数据变成迟到被丢(结果偏小、丢数)。
- 容忍时长太大 → 极少丢数(完整),但窗口迟迟不关、结果延迟高,且未触发的窗口一直占着状态内存(延迟高、状态堆积)。
没有"正确值",只有"根据你数据的实际乱序程度和业务对延迟/完整性的要求做的取舍"。
无界流不能"等全到齐再算",于是要用窗口把它切成一段段有界的集合来聚合。三种最常用:
- 滚动窗口(tumbling):固定长度、首尾相接不重叠。如"每 10 分钟的订单量"。每条数据只属于一个窗口。
- 滑动窗口(sliding / 又称 hop):固定长度、按固定步长滑动,步长 < 窗口长则相邻窗口重叠,一条数据可能属于多个窗口。如"每 1 分钟输出一次过去 5 分钟的均值"。
- 会话窗口(session):没有固定边界,由"活动间隙(gap)"划分——同一个 key 的事件,只要相邻两条间隔小于 gap 就归入同一窗口,间隔超过 gap 就开新窗口。如"用户连续操作算一个会话,静默 30 分钟就断开"。
滚动(size=10): [0──10)[10──20)[20──30) 不重叠
滑动(size=10,slide=5): [0──10) 重叠
[5───15)
[10──20)
会话(gap=5): x x x·····x x ···· x 按间隙自动切
└─会话1─┘ └会话2┘ └会话3┘
术语提示:Flink SQL 里滑动窗口的表值函数(TVF)叫 HOP(业界也叫 sliding window),别被名字绕晕——HOP = 滑动窗口。
为什么流计算必须有状态? 对照批引擎:批每次从头读全量数据算,算完就结束,不需要跨作业保留中间结果。而流是无界的、结果要随数据持续更新,引擎必须把"到目前为止的中间结果"持久地存在算子里,否则:
- 聚合(SUM/COUNT):每来一条要在旧的累计值上加,累计值就是状态;
- join:两条流做 join,一侧先到、另一侧还没到,先到的得缓存起来等待匹配,这缓存就是状态;
- 窗口:尚未触发的窗口里累积的数据,也是状态;
- 去重 / 维表关联:同理都需要状态。
Flink 的状态分两类:keyed state(按 key 分区的状态,和 SQL 里的 GROUP BY key / keyBy 对应,每个 key 有独立状态)和 operator state(算子级状态,如 source 记录的 Kafka 消费位点)。
state backend(状态后端) 决定"状态存在哪、怎么存"。Flink 内置两种(自 Flink 1.13 起改成如下命名,来源:Flink 官方 state_backends 文档):
| 状态后端 | 存储位置 | 特点 | 代价 |
|---|---|---|---|
| HashMapStateBackend | 状态作为对象存在 JVM 堆上 | 访问快(无序列化) | 受堆内存限制,大状态会 OOM;checkpoint 是全量 |
| EmbeddedRocksDBStateBackend | 状态序列化后存在内嵌的 RocksDB(本地磁盘的嵌入式 KV) | 状态可超过内存、达磁盘量级;支持增量 checkpoint | 每次读写都要序列化/反序列化 + 磁盘 I/O,单次访问比堆慢 |
开箱默认是堆内的 HashMapStateBackend;生产上状态较大的作业普遍显式切到 EmbeddedRocksDBStateBackend。(默认值随版本/部署方式略有差异,以官方
state_backends文档为准。)补充历史:1.13 之前的老类名
MemoryStateBackend/FsStateBackend/RocksDBStateBackend被重构为"状态后端 + checkpoint 存储"两个正交概念——例如老的RocksDBStateBackend≈EmbeddedRocksDBStateBackend(状态后端)+FileSystemCheckpointStorage(checkpoint 存到文件系统)。这只是 API/命名的澄清,运行时行为不变。
checkpoint / savepoint 与 exactly-once
这是流引擎容错的核心,也是 Flink 相对 Storm 的关键优势。
checkpoint(检查点):引擎周期性地对所有算子的状态做一次全局一致的快照,持久化到外部存储(HDFS/OSS 等)。作业失败时,把所有算子的状态回滚到最近一次成功的快照,并让 source 重放该快照点之后的数据(如 Kafka 重置到快照里记录的 offset),从而继续。
savepoint 是"手动触发、用于运维"的 checkpoint:升级作业、改并行度、迁移集群时手动打一个,之后从它恢复。机制同源,用途不同(自动容错 vs 手动运维)。
难点:怎么在不停止数据流的情况下,拍一张"全局一致"的快照?
分布式系统里,各算子分布在不同机器、各自处理进度不同,你没法"同时暂停所有算子"再拍照。这正是经典的 Chandy-Lamport 分布式快照算法要解决的问题。它的直觉是:不追求"物理上同一时刻",而是追求"逻辑上一致的切面"——只要保证快照对应"每条数据要么完全算进了快照、要么完全没算进",就是一致的。
Flink 的实现靠 barrier(检查点屏障):
- JobManager 里的 Checkpoint Coordinator 周期性通知各 source,在数据流里插入编号为 n 的 barrier;
- barrier 像一条普通记录一样,随数据向下游流动,把数据流"切"成"barrier n 之前"和"barrier n 之后";
- 每个算子一收到 barrier n,就对自己当前的状态做快照(异步写到外部存储),然后把 barrier n 转发给下游;
- 当 barrier n 流到所有 sink、全部算子都完成快照,编号 n 的 checkpoint 就完成了。
barrier 对齐(alignment)——为什么需要,代价是什么?
多输入算子(如 join,有两条输入流)会遇到问题:barrier n 从两个输入通道到达的时刻不同。为保证一致性,算子必须等 barrier n 从所有输入通道都到齐,才做快照——这叫对齐。对齐期间,先到 barrier 的那条通道,其后续数据被缓冲、暂不处理(否则那些"barrier 之后"的数据就会污染"barrier 之前"的快照)。
两输入算子的 barrier 对齐:
输入通道 A: ── d d [barrier n] d d d ... ← barrier 先到,后续 d 被缓冲
│
├─(等待对齐)─┐
│ ↓
输入通道 B: ── d d d d d [barrier n] ... ← barrier 后到
│
两个 barrier n 都到 → 算子对状态快照 → 转发 barrier n
- 代价:对齐会引入等待延迟;若某条通道慢(反压时尤甚),对齐时间会拉长,checkpoint 变慢甚至超时失败。
- at-least-once 模式下引擎不做对齐(不缓冲),开销小但可能重复。
- unaligned checkpoint(非对齐检查点,Flink 1.11 引入):把"正在通道中传输的 in-flight 数据"也一并纳入快照,从而不必等待对齐——在重反压场景下 checkpoint 更容易成功,代价是快照体积变大、恢复时要重放这些 in-flight 数据。
为什么能"不重不漏"(exactly-once)?
准确的说法是 exactly-once state semantics(状态恰好一次语义):失败恢复时,状态回滚到快照点,source 也重放到快照点之后,于是每条数据对状态的影响恰好生效一次——不会因重放而重复累加,也不会漏。
关键澄清(读者最容易误解的点):Flink 的 exactly-once 默认指的是引擎内部状态的恰好一次。要做到端到端(end-to-end)exactly-once,还必须要求 sink 配合,二选一:
- 幂等写入:同一条结果写多次等于写一次(如按主键 upsert 到支持幂等的存储);
- 两阶段提交(2PC / TwoPhaseCommitSink):sink 把"写出"和"checkpoint 完成"绑定成事务——checkpoint 成功才 commit,失败则 abort(如 Kafka 事务、支持事务的数据库)。
如果 sink 既不幂等也不支持事务,那么故障重放时对外仍可能产生重复——此时你只有"引擎内 exactly-once",没有"端到端 exactly-once"。
checkpoint 的代价:周期性快照消耗 CPU/网络/IO;对齐带来延迟;状态越大快照越慢。checkpoint 间隔本身也是权衡:间隔小 → 恢复时要重放的数据少(恢复快),但快照开销占比高;间隔大 → 平时开销小,但故障后要重放更多数据(恢复慢)。
一条乱序 + 迟到数据在窗口里的完整时间线
设定:10 秒滚动窗口,watermark 策略 = 最大事件时间 - 2 秒(容忍 2 秒乱序)。窗口边界:W1 = [00:00, 00:10),W2 = [00:10, 00:20)。下表是数据的物理到达顺序(注意 order_time 是乱序的):
| 到达序 | order_time(事件时间) | amount | 到达时的 max_ts | watermark = max_ts − 2s | 归属窗口 | 发生了什么 |
|---|---|---|---|---|---|---|
| 1 | 00:02 | 10 | 00:02 | 00:00 | W1 | 入窗 |
| 2 | 00:05 | 20 | 00:05 | 00:03 | W1 | 入窗 |
| 3 | 00:11 | 30 | 00:11 | 00:09 | W2 | 入窗(W=09 < 10,W1 仍开着) |
| 4 | 00:07 | 15 | 00:11 | 00:09 | W1 | 乱序但被正确纳入 W1(W 还没到 10) |
| 5 | 00:13 | 25 | 00:13 | 00:11 | W2 | 入窗;W=11 ≥ 10 → 触发 W1,输出 sum=45 |
| 6 | 00:09 | 5 | 00:13 | 00:11 | W1 | 迟到! W 已过 10、W1 已关 → 默认丢弃 |
事件时间轴上看(↑ 标出关键时刻):
事件时间 → 00:00 02 05 07 09 10 11 13 ... 20
E1 E2 E4 E6 E5 ┊
│ │ │ (迟到) │ ┊
窗口 W1 [00:00 ────────────────────) ┊
窗口 W2 [00:10 ───────)
▲
watermark 越过 00:10 的瞬间(由 E5 把 max_ts 推到 13)
→ W1 判定"数据到齐"→ 触发计算、输出 45、清理状态
→ 此后到达的 E6(00:09)落在已关闭的 W1 → 迟到丢弃
这张图要记住三件事:①E4(00:07)乱序晚到,但因为 watermark 还没越过窗口边界,被正确算进 W1;②窗口不是按墙上时钟关,而是按 watermark 越过 end 才关;③E6(00:09)在窗口关闭后才到,成为迟到数据、默认被丢——所以 W1 结果是 45 而非 50。
checkpoint barrier 流过算子
flowchart LR
JM["JobManager<br/>Checkpoint Coordinator"]
SRC["Source<br/>(记录 Kafka offset)"]
OP1["Map / Filter<br/>(无状态)"]
AGG["Keyed Aggregate<br/>(有状态: 累计值)"]
SINK["Sink<br/>(两阶段提交)"]
DFS[("持久化存储<br/>HDFS / OSS")]
JM -. "① 触发 checkpoint n<br/>让 source 插入 barrier n" .-> SRC
SRC -- "数据流 + barrier n" --> OP1
OP1 -- "数据流 + barrier n" --> AGG
AGG -- "数据流 + barrier n" --> SINK
SRC -. "② 快照 offset" .-> DFS
AGG -. "② 快照累计状态" .-> DFS
SINK -. "③ barrier n 到达 sink<br/>预提交事务" .-> DFS
SINK -. "④ 全部完成 → 通知 JM<br/>checkpoint n 成功 → commit 事务" .-> JM
barrier n 从 source 一路流到 sink,沿途每个有状态算子把自己的状态异步快照到持久化存储;当所有算子完成、barrier 到达所有 sink,checkpoint n 才算成功;支持事务的 sink 在此刻才 commit——这就是端到端 exactly-once 的实现骨架。
代码:Flink SQL 开窗聚合 + 乱序输入
用一段最小的 Flink SQL 复现 5.1 的场景。语法基于 **Flink 1.13+ 的窗口表值函数(Windowing TVF)**写法(来源:Flink 官方 window-tvf 文档)。
-- ① 源表:从 Kafka 读订单流(呼应第 06 篇),声明事件时间与 watermark
CREATE TABLE orders (
order_id STRING,
amount DECIMAL(10, 2),
order_time TIMESTAMP(3), -- 事件时间:订单真实发生的时刻
WATERMARK FOR order_time AS order_time - INTERVAL '2' SECOND -- 水位线:容忍最多 2 秒乱序
) WITH (
'connector' = 'kafka',
'topic' = 'orders',
'properties.bootstrap.servers' = 'broker:9092',
'format' = 'json',
'scan.startup.mode' = 'latest-offset'
);
-- ② 10 秒滚动窗口,按窗口聚合金额与笔数(TUMBLE 表值函数写法)
SELECT
window_start,
window_end,
SUM(amount) AS total_amount,
COUNT(*) AS order_cnt
FROM TABLE(
TUMBLE(TABLE orders, DESCRIPTOR(order_time), INTERVAL '10' SECOND)
)
GROUP BY window_start, window_end;
模拟输入(即 5.1 的物理到达顺序,order_time 故意乱序;日期省略为同一天 2026-07-28):
到达序 order_id order_time amount
1 o1 00:00:02 10
2 o2 00:00:05 20
3 o3 00:00:11 30
4 o4 00:00:07 15 ← 乱序:比 o3 晚到,但事件时间更早
5 o5 00:00:13 25 ← 到达后 watermark 推进到 00:00:11,触发 W1
6 o6 00:00:09 5 ← 迟到:W1 已关闭
运行结果(只会输出这一行):
window_start window_end total_amount order_cnt
2026-07-28 00:00:00 2026-07-28 00:00:10 45.00 3
一句话解释:窗口 [00:00:00, 00:00:10) 在第 5 条数据把 watermark 推过 00:00:10 的瞬间被触发——此时乱序晚到的 o4(00:07,15) 已被正确纳入(所以是 3 笔、金额含它),而随后到达的迟到数据 o6(00:09,5) 因 watermark 已越过窗口边界被判定迟到、默认丢弃,故金额是 45 而非 50;窗口 [00:00:10, 00:00:20) 因 watermark 尚未越过 00:00:20(当前最大事件时间才 00:13),仍在等待、尚未输出——这直观说明了"窗口按 watermark 触发,而非按墙上时钟触发"。
补充:Flink SQL 的窗口 TVF 目前支持 TUMBLE / HOP / CUMULATE / SESSION;其中 SESSION 的 TVF 形式较新(据 1.20 文档在流模式下可用,更早版本需用旧式分组窗口函数),以你所用版本的官方文档为准。
状态膨胀(state 越长越大)
成因:状态会随时间无限增长。典型触发点——① key 空间无限膨胀(如按 user_id 聚合、且用户持续新增,每个新 key 都新增一份状态);② 状态没设 TTL(过期时间),历史 key 的状态永不清理;③ 大窗口、无界的双流 join 缓存大量待匹配数据。
后果:堆内存 OOM(HashMapStateBackend),或 RocksDB 磁盘打满;checkpoint 因状态过大而变慢、甚至超时失败,进而拖垮整个作业。
对策:给状态设 state TTL;大状态改用 EmbeddedRocksDBStateBackend + 增量 checkpoint;审视 key 设计避免无意义的高基数 key;双流 join 加时间界限(interval join)。
何时不该用流:如果业务本质要求无限期保留全量明细状态且无法设 TTL(比如需要随时对任意历史区间做全量重算),那它更契合批处理(定期全量重跑),硬用流只会把状态撑爆。
watermark 设置不当
- 容忍时长太小 → 大量乱序数据变迟到被丢 → 结果偏小、悄悄丢数(最隐蔽,因为作业不报错、只是结果不对)。
- 容忍时长太大 → 窗口迟迟不触发 → 延迟高、未触发窗口的状态越堆越多。
- 空闲源(idle source)问题:watermark 取所有并行分区中最小的那个来推进(木桶效应)。若某个 Kafka 分区一段时间没数据,它的 watermark 不推进,会拖住整个下游的 watermark、导致窗口永远不触发。对策:配置源的空闲检测(DataStream 的
withIdleness,SQL 侧的table.exec.source.idle-timeout,具体参数以版本文档为准),让空闲分区暂时不参与 watermark 计算。
反压(backpressure)
是什么:下游算子处理不过来 → 它的输入缓冲区填满 → 逐级向上游反向施加"限速"信号,直到 source 也放慢读取速度。它本身是一种保护机制,不是 bug——防止快的上游把慢的下游冲垮、内存爆掉。
为什么要重视:持续反压会拖慢 checkpoint 的 barrier 传播与对齐(barrier 卡在满的缓冲区里流不动),导致 checkpoint 超时失败、端到端延迟飙升。
常见成因与定位:数据倾斜(某个热点 key 把一个并行子任务压垮)、外部 sink 写入慢(如下游数据库扛不住)、资源不足、频繁 GC。定位靠 Flink Web UI 的反压(Backpressure)指标逐算子看是哪一环最慢。unaligned checkpoint 能缓解"反压导致 checkpoint 失败"的问题,但治本还是要解决反压根因。
| 维度 | 批处理(Spark) | 流处理(Flink) |
|---|---|---|
| 数据边界 | 有界:有限、已到齐的数据集 | 无界:持续到达、无终点 |
| 触发 / 延迟 | 作业调度触发,小时 / 天级 | 事件驱动,毫秒 ~ 秒级 |
| 处理模型 | 全量读入 → 构建 DAG → 按 stage 调度 → 落盘 | 常驻算子逐条 / 事件驱动处理 |
| 状态 | 无长期状态,每次从头算 | 原生有状态,增量维护(state backend) |
| 时间语义 | 通常按数据分区 / 批次,少谈事件时间 | event / processing time + watermark |
| 容错机制 | 失败重算(RDD 血缘 / 重跑分区) | checkpoint 快照回滚 + source 重放 |
| 结果修正 | 重跑整批覆盖 | 迟到更新 / 回撤(retract)增量修正 |
| 典型场景 | 大规模离线加工、历史回溯、报表 | 实时监控、风控、实时数仓、实时大屏 |
本质:批把"数据"当成静止的一堆,一次算完;流把"数据"当成流动的序列,持续算、结果持续更新。而流批一体说的是:既然批是"有界流",就让同一套引擎/SQL同时覆盖两栏——Flink 对有界流可切到 BATCH 执行模式(自 1.12 起,DataStream 支持;此模式下按 stage 调度、不做周期 checkpoint、末尾 watermark 视为 +∞),对无界流用 STREAMING 模式。一份业务逻辑,两种运行方式。
小结
- 流计算为解决批的延迟痛点而生;Flink 相对 Storm(无强一致状态)/ Spark Streaming(微批、时间语义弱)的关键升级是:真逐条 + 原生有状态 + exactly-once,代价是状态、乱序、运维三重复杂度。
- 运行架构:JobManager(Dispatcher / ResourceManager / JobMaster)是控制面,TaskManager 是干活的 JVM 进程;slot 是资源调度最小单位,只隔离托管内存、不隔离 CPU,默认 slot sharing 让一个 slot 装下整条 pipeline;算子链把多算子并进一个线程换取吞吐与低延迟;SQL 经 StreamGraph → JobGraph → ExecutionGraph 展开成 subtask 部署到 slot——这套骨架是后面 watermark、状态、checkpoint、反压的物理载体。
- 事件时间 + watermark 是正确处理乱序/迟到的地基:watermark 是"事件时间进展到哪"的单调声明,它决定窗口何时触发;乱序容忍度是延迟 vs 完整性的权衡旋钮。
- 状态 + state backend 让流能做聚合/join;checkpoint(Chandy-Lamport + barrier 对齐)+ source 重放 提供引擎内 exactly-once,端到端还需 sink 幂等或两阶段提交。
- 三大坑:状态膨胀、watermark 设错(丢数或延迟)、反压。
- 流批一体 = 把批当作有界流,用一套引擎/SQL 统一实时与离线。