<?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>引擎设计 on Niu Zhiwei 的个人技术博客</title><link>https://nzw921rx.github.io/nzw921rx-blog/tags/%E5%BC%95%E6%93%8E%E8%AE%BE%E8%AE%A1/</link><description>Recent content in 引擎设计 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/tags/%E5%BC%95%E6%93%8E%E8%AE%BE%E8%AE%A1/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></channel></rss>