同一份数据处理逻辑只改一个参数跑完的时间差了将近一个数量级——第一次遇到这种事的时候我以为是自己哪里配错了反复检查了几遍作业图才发现问题根本不在代码里。真正起作用的是 Flink 的执行模式Execution Mode流STREAMING和批BATCH。这个开关决定了作业是按数据一条一条流过来、算子一直活着的方式跑还是按数据先落盘、分阶段推进、算子用完就撤的方式跑。它影响的不只是快慢还包括你能不能在这份代码里用状态、能不能靠 Checkpoint 兜底、中间结果走网络还是走磁盘、TaskManager 挂了要重跑多少。这篇主要讲清楚三件事执行模式到底是什么、它改动了作业运行时的哪些底层行为、以及在代码里和提交时怎么正确设置它。适合已经能跑通 Flink 作业、但对为什么我的作业内存一直涨为什么加了状态就报错为什么批任务比流任务快这么多这类问题还没有系统答案的同学。上篇聚焦概念、配置入口、执行计划与调度的差异状态、容错和调优放到后面单独拆。1. 先把执行模式这个概念讲透1.1 流与批的差别不在 API在运行时怎么组织数据很多人对 Flink 的印象停在流批一体这四个字上但真正落到代码里一体指的其实是同一套算子、同一套 API、同一份作业逻辑而流和批是这份逻辑在运行时可以被组织成的两种形态。这个区分非常关键因为它解释了为什么你写keyBy().window()的时候完全不用关心自己是在做实时还是离线——那是运行时的事不是 API 的事。从数据本身的属性来看差别只有一条数据有没有尽头。无界数据比如持续写入的消息队列只会结束于你手动停止作业的那一刻所以算子必须一直活着随时准备接收下一条有界数据比如一个 HDFS 目录下的历史文件有明确的结束点作业处理完最后一条就可以收工。这一条差异往下传导会引发一连串连锁反应算子生命周期不同、中间结果的处理方式不同、失败恢复的策略不同、内存和磁盘的使用比例不同。我见过不少同学把批处理理解成给流处理加个结束条件这个理解在逻辑上没错但在工程上会踩坑。因为一旦承认数据有尽头引擎就可以做一些流模式下绝对不敢做的激进优化把上游全部跑完、结果整齐落到磁盘、再启动下游按 key 排序后顺序扫描把整个 stage 的算子一次性调度起来抢占资源。这些优化在流模式下是不成立的因为上游永远跑不完。所以执行模式不是一个语法糖它是引擎给自己开的一扇允许做更激进优化的门。1.2 为什么要在一个引擎里塞两种跑法如果只是为了跑批完全可以用一套独立的技术栈如果只是为了跑流也犯不着搞这么复杂。真正驱动这件事的是口径一致性。同一个指标实时链路算一遍、离线链路再算一遍两套代码两套逻辑结果对不上是常态。业务方看到实时大屏上是 1024第二天报表出来是 1018然后就开始质疑数据质量。这种问题我在不止一个团队里见过排查到最后往往不是谁算错了而是两条链路的边界条件处理方式不一样——一条把迟到数据算进当前窗口另一条算进了下一批。把执行模式做成一个开关本质上是让同一份逻辑在两种运行时下跑出同样的语义结果。你写的是同一段聚合逻辑流模式下它按事件时间窗口滚动批模式下它按数据处理完毕的整体口径算一次虽然底层执行路径完全不同但对外的结果口径是一致的。这才是流批一体真正想解决的问题不是省一套代码是省掉两套结果对不齐的扯皮成本。从版本演进的脉络看这条路走得并不轻松。早期是 DataStream API 和 DataSet API 两套并存写批要用 DataSet写流要用 DataStream两套 API 的算子语义还不完全一样。后来是把批的能力往 DataStream API 上收让一份代码通过执行模式切换来跑两种场景DataSet API 逐渐进入维护状态。所以如果你现在维护的老项目里还有大量 DataSet 代码那属于历史包袱新写的逻辑应该统一走 DataStream 或 Table/SQL。1.3 三种取值STREAMING、BATCH、AUTOMATIC执行模式在工程上有三个取值这点经常被忽略很多人只知道流和批两个。取值含义典型适用场景STREAMING经典流式执行所有算子同时部署、常驻运行中间结果走网络实时链路、无界数据源、需要低延迟输出BATCH批式执行按阻塞式数据交换切分阶段中间结果落盘算子用完即撤历史数据回溯、T1 报表、大批量 ETLAUTOMATIC由引擎根据数据源的有界性自动判定通用型作业、不确定上游是否有界AUTOMATIC这个取值很多人没注意过。它的判定逻辑其实很朴素作业图构建完成后引擎会检查所有数据源的有界性如果全都是有界的就走 BATCH只要出现一个无界源就退回 STREAMING。这个设计的好处是同一份代码既可以被离线调度系统当作批任务拉起也可以被实时平台当作流任务常驻不用改配置。但我不太建议在生产环境里长期依赖AUTOMATIC。原因是它的判定发生在作业图构建之后属于隐式决策一旦上游数据源的有界性被改动比如某个 Source 从读固定目录改成读持续写入的路径作业的执行模式会悄悄变化行为差异可能是灾难性的——原本走批模式、不需要状态、不需要 Checkpoint 的作业突然变成了流模式开始申请状态后端、开始做快照资源占用直接翻倍。显式指定永远比隐式推断可控这是我在多个项目里反复验证过的结论。注意默认值是STREAMING。这意味着你不写任何配置作业就是流模式。很多为什么我的离线任务内存一直涨的问题根源就是忘了切模式。2. 配置入口与优先级三种改法怎么选2.1 代码内设置DataStream API 与 Table API最直接的方式是在构建执行环境之后立刻设置。DataStream API 里调用setRuntimeMode参数是枚举值import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment; import org.apache.flink.api.common.RuntimeExecutionMode; StreamExecutionEnvironment env StreamExecutionEnvironment.getExecutionEnvironment(); // 显式指定批模式覆盖配置文件中的默认值 env.setRuntimeMode(RuntimeExecutionMode.BATCH); // 后续的 source / transformation / sink 写法完全不变 env.fromSource(boundedSource, WatermarkStrategy.noWatermarks(), history-source) .keyBy(Event::getCategory) .sum(amount) .print(); env.execute(batch-demo);注意这里的水位线策略传的是noWatermarks()。批模式下水位线本身没有意义传任何策略都会被忽略但 API 签名要求你传一个所以直接给空策略最干净也避免读者误以为水位线在这里还在起作用。Table API / SQL 的写法略有不同因为TableEnvironment是从配置对象构建的import org.apache.flink.table.api.EnvironmentSettings; import org.apache.flink.table.api.TableEnvironment; EnvironmentSettings settings EnvironmentSettings.newInstance() .inBatchMode() // 等价于设置 execution.runtime-mode batch .build(); TableEnvironment tEnv TableEnvironment.create(settings);inBatchMode()和inStreamingMode()是封装好的快捷方法底层改的就是execution.runtime-mode这个配置项。如果你想统一管理也可以直接写配置EnvironmentSettings settings EnvironmentSettings.newInstance() .withConfiguration( org.apache.flink.configuration.Configuration.fromMap( java.util.Map.of(execution.runtime-mode, batch))) .build();这两种写法效果一样区别只在于可读性。当配置项数量变多比如同时要调 shuffle 模式、并行度、内存比例直接写配置 Map 更方便集中维护只切执行模式的时候用快捷方法更清爽。2.2 提交时设置命令行与配置文件代码里写死执行模式有个明显问题同一份 jar白天想跑批做回溯晚上想跑流做实时的改代码重新打包显然不现实。所以更常见的做法是把执行模式放到提交参数里。# 提交时用 -D 覆盖注意是新版客户端的写法 flink run \ -D execution.runtime-modeBATCH \ -D parallelism.default16 \ -c com.example.HistoryBackfillJob \ ./jobs/backfill.jar如果是 SQL 客户端或者交互式环境用SET语句SET execution.runtime-mode batch; -- 之后再执行 INSERT INTO ... SELECT ...放在配置文件里也可以改flink-conf.yaml新版叫config.yamlexecution: runtime-mode: BATCH这种方式影响的是整个集群上所有没显式指定的作业属于全局默认值。我个人不太推荐在生产集群上这么干因为集群上往往同时跑着实时和离线作业全局改成批模式会让实时任务的作业图构建直接失败——无界源加批模式是明确不允许的组合报错信息大意是检测到无界数据源但执行模式被设置为 BATCH此组合不被允许。2.3 优先级与踩坑为什么我设了 BATCH 却没生效配置的生效顺序是有讲究的从高到低大致是代码里setRuntimeMode显式调用 提交命令行-D参数 集群配置文件默认值。AUTOMATIC的自动判定则发生在最外层只有当前面几层都没有明确给出STREAMING或BATCH时才起作用。这条链路上最典型的坑有两个。第一个是代码里写死了。有些脚手架项目为了省事在环境初始化工具类里统一调用了setRuntimeMode(STREAMING)后面所有作业都继承了这个设置命令行传-D参数根本覆盖不掉因为代码里的显式调用优先级更高。这种情况排查起来很费时间因为从提交命令看一切正常日志里也不会提示配置被覆盖。我的建议是基础脚手架里不要碰这个设置让它保持默认把决定权留给提交时。第二个坑是配置项写错位置。execution.runtime-mode是带横杠的 kebab-case 写法在命令行-D里必须原样写不能写成驼峰。有些同学习惯性写成execution.runtimeModeFlink 对未知配置项的处理是打一条警告然后忽略所以作业会安静地以流模式跑起来一点报错都没有只是内存曲线和预期完全对不上。提示不确定配置有没有生效最靠谱的办法是去 Web UI 的作业配置页翻一遍或者在代码里打一行日志把env.getRuntimeMode()输出出来。别靠猜。3. 执行计划层面的差异从算子行为到数据交换3.1 算子行为差异join 和 aggregate 是重灾区同一个算子在两种模式下的实现路径可能完全不同这是理解执行模式最核心的一环。举两个最常见的例子。先看聚合。流模式下的sum/count这类聚合底层是带状态的增量计算每条数据来了就更新一下累加器状态常驻在内存或 RocksDB 里。批模式下不需要这么干它可以把同一个 key 的所有数据先排序归拢到一起然后顺序扫一遍边扫边累加累加完这个 key 就彻底释放不需要维护跨批次的状态。这个差异带来的直接后果是批模式可以处理单 key 数据量大到内存放不下的场景因为它走的是外部排序而流模式遇到严重的数据倾斜就只能靠加大并行度或者拆分 key 来缓解。再看 join。流模式下的双流 join 要想清楚两边数据到达顺序不确定这件事所以要么用窗口把两边框在同一个时间范围里要么用状态把一边缓存起来等另一边无论哪种都有状态膨胀的风险。批模式下的 join 就简单粗暴得多先把两边都落盘然后按 join key 排序做 sort-merge join全程不需要维护等另一边的状态。这也解释了为什么批模式的 join 写起来不用考虑迟到问题——数据都到齐了才开始算哪来的迟到。这个差异往下延伸还有一个经常被忽略的点批模式下ProcessFunction里的定时器timer不会触发。定时器依赖的是处理时间或者事件时间的推进而批模式里根本没有时间推进这个概念数据是有序处理完就完事的。如果你的逻辑里用定时器做超时清理、延迟触发之类的事情切到批模式之后这部分逻辑会静默失效不报错就是不走。这种不报错但行为变了的问题是最难查的。3.2 数据交换方式pipelined 与 blocking数据在两个算子之间怎么传是执行模式差异里最底层、也最影响性能的一环。流模式用的是 pipelined shuffle。上游算子每产生一条结果就通过网络直接推给下游上下游同时运行。好处是延迟极低一条数据从源头到汇点可能只要几毫秒坏处是整条链路必须同时在跑任何一个环节卡住反压会一路顶到数据源而且所有中间结果都在网络缓冲区或者内存里一旦机器挂了就全没了。批模式默认用 blocking shuffle业界也叫 shuffle 落盘。上游算子全部跑完结果写进本地磁盘的临时文件下游算子再启动去读这些文件。这个设计听起来很笨——为什么要多一次磁盘往返但它的收益在数据量大起来之后非常明显第一内存压力骤降不需要为了缓冲中间结果预留大量堆外内存第二阶段与阶段之间彻底解耦下游启动的时机和上游完成的时机可以错开资源可以分时复用第三失败恢复的粒度变细了这个后面会讲。这里的取舍本质上是一个经典的工程权衡延迟换吞吐内存换磁盘。数据量小的时候 blocking 的磁盘 IO 开销显得很浪费数据量大到内存装不下的时候pipelined 直接就跑不起来。新版本里还引入了混合模式思路是在内存缓冲和磁盘落盘之间做折中数据优先通过网络传输缓冲区满了之后溢写到磁盘既保留了一部分流水线的并行度又不会因为内存不足而崩掉。相关配置项是execution.batch-shuffle-mode取值包括全阻塞、全流水、以及两种混合策略。这个属于调优范畴上篇先不展开但你需要知道它的存在因为某些场景下默认的 blocking 确实不是最优解。维度pipelined shuffleblocking shuffle上下游关系同时运行边算边传串行上游完成才启动下游中间结果位置网络缓冲区 / 内存本地磁盘文件延迟低高内存占用高低磁盘 IO低高适用数据规模中小规模、要求低延迟大规模、可容忍分钟级延迟3.3 调度模型all-at-once 与分阶段推进调度层面的差异是很多人忽略的但它对资源利用率的影响非常大。流模式下作业图里所有 Task 在启动时就被一次性部署到 TaskManager 的 slot 上之后长期驻留。这意味着整个作业的并行度决定了峰值资源需求哪怕某个算子实际上一直是空闲的它占着的 slot 也不会释放给别人。这也是为什么流作业的资源曲线通常是一条平直线。批模式完全不是这样。因为存在 blocking shuffle作业图会被切成若干个独立的执行区域region。调度时按 region 顺序推进第一个 region 的所有 Task 一起启动跑完把结果落盘释放 slot第二个 region 再启动如此循环。整个过程像流水线一样一段一段往前走峰值资源需求只取决于单个 region 内最大的并行度而不是整个作业的总并行度。这个特性带来一个很实用的效果批作业可以在一个资源不算大的集群上跑出很高的吞吐。我试过在一个 slot 数量只有作业总并行度三分之一的集群上跑批任务如果换成流模式根本起不来会报资源不足但批模式下因为分阶段释放跑得很稳。反过来说流作业的资源规划必须按全量算没有这种错峰复用的空间。另一个差异在失败恢复粒度。批模式下一个 region 内的 Task 失败了理论上只需要重跑这个 region前面已经落盘的中间结果不用重算。这个能力叫 region failover是批模式天然具备的优势。流模式做不到这一点因为中间结果没落盘重跑只能从最近一次成功的 Checkpoint 开始而 Checkpoint 是有间隔的这中间的数据得从源头重放。注意批模式的分阶段调度意味着作业启动慢。第一个 region 的 Task 全部就绪才开始干活小数据量下这个启动开销可能比计算本身还长。如果你跑的是几秒钟就能算完的小任务批模式的墙钟时间反而不如流模式好看这是正常的。4. 时间、水位线与状态在批模式下的变化4.1 批模式里的时间是数据自带属性流处理里时间是个麻烦事因为数据到达的顺序和它实际发生的顺序往往不一致所以才需要水位线这套机制来推断什么时候可以认为某段时间的数据已经到齐了。水位线的本质是一种妥协允许一定程度的乱序超出的部分当作迟到数据单独处理。批模式下这个问题不存在。数据都已经躺在磁盘上了引擎可以先把它们按某种顺序整理好再处理乱序这个前提被消除了。所以批模式里没有水位线的概念assignTimestampsAndWatermarks传什么都无所谓窗口的触发逻辑也不依赖水位线推进而是依赖数据处理完了这个事实。这个差异在写法上会有一些细节需要注意。比如你写TumblingEventTimeWindows.of(Time.hours(1))流模式下窗口什么时候输出取决于水位线什么时候越过去可能会等到几分钟甚至更久批模式下所有数据算完之后各个窗口的结果会统一吐出来。结果集是一样的但输出的时机和顺序完全不同。还有一个容易踩的点批模式下事件时间的取值来源。如果你用的数据源本身不带时间戳字段流模式下可以退化成处理时间但批模式下没有处理时间这个物理概念——机器跑得快跑得慢不应该影响结果。所以批模式的窗口必须有明确的时间字段来源要么数据里有时间列要么通过assignTimestamps显式指定。这个约束在开发阶段就该明确不要等到上线才发现时间字段是空的。4.2 状态与状态后端批模式下基本用不上这一点经常有人误解。批模式不是不支持状态而是不需要用户维护状态。引擎内部当然还是会用状态来做排序、聚合、join 的中间数据但那是引擎自己的事跟你写的ValueState、ListState没有关系。具体来说批模式下不支持用户自定义的 keyed state。你如果在KeyedProcessFunction里声明了ValueState切到批模式后作业会在部署阶段直接抛异常提示该模式下不支持状态操作。这个报错其实是一件好事它强制你把依赖跨记录状态的逻辑和纯粹的批量转换逻辑分开而不是让状态悄悄失效。我遇到过一类典型的重构场景某个实时链路里用ValueState缓存了用户最近一次的行为用来做去重。要把这段逻辑挪到批处理里做历史回溯就得把状态换成一个显式的 join 或者窗口聚合。改写之后的代码量可能更大但语义更清晰也更容易验证——这算是批模式带来的一点意外好处。另外批模式下 Checkpoint 是被禁用的。原因很直接中间结果已经落盘了失败了重跑对应 region 就行不需要再做快照。如果你在代码里配了 Checkpoint 间隔批模式下这些配置不会生效。有些版本会打一条警告日志提醒你有些版本安静地忽略所以别指望配了 Checkpoint 就一定在跑。提示判断一个作业到底跑在什么模式下别只看自己写的配置。看 Web UI 里有没有出现 Checkpoint 页签、看有没有水位线指标、看 Task 是不是分阶段启动的这三个观察点基本能确定真实情况。5. 一份可以直接复现的对比实验5.1 数据准备与作业骨架光讲原理容易忘动手跑一遍差异会非常直观。下面这份实验用一份本地生成的有界数据同一段聚合逻辑分别在两种模式下跑观察耗时、内存和日志差异。数据用最简单的 CSV 生成字段是类别、金额、时间戳大概几百万行单文件几十 MB 就够看出差异了# 生成测试数据写入本地目录 python3 - EOF import random, time cats [fc{i} for i in range(200)] base_ts 1700000000000 with open(/tmp/demo_data/events.csv, w) as f: for i in range(3_000_000): cat random.choice(cats) amt round(random.uniform(1, 1000), 2) ts base_ts i * 100 f.write(f{cat},{amt},{ts}\n) EOF作业逻辑故意写得简单就是为了让差异集中在执行模式本身而不是被业务逻辑干扰public class ModeCompareJob { public static void main(String[] args) throws Exception { StreamExecutionEnvironment env StreamExecutionEnvironment.getExecutionEnvironment(); // 通过参数控制模式方便同一份 jar 跑两次对比 String mode ParameterTool.fromArgs(args).get(mode, streaming); env.setRuntimeMode(RuntimeExecutionMode.valueOf(mode.toUpperCase())); DataStreamString lines env.readTextFile(file:///tmp/demo_data/events.csv); lines.map(new RichMapFunctionString, Tuple3String, Double, Long() { Override public Tuple3String, Double, Long map(String s) { String[] p s.split(,); return Tuple3.of(p[0], Double.parseDouble(p[1]), Long.parseLong(p[2])); } }) .keyBy(t - t.f0) .sum(1) .print(); env.execute(mode-compare- mode); } }注意readTextFile读的是本地目录属于有界源批模式下完全合法。如果你换成读消息队列批模式会在作业图构建阶段直接失败这个报错前面提过。5.2 两种模式的运行观察用同样的并行度分别提交两次重点看三个地方Web UI 上的时间线、日志里的阶段推进、以及 TaskManager 的内存曲线。# 流模式 flink run -D parallelism.default8 \ -c com.example.ModeCompareJob job.jar --mode streaming # 批模式 flink run -D execution.runtime-modeBATCH -D parallelism.default8 \ -c com.example.ModeCompareJob job.jar --mode batch流模式下你会看到所有 Task 在作业启动的瞬间全部变成 RUNNING时间线上是一条平行的横条从开始一直延续到结束。数据是在边读边算边输出的print的结果会陆陆续续出现在日志里中间还夹杂着反压指标的变化——如果聚合算子成为了瓶颈反压会一路传导到 Source 端。批模式下时间线是阶梯状的。第一段是读文件和解析跑完之后这一段 Task 消失第二段开始做聚合和输出。中间那段什么都没跑的空档就是上游写磁盘、下游等待调度的间隙。print的结果会在最后一个阶段集中出现而不是零散穿插。如果数据量足够大你还能看到 slot 的占用数量呈现高—低—高的波动这就是分阶段调度在复用资源。耗时方面同样的数据量、同样的并行度批模式的墙钟时间通常会明显低于流模式尤其在数据倾斜比较严重的 keyBy 场景下差距更明显。原因不神秘批模式走的是排序归并倾斜 key 的处理靠的是磁盘顺序读写流模式的倾斜 key 全部压在一个 Subtask 上只能靠内存硬扛。5.3 配置生效的验证方式跑完之后别急着下结论先确认配置真的生效了。有三个可靠的验证点。第一看作业图里有没有 Checkpoint 相关的节点。流模式下会有 Checkpoint 协调器批模式下不会。第二看 Web UI 的 Backpressure 页签批模式下往往是空的因为没有持续的流水线运行。第三在代码里加一行日志打印env.getRuntimeMode()直接输出最终生效的值。第三种最可靠因为它排除了所有我以为我配了的干扰。如果你发现三次验证的结果互相矛盾那大概率是有一层配置把另一层覆盖了。回头查一下初始化工具类八成问题出在那里。6. 常见问题与排查速查6.1 报错与告警速查表下面这些是我在不同项目里实际遇到过的问题按现象归类方便对照排查。现象大概率原因处理方式启动即失败提示无界源与批模式组合不被允许数据源是无界的但执行模式设成了 BATCH换成有界源或改回 STREAMING批模式下抛异常提示不支持状态操作用了ValueState/ListState等 keyed state改用 join 或窗口聚合替代定时器逻辑不触发也不报错批模式不支持ProcessFunction的 timer改用显式的时间窗口处理加了-D参数但内存占用没变化代码里已经显式setRuntimeMode去掉代码里的硬编码批模式作业启动很慢分阶段调度的启动开销数据量小时改用流模式或调大单个 region 的并行度批模式输出顺序和流模式不一致批模式按 key 分组排序后输出需要固定顺序时在输出前显式排序水位线指标一直是 0 或不存在批模式下水位线不生效属正常行为忽略6.2 我踩过的几个坑第一个坑是隐式覆盖。有次排查一个作业提交命令里明明写了-D execution.runtime-modeBATCH但 Web UI 上就是看不到分阶段调度。查了半个多小时才发现项目的基础环境工具类里有一行env.setRuntimeMode(RuntimeExecutionMode.STREAMING)是两年多前某个同学为了让一个特殊作业能跑起来临时加的后来没人清理。这种基础设施层的隐形硬编码是非常典型的团队协作问题解决办法只有一条约定基础脚手架不碰执行模式谁需要谁在作业入口显式声明。第二个坑是批模式下还想着靠 Checkpoint 兜底。有位同学把流作业直接切到批模式跑历史回溯结果中途某个 Task 挂了整个 region 重跑跑了一半的时间白费了。他以为是 Checkpoint 没配好实际上批模式压根不走 Checkpoint 那套恢复逻辑。批模式的恢复粒度虽然更细但前提是你接受从 region 的起点重算而不是从最近一次快照续上。这两种恢复语义是不同的别混着理解。第三个坑和输出有关。流模式下print是流水式输出顺序基本反映处理顺序批模式下因为走了排序归并输出顺序会变成按 key 分组、组内有序。有次对账脚本依赖输出顺序做行号匹配切模式之后全乱套了对账结果出现大面积不一致。这类问题在代码层面完全看不出来因为逻辑没变变的是运行时行为。后来我们的做法是任何依赖顺序的输出都在写出前显式加一个排序算子把顺序要求固化在逻辑里而不是依赖某种执行模式的默认行为。第四个坑是资源配置。批模式因为中间结果落盘磁盘空间成了一个必须提前算的量。一个中等规模的批作业shuffle 数据量可能是输入数据的两三倍。我们曾经在一个磁盘配额偏紧的节点组上跑批任务跑到 70% 左右开始报磁盘写入失败排查了半天才意识到是 shuffle 临时文件把盘占满了。这个和流模式的经验完全不同——流作业基本不关心本地磁盘容量批作业必须把临时目录的容量当成硬约束来规划。注意批模式的临时文件目录由配置项控制默认在 TaskManager 本地目录下。生产环境一定要单独挂一块盘给这个目录并且做好容量监控别和日志目录共用。最后分享一个我觉得挺好用的小技巧给同一个作业准备两套提交脚本一套走流模式常驻一套走批模式回溯两者共用同一个 jar 包和同一份逻辑只靠提交参数区分。这样切模式变成一个运维动作而不是一次代码变更。我们现在的做法是把模式、并行度、资源规格都外置到脚本的变量里谁需要回溯就直接调批模式那个脚本不用找开发改代码。这个模式跑了大半年没再出现过改配置改出线上问题的事故。