1. 从一个反直觉的现象说起为什么批处理跑得好好的换成流处理就出问题刚接触 Flink 的人十有八九会在某个时刻被一个看似简单的问题绊住我明明写的是同一套算子逻辑为什么换成流模式跑结果要么迟迟不输出要么输出的结果和批模式对不上更让人抓狂的是有些任务在测试环境跑得飞快一上生产就卡在某个算子上不动了日志里也看不出明显报错。这个现象背后几乎都指向同一个根因——没有搞清楚自己手里的数据到底是有界流还是无界流以及 Flink 针对这两种数据形态在运行时到底做了哪些不同的假设。很多人学 Flink 的时候把注意力全放在算子 API、状态管理、Checkpoint 机制上却忽略了最基础的一个分类数据源的边界性。而这个分类恰恰决定了你的作业能不能正常结束、窗口能不能触发、状态会不会无限膨胀。我见过不少团队在迁移离线任务到实时链路时踩坑本质上就是把一个无界流当成了有界流来写或者反过来把一个本来有界的数据源硬生生用流的方式处理结果引入了一堆不必要的复杂度。这篇文章就围绕“有界流”和“无界流”这两个概念把它们的本质区别、运行时行为差异、算子层面的影响、以及实际开发中怎么选型和避坑一次性讲透。不管你是刚学 Flink 的新手还是已经写过几个作业但总觉得理解不够扎实的开发者相信都能从中找到一些之前没注意到的细节。2. 有界流与无界流的本质不只是“数据量大小”的区别2.1 边界的定义来自数据源而不是数据本身很多人第一次听到“有界流”和“无界流”下意识会理解成“数据多的就是无界数据少的就是有界”。这个理解是错的。边界性描述的是数据源是否有一个明确的终点而不是数据量的大小。举个生活化的例子你把一个水库的闸门打开水会一直流出来你不知道它什么时候流完这就是无界流而你把一桶水倒进管道桶空了水流就结束了这就是有界流。关键不在于水的多少而在于“有没有一个确定的结束时刻”。在 Flink 里有界流的典型代表是读取一个文件、查询一张数据库表的全量数据、读取一个固定时间范围内的消息队列积压数据。这些数据源都有一个共同特征消费到某个位置之后就不会再有新数据进来了。而无界流的典型代表是实时消息队列、传感器数据流、用户行为日志流这些数据源的特点是只要系统还在运行数据就可能源源不断地产生。这个区别看起来很简单但它带来的连锁反应非常深远。Flink 的运行时在执行作业时需要知道“这个作业什么时候算跑完”。对于有界流作业处理完所有数据后可以正常终止资源释放结果输出。对于无界流作业理论上永远不会自己结束除非人为取消或者发生故障。这个最根本的差异直接影响了 Flink 在作业调度、Checkpoint、窗口触发、状态清理等各个层面的设计。2.2 为什么 Flink 要用同一套 API 处理两种数据既然有界流和无界流差别这么大为什么 Flink 不干脆设计两套 API一套专门做批处理一套专门做流处理这个问题在 Flink 社区里被讨论过很多次答案其实和 Flink 的核心设计哲学有关。Flink 的目标是提供一个统一的处理引擎让开发者用同一套编程模型就能应对批和流两种场景。这个理念的好处是显而易见的业务逻辑只需要写一遍不用维护两套代码批处理可以看作是流处理的一个特例也就是“有界流”而流处理则是更通用的情况。这种统一性带来的直接好处是当你的业务从离线统计逐渐演进到实时统计时不需要重写整个作业只需要调整数据源的接入方式和一些配置参数。但统一 API 也带来了一个副作用开发者容易忽略两种数据形态在运行时行为上的差异。因为代码看起来差不多就容易想当然地认为行为也差不多。实际上Flink 在执行有界流和无界流时底层走的可能是完全不同的执行路径。比如在 Flink 1.12 之后批执行模式BATCH和流执行模式STREAMING就是两套不同的执行策略前者针对有界流做了大量优化后者则是为无界流设计的通用路径。2.3 一个容易混淆的点有界流不等于批处理这里要特别澄清一个常见的误解有界流和批处理并不是一回事。有界流描述的是数据源的属性而批处理描述的是一种执行模式。你可以用流执行模式去处理一个有界流也可以用批执行模式去处理一个有界流。Flink 允许你在同一个作业里混用不同的执行模式这就带来了灵活性也带来了选择上的困惑。我个人的经验是如果你的数据源是有界的而且业务逻辑不涉及复杂的窗口触发和状态管理优先考虑用批执行模式性能和资源利用率都会更好。但如果你的业务逻辑里已经用了大量流式算子比如事件时间窗口、水印机制那即使数据源有界也可能需要保持流执行模式否则语义会对不上。这个取舍后面会详细展开。3. 运行时行为差异有界流和无界流在 Flink 内部到底走了哪条路3.1 作业终止条件的不同处理逻辑Flink 的 JobManager 在调度作业时需要判断作业什么时候算完成。对于有界流当所有数据源都报告“没有更多数据了”并且所有算子都处理完了手头的数据作业就可以正常结束进入 FINISHED 状态。这个过程涉及到 Flink 的一个核心机制数据源的结束信号传播。具体来说有界流的数据源在读取完所有数据后会向后续算子发送一个结束标记。这个标记沿着数据流图一路往下传每个算子收到后知道自己不会再收到新数据了就可以做一些收尾工作比如触发最后一次窗口计算、清理状态、输出最终结果。当所有算子都完成了收尾整个作业就结束了。而无界流的数据源永远不会发送这个结束标记所以作业会一直处于 RUNNING 状态。即使某个时间段没有新数据进来作业也不会自己结束而是继续等待。这就是为什么很多实时作业需要配合监控和告警因为一旦数据源断了作业不会报错只是静静地待在那里看起来一切正常实际上已经不再处理数据了。3.2 Checkpoint 机制在两种场景下的表现差异Checkpoint 是 Flink 保证容错的核心机制它定期对作业的状态做一次快照以便在故障恢复时能够从最近一次快照重新开始。对于有界流和无界流Checkpoint 的行为有一些微妙但重要的差异。对于无界流Checkpoint 是必须的因为作业可能运行几个月甚至几年中间任何一次故障都需要能够恢复。而且由于数据是持续不断的Checkpoint 的频率和超时设置需要仔细调优太频繁会影响吞吐太稀疏会导致恢复时重放大量数据。对于有界流Checkpoint 的必要性就没那么高了。如果作业能在合理时间内跑完而且失败后重跑的成本可以接受完全可以关掉 Checkpoint减少运行时开销。Flink 在批执行模式下默认就是关闭 Checkpoint 的因为批处理通常可以通过重跑整个作业来恢复不需要做状态快照。这里有一个实操中的坑有些开发者在流执行模式下处理有界流时忘记调整 Checkpoint 配置结果作业跑得比预期慢很多。因为流执行模式默认会开启 Checkpoint而有界流本来可以更快跑完却被 Checkpoint 的同步开销拖慢了。这种情况下要么切换到批执行模式要么手动调大 Checkpoint 间隔。3.3 状态后端的选择与数据边界性的关系状态后端决定了 Flink 把作业状态存在哪里以及怎么做快照。常见的选择有基于内存的、基于文件系统的、以及基于嵌入式数据库的。对于有界流和无界流状态后端的选择策略也不太一样。无界流通常需要更可靠的状态后端因为作业运行时间长状态可能积累得很大而且一旦丢失恢复成本很高。所以生产环境中的实时作业一般会选择基于分布式文件系统的状态后端配合增量 Checkpoint 来减少每次快照的开销。有界流则灵活得多。如果数据量不大状态可以直接放在内存里跑完就释放简单高效。如果数据量大但作业时间短也可以选择基于本地文件系统的状态后端避免分布式存储的网络开销。我在处理一些中等规模的有界流作业时经常直接用默认的内存状态后端跑完就结束省去了配置分布式存储的麻烦。3.4 水印机制在有界流中的特殊表现水印是 Flink 处理事件时间的核心机制它表示“在这个时间点之前的数据应该都已经到了”。对于无界流水印是持续生成的随着数据流的推进不断更新。对于有界流水印的生成有一个特殊的收尾阶段当数据源结束时会发送一个最大水印表示所有数据都已经到齐可以触发所有剩余的窗口计算。这个差异在实际开发中很容易被忽略。比如你写了一个基于事件时间窗口的作业在无界流场景下窗口会在水印超过窗口结束时间时触发。但在有界流场景下如果数据源结束了但水印没有正确推进到最大值最后一个窗口可能永远不会触发导致结果丢失。这个问题在测试环境可能看不出来因为测试数据量小水印很快就推进到位了但在生产环境数据量大、乱序严重的情况下就容易出问题。4. 算子层面的影响哪些算子对边界性敏感哪些不敏感4.1 窗口算子是边界性差异最明显的区域在所有算子中窗口算子对数据边界性最敏感。原因很简单窗口的本质是“把数据按照某种规则分组然后对每组做计算”。对于无界流窗口需要依靠水印来判断什么时候可以触发计算因为数据永远不会结束。对于有界流除了水印之外还可以依靠数据源的结束信号来触发最后的窗口。这就导致了一个实际开发中的常见问题同一个窗口逻辑在有界流和无界流下的输出结果可能不一样。比如一个滚动窗口窗口大小是 10 分钟。在无界流下窗口会在水印超过窗口结束时间时触发输出该窗口内的数据。在有界流下如果数据源在窗口结束前就结束了最后一个不完整的窗口可能会被特殊处理比如被丢弃或者被合并到前一个窗口。具体行为取决于窗口的类型和配置。我踩过的一个坑是用事件时间窗口处理一个有界流数据集测试时发现最后一个窗口的结果总是对不上。排查了很久才发现是因为数据源结束后水印没有推进到最大值导致最后一个窗口没有触发。后来在数据源后面加了一个手动推进水印的逻辑问题才解决。这个经验告诉我处理有界流时一定要确认水印能否正常推进到覆盖所有数据的时间点。4.2 聚合算子的状态清理时机差异聚合算子比如求和、计数、最大值通常需要维护状态来保存中间结果。对于无界流这些状态会一直存在因为随时可能有新数据进来需要更新。对于有界流当数据源结束后这些状态就可以清理掉了因为不会再有新数据。这个差异看起来不起眼但在长时间运行的有界流作业中可能造成问题。比如你用一个 KeyedProcessFunction 做自定义聚合维护了一个很大的状态。如果作业是有界流跑完之后状态应该被清理但如果配置不当状态可能一直留在状态后端里占用存储空间。更严重的是如果这个作业被反复提交每次都会留下一些残留状态时间长了就会拖垮整个集群。Flink 提供了一些状态过期配置可以设置状态的生存时间。对于有界流作业我通常会把状态过期时间设得比较短确保作业结束后状态能尽快被清理。对于无界流作业状态过期时间需要根据业务逻辑来定不能太短否则可能丢失必要的中间结果。4.3 连接算子在有界流和无界流下的不同策略连接算子用于把两条流的数据按照某个条件关联起来。在无界流场景下两条流都是无限的连接算子需要维护两边流的状态等待匹配的数据到来。这就带来了一个经典问题如果一条流的数据迟迟不来另一条流的数据就会一直留在状态里导致状态无限增长。在有界流场景下这个问题相对好处理一些因为两条流都有终点连接算子可以在两条流都结束后做最后的匹配然后清理状态。但即便如此如果两条流的数据量差异很大比如一条流有一亿条数据另一条只有一百条连接算子仍然可能因为状态过大而出现问题。实际开发中我一般会尽量避免在有界流场景下使用无界的连接算子。如果确实需要做连接会优先考虑用批执行模式下的连接操作或者把数据先落地到外部存储再用 SQL 做关联。这样虽然多了一步但稳定性和性能都更有保障。4.4 迭代算子有界流的专属能力迭代算子是 Flink 中一个比较特殊的存在它允许在数据流上做循环计算比如图算法、机器学习训练等。迭代算子有一个硬性要求数据源必须是有界流。因为迭代需要一个明确的终止条件而无界流永远不结束迭代也就无法终止。这个限制在实际开发中影响不大因为需要迭代计算的场景通常都是批处理任务数据源天然就是有界的。但如果你试图在一个无界流上使用迭代算子Flink 会直接报错不会让你提交作业。这个设计是合理的因为无界流上的迭代在语义上就没有明确定义。5. 执行模式的选择STREAMING 还是 BATCH这是个问题5.1 两种执行模式的本质区别Flink 提供了两种执行模式STREAMING 和 BATCH。STREAMING 模式是为无界流设计的所有算子都按照流的方式运行支持事件时间、水印、Checkpoint 等流式特性。BATCH 模式是为有界流设计的针对批处理场景做了大量优化比如算子可以阻塞式地消费数据、支持更高效的排序和连接算法、默认关闭 Checkpoint。这两种模式的核心区别在于算子如何处理数据。在 STREAMING 模式下算子收到一条数据就处理一条处理完立即发送给下游是典型的流水线模式。在 BATCH 模式下算子可以先把数据攒起来等攒够了再一起处理这样可以做更多优化比如排序、合并、批量计算。5.2 有界流在两种模式下的性能对比对于有界流用 BATCH 模式通常比 STREAMING 模式快很多。原因有几个BATCH 模式不需要维护 Checkpoint减少了同步开销BATCH 模式可以使用更高效的排序和连接算法减少了数据shuffle的量BATCH 模式可以在算子层面做更多优化比如把多个操作合并成一个。我做过一个简单的对比测试同样一份一亿条数据的日志文件用 STREAMING 模式跑一个分组聚合耗时大约 8 分钟用 BATCH 模式跑同样的逻辑耗时不到 3 分钟。差距主要来自 Checkpoint 开销和 shuffle 效率。当然这个数字会随着数据特征和集群配置变化但趋势是明显的有界流用 BATCH 模式通常更快。5.3 什么时候必须用 STREAMING 模式处理有界流虽然 BATCH 模式性能更好但有些场景下你不得不用 STREAMING 模式处理有界流。最常见的情况是业务逻辑里已经用了流式算子比如事件时间窗口、自定义水印、ProcessFunction 里的定时器。这些算子在 BATCH 模式下要么不支持要么语义会发生变化。另一个常见场景是作业需要和真正的无界流做连接。比如你有一个实时流需要和一张维表做关联而维表的数据是有界的。这种情况下整个作业必须用 STREAMING 模式因为实时流是无界的。维表部分虽然是有界流但只能作为广播流或者侧输入不能单独用 BATCH 模式处理。还有一种情况是作业需要增量输出结果。BATCH 模式通常是等所有数据处理完才输出最终结果而 STREAMING 模式可以持续输出中间结果。如果你的业务需要实时看到部分结果比如监控大屏那就必须用 STREAMING 模式。5.4 执行模式切换的实操注意事项切换执行模式不是改一个配置那么简单有几个坑需要注意。首先不是所有算子都支持两种模式。有些算子只在 STREAMING 模式下可用比如迭代算子、某些类型的窗口。切换到 BATCH 模式后这些算子会直接报错。所以在切换之前要确认作业里用到的所有算子都支持目标模式。其次状态兼容性。如果你之前用 STREAMING 模式跑过作业保存了 Checkpoint 或 Savepoint切换到 BATCH 模式后可能无法从这些状态恢复。因为两种模式的状态结构可能不一样。这种情况下要么从零开始跑要么先用 STREAMING 模式跑完再切换到 BATCH 模式做后续处理。最后资源配置。BATCH 模式和 STREAMING 模式对资源的需求不一样。BATCH 模式通常需要更多的内存来做排序和缓存而 STREAMING 模式更依赖网络和 CPU。切换模式后最好重新评估一下资源配置避免出现资源瓶颈。6. 实战中的选型思路与避坑清单6.1 从数据源特征出发做第一层判断选型的第一步永远是先搞清楚数据源的性质。我一般会问三个问题数据是不是持续产生的有没有一个明确的结束时间数据量大概在什么级别根据这三个问题的答案可以快速定位到有界流还是无界流。如果数据是持续产生的没有明确结束时间那就是无界流必须用 STREAMING 模式。如果数据有明确的结束时间比如每天凌晨跑一次的全量统计那就是有界流可以优先考虑 BATCH 模式。如果数据量特别大比如几百 GB 甚至 TB 级别即使是有界流也要谨慎评估 BATCH 模式是否能在可接受的时间内跑完必要时可能需要拆分成多个作业并行处理。6.2 业务逻辑对延迟的要求决定执行模式除了数据源特征业务逻辑对延迟的要求也是选型的重要依据。如果业务要求秒级或毫秒级的延迟那即使数据源是有界的也可能需要用 STREAMING 模式因为 BATCH 模式通常要等所有数据处理完才输出结果延迟较高。反过来如果业务对延迟不敏感比如 T1 的报表统计那用 BATCH 模式完全没问题而且性能和稳定性都更好。我个人的经验是除非业务明确要求实时输出否则有界流优先用 BATCH 模式。这样不仅跑得快而且运维简单出了问题重跑就行不用操心 Checkpoint 和状态恢复。6.3 常见踩坑场景与排查思路在实际开发中有界流和无界流相关的坑主要集中在几个方面。第一个是作业不结束明明数据源已经读完了作业却一直处于 RUNNING 状态。这通常是因为某个算子没有正确处理数据源的结束信号或者水印没有推进到最大值。排查方法是看 Flink UI 里各个算子的状态找到那个一直处于 RUNNING 但不再处理数据的算子检查它的结束逻辑。第二个是结果不一致同样的逻辑批模式和流模式跑出来的结果对不上。这通常是因为窗口触发时机不同或者状态清理策略不同。排查方法是先确认两种模式下的水印推进情况再看窗口的触发条件是否一致。如果涉及自定义状态还要检查状态的初始化逻辑在两种模式下是否一致。第三个是状态无限增长作业跑了一段时间后状态越来越大最终导致内存溢出或 Checkpoint 超时。这通常发生在无界流场景下某个算子没有正确清理过期状态。排查方法是看 Flink UI 里各个算子的状态大小找到增长最快的那个检查它的状态清理逻辑。对于有界流如果出现这个问题通常是因为状态过期配置没设对或者作业结束后状态没有被正确清理。6.4 一个实用的决策流程图为了帮助快速决策我整理了一个简单的判断流程判断条件推荐方案理由数据源无界业务要求低延迟STREAMING 模式唯一选择无界流只能用流模式数据源有界业务要求低延迟STREAMING 模式需要增量输出批模式做不到数据源有界业务不要求低延迟BATCH 模式性能更好运维更简单数据源有界但逻辑用了流式算子STREAMING 模式批模式不支持这些算子数据源有界需要和无界流连接STREAMING 模式整体必须保持流模式这个表不是绝对的实际选型还要考虑集群资源、团队熟悉度、运维成本等因素。但作为一个快速参考应该能覆盖大部分常见场景。6.5 一些容易被忽略的配置细节最后分享几个实操中容易忽略的配置细节。第一个是数据源的并行度。对于有界流如果数据源是一个大文件可以设置较高的并行度来加速读取但如果数据源是一个数据库查询并行度太高可能会把数据库压垮。需要根据数据源的实际承载能力来调整。第二个是算子的链式合并。Flink 默认会把多个算子合并成一个算子链减少数据在网络上的传输。但对于有界流有时候拆开算子链反而更好因为可以让不同的算子独立调度提高资源利用率。可以通过配置禁用算子链观察性能变化。第三个是Checkpoint 的超时时间。对于有界流如果开启了 Checkpoint超时时间可以设得短一些因为数据量有限Checkpoint 应该很快完成。如果超时时间设得太长一旦 Checkpoint 卡住会拖慢整个作业。对于无界流超时时间要设得宽松一些因为状态可能很大Checkpoint 需要更多时间。第四个是水印的生成策略。对于有界流如果数据本身是乱序的水印生成策略需要能够处理乱序否则可能导致窗口触发过早或过晚。我通常会用带延迟的水印策略给乱序数据留出一定的缓冲时间。具体延迟设多少需要根据数据的实际乱序程度来定可以通过观察数据的时间戳分布来估算。7. 从理解概念到形成直觉回过头来看有界流和无界流的区别表面上是一个数据分类问题实际上它贯穿了 Flink 作业开发的整个生命周期。从数据源接入、算子选择、执行模式配置到状态管理、Checkpoint 调优、故障恢复每一个环节都受到数据边界性的影响。我自己的体会是刚开始学 Flink 的时候容易把注意力放在 API 的用法上觉得只要能把算子串起来跑通就行。但随着处理的场景越来越复杂踩的坑越来越多才逐渐意识到真正决定一个 Flink 作业能不能稳定运行的往往不是那些花哨的高级特性而是对最基础概念的理解是否到位。有界流和无界流就是这样一个基础概念它不难理解但真正吃透它需要在实践中反复体会。如果你现在正在写一个 Flink 作业不妨先停下来问自己几个问题我的数据源是有界的还是无界的我选的执行模式和数据源的边界性匹配吗我的算子在当前模式下会不会有状态清理或窗口触发的问题把这些想清楚了很多坑其实可以在写代码之前就避开。