<?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/%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86/</link><description>Recent content in 状态管理 on Niu Zhiwei 的个人技术博客</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Thu, 16 Jul 2026 14:00:00 +0800</lastBuildDate><atom:link href="https://nzw921rx.github.io/nzw921rx-blog/tags/%E7%8A%B6%E6%80%81%E7%AE%A1%E7%90%86/index.xml" rel="self" type="application/rss+xml"/><item><title>Apache Flink 状态管理深度解析：从 Barrier 到端到端 Exactly-Once</title><link>https://nzw921rx.github.io/nzw921rx-blog/posts/flink-state-management-consistent-snapshots/</link><pubDate>Thu, 16 Jul 2026 14:00:00 +0800</pubDate><guid>https://nzw921rx.github.io/nzw921rx-blog/posts/flink-state-management-consistent-snapshots/</guid><description>以一个双输入聚合任务贯穿 Flink 状态管理论文：逐记录推演 Barrier Alignment、异步增量快照、故障恢复、事务 Sink、扩缩容，并区分 2017 年论文与 Flink 2.3 的实现边界。</description><content:encoded><![CDATA[<p>很多文章用一句话概括 Flink Checkpoint：Barrier 随数据流传播，算子保存状态，失败后从最近一次快照恢复。这句话没有错，但它跳过了真正困难的部分：<strong>Source Offset、多个输入通道、算子状态以及 Sink 的外部副作用，怎样共同表示同一个逻辑时刻？</strong></p>
<p>本文精读 2017 年论文 <a href="https://www.vldb.org/pvldb/vol10/p1718-carbone.pdf">State Management in Apache Flink: Consistent Stateful Distributed Stream Processing</a>。它不是逐句翻译，也不罗列配置项。全文用同一个例子贯穿：两个 Kafka 分区输入一个有状态聚合算子，结果写入事务 Sink。我们会逐条记录检查 Barrier 到达时状态怎样变化，失败后哪些记录重放，以及一个外部事务究竟应该提交还是回滚。</p>
<p>阅读时需要分清三类内容：</p>
<ul>
<li><strong>论文事实</strong>：来自论文 §3.2、Algorithm 1、状态后端与生产实验；</li>
<li><strong>现代 Flink</strong>：以 Apache Flink 2.3 文档为版本基线；</li>
<li><strong>工程推论</strong>：从上述机制推导到 Connector 和 SeaTunnel/Zeta 设计，不代表论文原文结论。</li>
</ul>
<p>先给出全文结论：</p>
<blockquote>
<p>Checkpoint 不是“保存一批对象”，而是为一次确定的输入前缀建立可重新进入的执行世界。Source 位置、算子状态、必要的通道状态和 Sink 提交必须对这个输入前缀给出相容答案。</p>
</blockquote>
<h2 id="一一致性问题恢复点必须解释同一段历史">一、一致性问题：恢复点必须解释同一段历史</h2>
<h3 id="11-单独正确的状态组合起来可能错误">1.1 单独正确的状态，组合起来可能错误</h3>
<p>假设账户 <code>alice</code> 在 Checkpoint 41 中的余额状态是 900，Kafka 下一条待读 Offset 是 121。随后 Offset 121 到来一笔 <code>+50</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>Checkpoint 41:
</span></span><span style="display:flex;"><span>  nextOffset = 121
</span></span><span style="display:flex;"><span>  balance(alice) = 900
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>处理 P0@121(+50) 后:
</span></span><span style="display:flex;"><span>  nextOffset = 122
</span></span><span style="display:flex;"><span>  balance(alice) = 950
</span></span></code></pre></div><p>一个合法恢复点只能选择前后两个组合之一：</p>
<table>
	<thead>
			<tr>
					<th>Source 恢复位置</th>
					<th style="text-align: right">算子状态</th>
					<th>恢复后结果</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>121</td>
					<td style="text-align: right">900</td>
					<td>重放 <code>+50</code>，得到 950，正确</td>
			</tr>
			<tr>
					<td>122</td>
					<td style="text-align: right">950</td>
					<td>不再重放，保持 950，正确</td>
			</tr>
			<tr>
					<td>121</td>
					<td style="text-align: right">950</td>
					<td>重放后得到 1000，重复更新</td>
			</tr>
			<tr>
					<td>122</td>
					<td style="text-align: right">900</td>
					<td>不重放，永久丢失 50</td>
			</tr>
	</tbody>
</table>
<p>因此，问题不是 Offset 和状态有没有分别保存成功，而是它们是否属于同一个输入前缀。对于完整数据流，这个恢复合同至少涉及四部分：</p>
<div class="mermaid">flowchart LR
    A[Source 位置] --> E[同一个 Checkpoint / Epoch]
    B[Operator State] --> E
    C[必要的 Channel State] --> E
    D[Sink 未决事务] --> E
    E --> F[可确定恢复的输入前缀]
</div>
<ul>
<li><strong>Source 位置</strong>决定恢复后从哪里重放；</li>
<li><strong>Operator State</strong>必须恰好包含该位置之前记录的状态影响；</li>
<li><strong>Channel State</strong>解释已经离开上游但尚未进入下游状态的记录；</li>
<li><strong>Sink 事务</strong>解释哪些输出已经进入最终外部可见状态。</li>
</ul>
<p>这里的“一致时刻”不是要求所有机器在同一毫秒暂停，而是要求因果关系闭合：快照里不能只有结果没有原因，也不能把同一原因既放进状态、又放进待重放输入。</p>
<h3 id="12-为什么外部数据库不能自动解决问题">1.2 为什么外部数据库不能自动解决问题</h3>
<p>把算子状态放进外部数据库，可以解决状态字节的可靠存储，却不能自动回答“这个数据库版本对应哪个 Kafka Offset、哪个下游事务”。如果每条记录都同时更新状态表、提交 Offset、写目标系统，系统仍需要一个跨越这些参与者的提交协议；否则失败窗口只是从内存搬到了多个外部系统之间。</p>
<p>数据库可以是 State Backend 或 Sink，但 <strong>Epoch 的归属关系仍由流处理运行时协调</strong>。这正是论文把 Managed State、Snapshot Protocol 与外部提交放进同一架构讨论的原因。</p>
<h2 id="二managed-state运行时究竟管理什么">二、Managed State：运行时究竟管理什么</h2>
<h3 id="21-keyed-stateoperator-state-与隐藏状态">2.1 Keyed State、Operator State 与隐藏状态</h3>
<p>Managed State 的价值不在于 API 名字，而在于运行时知道状态的逻辑所有权、序列化方式以及恢复时如何重分配。</p>
<table>
	<thead>
			<tr>
					<th>状态</th>
					<th>逻辑所有权</th>
					<th>典型内容</th>
					<th>扩缩容时怎样处理</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Keyed State</td>
					<td>业务 key</td>
					<td>余额、窗口、去重集合、定时器</td>
					<td>按 key-group 重新分配</td>
			</tr>
			<tr>
					<td>Operator State</td>
					<td>算子并行实例</td>
					<td>Source Split、分区 Offset、局部模型分片</td>
					<td>按 union/list 等规则重新分配</td>
			</tr>
			<tr>
					<td>外部未决状态</td>
					<td>事务或请求 ID</td>
					<td>Pending Transaction、Committable</td>
					<td>必须由 Connector 协议显式恢复</td>
			</tr>
	</tbody>
</table>
<p><code>ValueState</code>、<code>ListState</code>、<code>MapState</code> 等只是 Keyed State 的访问接口。关键在于同一个 key 的状态不绑定当前线程或机器，而是先属于一个稳定逻辑分片，再由当前并行实例承载。</p>
<p>相反，普通成员字段、静态 Map、Connector 后台线程里的 Buffer，如果没有进入 Managed State 或另一个明确的恢复协议，运行时并不知道它们存在。它们可能参与业务结果，却不会自动出现在 Checkpoint 中。</p>
<h3 id="22-key-group-为什么是扩缩容的基础">2.2 key-group 为什么是扩缩容的基础</h3>
<p>Flink 不直接把每个业务 key 写进 Checkpoint 调度元数据，而是先把 key 映射到固定数量的 key-group。<code>maxParallelism</code> 决定 key-group 总数，当前 <code>parallelism</code> 只决定这些组怎样分给 Subtask。</p>
<p>扩缩容时，系统移动的是 key-group range 及其 State Handle，而不是重新扫描每个业务 key 决定归属。只要 <code>maxParallelism</code> 和 key 序列化/哈希语义稳定，同一个 key 就能找到原状态。第 8.1 节会用 <code>M=12、P=3→4</code> 的例子展示一个新 Subtask 怎样从多个旧 Handle 拼出自己的状态。</p>
<h3 id="23-被托管不等于永远兼容">2.3 “被托管”不等于永远兼容</h3>
<p>Checkpoint 还依赖三类身份：</p>
<ol>
<li><strong>Operator UID</strong>：恢复时把旧状态映射回哪个逻辑算子；</li>
<li><strong>State Name</strong>：在算子内部找到哪份状态；</li>
<li><strong>Serializer Snapshot</strong>：判断旧字节能否由新代码读取或迁移。</li>
</ol>
<p>自动生成的算子 ID 会随 Job Graph 变化，任意修改 UID、状态名、key 类型或序列化格式，都可能让状态无法恢复。Managed State 提供迁移机制，但不承诺任意代码升级天然兼容。</p>
<h2 id="三论文的-snapshot-model-与三个显式假设">三、论文的 Snapshot Model 与三个显式假设</h2>
<h3 id="31-epochmarker-与-barrier">3.1 Epoch、Marker 与 Barrier</h3>
<p>论文把连续输入划分为逻辑 Epoch。为了避免编号偏一位，本文统一采用下面的定义：</p>
<blockquote>
<p><strong>Checkpoint N 包含 Barrier N 之前的记录及其状态影响；Barrier N 之后的记录不属于 Checkpoint N。</strong></p>
</blockquote>
<p>论文称这个控制消息为 <code>Marker</code>，现代 Flink 文档通常称为 <code>Checkpoint Barrier</code>。本文复述 Algorithm 1 时使用 Marker，其余场景使用 Barrier。</p>
<p>Source 收到协调器触发后，在同一个有序边界记录读取位置并向输出注入 Barrier。Barrier 与业务记录经过同一数据通道传播，不是一个可以越过任意记录的旁路 RPC。论文 Algorithm 1 对普通 Task 的抽象调用顺序在第 4.3 节单独展开，不把这里的 Source 描述外推成所有现代实现的固定方法次序。</p>
<h3 id="32-三个假设不能省略">3.2 三个假设不能省略</h3>
<p>论文 §3.2.2 的协议建立在 fail-recovery、deterministic process model 上，并明确列出三项假设：</p>
<ol>
<li><strong>输入可持久重放。</strong> Source 能从某个逻辑位置重新消费，例如 Kafka Offset 或文件位置。</li>
<li><strong>Task 间通道可靠、FIFO，并且可以阻塞或恢复。</strong> 通道阻塞期间，在途消息由系统缓冲，必要时 spill 到磁盘。</li>
<li><strong>Task 能控制输入通道并向输出发送消息。</strong> Task 可以触发输入的 block/unblock，并向输出发送记录或控制消息。</li>
</ol>
<p>紧接着，论文另行说明 Marker 与普通记录由调用用户算子的同一底层线程顺序处理。这不是第三项假设的改写，而是 Algorithm 1 的本地执行顺序条件。</p>
<p>如果 FIFO 不成立，旧记录可能在 Barrier 之后到达；如果 Source 不可重放，回滚后无法补回 Checkpoint 之后的数据；如果用户逻辑依赖未记录的随机数、系统时间或不可重放外部调用，即使状态和 Offset 一致，重放结果也未必相同。</p>
<h2 id="四barrier-alignment逐条记录推演一致切面">四、Barrier Alignment：逐条记录推演一致切面</h2>
<h3 id="41-单输入为什么简单">4.1 单输入为什么简单</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>r1 -&gt; r2 -&gt; r3 -&gt; Barrier 42 -&gt; r4
</span></span></code></pre></div><p>由于 FIFO，看到 <code>Barrier 42</code> 时，<code>r1..r3</code> 已经完成本地处理，<code>r4</code> 尚未进入算子。此时固定本地状态版本，就得到了该通道上 Checkpoint 42 的输入前缀。</p>
<p>多输入算子不同：一个输入先越过边界，另一个输入还在旧 Epoch。如果继续同时消费，状态会混入两个输入前缀。</p>
<h3 id="42-一个双输入账户聚合的完整时间线">4.2 一个双输入账户聚合的完整时间线</h3>
<p>继续使用本文的账户状态。Checkpoint 41 中 <code>alice=900、bob=300</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>Input A: P0@121 alice +50, Barrier 42, P0@122 alice +100
</span></span><span style="display:flex;"><span>Input B: P1@81  bob   -20, Barrier 42, P1@82  bob   +200
</span></span></code></pre></div><p>合法的 Checkpoint 42 应是 <code>alice=950、bob=280</code>，不包含 Barrier 后的 <code>+100</code> 和 <code>+200</code>。假设 A 的 Barrier 先到：</p>
<table>
	<thead>
			<tr>
					<th style="text-align: right">步骤</th>
					<th>到达事件</th>
					<th>算子动作</th>
					<th>已阻塞输入</th>
					<th>在线状态</th>
					<th>Checkpoint 42</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td style="text-align: right">0</td>
					<td>从 CP41 开始</td>
					<td>恢复旧状态</td>
					<td>无</td>
					<td><code>(900,300)</code></td>
					<td>尚未触发</td>
			</tr>
			<tr>
					<td style="text-align: right">1</td>
					<td><code>A:P0@121 alice+50</code></td>
					<td>更新 alice</td>
					<td>无</td>
					<td><code>(950,300)</code></td>
					<td>尚未触发</td>
			</tr>
			<tr>
					<td style="text-align: right">2</td>
					<td><code>A:Barrier42</code></td>
					<td>阻塞 A</td>
					<td>A</td>
					<td><code>(950,300)</code></td>
					<td>等待 B</td>
			</tr>
			<tr>
					<td style="text-align: right">3</td>
					<td><code>A:P0@122 alice+100</code></td>
					<td>进入 A Buffer，不调用用户函数</td>
					<td>A</td>
					<td><code>(950,300)</code></td>
					<td>等待 B</td>
			</tr>
			<tr>
					<td style="text-align: right">4</td>
					<td><code>B:P1@81 bob-20</code></td>
					<td>继续处理 B</td>
					<td>A</td>
					<td><code>(950,280)</code></td>
					<td>等待 B</td>
			</tr>
			<tr>
					<td style="text-align: right">5</td>
					<td><code>B:Barrier42</code></td>
					<td>输入前缀闭合</td>
					<td>A、B</td>
					<td><code>(950,280)</code></td>
					<td>切面已确定</td>
			</tr>
			<tr>
					<td style="text-align: right">6</td>
					<td>论文顺序：传播 Barrier → <code>triggerSnapshot()</code> → unblock</td>
					<td>固定稳定视图后恢复输入</td>
					<td>无</td>
					<td><code>(950,280)</code></td>
					<td>快照为 <code>(950,280)</code></td>
			</tr>
			<tr>
					<td style="text-align: right">7</td>
					<td>处理 <code>P0@122</code>、<code>P1@82</code></td>
					<td>更新下一 Epoch</td>
					<td>无</td>
					<td><code>(1050,480)</code></td>
					<td>快照保持不变</td>
			</tr>
	</tbody>
</table>
<div class="mermaid">sequenceDiagram
    participant A as Input A
    participant O as Balance Operator
    participant B as Input B
    participant D as Downstream
    A->>O: P0@121 alice+50
    A->>O: Barrier 42，阻塞 A
    A-->>O: P0@122 留在 Buffer
    B->>O: P1@81 bob-20
    B->>O: Barrier 42
    O->>D: 先传播 Barrier 42
    O->>O: triggerSnapshot(950,280)
    O-->>A: unblock
    O-->>B: unblock
    A->>O: P0@122 alice+100
    B->>O: P1@82 bob+200
</div>
<p>如果不对齐，<code>P0@122 alice+100</code> 可能在 B 的 Barrier 到达前进入状态，使快照中的 alice 变成 1050；但 CP42 的 Source 位置仍会从 P0@122 开始。恢复后这条 <code>+100</code> 再执行一次，alice 变成错误的 1150。这正是论文中“关闭 Alignment”只提供 at-least-once 的原因。</p>
<h3 id="43-algorithm-1-到底做了什么">4.3 Algorithm 1 到底做了什么</h3>
<p>下面是论文 Algorithm 1 的等价伪代码，保留了关键顺序：</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>onMarker(marker, input):
</span></span><span style="display:flex;"><span>  if input != NIL:
</span></span><span style="display:flex;"><span>    blocked.add(input)
</span></span><span style="display:flex;"><span>    input.block()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  if blocked == allInputs:
</span></span><span style="display:flex;"><span>    for output in outputs:
</span></span><span style="display:flex;"><span>      output.send(marker)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    triggerSnapshot()
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    for input in allInputs:
</span></span><span style="display:flex;"><span>      input.unblock()
</span></span><span style="display:flex;"><span>    blocked.clear()
</span></span></code></pre></div><p>论文的抽象顺序是：<strong>阻塞到达 Marker 的输入 → 全部到齐 → 向下游发送 Marker → 触发本地快照 → 解除输入。</strong> <code>triggerSnapshot()</code> 仍可能包含同步建立稳定视图的开销；可以异步的是随后把该视图复制、压缩并写入持久存储的物化阶段。</p>
<p>论文先发送 Marker 再触发本地快照，是为了让下游传播和本地物化并行。这个顺序用于解释论文协议，不应被当成现代 Flink 每种算子、Source 和 Backend 的公开调用契约。</p>
<h3 id="44-为什么-dag-中通常不用保存普通在途数据">4.4 为什么 DAG 中通常不用保存普通在途数据</h3>
<p>对一个已对齐的多输入算子：</p>
<ul>
<li>每条输入的 Barrier 之前记录都已处理，因为通道 FIFO；</li>
<li>先到 Barrier 的通道被阻塞，所以其 Barrier 之后记录没有进入状态；</li>
<li>最后一个 Barrier 到达后，所有输入前缀同时闭合；</li>
<li>同一线程中，旧 Epoch 记录产生的输出先于下游 Marker 发送。</li>
</ul>
<p>因此，本地状态恰好对应所有输入在 Barrier 42 之前的前缀。普通 DAG 区域不必再把这些记录作为 Channel State 重复保存。</p>
<p>有环拓扑是例外：旧 Epoch 记录可能在环中长期流转。论文通过 <code>IterationHead/IterationTail</code> 只记录环内必要的在途记录，Marker 绕环返回后再停止日志。这个设计的重点不是某个已经具有版本背景的迭代 API，而是：<strong>一致切面无法排除的在途消息，必须成为快照的一部分。</strong></p>
<h3 id="45-从本地状态到-completed-checkpoint">4.5 从本地状态到 Completed Checkpoint</h3>
<p>单个算子固定状态，不代表 Checkpoint 已经可以恢复；单个 Task 返回 State Handle/ACK，也只说明该 Task 的物化结果可用。Coordinator 收齐计划内所有参与者的 ACK，并把 Pending Checkpoint 原子地转换为 Completed 后，它才是合法恢复点。</p>
<p>不同 Task 的 Barrier 传播、对齐、同步快照和异步物化会流水重叠，所以 End-to-End Duration 不是把每个指标简单相加。全局耗时由跨 Task 的关键路径和最慢 Subtask 决定。只有 Coordinator 已确认的 Completed Checkpoint 才能作为恢复点；部分 Task 已 ACK 的半成品不能与旧 Checkpoint 拼接。</p>
<h2 id="五什么时候必须保存-channel-state">五、什么时候必须保存 Channel State</h2>
<p>Aligned Checkpoint 在 DAG 中通过阻塞输入排除了普通在途数据；这不是说分布式快照永远不需要 Channel State，而是 Flink 利用 FIFO 数据流结构把保存范围降到了最小。</p>
<p>四种容易混淆的模式可以放在一张表中理解：</p>
<table>
	<thead>
			<tr>
					<th>模式</th>
					<th>Operator State</th>
					<th>Channel State</th>
					<th>恢复语义</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>论文的 aligned snapshot</td>
					<td>只含 Barrier 前更新</td>
					<td>普通 DAG 通常不需要；环内单独记录</td>
					<td>exactly-once State</td>
			</tr>
			<tr>
					<td>论文中关闭 Alignment</td>
					<td>可能混入 Barrier 后更新</td>
					<td>不保存</td>
					<td>at-least-once</td>
			</tr>
			<tr>
					<td>现代 unaligned checkpoint</td>
					<td>保持一致状态切面</td>
					<td>保存被 Barrier 越过的在途 Buffer</td>
					<td>exactly-once State</td>
			</tr>
			<tr>
					<td>有环图的局部 Channel Log</td>
					<td>保持一致状态切面</td>
					<td>只记录环中无法排除的旧 Epoch 消息</td>
					<td>exactly-once State</td>
			</tr>
	</tbody>
</table>
<h3 id="51-论文的关闭对齐为什么会退化">5.1 论文的“关闭对齐”为什么会退化</h3>
<p>继续使用上一节的例子。如果 A 收到 Barrier 42 后仍处理 <code>P0@122 alice+100</code>，而 B 的旧 Epoch 记录尚未结束，那么快照可能包含这条记录的状态影响。恢复后，Source A 又会从 P0@122 重放；由于快照没有描述它已经混入状态，运行时无法消除第二次更新。</p>
<p>这是一种主动放宽一致性的模式：少等待，但允许恢复重放造成重复状态更新。</p>
<h3 id="52-现代-unaligned-checkpoint-用-io-换等待时间">5.2 现代 Unaligned Checkpoint 用 I/O 换等待时间</h3>
<p>Unaligned Checkpoint 不是“更名后的关闭对齐”。当第一个 Barrier 到达一个被背压的算子时，Barrier 可以越过部分排队数据继续传播，但这些被越过的输入/输出 Buffer 会作为 Channel State 写入 Checkpoint。恢复时，运行时先恢复相应在途数据，再继续普通输入处理，因此状态语义仍可保持 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>aligned:   等待慢通道追上 Barrier
</span></span><span style="display:flex;"><span>unaligned: 保存并恢复 Barrier 越过的 Channel State
</span></span></code></pre></div><p>所以 unaligned 主要针对“Barrier 被拥堵数据长期阻挡”。如果瓶颈是单条用户函数执行几分钟、Checkpoint Storage 限流或状态物化 I/O 已饱和，它不会凭空消除瓶颈，反而可能因为额外 Channel State 增加写入量。</p>
<p>以 Flink 2.3 文档为基线，还应保留这些边界：</p>
<ul>
<li>unaligned 只用于 <code>EXACTLY_ONCE</code> Checkpoint Mode；</li>
<li>当前不支持多个 unaligned checkpoint 并发执行；</li>
<li>configured alignment timeout 按 Checkpoint 触发后的已用时间判断是否从 aligned 切换，而不是只计算某一个输入的局部等待；</li>
<li>pointwise、broadcast 等相应连接上会禁用 unaligned 路径，不能笼统表述成“所有连接都保存 Channel State”；</li>
<li>Barrier 无法抢占正在执行的单条记录；</li>
<li>恢复时原有的 Watermark 与在途数据隐式顺序保证可能不再成立，部分算子可能得到不同结果，并非 Watermark 完全不可用；</li>
<li>Savepoint 始终采用 aligned 方式。</li>
</ul>
<p>选择 unaligned 之前，至少要同时查看 Barrier 传播/Alignment 时间、Channel State 大小和 Checkpoint Storage 余量。它是背压下的一种工程折中，不是全局更优模式。</p>
<h2 id="六状态怎样物化backendstorage-与增量-sst">六、状态怎样物化：Backend、Storage 与增量 SST</h2>
<h3 id="61-barrier-决定-whenbackend-决定-how">6.1 Barrier 决定 when，Backend 决定 how</h3>
<p>Snapshot Protocol 只决定“Checkpoint 42 对应哪个逻辑状态版本”。State Backend 负责：</p>
<ul>
<li>在线状态怎样组织和访问；</li>
<li>怎样固定一个不会被后续记录污染的 point-in-time view；</li>
<li>怎样把该视图转换成可恢复的 State Handle。</li>
</ul>
<p>现代 Flink 自 1.13 起又把 Checkpoint Storage 从 State Backend 职责中明确拆出：</p>
<table>
	<thead>
			<tr>
					<th>概念</th>
					<th>回答的问题</th>
					<th>例子</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>State Backend</td>
					<td>任务运行时状态放哪里、怎样读写与快照</td>
					<td>JVM Heap、Embedded RocksDB、ForSt</td>
			</tr>
			<tr>
					<td>Checkpoint Storage</td>
					<td>快照字节和元数据持久化到哪里</td>
					<td>JobManager Heap、文件系统、对象存储</td>
			</tr>
	</tbody>
</table>
<p>例如运行时状态在本地 Embedded RocksDB，而 Checkpoint 写到 S3，并不表示每次 <code>state.get()</code> 都远程访问 S3。论文中的 External State Backend 也不是 Checkpoint Storage 的旧名称：前者指运行期间状态访问由外部 KV/数据库承载。</p>
<h3 id="62-同步阶段只固定稳定视图">6.2 同步阶段只固定稳定视图</h3>
<p>如果对齐之后一直阻塞任务，直到数百 GB 状态全部上传完毕，周期性 Checkpoint 就会变成周期性停机。正确分层是：</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>同步阶段：确定 Checkpoint N 的稳定状态视图
</span></span><span style="display:flex;"><span>异步阶段：压缩、复制并把该视图写入持久存储
</span></span></code></pre></div><p>解除输入后，在线状态已经可以继续处理下一个 Epoch，后台线程仍在物化旧视图。Backend 因此不能只把一个可变 Map 引用交给后台线程；它必须使用 Copy-on-Write、不可变版本、RocksDB Snapshot 或等价机制，确保后台看到的始终是 Barrier 时刻的内容。</p>
<p>异步不等于免费。上传仍会与前台竞争 CPU、磁盘、网络、对象存储请求额度以及 RocksDB Compaction 资源。Checkpoint 很慢时要区分：Barrier 没到、稳定视图建立慢，还是异步 I/O 慢。</p>
<h3 id="63-增量-checkpoint-的真实对象是共享文件">6.3 增量 Checkpoint 的真实对象是共享文件</h3>
<p>RocksDB 的 SST 文件不可变，天然适合作为复用单位。假设第一次增量 Checkpoint 建立完整基线：</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>C100 = {A, B, C}
</span></span><span style="display:flex;"><span>C101 = {A, B, D}
</span></span><span style="display:flex;"><span>C102 = {B, D, E}
</span></span></code></pre></div><p>创建 C101 时，A、B 已在共享存储中，通常只需上传新文件 D 和新的元数据；但恢复 C101 必须读取 <code>A+B+D</code>，不能只拿 D。</p>
<div class="mermaid">flowchart TB
    C100[Checkpoint 100] --> A[SST A]
    C100 --> B[SST B]
    C100 --> C[SST C]
    C101[Checkpoint 101] --> A
    C101 --> B
    C101 --> D[SST D]
    C102[Checkpoint 102] --> B
    C102 --> D
    C102 --> E[SST E]
</div>
<p>删除 C100 时，C 可以在没有其他引用后清理；A、B 仍被 C101 引用，不能一起删除。共享状态的生命周期必须跟随 Checkpoint 引用关系，而不是简单删除某个目录。</p>
<p>Compaction 还会把旧 SST 合并成新 SST。即使逻辑状态变化不大，新生成文件也可能需要重新上传。因此：</p>
<ul>
<li>第一份增量 Checkpoint 需要建立基线；</li>
<li><code>Checkpointed Data Size</code> 在增量模式下表示本次 delta，不是完整逻辑状态大小；</li>
<li>增量快照减少的是重复上传，不保证恢复永远更快；</li>
<li>恢复快慢取决于文件数量、网络、CPU、IOPS 和本地 Backend 初始化；</li>
<li>异步快照、增量快照、本地恢复是不同优化，不能当成一个开关。</li>
</ul>
<p>Flink 2.3 文档还列出 HashMap、Embedded RocksDB，并将面向存算分离的 ForSt 标记为 experimental。论文告诉我们为什么 Backend 可以替换；具体选型仍应以版本文档和自己的状态访问模式压测为准。</p>
<h2 id="七故障恢复与端到端-exactly-once用一个事务走完所有窗口">七、故障恢复与端到端 Exactly-Once：用一个事务走完所有窗口</h2>
<h3 id="71-completed-checkpoint-保存的是一个恢复元组">7.1 Completed Checkpoint 保存的是一个恢复元组</h3>
<p>回到本文的账户例子。假设 Checkpoint 41 已完成：</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:
</span></span><span style="display:flex;"><span>  P0.nextOffset = 121
</span></span><span style="display:flex;"><span>  P1.nextOffset = 81
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Operator State:
</span></span><span style="display:flex;"><span>  balance(alice) = 900
</span></span><span style="display:flex;"><span>  balance(bob)   = 300
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Sink:
</span></span><span style="display:flex;"><span>  transactions through T41 = COMMITTED
</span></span></code></pre></div><p>这里 <code>nextOffset=121</code> 明确表示下一条要读 121，之前的记录已经包含在该状态中。随后任务处理两条记录：</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>P0@121: alice +50  -&gt; alice=950
</span></span><span style="display:flex;"><span>P1@81:  bob   -20  -&gt; bob=280
</span></span></code></pre></div><p>Checkpoint 42 的正常路径如下：</p>
<table>
	<thead>
			<tr>
					<th>阶段</th>
					<th>Source</th>
					<th>Operator</th>
					<th>Sink</th>
					<th>CP42</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>处理完成</td>
					<td>next=<code>(122,82)</code></td>
					<td><code>(alice=950,bob=280)</code></td>
					<td>T42=OPEN</td>
					<td>尚未触发</td>
			</tr>
			<tr>
					<td>Barrier 传播</td>
					<td>保存 next=<code>(122,82)</code></td>
					<td>固定状态视图</td>
					<td>T42 接收边界</td>
					<td>IN_PROGRESS</td>
			</tr>
			<tr>
					<td>Sink prepare</td>
					<td>不变</td>
					<td>异步物化</td>
					<td>T42=PREPARED，句柄进入状态</td>
					<td>IN_PROGRESS</td>
			</tr>
			<tr>
					<td>全部 ACK</td>
					<td>State Handle 已汇集</td>
					<td>State Handle 已汇集</td>
					<td>T42 仍为 PREPARED</td>
					<td>READY_TO_COMPLETE，尚不可恢复</td>
			</tr>
			<tr>
					<td>Coordinator 确认完成</td>
					<td>恢复元数据可用</td>
					<td>恢复元数据可用</td>
					<td>获得提交许可</td>
					<td>COMPLETED，可作为恢复点</td>
			</tr>
			<tr>
					<td>外部提交</td>
					<td>从 122/82 继续</td>
					<td>继续处理</td>
					<td>幂等 commit T42</td>
					<td>COMPLETED</td>
			</tr>
	</tbody>
</table>
<p>因此 CP42 不是三个互不相关的文件，而是一个逻辑元组：</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>CP42 = {
</span></span><span style="display:flex;"><span>  sourceProgress: (P0=122, P1=82),
</span></span><span style="display:flex;"><span>  operatorState:  (alice=950, bob=280),
</span></span><span style="display:flex;"><span>  pendingSink:    T42,
</span></span><span style="display:flex;"><span>  metadata:       handles + completion decision
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>只有 Coordinator 已确定 Completed 的元组才能恢复。TaskManager 磁盘上即使已经存在某些状态文件，如果 Completed Checkpoint 的元数据和 State Handle 不可用，它们也不是一个合法恢复点；这就是 JobManager/控制面高可用同样重要的原因。</p>
<h3 id="72-sink-需要的是可恢复状态机不是一次-flush">7.2 Sink 需要的是可恢复状态机，不是一次 flush</h3>
<p>一个抽象事务至少有这些状态：</p>
<div class="mermaid">stateDiagram-v2
    [*] --> OPEN
    OPEN --> PREPARED: Barrier / prepare
    OPEN --> ORPHANED: writer failure
    ORPHANED --> ABORTED: recovery cleanup / timeout
    PREPARED --> COMMITTING: checkpoint completed
    PREPARED --> ABORTED: authoritative checkpoint abort
    COMMITTING --> COMMITTED: commit or query confirms success
    COMMITTING --> UNKNOWN: response lost
    UNKNOWN --> COMMITTED: query or idempotent retry confirms
    COMMITTED --> COMMITTED: retry or query result
    ABORTED --> ABORTED: retry cleanup
</div>
<p>下面是<strong>概念伪代码，不对应某个 Flink API 的固定方法签名</strong>：</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>onCheckpointBarrier(<span style="color:#66d9ef">long</span> checkpointId) {
</span></span><span style="display:flex;"><span>    awaitOrFenceInflightRequests();
</span></span><span style="display:flex;"><span>    TransactionHandle h <span style="color:#f92672">=</span> target.<span style="color:#a6e22e">prepare</span>(currentTransaction);
</span></span><span style="display:flex;"><span>    pending.<span style="color:#a6e22e">put</span>(checkpointId, h); <span style="color:#75715e">// pending 随本次状态一起持久化</span>
</span></span><span style="display:flex;"><span>    currentTransaction <span style="color:#f92672">=</span> target.<span style="color:#a6e22e">begin</span>(nextStableTransactionId());
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>onCheckpointCompleted(<span style="color:#66d9ef">long</span> checkpointId) {
</span></span><span style="display:flex;"><span>    pending.<span style="color:#a6e22e">upTo</span>(checkpointId)
</span></span><span style="display:flex;"><span>           .<span style="color:#a6e22e">forEach</span>(target::commitIdempotentlyOrQueryResult);
</span></span><span style="display:flex;"><span>    <span style="color:#75715e">// 只有确认 COMMITTED 后才能删除 pending；UNKNOWN 必须保留稳定身份</span>
</span></span><span style="display:flex;"><span>}
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>onCheckpointAborted(<span style="color:#66d9ef">long</span> checkpointId) {
</span></span><span style="display:flex;"><span>    pending.<span style="color:#a6e22e">exact</span>(checkpointId)
</span></span><span style="display:flex;"><span>           .<span style="color:#a6e22e">forEach</span>(target::abortIdempotently);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>这里有三个不可省略的要求：</p>
<ol>
<li>Pending Transaction 必须跟 Checkpoint 一起恢复；</li>
<li>commit 和 abort 必须幂等，或者能按稳定事务 ID 查询结果；</li>
<li>新执行必须 fence 旧 Writer，防止失败实例在恢复后继续写。</li>
</ol>
<p><code>flush()</code> 只表示把 Buffer 送到外部系统或等待异步请求完成，不自动等于事务进入最终外部可见状态。只有当成功结果在 Source Progress 被确认前可判定，或 Pending Request 身份能够恢复并重试时，flush 才能参与建立可靠的 at-least-once；端到端 exactly-once 仍取决于外部提交协议。</p>
<h3 id="73-五个失败窗口怎样收敛">7.3 五个失败窗口怎样收敛</h3>
<table>
	<thead>
			<tr>
					<th>故障位置</th>
					<th>合法恢复点</th>
					<th>Source/Operator 恢复</th>
					<th>外部事务必须怎样处理</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>CP42 触发前</td>
					<td>CP41</td>
					<td>恢复状态 <code>(900,300)</code>，Source 从 <code>(121,81)</code> 开始重放</td>
					<td>中止或 fence 当前 OPEN 事务</td>
			</tr>
			<tr>
					<td>T42 已 PREPARED，CP42 未完成</td>
					<td>CP41</td>
					<td>两条记录重新执行</td>
					<td>T42 不得可见；abort、超时清理或 fence</td>
			</tr>
			<tr>
					<td>CP42 已完成，完成通知丢失</td>
					<td>CP42</td>
					<td>恢复状态 <code>(950,280)</code>，Source 从 <code>(122,82)</code> 继续</td>
					<td>恢复 Pending T42，并补做幂等 commit</td>
			</tr>
			<tr>
					<td>commit T42 成功、响应丢失</td>
					<td>CP42</td>
					<td>不重放已包含记录</td>
					<td>重试时查询或返回同一 COMMITTED 结果</td>
			</tr>
			<tr>
					<td>CP42 后处理新数据时失败</td>
					<td>CP42</td>
					<td>回滚新在线状态并重放</td>
					<td>隔离新事务，把重放写入新的有效事务</td>
			</tr>
	</tbody>
</table>
<p>最危险的是“外部 commit 成功、响应丢失”。调用方看到超时，并不知道应该重试还是回滚；但外部系统已经做出协议上的最终提交决定。稳定事务 ID、结果查询和幂等 commit 是解决模糊结果的必要条件。</p>
<p>完成通知也不应被假设为“每个 Checkpoint 恰好一次到达”。并发 Checkpoint 可能让较新的完成点覆盖较旧点，通知链路也可能失败。提交实现应遵守 subsuming 语义：确认完成 N 时，能够安全处理所有不大于 N 且仍待提交的结果。</p>
<p>论文的 Snapshot Notification、旧 <code>TwoPhaseCommitSinkFunction</code> 风格的 completion callback、现代 Sink V2 的 Writer/Committable/Committer，是不同年代和 API 的实现方式。它们共享的是状态机不变量，而不是同一条固定 Java 调用链。</p>
<h3 id="74-exactly-once-指效果不是-cpu-只执行一次">7.4 Exactly-once 指效果，不是 CPU 只执行一次</h3>
<p>如果 CP42 完成后又处理了 P0@122，随后 CP43 未完成就失败，系统会恢复 CP42 并再次执行 P0@122。记录物理上执行了两次，但第一次更新的在线状态被回滚，第一次外部事务也没有成为有效提交，所以最终效果可以等价于一次。</p>
<p>幂等 Upsert 也能吸收重放，但条件比“有主键”严格：主键必须稳定，写入值必须确定，而且旧重放不能覆盖更新版本。常见做法是在目标行中带上单调 Epoch/版本，使用条件更新拒绝版本回退。</p>
<p>反过来，如果用户函数读取未记录的系统时间、随机数或不可重放外部服务，第二次执行可能产生不同结果。Checkpoint 保护的是纳入协议的状态，不会自动把任意副作用变成确定性操作。</p>
<h2 id="八rescalesavepoint-与现代运行时职责">八、Rescale、Savepoint 与现代运行时职责</h2>
<h3 id="81-rescale-实际上怎样读取旧-state-handle">8.1 Rescale 实际上怎样读取旧 State Handle</h3>
<p>前面已经说明 key-group 把逻辑分片与物理并行度解耦。连续 range 的计算可以抽象为：</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>start(i) = ceil(i * M / P)
</span></span><span style="display:flex;"><span>end(i)   = ceil((i + 1) * M / P) - 1
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>M = maxParallelism
</span></span><span style="display:flex;"><span>P = 当前 parallelism
</span></span><span style="display:flex;"><span>i = subtask index
</span></span></code></pre></div><p>假设 <code>M=12</code>，旧并行度为 3：</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>old-0 -&gt; [0..3]
</span></span><span style="display:flex;"><span>old-1 -&gt; [4..7]
</span></span><span style="display:flex;"><span>old-2 -&gt; [8..11]
</span></span></code></pre></div><p>扩容到 4 后，新 <code>subtask-1</code> 负责 <code>[3..5]</code>。恢复器必须从 old-0 的 State Handle 读取 key-group 3，再从 old-1 读取 4、5；它不能把“old-1 的整个本地数据库”简单复制过来。</p>
<p>下图只展示发生跨旧 Handle 读取的两个中间范围，省略没有交叉的 new-0 和 new-3：</p>
<div class="mermaid">flowchart LR
    O0[old-0<br/>kg 0..3] --> N1[new-1<br/>kg 3..5]
    O1[old-1<br/>kg 4..7] --> N1
    O1 --> N2[new-2<br/>kg 6..8]
    O2[old-2<br/>kg 8..11] --> N2
</div>
<p>Operator State 不能按 key-group 处理。Source Split、分区元数据等通常使用 list/union 等 redistribution contract；Pending Transaction 则需要稳定事务身份和明确的接管规则。把状态永久绑定到 <code>taskIndex</code>，会让 Failover 和 Rescale 都失去逻辑归属。</p>
<h3 id="82-checkpoint-与-savepoint-共用基础设施但合同不同">8.2 Checkpoint 与 Savepoint 共用基础设施，但合同不同</h3>
<table>
	<thead>
			<tr>
					<th>维度</th>
					<th>Checkpoint</th>
					<th>Savepoint</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>主要目的</td>
					<td>自动故障恢复</td>
					<td>计划内停止、升级、迁移、改并行度</td>
			</tr>
			<tr>
					<td>所有权</td>
					<td>通常由 Flink 管理和清理</td>
					<td>用户显式创建、持有、删除</td>
			</tr>
			<tr>
					<td>格式取向</td>
					<td>State Backend 原生、高效，可增量</td>
					<td>默认 canonical；也可选择 native</td>
			</tr>
			<tr>
					<td>Alignment</td>
					<td>可 aligned 或符合条件时 unaligned</td>
					<td>始终 aligned</td>
			</tr>
			<tr>
					<td>Job Graph 变更</td>
					<td>不应假定任意兼容</td>
					<td>仍受 UID、状态映射和 Serializer 约束</td>
			</tr>
	</tbody>
</table>
<p>“能从快照启动”不等于“升级一定安全”。计划变更前至少检查 Operator UID、State Name、Serializer 兼容性、<code>maxParallelism</code> 和 Connector Pending State。Native Savepoint 往往创建/恢复更快，但可移植性与允许的变更范围要按具体版本能力矩阵判断。</p>
<h3 id="83-论文概念到现代-flink-只能映射职责">8.3 论文概念到现代 Flink 只能映射职责</h3>
<p>下面的类名用于理解 Flink 2.x 一代运行时，不是稳定用户 API，也不是论文伪代码的逐行调用栈：</p>
<table>
	<thead>
			<tr>
					<th>论文概念</th>
					<th>现代运行时的大致职责落点</th>
					<th>不变量</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>集中触发 Epoch</td>
					<td><code>CheckpointCoordinator</code></td>
					<td>分配 ID、跟踪 Pending、形成 Completed</td>
			</tr>
			<tr>
					<td>Marker</td>
					<td><code>CheckpointBarrier</code></td>
					<td>在数据流中表达同一 Checkpoint 边界</td>
			</tr>
			<tr>
					<td>blocked input set</td>
					<td>Barrier Handler / Input Gate 内部逻辑</td>
					<td>aligned 时阻止新 Epoch 记录进入用户状态</td>
			</tr>
			<tr>
					<td><code>triggerSnapshot()</code></td>
					<td>StreamTask、Operator snapshot、State Backend</td>
					<td>固定 Operator/Keyed/Timer 等状态版本</td>
			</tr>
			<tr>
					<td>异步物化</td>
					<td>Snapshot Future、State Handle、Checkpoint Storage</td>
					<td>后台写入不能污染稳定视图</td>
			</tr>
			<tr>
					<td>外部提交</td>
					<td>Writer/Committable/Committer 或版本对应的回调</td>
					<td>提交决策必须跟 Completed Checkpoint 相容</td>
			</tr>
	</tbody>
</table>
<p>现代 Source State 也不一定是一个 <code>long offset</code>。它可能包含 Enumerator 状态、已发现和已分配 Split、每个 Split 的读取位置以及 CDC Snapshot/Log 切换阶段。论文的 Source Offset 是恢复进度的最小模型，不应把 Connector 接口简化成一个 getter。</p>
<h2 id="九king-生产数据论文到底证明了什么">九、King 生产数据：论文到底证明了什么</h2>
<p>论文使用 King 的 Rule-Based Event Aggregator（RBEA）展示真实大状态作业。查询规则广播到 Query Processor，事件按用户 ID 分区，算子维护每个用户状态，再把聚合结果写向数据库或 Kafka。</p>
<h3 id="91-实验中真正公开的数据">9.1 实验中真正公开的数据</h3>
<table>
	<thead>
			<tr>
					<th>项目</th>
					<th>论文披露值</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>业务规模</td>
					<td>超过 3.5 亿月活用户、超过 300 亿事件/天</td>
			</tr>
			<tr>
					<td>集群</td>
					<td>共享 YARN，18 台物理机</td>
			</tr>
			<tr>
					<td>单机</td>
					<td>32 Cores、378 GB RAM、SSD + HDD</td>
			</tr>
			<tr>
					<td>Flink</td>
					<td>1.2.0</td>
			</tr>
			<tr>
					<td>运行状态</td>
					<td>本地 SSD 上的 RocksDB</td>
			</tr>
			<tr>
					<td>Checkpoint Storage</td>
					<td>HDD 支撑的 HDFS</td>
			</tr>
			<tr>
					<td>观察对象</td>
					<td>5 个生产部署</td>
			</tr>
			<tr>
					<td>状态规模实验</td>
					<td>并行度 70，约 100–500 GB 全局状态</td>
			</tr>
			<tr>
					<td>平均 Alignment Delay</td>
					<td>每次完整快照约 1.3 秒</td>
			</tr>
	</tbody>
</table>
<p>最容易出现的错误解读是“500 GB 状态 1.3 秒就上传完”。论文测得的是平均 <strong>Alignment Delay</strong>，不是全部 SST 写入 HDFS、ACK 和全局确认的 End-to-End Duration。低 Alignment 说明异步快照可以把大部分物化成本移出前台一致性切面，不说明存储 I/O 没有成本。</p>
<p>论文还观察到：在其部署范围内，状态变大没有让 Alignment 显著增长；并行度和拓扑连接增加会带来一定对齐开销，但结果不是简单线性关系。这证明架构在真实生产中可行，却不是对所有云环境的通用 Benchmark。</p>
<p>实验没有给出大规模恢复 P99、对象存储尾延迟、严重持续背压下的 aligned 表现，也不可能覆盖后来出现的 unaligned、现代 Sink API 和 ForSt。今天仍需按自己的拓扑与存储压测。</p>
<h3 id="92-checkpoint-变慢时看哪一层">9.2 Checkpoint 变慢时看哪一层</h3>
<table>
	<thead>
			<tr>
					<th>观测</th>
					<th>更可能的瓶颈</th>
					<th>优先验证</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Start Delay/Barrier 传播高</td>
					<td>上游持续背压、长链路、慢 Source</td>
					<td>Backpressure 图、各 Subtask 首个 Barrier 时间</td>
			</tr>
			<tr>
					<td>Alignment 高</td>
					<td>多输入速率差、热点、Barrier 被旧记录阻挡</td>
					<td>输入间 Barrier 到达差、热点 key、Buffer</td>
			</tr>
			<tr>
					<td>Sync Duration 高</td>
					<td>Serializer、Copy-on-Write、GC、同步 Backend 工作</td>
					<td>Task Thread、GC、快照创建火焰图</td>
			</tr>
			<tr>
					<td>Async Duration 高</td>
					<td>本地盘、网络、对象存储或限流</td>
					<td>上传吞吐、请求延迟、失败重试</td>
			</tr>
			<tr>
					<td>增量 delta 突增</td>
					<td>状态高更新率或 RocksDB Compaction</td>
					<td>SST 生成/复用、Compaction 指标</td>
			</tr>
			<tr>
					<td>Unaligned Channel State 很大</td>
					<td>背压积压被转成快照 I/O</td>
					<td>输入/输出 Buffer、Storage 余量</td>
			</tr>
			<tr>
					<td>Checkpoint 快、Restore 慢</td>
					<td>文件多、下载慢、Backend 初始化或重放量大</td>
					<td>Restore 指标、文件数量、Source Lag</td>
			</tr>
	</tbody>
</table>
<p>Timeout 从 10 分钟改到 30 分钟只能减少超时失败，不能解释为什么关键路径需要 20 分钟。应先定位 Barrier、Sync、Async、Storage 或 Restore 中的哪一段变慢，再选择扩容、Buffer Debloating、增量、unaligned 或存储调优。</p>
<h2 id="十对-seatunnelzeta-与-connector-的工程推论">十、对 SeaTunnel/Zeta 与 Connector 的工程推论</h2>
<p>本节是从论文不变量推导出的设计检查表，不把 Flink 内部类名等同于 SeaTunnel 某个版本的 API。具体接口仍应以对应 SeaTunnel commit 为准。</p>
<h3 id="101-跨越-checkpoint-边界的东西必须有去处">10.1 跨越 Checkpoint 边界的东西必须有去处</h3>
<p>并不是引擎队列、Sink Buffer 和所有异步请求都必须原样序列化进 Checkpoint。正确要求是：</p>
<blockquote>
<p>任何跨越快照边界的状态或副作用，都必须可恢复、可重放、可撤销、可 fence，或可通过幂等/事务消除重复。</p>
</blockquote>
<table>
	<thead>
			<tr>
					<th>未决内容</th>
					<th>可接受策略举例</th>
					<th>不能接受的状态</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Source 已发出、下游未处理的记录</td>
					<td>aligned 时依靠 FIFO+Barrier；unaligned 时保存 Channel State；或从 Source 重放</td>
					<td>Offset 已前移，但记录实际发在 Barrier 后且无 Channel State</td>
			</tr>
			<tr>
					<td>引擎内部队列</td>
					<td>drain/fence 到边界、保存队列，或让 Source 重放</td>
					<td>恢复后既不重放也不恢复</td>
			</tr>
			<tr>
					<td>Sink 异步请求</td>
					<td>Barrier 时等待、保存请求身份，或按幂等键重试</td>
					<td>后台线程继续写，失败无法回传</td>
			</tr>
			<tr>
					<td>Sink Buffer</td>
					<td>snapshot、flush+可重试，或事务 prepare</td>
					<td>Buffer 丢失但 Source 已跳过对应输入</td>
			</tr>
			<tr>
					<td>Pending Transaction</td>
					<td>持久化稳定事务 ID，由新 Committer 接管</td>
					<td>绑定旧 Worker 临时编号，Failover 后无人认领</td>
			</tr>
	</tbody>
</table>
<p>Source Progress 的不变量也不是“记录已经越过所有 Buffer”。在 aligned 模式下，Barrier 前记录可以仍在下游网络中；正确性来自：<strong>保存的位置描述了 Barrier 前已经按序发出的精确输入前缀</strong>，下游 FIFO 和 Alignment 会保证它们先于 Barrier 被处理。Unaligned 才会把必要的在途 Buffer 纳入快照。</p>
<h3 id="102-后台线程必须服从-epoch-fence">10.2 后台线程必须服从 Epoch Fence</h3>
<p>我在<a href="https://nzw921rx.github.io/nzw921rx-blog/posts/seatunnel-engine-flush-signal/">之前关于 Engine-Level FlushSignal 的文章</a>中讨论过，为什么 Connector 自己启动定时线程会带来并发和异常传播问题。从 Checkpoint 角度看，还必须回答：</p>
<ul>
<li>请求属于哪个 Epoch，Barrier 到来时是否还允许产生新请求；</li>
<li>已完成但未发射的结果保存在哪里，异步异常怎样回到 Task 生命周期；</li>
<li>cancel/fail 后旧线程怎样失效，新 Writer 怎样 fence 旧 Writer；</li>
<li>重试使用什么稳定幂等键或事务 ID。</li>
</ul>
<p>一个安全的边界通常包含 <code>freeze/drain -&gt; snapshot pending -&gt; handoff -&gt; resume</code>，而不是在主线程保存一个 Buffer 后，让后台线程继续无约束地修改同一对象。</p>
<p>把第 7.2 节的事务状态机落到 SeaTunnel 时，还要增加两个引擎级约束。第一，事务身份不能只依赖当前 <code>workerId/taskIndex</code>，否则 Rescale 或 Failover 后可能重复创建事务，或让 PREPARED 事务失去接管者。第二，多 Writer 分别提交不自动等于跨分区原子可见；最终保证取决于外部系统以及是否存在引擎级的全局提交协调。</p>
<h3 id="103-e2e-测试要控制协议阶段而不是控制时间">10.3 E2E 测试要控制协议阶段，而不是控制时间</h3>
<p>最有价值的测试不是任务正常结束后比较一次行数，而是在第 7.3 节的模糊窗口精确停住系统。例如验证“外部 commit 已成功，但响应丢失”：</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:#75715e">// 概念测试代码：服务端在持久提交后、返回 ACK 前暂停</span>
</span></span><span style="display:flex;"><span>commitServer.<span style="color:#a6e22e">pauseAt</span>(AFTER_DURABLE_COMMIT_BEFORE_ACK);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>job.<span style="color:#a6e22e">triggerCheckpoint</span>(42);
</span></span><span style="display:flex;"><span>commitServer.<span style="color:#a6e22e">awaitPaused</span>(42); <span style="color:#75715e">// commit 已落盘，ACK 尚未返回</span>
</span></span><span style="display:flex;"><span>job.<span style="color:#a6e22e">killCommitOwner</span>();       <span style="color:#75715e">// 具体可能是 Writer、Committer 或 Coordinator</span>
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>job.<span style="color:#a6e22e">restartFromLatestCompletedCheckpoint</span>();
</span></span><span style="display:flex;"><span>commitServer.<span style="color:#a6e22e">resume</span>();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>assertThat(commitServer.<span style="color:#a6e22e">commitAttempts</span>(42)).<span style="color:#a6e22e">isGreaterThanOrEqualTo</span>(2);
</span></span><span style="display:flex;"><span>assertThat(commitServer.<span style="color:#a6e22e">logicalCommitCount</span>(42)).<span style="color:#a6e22e">isEqualTo</span>(1);
</span></span><span style="display:flex;"><span>assertThat(target.<span style="color:#a6e22e">rows</span>()).<span style="color:#a6e22e">containsExactlyInAnyOrderElementsOf</span>(expectedRows);
</span></span></code></pre></div><p>如果测试用 <code>Thread.sleep(5000)</code> 猜测“应该已经 commit”，它既容易 flaky，也无法证明失败发生在目标窗口。更可靠的测试使用 latch、Hook、可控代理或事务测试服务端，并对每个阶段做确定性断言：</p>
<table>
	<thead>
			<tr>
					<th>故障 Hook</th>
					<th>必须断言</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>部分 Task ACK 后失败</td>
					<td>未完成 Checkpoint 绝不成为恢复点</td>
			</tr>
			<tr>
					<td>prepare 后、Completed 前失败</td>
					<td>外部结果不可见，重放后无重复</td>
			</tr>
			<tr>
					<td>Completed 后通知丢失</td>
					<td>Pending Transaction 被恢复并补交</td>
			</tr>
			<tr>
					<td>commit 成功、ACK 丢失</td>
					<td>可重试至少两次，逻辑提交恰好一次</td>
			</tr>
	</tbody>
</table>
<p>这些测试验证的是协议不变量，而不是某次 CI 恰好通过。对于 Connector 稳定性，这比增加重试次数或放宽超时更有价值。</p>
<h2 id="十一结论把-checkpoint-看成可重新进入的世界">十一、结论：把 Checkpoint 看成可重新进入的世界</h2>
<p>这篇论文最重要的贡献，不是某个 State API，也不是一个 Barrier 流程图，而是把状态归属、全局一致切面、异步物化、恢复重放和外部提交放进了同一个协议。</p>
<p>全文可以压缩成三条：</p>
<ol>
<li><strong>一致性对象是输入前缀及其全部状态影响，不是若干独立状态文件。</strong> Source、Operator、必要 Channel State 和 Sink 必须解释同一段历史。</li>
<li><strong>大状态系统的关键是把“确定逻辑切面”与“物化大量字节”解耦。</strong> Barrier/Alignment 解决 when，Backend/Storage 解决 how；增量 SST 再减少重复传输。</li>
<li><strong>端到端 exactly-once 的最后边界在外部提交。</strong> 记录可以被重放，但 Pending Transaction、稳定事务身份、幂等 commit 和 fencing 必须让外部效果收敛到一次。</li>
</ol>
<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
</span></span><span style="display:flex;"><span>  -&gt; Barrier 定义输入前缀
</span></span><span style="display:flex;"><span>  -&gt; Alignment 或 Channel State 建立一致切面
</span></span><span style="display:flex;"><span>  -&gt; Backend 固定并物化状态版本
</span></span><span style="display:flex;"><span>  -&gt; Coordinator 确认 Completed Checkpoint
</span></span><span style="display:flex;"><span>  -&gt; 恢复时回滚状态并重放
</span></span><span style="display:flex;"><span>  -&gt; Sink 协议提交唯一外部结果
</span></span></code></pre></div><p>论文使用的是 Flink 1.2.0 时代的实现，今天的 Source/Sink API、State Backend 和 unaligned checkpoint 已经演进；但只要系统还需要在失败后继续处理无界输入，这条一致性链路就不会过时。</p>
<h2 id="参考资料">参考资料</h2>
<ol>
<li>Paris Carbone et al., <a href="https://www.vldb.org/pvldb/vol10/p1718-carbone.pdf">State Management in Apache Flink: Consistent Stateful Distributed Stream Processing</a>, PVLDB 10(12), 2017.</li>
<li>Apache Flink 2.3, <a href="https://nightlies.apache.org/flink/flink-docs-release-2.3/docs/dev/datastream/fault-tolerance/checkpointing/">Checkpointing</a>.</li>
<li>Apache Flink 2.3, <a href="https://nightlies.apache.org/flink/flink-docs-release-2.3/docs/ops/state/checkpointing_under_backpressure/">Checkpointing under backpressure</a>.</li>
<li>Apache Flink 2.3, <a href="https://nightlies.apache.org/flink/flink-docs-release-2.3/docs/ops/state/state_backends/">State Backends</a>.</li>
<li>Apache Flink 2.3, <a href="https://nightlies.apache.org/flink/flink-docs-release-2.3/docs/ops/state/checkpoints_vs_savepoints/">Checkpoints vs. Savepoints</a>.</li>
<li>Apache Flink 2.3, <a href="https://nightlies.apache.org/flink/flink-docs-release-2.3/docs/ops/state/large_state_tuning/">Tuning Checkpoints and Large State</a>.</li>
<li>Apache Flink 2.3, <a href="https://nightlies.apache.org/flink/flink-docs-release-2.3/docs/connectors/datastream/guarantees/">Fault Tolerance Guarantees of Data Sources and Sinks</a>.</li>
<li>Apache Flink 2.3, <a href="https://nightlies.apache.org/flink/flink-docs-release-2.3/api/java/deprecated-list.html">Deprecated API List</a>.</li>
<li>Apache Flink 2.3, <a href="https://nightlies.apache.org/flink/flink-docs-release-2.3/api/java/org/apache/flink/api/common/state/CheckpointListener.html">CheckpointListener API</a>.</li>
<li>Apache Flink 2.3, <a href="https://nightlies.apache.org/flink/flink-docs-release-2.3/api/java/org/apache/flink/api/connector/sink2/SupportsCommitter.html">SupportsCommitter API</a>.</li>
</ol>
]]></content:encoded></item></channel></rss>