<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>SeaTunnel on Niu Zhiwei 的个人技术博客</title><link>https://nzw921rx.github.io/nzw921rx-blog/categories/seatunnel/</link><description>Recent content in SeaTunnel on Niu Zhiwei 的个人技术博客</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Fri, 10 Jul 2026 00:00:00 +0800</lastBuildDate><atom:link href="https://nzw921rx.github.io/nzw921rx-blog/categories/seatunnel/index.xml" rel="self" type="application/rss+xml"/><item><title>SeaTunnel 引擎进阶之路：从 JDBC 定时 Flush 到 Engine-Level FlushSignal</title><link>https://nzw921rx.github.io/nzw921rx-blog/posts/seatunnel-engine-flush-signal/</link><pubDate>Fri, 10 Jul 2026 00:00:00 +0800</pubDate><guid>https://nzw921rx.github.io/nzw921rx-blog/posts/seatunnel-engine-flush-signal/</guid><description>从 JDBC Sink 的 batch_interval_ms 定时 flush 问题出发，理解为什么 FlushSignal 应该成为 SeaTunnel Zeta 的引擎层能力。</description><content:encoded><![CDATA[<p>我一开始只是想解决 JDBC Sink 里的一个参数：<code>batch_interval_ms</code>。</p>
<p>这个参数看起来很简单：buffer 里的数据攒到一定时间就 flush。但真正做下去会发现，它并不是一个 JDBC Connector 自己能优雅解决的问题。</p>
<p>因为它背后牵扯的不是 JDBC 怎么执行 SQL，而是：</p>
<ul>
<li>定时任务由谁管理；</li>
<li>flush 应该在哪个线程执行；</li>
<li>flush 失败后异常怎么传给 Task；</li>
<li>checkpoint、close、cancel 时如何避免并发问题；</li>
<li>Source 空闲时，是否还能按时间触发 flush。</li>
</ul>
<p>这篇文章就从这个小问题出发，聊聊我是如何从 JDBC <code>batch_interval_ms</code> 一步步看进 SeaTunnel Zeta Engine 的，以及 STIP-23 里的 Engine-Level <code>FlushSignal</code> 为什么是一个更合理的设计。</p>
<h2 id="从-jdbc-batch_interval_ms-开始">从 JDBC batch_interval_ms 开始</h2>
<p>JDBC Sink 常见的 batch 写入通常有两个触发条件：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>batch_size = 1000
</span></span><span style="display:flex;"><span>batch_interval_ms = 5000
</span></span></code></pre></div><p><code>batch_size</code> 很直接。每来一条数据，放入 buffer，然后判断 buffer 数量是否达到阈值。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>if buffer.size &gt;= batchSize:
</span></span><span style="display:flex;"><span>    flush()
</span></span></code></pre></div><p>这个判断天然适合放在 <code>writeRecord()</code> 里，因为只有新数据到来时，buffer size 才会变化。</p>
<p>但 <code>batch_interval_ms</code> 不一样。它表达的不是“来了新数据再顺便看一下时间”，而是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>即使暂时没有新数据，只要距离上次 flush 达到指定时间，也应该有机会触发 flush。
</span></span></code></pre></div><p>为了避免“墙钟”这个词带来的阅读负担，后面我统一用“真实时间”“定时触发”来描述这个语义。</p>
<h2 id="方案一connector-内部起线程">方案一：Connector 内部起线程</h2>
<p>最直接的想法是在 JDBC Connector 里起一个 <code>ScheduledExecutorService</code>。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span>scheduledExecutor.<span style="color:#a6e22e">scheduleAtFixedRate</span>(() <span style="color:#f92672">-&gt;</span> {
</span></span><span style="display:flex;"><span>    flush();
</span></span><span style="display:flex;"><span>}, batchIntervalMs, batchIntervalMs, TimeUnit.<span style="color:#a6e22e">MILLISECONDS</span>);
</span></span></code></pre></div><p>这个方案确实能做到定时触发。即使 Source 暂时空闲，后台线程也会按时间调用 <code>flush()</code>。</p>
<p>但问题也很明显：flush 从 SeaTunnel Task 的正常执行链路里“逃逸”出去了。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Task Thread:
</span></span><span style="display:flex;"><span>  writeRecord(record)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Connector Background Thread:
</span></span><span style="display:flex;"><span>  flush()
</span></span></code></pre></div><p>这样会带来几个麻烦：</p>
<ul>
<li><code>writeRecord()</code> 和 <code>flush()</code> 可能并发访问同一个 buffer；</li>
<li>flush 可能和 checkpoint、schema evolution、close 同时发生；</li>
<li>后台线程里的异常不能自然传递到 Task 主执行路径；</li>
<li>任务 fail、cancel、close 时，还要额外保证线程被正确停止；</li>
<li>每个 Sink Connector 都可能重复实现一套类似的 timer 逻辑。</li>
</ul>
<p>也就是说，JDBC 为了实现一个时间参数，开始被迫维护运行时线程模型。</p>
<p>这不是 Connector 应该承担的职责。</p>
<h2 id="方案二在-writerecord-里判断时间">方案二：在 writeRecord 里判断时间</h2>
<p>为了避免后台线程，另一个方案是把时间判断放回 <code>writeRecord()</code>。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> <span style="color:#a6e22e">writeRecord</span>(Row row) {
</span></span><span style="display:flex;"><span>    buffer.<span style="color:#a6e22e">add</span>(row);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (buffer.<span style="color:#a6e22e">size</span>() <span style="color:#f92672">&gt;=</span> batchSize) {
</span></span><span style="display:flex;"><span>        flush();
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span>;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">if</span> (System.<span style="color:#a6e22e">currentTimeMillis</span>() <span style="color:#f92672">-</span> lastFlushTime <span style="color:#f92672">&gt;=</span> batchIntervalMs) {
</span></span><span style="display:flex;"><span>        flush();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>这个方案的优点很明显：</p>
<ul>
<li>没有额外线程；</li>
<li>flush 和 write 在同一个执行路径；</li>
<li>异常可以直接抛给 Task；</li>
<li>生命周期更简单。</li>
</ul>
<p>但它有一个致命问题：<strong>没有新数据时，<code>writeRecord()</code> 根本不会被调用。</strong></p>
<p>所以它实际表达的不是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>每隔 5 秒 flush 一次。
</span></span></code></pre></div><p>而是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>下一条数据到来时，如果距离上次 flush 已经超过 5 秒，就顺便 flush。
</span></span></code></pre></div><p>在高吞吐场景下，这个差异不明显。因为数据一直来，<code>writeRecord()</code> 会频繁触发。</p>
<p>但在低吞吐、CDC 间歇变更、小表同步、Source 分区暂时空闲等场景下，buffer 里可能已经有数据，却迟迟等不到下一条数据。此时 <code>batch_interval_ms</code> 就失去了真正的定时语义。</p>
<h2 id="真正的问题不在-jdbc">真正的问题不在 JDBC</h2>
<p>到这里可以看到，两个方案都不完美：</p>
<table>
	<thead>
			<tr>
					<th>方案</th>
					<th>优点</th>
					<th>核心问题</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Connector 内部起线程</td>
					<td>能按真实时间触发</td>
					<td>并发、异常传播、生命周期都变复杂</td>
			</tr>
			<tr>
					<td><code>writeRecord()</code> 里判断时间</td>
					<td>单线程、fail-fast</td>
					<td>Source 空闲时无法触发</td>
			</tr>
	</tbody>
</table>
<p>这说明问题不在 JDBC 代码本身，而在抽象层级。</p>
<p><code>batch_interval_ms</code> 需要的是一种引擎能力：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>即使没有新数据输入，引擎也能按时间产生一个控制事件；
</span></span><span style="display:flex;"><span>这个事件不直接操作 Sink，而是进入正常数据通道；
</span></span><span style="display:flex;"><span>最终由 Sink 在自己的消费线程里执行 flush。
</span></span></code></pre></div><p>也就是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Engine Timer -&gt; FlushSignal -&gt; Sink flushAction
</span></span></code></pre></div><p>这就是 STIP-23 想解决的问题。</p>
<h2 id="engine-和-connector-的边界">Engine 和 Connector 的边界</h2>
<p>这次问题让我重新理解了 Engine 和 Connector 的职责边界。</p>
<p>Connector 应该负责外部系统语义，比如：</p>
<ul>
<li>JDBC 如何 batch；</li>
<li>SQL 如何执行；</li>
<li>transaction 如何 prepare / commit / rollback；</li>
<li>flush 到底做什么；</li>
<li>当前 Sink 是否允许定时 flush。</li>
</ul>
<p>Engine 应该负责运行时机制，比如：</p>
<ul>
<li>timer 生命周期；</li>
<li>Task 线程模型；</li>
<li>控制信号如何进入数据流；</li>
<li>signal 如何穿过 Transform；</li>
<li>Sink 如何在正确线程消费 signal；</li>
<li>异常、背压、checkpoint lock、cancel、close 如何协调。</li>
</ul>
<p>所以更合理的设计不是让 JDBC 自己起线程，而是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Connector 注册 flushAction；
</span></span><span style="display:flex;"><span>Engine 负责定时产生 FlushSignal，并把它送到 Sink。
</span></span></code></pre></div><h2 id="stip-23-的设计核心">STIP-23 的设计核心</h2>
<p>STIP-23 的核心可以压缩成一句话：</p>
<blockquote>
<p>引擎提供定时 <code>FlushSignal</code>，Connector 选择性注册 <code>flushAction</code>，Sink 收到信号后在消费线程里执行自己的 flush 逻辑。</p>
</blockquote>
<p>这个设计里有三个关键点。</p>
<h3 id="1-sinkflushinterval-是引擎配置">1. sink.flush.interval 是引擎配置</h3>
<p>定时触发由引擎管理，所以需要一个引擎侧配置：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>sink.flush.interval = 5000
</span></span></code></pre></div><p>它表达的是：引擎每隔指定时间尝试产生一次 <code>FlushSignal</code>。</p>
<p>默认值可以是 <code>0</code>，表示关闭。这样不会影响已有任务，也不会强制所有 Connector 改行为。</p>
<h3 id="2-connector-只注册-flushaction">2. Connector 只注册 flushAction</h3>
<p>引擎不应该知道 JDBC 具体怎么 flush。</p>
<p>JDBC 可能是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>statement.executeBatch()
</span></span></code></pre></div><p>其他 Sink 可能是 bulk write、stream load、事务 buffer 刷新，甚至根本不适合定时 flush。</p>
<p>所以 Connector 只需要通过 Context 注册动作：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-java" data-lang="java"><span style="display:flex;"><span>context.<span style="color:#a6e22e">registerFlushAction</span>(() <span style="color:#f92672">-&gt;</span> {
</span></span><span style="display:flex;"><span>    flush();
</span></span><span style="display:flex;"><span>});
</span></span></code></pre></div><p>是否注册，由 Connector 自己决定。</p>
<h3 id="3-timer-不直接调用-sink">3. Timer 不直接调用 Sink</h3>
<p>这是整个设计最重要的一点。</p>
<p>如果 timer 线程直接调用 Sink 的 <code>flush()</code>，那就回到了 Connector 后台线程方案：并发、异常传播、生命周期问题依旧存在。</p>
<p>所以 STIP-23 选择让 timer 产生 <code>FlushSignal</code>，并把这个 signal 放进 SeaTunnel 正常的数据通道里。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>错误方向：Timer Thread -&gt; SinkWriter.flush()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>正确方向：Timer -&gt; FlushSignal -&gt; data path -&gt; SinkFlowLifeCycle -&gt; flushAction
</span></span></code></pre></div><h2 id="flushsignal-的核心链路">FlushSignal 的核心链路</h2>
<p>完整链路可以用一张图表示：</p>
<div class="mermaid">sequenceDiagram
    participant Timer as Engine Timer
    participant Source as SourceFlowLifeCycle
    participant Collector as SeaTunnelSourceCollector
    participant Queue as Queue / Disruptor
    participant Transform as TransformFlowLifeCycle
    participant Sink as SinkFlowLifeCycle
    participant Writer as SinkWriter

    Timer->>Source: timer tick
    Source->>Collector: send FlushSignal
    Collector->>Queue: publish signal
    Queue->>Transform: consume signal
    Transform->>Sink: passthrough signal
    Sink->>Writer: flushAction.run()
</div>
<p>这张图里最重要的不是调用步骤有多少，而是三个原则。</p>
<p>第一，timer 不直接碰 Sink。<br>
它只负责触发控制事件，避免把 Sink flush 放到 timer 线程里执行。</p>
<p>第二，<code>FlushSignal</code> 走正常数据通道。<br>
它从 Source 侧注入，经由 Collector、Queue / Disruptor、Transform，最终到达 Sink。</p>
<p>第三，flush 在 Sink 消费线程里执行。<br>
这样 flush 和普通 record 的处理路径保持一致，异常也能沿 Task 执行路径暴露出来。</p>
<h2 id="为什么从-source-侧注入">为什么从 Source 侧注入</h2>
<p>FlushSignal 需要进入数据流，而 SeaTunnel 的数据流方向本来就是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Source -&gt; Transform -&gt; Sink
</span></span></code></pre></div><p>所以从 SourceFlowLifeCycle 侧注入 signal 是比较自然的选择。</p>
<p>这样有几个好处：</p>
<ul>
<li>不绕过已有的数据通道；</li>
<li>可以和 Source 侧的 checkpoint lock 协调；</li>
<li>Transform 不需要理解 flush 语义，只要透传 Signal；</li>
<li>Sink 最终在自己的消费路径中处理 flush。</li>
</ul>
<p>换句话说，<code>FlushSignal</code> 不是一个外部线程强行插进来的动作，而是一个沿着引擎内部数据流向下游传递的控制事件。</p>
<h2 id="transform-为什么只透传">Transform 为什么只透传</h2>
<p>Transform 不应该执行 flush。</p>
<p>它既不知道下游是不是 JDBC，也不知道这个 flush 会不会影响事务边界。Transform 只需要识别这是一个 Signal，然后继续传给下游。</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Record -&gt; 执行业务转换
</span></span><span style="display:flex;"><span>Signal -&gt; 原样透传
</span></span></code></pre></div><p>这个设计让 FlushSignal 可以穿过任意数量的 Transform，而不会把 Sink 语义泄露到 Transform 层。</p>
<h2 id="sinkflowlifecycle-做什么">SinkFlowLifeCycle 做什么</h2>
<p>最终，FlushSignal 到达 SinkFlowLifeCycle。</p>
<p>Sink 侧只需要区分两类事件：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Record      -&gt; sinkWriter.write(record)
</span></span><span style="display:flex;"><span>FlushSignal -&gt; flushAction.run()
</span></span></code></pre></div><p>如果 SinkWriter.Context 中没有注册 flushAction，那么收到 FlushSignal 也不会产生额外行为。</p>
<p>这就是 opt-in 的意义：引擎提供能力，但不替 Connector 决定语义。</p>
<h2 id="exactly-once-下为什么必须-opt-in">Exactly-Once 下为什么必须 opt-in</h2>
<p>定时 flush 看起来通用，但不能强制所有 Sink 使用。</p>
<p>因为不同 Sink 的 flush 语义不同。</p>
<p>对 JDBC XA 来说，flush 可能只是 <code>executeBatch()</code>，数据仍然留在当前事务里，真正提交发生在 checkpoint commit 阶段。</p>
<p>但对某些 Sink 来说，flush 可能意味着结束一次 load、形成一次事务边界，甚至让数据对外可见。</p>
<p>如果引擎强行对所有 Sink 执行定时 flush，就可能破坏 Connector 自己的 Exactly-Once 或事务语义。</p>
<p>所以正确方式一定是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Engine 提供 FlushSignal；
</span></span><span style="display:flex;"><span>Connector 自己决定是否注册 flushAction。
</span></span></code></pre></div><h2 id="从一个参数走向引擎进阶">从一个参数走向引擎进阶</h2>
<p>回头看，这条路径其实很典型。</p>
<p>一开始只是一个 JDBC 参数：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>batch_interval_ms
</span></span></code></pre></div><p>然后问题逐层上升：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>低吞吐时为什么不 flush？
</span></span><span style="display:flex;"><span>Connector 自己起线程安全吗？
</span></span><span style="display:flex;"><span>后台线程异常怎么 fail-fast？
</span></span><span style="display:flex;"><span>任务 cancel / close 时 timer 谁来停？
</span></span><span style="display:flex;"><span>这个能力是不是应该放到 Engine？
</span></span></code></pre></div><p>当一个局部方案越来越别扭，通常说明问题被放在了错误的抽象层级。</p>
<p>JDBC 定时 flush 就是这样。</p>
<p>它表面上是 JDBC Sink 的 batch 参数问题，实际上是 SeaTunnel Engine 缺少 runtime control signal 的问题。</p>
<h2 id="总结">总结</h2>
<p>Engine-Level FlushSignal 的价值，不是简单地“加一个定时器”。</p>
<p>它真正解决的是三个问题：</p>
<ol>
<li><code>batch_interval_ms</code> 不再依赖下一条数据到来，空闲时也有机会触发 flush；</li>
<li>Connector 不再需要自己维护后台线程；</li>
<li>flush 回到 SeaTunnel 正常数据通道，并在 Sink 消费线程里执行。</li>
</ol>
<p>最终形成的边界是：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>Connector 负责 flush 的外部系统语义；
</span></span><span style="display:flex;"><span>Engine 负责 timer、Signal、线程和生命周期。
</span></span></code></pre></div><p>对我来说，这就是一次从 Connector 开发走向引擎设计的过程。</p>
<p>不是为了读源码而读源码，而是从一个真实问题出发，一层层追到引擎真正应该提供的能力。</p>
]]></content:encoded></item><item><title>SeaTunnel 提交一个任务都经过了什么？</title><link>https://nzw921rx.github.io/nzw921rx-blog/posts/seatunnel-submit-job-flow/</link><pubDate>Tue, 07 Jul 2026 18:00:00 +0800</pubDate><guid>https://nzw921rx.github.io/nzw921rx-blog/posts/seatunnel-submit-job-flow/</guid><description>从 SubmitJobServlet 到 TaskExecutionService，梳理 SeaTunnel submitJob 的任务提交、调度、资源申请与 TaskGroup 部署主链路。</description><content:encoded><![CDATA[<p>一次 SeaTunnel 任务提交，看起来只是一次 <code>submitJob</code> 请求；但在 Server 内部，它会经过 Master 判断、任务协调、<code>JobMaster</code> 初始化、物理执行计划构建、Pipeline 资源申请、TaskGroup 部署等多个阶段。</p>
<p>这篇文章基于我整理的 submitJob 时序，先聚焦一条主线：<strong>任务从提交请求进入 SeaTunnel Server，到最终调用 <code>TaskExecutionService.deployTask()</code> 部署 TaskGroup，中间都经过了什么。</strong></p>
<p>本文暂不展开 <code>TaskExecutionService</code> 内部的线程模型、Task 运行细节和数据流转，重点放在任务提交与调度部署链路上。</p>
<h2 id="核心角色">核心角色</h2>
<p>在进入流程之前，先看 submitJob 主链路里几个关键对象分别负责什么。</p>
<table>
	<thead>
			<tr>
					<th>角色</th>
					<th>职责</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>SubmitJobServlet</code></td>
					<td>接收外部提交任务请求，是 Server 侧入口之一。</td>
			</tr>
			<tr>
					<td><code>JobInfoService</code></td>
					<td>处理任务提交入口逻辑，判断当前节点是 Master 还是 Worker。</td>
			</tr>
			<tr>
					<td><code>MasterNode</code></td>
					<td>当前节点不是 Master 时，将任务提交请求转发给 Master。</td>
			</tr>
			<tr>
					<td><code>CoordinatorService</code></td>
					<td>任务协调入口，判断任务是否已经运行，并创建 / 管理 <code>JobMaster</code>。</td>
			</tr>
			<tr>
					<td><code>JobMaster</code></td>
					<td>单个 Job 的运行控制中心，负责初始化运行上下文、classloader、checkpoint 配置等。</td>
			</tr>
			<tr>
					<td><code>PhysicalPlan</code></td>
					<td>从逻辑 DAG 构建出的物理执行计划，负责驱动 Job 级别状态流转。</td>
			</tr>
			<tr>
					<td><code>SubPlan</code></td>
					<td>Pipeline 级别的调度单元，负责资源申请和 Pipeline 状态推进。</td>
			</tr>
			<tr>
					<td><code>ResourceUtils</code></td>
					<td>为 Pipeline 申请运行资源。</td>
			</tr>
			<tr>
					<td><code>PhysicalVertex</code></td>
					<td>更细粒度的物理执行节点，负责 TaskGroup 部署。</td>
			</tr>
			<tr>
					<td><code>TaskExecutionService</code></td>
					<td>最终接收并部署 TaskGroup 的执行服务。</td>
			</tr>
	</tbody>
</table>
<h2 id="总体流程">总体流程</h2>
<p>先用一张简化流程图看全局链路。</p>
<div class="mermaid">flowchart TD
    A[SubmitJobServlet 接收提交请求] --> B[JobInfoService.submitJob]
    B --> C{当前节点是否 Master?}
    C -- 是 --> D[本地提交]
    C -- 否 --> E[转发到 MasterNode]
    E --> D
    D --> F[CoordinatorService.submitJob]
    F --> G{Job 是否已经运行?}
    G -- 是 --> H[直接返回 success]
    G -- 否 --> I[创建并初始化 JobMaster]
    I --> J[构建 PhysicalPlan]
    J --> K[Job 状态进入 PENDING / SCHEDULED]
    K --> L[SubPlan 开始调度]
    L --> M[申请 Pipeline 资源]
    M --> N[PhysicalVertex 部署 TaskGroup]
    N --> O[TaskExecutionService.deployTask]
    O --> P[Task 进入 RUNNING]
</div>
<p>这条链路可以压缩成一句话：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>SubmitJobServlet
</span></span><span style="display:flex;"><span>  -&gt; JobInfoService
</span></span><span style="display:flex;"><span>  -&gt; MasterNode / CoordinatorService
</span></span><span style="display:flex;"><span>  -&gt; JobMaster
</span></span><span style="display:flex;"><span>  -&gt; PhysicalPlan
</span></span><span style="display:flex;"><span>  -&gt; SubPlan
</span></span><span style="display:flex;"><span>  -&gt; PhysicalVertex
</span></span><span style="display:flex;"><span>  -&gt; TaskExecutionService
</span></span></code></pre></div><p>下面按阶段拆开看。</p>
<h2 id="第一阶段请求进入-jobinfoservice">第一阶段：请求进入 JobInfoService</h2>
<p>任务提交入口首先进入 <code>SubmitJobServlet</code>，随后交给 <code>JobInfoService</code> 处理。</p>
<p>这里的关键点不是马上启动任务，而是先判断：<strong>当前接收请求的节点是不是 Master。</strong></p>
<div class="mermaid">flowchart TD
    A[SubmitJobServlet] --> B[JobInfoService]
    B --> C{当前节点是 Master?}
    C -- 是 --> D[本地 submitJob]
    C -- 否 --> E[MasterNode.submitJob]
    E --> F[转发给 Master 处理]
</div>
<p>如果当前节点就是 Master，<code>JobInfoService</code> 可以继续本地提交；如果当前节点是 Worker，则需要通过 <code>MasterNode.submitJob()</code> 转发给 Master。</p>
<p>这个设计保证了任务提交最终由 Master 统一协调，避免多个节点各自独立创建 Job 调度上下文。</p>
<h2 id="第二阶段coordinatorservice-接管任务">第二阶段：CoordinatorService 接管任务</h2>
<p>请求进入 Master 后，会继续调用 <code>CoordinatorService.submitJob()</code>。</p>
<p><code>CoordinatorService</code> 在这里主要做两件事：</p>
<ol>
<li>判断这个 Job 是否已经存在或正在运行。</li>
<li>如果是新任务，则创建并初始化对应的 <code>JobMaster</code>。</li>
</ol>
<p>如果任务已经运行，SeaTunnel 不需要重复创建调度上下文，可以直接返回提交成功。如果是一个新任务，就会进入 <code>JobMaster</code> 初始化流程。</p>
<p>也就是说，<code>submitJob</code> 到这里已经从“接口请求处理”进入了“调度系统处理”。</p>
<h2 id="第三阶段jobmaster-初始化">第三阶段：JobMaster 初始化</h2>
<p><code>JobMaster</code> 可以理解为一个 Job 的运行控制中心。</p>
<p>创建 <code>JobMaster</code> 后，会完成一些运行前准备工作，例如：</p>
<ul>
<li>构建任务运行所需的 classloader。</li>
<li>初始化 checkpoint 相关配置。</li>
<li>准备从逻辑 DAG 构建物理执行计划所需的上下文。</li>
</ul>
<p>这一阶段还没有真正部署 Task，它更像是在为后续调度准备运行环境。</p>
<h2 id="第四阶段从逻辑-dag-到-physicalplan">第四阶段：从逻辑 DAG 到 PhysicalPlan</h2>
<p><code>JobMaster</code> 初始化后，会基于逻辑 DAG 构建 <code>PhysicalPlan</code>。</p>
<div class="mermaid">flowchart TD
    A[JobMaster 初始化完成] --> B[PlanUtils.fromLogicalDAG]
    B --> C[创建 PhysicalPlan]
    C --> D[初始化 Job 状态 Future]
    D --> E[CoordinatorService 更新 Job 状态为 PENDING]
    E --> F[PhysicalPlan.stateProcess]
    F --> G{当前 Job 状态}
    G -- CREATED --> H[更新为 SCHEDULED]
    G -- SCHEDULED --> I[启动 SubPlan 状态处理]
</div>
<p>这里有一个重要概念：<strong>SeaTunnel 不是一次性把任务全部启动，而是通过状态机逐步推进。</strong></p>
<p>在 Job 级别，核心状态推进可以简化理解为：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>CREATED -&gt; SCHEDULED -&gt; startSubPlanStateProcess
</span></span></code></pre></div><p><code>PhysicalPlan</code> 负责 Job 级别状态流转，而真正的 Pipeline 调度会继续下沉到 <code>SubPlan</code>。</p>
<h2 id="第五阶段subplan-申请资源并进入部署">第五阶段：SubPlan 申请资源并进入部署</h2>
<p>到了 <code>SubPlan</code> 这一层，SeaTunnel 关注的粒度已经从整个 Job 下沉到 Pipeline。</p>
<p><code>SubPlan.stateProcess()</code> 会根据当前 Pipeline 状态执行不同逻辑：</p>
<div class="mermaid">flowchart TD
    A[SubPlan.stateProcess] --> B{Pipeline 状态}
    B -- CREATED --> C[updatePipelineState SCHEDULED]
    B -- SCHEDULED --> D[ResourceUtils.applyResourceForPipeline]
    D --> E{资源申请结果}
    E -- 成功 --> F[updatePipelineState DEPLOYING]
    E -- 失败 --> G[makePipelineFailing]
    B -- DEPLOYING --> H[部署 PhysicalVertex]
    H --> I[Pipeline 进入 RUNNING]
</div>
<p>这一层的重点是：</p>
<ul>
<li><code>CREATED</code> 状态下，Pipeline 会先推进到 <code>SCHEDULED</code>。</li>
<li><code>SCHEDULED</code> 状态下，开始通过 <code>ResourceUtils.applyResourceForPipeline()</code> 申请资源。</li>
<li>资源申请成功后，Pipeline 进入 <code>DEPLOYING</code>。</li>
<li>如果资源申请失败，则进入 <code>makePipelineFailing(e)</code>。</li>
</ul>
<p>因此，Pipeline 不是被立即部署的；它必须先具备运行资源。</p>
<h2 id="第六阶段physicalvertex-部署-taskgroup">第六阶段：PhysicalVertex 部署 TaskGroup</h2>
<p>当 Pipeline 进入 <code>DEPLOYING</code> 后，<code>SubPlan</code> 会开始启动内部的 <code>PhysicalVertex</code>。</p>
<p><code>PhysicalVertex</code> 会先把 Task 状态更新为 <code>DEPLOYING</code>，然后根据分配到的 <code>slotProfile</code> 执行部署。</p>
<p>部署时有一个关键分支：目标 Worker 是本地还是远端。</p>
<div class="mermaid">flowchart TD
    A[PhysicalVertex.deploy slotProfile] --> B{目标 Worker 是否本地?}
    B -- 本地 --> C[TaskExecutionService.deployTask]
    B -- 远端 --> D[DeployTaskOperation 发送到远端 Worker]
    D --> E[远端 TaskExecutionService.deployTask]
    C --> F{部署结果}
    E --> F
    F -- 成功 --> G[updateTaskState RUNNING]
    F -- 失败 --> H[makeTaskGroupFailing]
</div>
<p>如果目标 Worker 就是当前节点，可以直接调用本地 <code>TaskExecutionService.deployTask(taskGroupInfo)</code>。</p>
<p>如果目标 Worker 是远端节点，则需要通过 <code>DeployTaskOperation</code> 把部署请求发送过去，最终仍然会在目标 Worker 上进入 <code>TaskExecutionService.deployTask(taskGroupInfo)</code>。</p>
<p>部署成功后，<code>PhysicalVertex</code> 会把 Task 状态更新为 <code>RUNNING</code>；如果失败，则进入 <code>makeTaskGroupFailing()</code>。</p>
<p>当 Pipeline 内部的 TaskGroup 部署完成并进入运行态后，<code>SubPlan</code> 也会推进到 <code>RUNNING</code>。</p>
<h2 id="失败取消与恢复分支">失败、取消与恢复分支</h2>
<p>除了正常提交和部署，<code>SubPlan</code> 状态机里还需要处理失败、取消和恢复。</p>
<p>可以简化成下面这张图：</p>
<div class="mermaid">flowchart TD
    A[SubPlan 状态机] --> B{当前状态}
    B -- FAILING / CANCELING --> C[取消每个 PhysicalVertex]
    B -- FAILED / CANCELED --> D{是否可以恢复?}
    D -- 可以 --> E[释放并重新申请资源]
    E --> F[restorePipeline]
    D -- 不可以 --> G[subPlanDone 并完成 future]
    B -- FINISHED --> H[subPlanDone 并完成 future]
</div>
<p>这也是为什么前面的状态机设计很重要：</p>
<ul>
<li>正常路径可以推进部署和运行。</li>
<li>失败路径可以进入 failing / failed。</li>
<li>取消路径可以进入 canceling / canceled。</li>
<li>如果满足恢复条件，还可以释放资源后重新申请并恢复 Pipeline。</li>
</ul>
<p>换句话说，状态机不是为了让流程复杂，而是为了让任务生命周期可控。</p>
<h2 id="完整时序图">完整时序图</h2>
<p>最后，把主流程用时序图串起来，方便对照整体调用顺序。</p>
<div class="mermaid">sequenceDiagram
    participant Servlet as SubmitJobServlet
    participant JobInfo as JobInfoService
    participant Master as MasterNode
    participant Coord as CoordinatorService
    participant JM as JobMaster
    participant Plan as PhysicalPlan
    participant Sub as SubPlan
    participant RU as ResourceUtils
    participant Vtx as PhysicalVertex
    participant TES as TaskExecutionService
    participant DTO as DeployTaskOperation

    Servlet->>JobInfo: submit task
    alt this node is master
        JobInfo->>JobInfo: submitJob()
    else this node is worker
        JobInfo->>Master: submitJob()
    end

    JobInfo->>Coord: submitJob()
    alt job already running
        Coord-->>Servlet: success
    else new job
        Coord->>JM: new + init()
        JM->>JM: build classloaders, checkpoint config
        JM->>Plan: PlanUtils.fromLogicalDAG() + initStateFuture()
        Coord->>Plan: updateJobState(PENDING)
        Plan->>Plan: stateProcess()
        alt CREATED
            Plan->>Plan: updateJobState(SCHEDULED)
        else SCHEDULED
            Plan->>Sub: startSubPlanStateProcess()
        end
        JobInfo-->>Servlet: success {jobId, jobName}
    end

    loop SubPlan.stateProcess()
        alt CREATED
            Sub->>Sub: updatePipelineState(SCHEDULED)
        else SCHEDULED
            Sub->>RU: applyResourceForPipeline()
            alt ok
                Sub->>Sub: updatePipelineState(DEPLOYING)
            else error
                Sub->>Sub: makePipelineFailing(e)
            end
        else DEPLOYING
            Sub->>Vtx: startPhysicalVertex + makeTaskGroupDeploy
            Vtx->>Vtx: updateTaskState(DEPLOYING)
            Vtx->>Vtx: deploy(slotProfile)
            alt local worker
                Vtx->>TES: deployTask(taskGroupInfo)
            else remote worker
                Vtx->>DTO: send to worker
                DTO->>TES: deployTask(taskGroupInfo)
            end
            alt ok
                Vtx->>Vtx: updateTaskState(RUNNING)
            else fail
                Vtx->>Vtx: makeTaskGroupFailing()
            end
            Sub->>Sub: updatePipelineState(RUNNING)
        else RUNNING
            Sub->>Sub: idle
        else FAILING or CANCELING
            Sub->>Vtx: cancel each vertex
        else FAILED or CANCELED
            alt can restore
                Sub->>JM: release + reapply resources
                Sub->>Sub: restorePipeline()
            else terminal
                Sub->>Sub: subPlanDone, complete future
            end
        else FINISHED
            Sub->>Sub: subPlanDone, complete future
        end
    end
</div>
<h2 id="总结">总结</h2>
<p>SeaTunnel 提交任务后的核心逻辑，并不是“收到请求后直接启动任务”。</p>
<p>它大致会经过下面这条主链路：</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#272822;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-text-size-adjust:none;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>SubmitJobServlet
</span></span><span style="display:flex;"><span>  -&gt; JobInfoService
</span></span><span style="display:flex;"><span>  -&gt; MasterNode / CoordinatorService
</span></span><span style="display:flex;"><span>  -&gt; JobMaster
</span></span><span style="display:flex;"><span>  -&gt; PhysicalPlan
</span></span><span style="display:flex;"><span>  -&gt; SubPlan
</span></span><span style="display:flex;"><span>  -&gt; PhysicalVertex
</span></span><span style="display:flex;"><span>  -&gt; TaskExecutionService
</span></span></code></pre></div><p>其中：</p>
<ul>
<li><code>JobInfoService</code> 负责处理提交入口，并判断是否需要转发到 Master。</li>
<li><code>CoordinatorService</code> 负责接管任务协调，避免重复提交，并创建 <code>JobMaster</code>。</li>
<li><code>JobMaster</code> 负责初始化 Job 运行上下文。</li>
<li><code>PhysicalPlan</code> 负责 Job 级别状态推进。</li>
<li><code>SubPlan</code> 负责 Pipeline 级别的资源申请和调度。</li>
<li><code>PhysicalVertex</code> 负责 TaskGroup 部署。</li>
<li><code>TaskExecutionService</code> 是最终执行 TaskGroup 部署的入口。</li>
</ul>
<p>理解这条链路之后，再去看 SeaTunnel 的 Task 执行线程模型、数据流转和 checkpoint 机制，就会更容易把各个模块放到正确的位置上。</p>
]]></content:encoded></item></channel></rss>