全文共 7,676 字 预计阅读 22 分钟
bg

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(恰好一次)状态语义。它把前面两条路的优点合到一起——既低延迟,又有强一致的状态。

代价(这是本篇后半段要展开的):

  1. 状态管理变复杂:引擎要长期、可靠地维护每个算子的中间状态,还要能高效快照与恢复。
  2. 必须直面乱序:一旦按"业务发生时间"算,就要处理"晚到的数据",这引入了 watermark、迟到处理等一整套机制。
  3. 运维成本上升: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 各取一个并行实例)。

两个好处(官方给出的理由,附因果):

  1. 集群只需要"作业中最高并行度"那么多 slot,不用把各算子的 task 数逐一累加。例:source 并行度 4、window 并行度 4,不共享要 8 个 slot,共享后 4 个就够。
  2. 资源利用率更高:不共享的话,轻量的 source/map subtask 会和重量级的 window subtask 占用同样多的资源;共享后,轻重任务混在一个 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。逻辑是:

  1. 窗口把落在 [start, end) 的事件不断累积进状态;
  2. 当 watermark 推进到 ≥ end,引擎判定"事件时间到 end 为止的数据都到齐了",于是触发窗口计算、输出结果、清理该窗口占用的状态;
  3. 此后再来一条本该属于这个窗口、但事件时间 < 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-docs skill 或 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 存储"两个正交概念——例如老的 RocksDBStateBackendEmbeddedRocksDBStateBackend(状态后端)+ FileSystemCheckpointStorage(checkpoint 存到文件系统)。这只是 API/命名的澄清,运行时行为不变。

checkpoint / savepoint 与 exactly-once

这是流引擎容错的核心,也是 Flink 相对 Storm 的关键优势。

checkpoint(检查点):引擎周期性地对所有算子的状态做一次全局一致的快照,持久化到外部存储(HDFS/OSS 等)。作业失败时,把所有算子的状态回滚到最近一次成功的快照,并让 source 重放该快照点之后的数据(如 Kafka 重置到快照里记录的 offset),从而继续。

savepoint 是"手动触发、用于运维"的 checkpoint:升级作业、改并行度、迁移集群时手动打一个,之后从它恢复。机制同源,用途不同(自动容错 vs 手动运维)。

难点:怎么在不停止数据流的情况下,拍一张"全局一致"的快照?

分布式系统里,各算子分布在不同机器、各自处理进度不同,你没法"同时暂停所有算子"再拍照。这正是经典的 Chandy-Lamport 分布式快照算法要解决的问题。它的直觉是:不追求"物理上同一时刻",而是追求"逻辑上一致的切面"——只要保证快照对应"每条数据要么完全算进了快照、要么完全没算进",就是一致的。

Flink 的实现靠 barrier(检查点屏障):

  1. JobManager 里的 Checkpoint Coordinator 周期性通知各 source,在数据流里插入编号为 n 的 barrier;
  2. barrier 像一条普通记录一样,随数据向下游流动,把数据流"切"成"barrier n 之前"和"barrier n 之后";
  3. 每个算子一收到 barrier n,就对自己当前的状态做快照(异步写到外部存储),然后把 barrier n 转发给下游;
  4. 当 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 统一实时与离线。
Back to Blog