<?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/%E6%B5%81%E5%A4%84%E7%90%86/</link><description>Recent content in 流处理 on Niu Zhiwei 的个人技术博客</description><generator>Hugo</generator><language>zh-CN</language><lastBuildDate>Fri, 31 Jul 2026 12:00:00 +0800</lastBuildDate><atom:link href="https://nzw921rx.github.io/nzw921rx-blog/tags/%E6%B5%81%E5%A4%84%E7%90%86/index.xml" rel="self" type="application/rss+xml"/><item><title>从 Java 微基准到流处理系统：三篇性能评估论文精读与 JMH 实战</title><link>https://nzw921rx.github.io/nzw921rx-blog/posts/rigorous-java-stream-benchmarking/</link><pubDate>Fri, 31 Jul 2026 12:00:00 +0800</pubDate><guid>https://nzw921rx.github.io/nzw921rx-blog/posts/rigorous-java-stream-benchmarking/</guid><description>沿着“测量对象、实验单位、重复预算、结果解释”四个问题精读三篇性能评估论文，再从源码层解释 JMH 的生成代码、Fork、Warmup、Measurement 与结果聚合，并给出四个可落地的微基准场景。</description><content:encoded><![CDATA[<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>baseline    102.0 ns/op
</span></span><span style="display:flex;"><span>candidate    98.5 ns/op
</span></span></code></pre></div><p>这两行最多说明“某两次测量得到两个数字”，还不能证明 candidate 真快了 3.5 ns。我们至少还需要知道：</p>
<ul>
<li>测量的是冷启动、稳定态，还是某个随机的 JVM 生命周期位置？</li>
<li>样本来自多个独立 JVM，还是同一个 JVM 中高度相关的 Iteration？</li>
<li>差异来自代码，还是 JIT、GC、进程布局、机器漂移与测试顺序？</li>
<li>3.5 ns 即使真实存在，对实际系统是否有意义？</li>
</ul>
<p>到了分布式流处理系统，问题又多一层：如果事件已经在系统外排队 30 秒，而指标只从 Source 接收到事件后开始计时，那么“P99 延迟 20 ms”可能完全正确，也完全没有用户价值。</p>
<p>本文只做三件事：</p>
<ol>
<li>精读三篇论文，讲清它们分别解决了什么问题、怎样解决、用什么证据论证，以及结论的边界；</li>
<li>从源码层解释 JMH 怎样运行，再用四个具体场景讲清它的正确用法；</li>
<li>总结我对“可信性能证据”的理解。</li>
</ol>
<p>三篇论文不是三份互不相关的摘要，而是依次补上性能测试中的三个缺口：</p>
<table>
	<thead>
			<tr>
					<th>论文</th>
					<th>它追问的核心问题</th>
					<th>给出的主要答案</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>2007，Statistically Rigorous Java Performance Evaluation</td>
					<td>Java 程序每次运行都不同，应该怎样得到可信结果？</td>
					<td>区分启动与稳定态；以独立 JVM 运行作为重要实验单位；报告均值与置信区间，不取最好值</td>
			</tr>
			<tr>
					<td>2013，Rigorous Benchmarking in Reasonable Time</td>
					<td>严谨测试需要大量重复，怎样把时间花在真正产生不确定性的层级？</td>
					<td>识别 Build、Execution、Iteration 的分层方差；先定标，再分配重复预算；报告效应量区间</td>
			</tr>
			<tr>
					<td>2018，Benchmarking Distributed Stream Data Processing Systems</td>
					<td>流系统中的吞吐和延迟到底从哪里开始、到哪里结束？</td>
					<td>分离 Driver 与 SUT；使用外部队列和生成时间；测 Event-time Latency 与 Sustainable Throughput</td>
			</tr>
	</tbody>
</table>
<p>贯穿全文的核心思想只有一句话：</p>
<blockquote>
<p>可信 Benchmark 不是“多跑几次”，而是先定义测量对象，再找到独立实验单位，最后用与问题匹配的统计方法表达不确定性。</p>
</blockquote>
<h2 id="一三篇论文精读从测得准到测得对">一、三篇论文精读：从“测得准”到“测得对”</h2>
<h3 id="11-2007java-性能不是一个常数">1.1 2007：Java 性能不是一个常数</h3>
<p>论文：<a href="https://dri.es/files/oopsla07-georges.pdf">Statistically Rigorous Java Performance Evaluation</a>，Andy Georges、Dries Buytaert、Lieven Eeckhout，OOPSLA 2007。</p>
<h4 id="它要解决什么问题">它要解决什么问题</h4>
<p>Java 程序的执行时间会被很多运行时因素影响：</p>
<ul>
<li>JIT 的采样与编译时机；</li>
<li>线程调度；</li>
<li>GC 发生的时间与堆布局；</li>
<li>操作系统中断、缓存与其他机器状态。</li>
</ul>
<p>因此，即使代码、参数和机器都不变，多次运行也会得到不同结果。论文真正质疑的不是“有噪声”这件事，而是当时大量研究处理噪声的方式没有统计含义。</p>
<p>作者调查了 50 篇 Java 性能论文：</p>
<ul>
<li>16 篇没有说明实验方法；</li>
<li>10 篇报告最好值，8 篇报告平均值；</li>
<li>中位数与第二好值各有 4 篇，最差值有 3 篇；</li>
<li>只有 4 篇报告置信区间。</li>
</ul>
<p><code>best of N</code> 看似在排除机器噪声，实际回答的是“这 N 次中最幸运的一次有多快”。N 越大，遇到极端好运样本的机会越大，所以 <code>best of 3</code> 与 <code>best of 30</code> 甚至不是同一个估计目标。</p>
<p>假设两个实现的测量结果如下：</p>
<table>
	<thead>
			<tr>
					<th>实现</th>
					<th style="text-align: right">大多数运行</th>
					<th style="text-align: right">偶然出现的最好值</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>A</td>
					<td style="text-align: right">约 100 ms，波动很小</td>
					<td style="text-align: right">98 ms</td>
			</tr>
			<tr>
					<td>B</td>
					<td style="text-align: right">约 103 ms，波动很大</td>
					<td style="text-align: right">91 ms</td>
			</tr>
	</tbody>
</table>
<p>取最好值会宣布 B 获胜；但如果明天随机运行一次，A 更可能更快。最好值偏爱“容易偶尔走运”的实现，而不是典型情况下更快的实现。</p>
<h4 id="它怎样解决">它怎样解决</h4>
<p>论文先把两个经常混在一起的问题拆开。</p>
<p>启动性能关注 JVM 启动、类加载、初始化、JIT 与任务执行的总成本。实验方法是：</p>
<ol>
<li>启动多个独立 JVM；</li>
<li>每个 JVM 只执行一次任务；</li>
<li>将每次 JVM invocation 视为一个观测；</li>
<li>在这些观测上计算均值与置信区间。</li>
</ol>
<p>稳定态性能关注长时间运行程序在初始化之后的执行能力。论文的方法是：</p>
<ol>
<li>启动多个独立 JVM；</li>
<li>每个 JVM 执行多个 Iteration；</li>
<li>在每个 JVM 内找到作者认为已经稳定的窗口，并计算该 JVM 的平均值；</li>
<li>最后在多个 JVM 平均值之间计算总体均值与置信区间。</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>Fork 1: Iteration 1 ... Iteration k  -&gt;  Fork 1 的稳定态均值 m1
</span></span><span style="display:flex;"><span>Fork 2: Iteration 1 ... Iteration k  -&gt;  Fork 2 的稳定态均值 m2
</span></span><span style="display:flex;"><span>...
</span></span><span style="display:flex;"><span>Fork n: Iteration 1 ... Iteration k  -&gt;  Fork n 的稳定态均值 mn
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>最终在 m1, m2, ..., mn 上估计均值与不确定性
</span></span></code></pre></div><p>同一个 JVM 内的 Iteration 共享 JIT Profile、已编译代码、堆与 GC 历史，因此不能简单地把 5 个 Fork × 10 个 Iteration 宣称为 50 次彼此独立的 JVM 实验。</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>CI = x̄ ± t(0.975, n - 1) × s / √n
</span></span></code></pre></div><p>每个符号的含义是：</p>
<ul>
<li><code>CI</code>：这里是 95% 置信区间；</li>
<li><code>x̄</code>：n 个独立实验单位的样本均值；</li>
<li><code>s</code>：这 n 个样本的样本标准差；</li>
<li><code>n</code>：独立实验单位的数量，在这里通常应理解为独立 JVM invocation 的数量，而不是方法调用次数；</li>
<li><code>√n</code>：样本数量 n 的平方根；</li>
<li><code>t(0.975, n - 1)</code>：自由度为 <code>n - 1</code> 的 t 分布 97.5% 分位点，用来形成双侧 95% 区间。</li>
</ul>
<p>95% 置信区间的严格含义不是“真实均值有 95% 概率位于这个已经算出的区间内”，而是：如果按照同一规则反复做实验并构造区间，长期来看约 95% 的区间会覆盖真实均值。</p>
<h4 id="它怎样论证">它怎样论证</h4>
<p>作者没有只做一个玩具算例，而是构造了较大的实验矩阵：</p>
<ul>
<li>5 种 Jikes RVM 垃圾收集策略；</li>
<li>14 个 Benchmark，其中 7 个来自 SPECjvm98，7 个来自 DaCapo；</li>
<li>每个 Benchmark 从最小可运行堆开始，到 6 倍最小堆结束，以 0.25 倍为步长，共 21 个堆大小；</li>
<li>3 台不同硬件平台。</li>
</ul>
<p>论文使用 30 个 JVM invocation 分析启动性能；稳定态实验则使用多个 JVM，并在每个 JVM 内执行多个 Iteration。作者把常见的 best、second best、mean、median、worst 方法与更严格的置信区间比较结论进行对照。</p>
<p>在这套特定实验中：</p>
<ul>
<li>启动性能的常见简化方法，误导性结论最高达到约 16%；</li>
<li>某些方法给出相反优劣方向的错误结论超过 3%；</li>
<li>稳定态比较在最小关注差异为 1% 时，误导性结论超过 20%；</li>
<li>即使使用 Replay Compilation 控制一部分 JIT 随机性，误导也没有完全消失。</li>
</ul>
<p>这些百分比不能脱离论文的 GC、堆、硬件与阈值设置外推；它们真正证明的是：错误的汇总方法并非只在理论上有风险，而是足以改变工程结论。</p>
<p>论文还发现，达到同一精度所需的样本数会随 Benchmark、GC 与堆大小显著变化。有的组合不到 10 次就得到很窄的区间，有的组合做满 30 次后区间仍然较宽。因此，“统一跑 5 次”或“达到 30 次就可靠”都没有理论依据。</p>
<h4 id="这篇论文的核心结论">这篇论文的核心结论</h4>
<p>我认为它最重要的贡献不是某个统计公式，而是确立了四个基本原则：</p>
<ol>
<li>启动性能与稳定态性能是两个不同的问题；</li>
<li>JVM invocation 是 Java 性能评估中不可忽略的独立层级；</li>
<li>最好值、最差值不能代表典型性能；</li>
<li>性能结果必须同时报告估计值与不确定性。</li>
</ol>
<h4 id="它没有解决什么">它没有解决什么</h4>
<p>论文用“最近一段 Iteration 的变异系数 CoV 低于阈值”寻找稳定窗口。CoV 是标准差除以均值，它只能说明最近的点挤得是否紧，不能证明：</p>
<ul>
<li>序列没有缓慢上升或下降；</li>
<li>相邻 Iteration 彼此独立；</li>
<li>不存在奇偶交替或周期；</li>
<li>后面不会发生 Deoptimization 或状态切换。</li>
</ul>
<p>换句话说，低波动不等于稳态。这正是第二篇论文要修正的地方。</p>
<h3 id="12-2013严谨不等于把每一层都机械地跑很多次">1.2 2013：严谨不等于把每一层都机械地跑很多次</h3>
<p>论文：<a href="https://dl.acm.org/doi/10.1145/2491894.2464160">Rigorous Benchmarking in Reasonable Time</a>，Tomas Kalibera、Richard Jones，ISMM 2013（<a href="https://www.researchgate.net/publication/257193308_Rigorous_Benchmarking_in_Reasonable_Time">可访问全文</a>）。</p>
<h4 id="它要解决什么问题-1">它要解决什么问题</h4>
<p>2007 年论文告诉我们要运行多个 JVM、多个 Iteration，并报告区间。但新的现实问题是：如果 Build、JVM Execution、Iteration 都重复很多次，实验成本会迅速爆炸。</p>
<p>作者调查了 2011 年 PLDI、ASPLOS、ISMM、TOPLAS 与 TACO 的 122 篇论文：</p>
<ul>
<li>90 篇测量了执行时间；</li>
<li>其中 71 篇没有报告任何变异信息；</li>
<li>65 篇报告了执行时间比值；</li>
<li>只有 3 篇尝试为比值给出置信区间。</li>
</ul>
<p>论文认为，研究者不是不知道应该严谨，而是不知道：</p>
<ul>
<li>哪一层必须重复；</li>
<li>每层重复多少次；</li>
<li>多花一分钟应该增加 Build、Execution，还是 Iteration；</li>
<li>怎样直接表达“新版本究竟快了多少”。</li>
</ul>
<h4 id="它怎样解决-1">它怎样解决</h4>
<p>第一步是区分三类变量：</p>
<table>
	<thead>
			<tr>
					<th>变量类型</th>
					<th>含义</th>
					<th>正确处理</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Controlled</td>
					<td>实验者主动固定或选择的变量，如 JVM 参数、堆大小</td>
					<td>明确记录并保持一致，或作为实验因素系统变化</td>
			</tr>
			<tr>
					<td>Random</td>
					<td>每次实验会随机变化的变量，如调度与运行时扰动</td>
					<td>通过独立重复估计其影响</td>
			</tr>
			<tr>
					<td>Uncontrolled</td>
					<td>实验期间近似固定、但实验者没有控制的变量，如代码布局偏置</td>
					<td>尽量控制；不能控制时将其随机化，否则会形成系统偏差</td>
			</tr>
	</tbody>
</table>
<p>统计方法可以描述随机误差，却无法自动修复系统偏差。如果 baseline 总在机器冷的时候跑、candidate 总在机器热的时候跑，再漂亮的置信区间也只是在精确描述一个有偏实验。</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>Build 1
</span></span><span style="display:flex;"><span>  ├─ JVM Execution 1
</span></span><span style="display:flex;"><span>  │    ├─ Iteration 1
</span></span><span style="display:flex;"><span>  │    └─ Iteration 2
</span></span><span style="display:flex;"><span>  └─ JVM Execution 2
</span></span><span style="display:flex;"><span>       ├─ Iteration 1
</span></span><span style="display:flex;"><span>       └─ Iteration 2
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>Build 2
</span></span><span style="display:flex;"><span>  └─ ...
</span></span></code></pre></div><p>如果只考虑 Fork 与 Iteration 两层，总体均值方差可以用下面的简化模型理解：</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>Var(x̄) ≈ σ²fork / F + σ²iter / (F × I)
</span></span></code></pre></div><p>符号含义如下：</p>
<ul>
<li><code>Var(x̄)</code>：最终均值估计的不确定性；</li>
<li><code>σ²fork</code>：不同 Fork 之间的方差分量；</li>
<li><code>σ²iter</code>：同一 Fork 内不同 Iteration 的方差分量；</li>
<li><code>F</code>：Fork 数量；</li>
<li><code>I</code>：每个 Fork 中参与测量的 Iteration 数量。</li>
</ul>
<p>这个式子揭示了一个非常实际的结论：</p>
<blockquote>
<p>增加 Iteration 只能缩小第二项，无法消除 Fork 之间的差异。</p>
</blockquote>
<p>如果主要噪声来自不同 JVM 进程，那么把每个 Fork 的 Iteration 从 5 增加到 50，收益可能很小；增加独立 Fork 才真正压缩主要不确定性。如果重新 Build 也会改变代码布局与性能，那么还需要在更高层加入 Build 方差，并重复 Build。</p>
<p>论文因此提出“定标实验”：</p>
<ol>
<li>第一次为某个 Benchmark、VM 与平台组合做较充分的嵌套重复；</li>
<li>估计每一层贡献的方差；</li>
<li>测量每一层重复的时间成本，例如启动 JVM、完成预热、重新构建；</li>
<li>在给定总时间预算下，把重复分配到“方差贡献大且单位成本合理”的层级；</li>
<li>只有当 Benchmark、VM 或平台明显变化时，才重新定标。</li>
</ol>
<p>第三步是修正“低 CoV 等于稳态”。作者区分：</p>
<ul>
<li>Initialized state：明显的初始化阶段已经结束；</li>
<li>Independent state：后续 Iteration 不仅看似平稳，而且没有明显自相关。</li>
</ul>
<p>他们结合 Run-sequence Plot、Lag Plot 与自相关函数 ACF 检查序列，而不是只看最近窗口的波动大小。</p>
<h4 id="它怎样论证-1">它怎样论证</h4>
<p>作者对 DaCapo 的多种 Benchmark、JVM 与平台组合执行最多 300 个 Iteration，并比较手工诊断、DaCapo Harness 启发式和 2007 年 CoV 方法。</p>
<p>下面是论文 Table 3 中 Platform P1 的三个典型例子：</p>
<table>
	<thead>
			<tr>
					<th>Benchmark</th>
					<th style="text-align: right">初始化完成</th>
					<th style="text-align: right">达到独立态</th>
					<th style="text-align: right">DaCapo Harness 选择</th>
					<th style="text-align: right">2007 CoV 方法选择</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>avrora9</td>
					<td style="text-align: right">2</td>
					<td style="text-align: right">128</td>
					<td style="text-align: right">4</td>
					<td style="text-align: right">1</td>
			</tr>
			<tr>
					<td>lusearch9</td>
					<td style="text-align: right">3</td>
					<td style="text-align: right">5</td>
					<td style="text-align: right">5</td>
					<td style="text-align: right">247</td>
			</tr>
			<tr>
					<td>xalan6</td>
					<td style="text-align: right">2</td>
					<td style="text-align: right">2</td>
					<td style="text-align: right">29</td>
					<td style="text-align: right">无法收敛</td>
			</tr>
	</tbody>
</table>
<p>三行数据分别说明：</p>
<ul>
<li><code>avrora9</code> 中，CoV 方法在初始化完成之前就宣布稳定；</li>
<li><code>lusearch9</code> 第 5 轮已经达到论文判定的独立态，CoV 方法却预热到第 247 轮；</li>
<li><code>xalan6</code> 第 2 轮已经独立，CoV 方法反而无法收敛。</li>
</ul>
<p>因此，固定 <code>Warmup(iterations = 5)</code> 只是配置，不是“已经进入稳定态”的证据；同样，无限增加 Warmup 也未必能得到独立样本。</p>
<p>论文还给出一个很重要、但经常被忽略的退路：如果 Benchmark 在合理时间内无法达到独立态，就不要让在线启发式在每次运行中随意挑不同窗口，而应在所有运行中选择同一个、已经完成初始化的生命周期位置。它测量的可能不是理想“峰值稳态”，但至少比较对象一致。</p>
<p>第四步是直接报告效应量区间。只说 <code>p &lt; 0.05</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>candidate / baseline = 0.94，95% CI [0.92, 0.97]
</span></span></code></pre></div><p>这是一个教学示例，不是论文的实测数据。它表达的是：candidate 的执行时间估计为 baseline 的 94%，区间为 92% 到 97%；也就是性能改善大约在 3% 到 8% 之间。差异的方向、幅度和不确定性一次说清楚了。</p>
<h4 id="这篇论文的核心结论-1">这篇论文的核心结论</h4>
<p>第二篇论文把“多跑几次”推进成了“在哪一层多跑几次”：</p>
<ol>
<li>先控制或随机化系统偏差；</li>
<li>找到真正引入随机性的最高层；</li>
<li>用定标实验估计各层方差与成本；</li>
<li>把重复预算花在主要不确定性上；</li>
<li>报告性能变化的幅度与区间，而不只报告显著性。</li>
</ol>
<h4 id="它没有解决什么-1">它没有解决什么</h4>
<p>这套方法不是一组可以从论文直接复制的固定次数。作者明确强调：重复次数依赖 Benchmark、VM 与平台，论文表格中的数字不能原样套用到另一台机器。</p>
<p>它还需要几个前提：</p>
<ul>
<li>分层结构和方差模型与实际实验大致匹配；</li>
<li>定标之后，各层成本与方差没有发生根本变化；</li>
<li>实验者愿意查看时间序列并判断初始化与独立性；</li>
<li>测量指标本身已经定义正确。</li>
</ul>
<p>最后一个前提非常关键。统计方法能告诉我们一个指标估计得有多准，却不能告诉我们这个指标是否代表用户真正关心的问题。第三篇论文就从这里开始。</p>
<h3 id="13-2018流系统的最大输入速度不等于可持续吞吐">1.3 2018：流系统的“最大输入速度”不等于可持续吞吐</h3>
<p>论文：<a href="https://arxiv.org/pdf/1802.08496">Benchmarking Distributed Stream Data Processing Systems</a>，Jeyhun Karimov 等，ICDE 2018。</p>
<h4 id="它要解决什么问题-2">它要解决什么问题</h4>
<p>当时不少流处理 Benchmark 存在三类测量边界错误。</p>
<p>第一，Kafka、Redis 等外部组件可能先成为瓶颈，最后测到的是整套部署中最慢的外部组件，而不是流处理引擎。</p>
<p>第二，Driver 与 SUT（System Under Test）混在一起。不同系统使用自己的内部指标，延迟起点、吞吐口径和结果数量都不一致，横向比较失去共同尺度。</p>
<p>第三，只测 Processing-time Latency。事件在 Source 之前的等待时间被排除后，背压越严重，SUT 反而可能摄入得越慢、内部处理延迟越稳定；外部队列却在持续增长。</p>
<p>这就是 Coordinated Omission：测量节奏跟着被测系统变慢，恰好漏掉系统无法及时服务的那部分等待。</p>
<h4 id="它怎样解决-2">它怎样解决</h4>
<p>论文把 Driver 与 SUT 完全分离：</p>
<div class="mermaid">flowchart LR
    D["Driver<br/>固定速率生成并打时间戳"] --> Q["外部内存队列<br/>观察 backlog"]
    Q --> S["SUT<br/>Source → Window → Sink"]
    S --> O["Observer<br/>记录输出时间与吞吐"]
    D -. "生成时间" .-> O
    Q -. "队列长度" .-> O
</div>
<p>具体做法包括：</p>
<ul>
<li>Driver 在独立机器上按固定速率生成数据，不等待上一条处理完成；</li>
<li>每条事件在生成时记录 Event Time；</li>
<li>Driver 与 SUT Source 之间放置内存队列，吸收瞬时摄入波动并暴露 backlog；</li>
<li>吞吐从 Driver/队列侧测量，延迟在输出侧根据原始时间戳计算；</li>
<li>Driver 不与 SUT 共用机器，避免生成负载抢占被测系统资源。</li>
</ul>
<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>Event-time latency      = 输出时间 - 事件生成时间
</span></span><span style="display:flex;"><span>Processing-time latency = 输出时间 - SUT 摄入时间
</span></span></code></pre></div><p>前者包含 SUT 外部排队，更接近事件从产生到获得结果的等待；后者只描述事件进入 SUT 之后的处理时间。两者都有诊断价值，但不能互相替代。</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>窗口输出的 event time = 所有贡献输入中最大的 event time
</span></span><span style="display:flex;"><span>窗口输出延迟 = 输出时间 - 窗口输出的 event time
</span></span></code></pre></div><p>其中：</p>
<ul>
<li>“贡献输入”是实际参与该聚合或 Join 输出的输入事件；</li>
<li>“最大的 event time”代表最后一个必要输入到达的逻辑时间；</li>
<li>“输出时间”是 SUT 产生结果的时刻。</li>
</ul>
<p>例如一个聚合结果由 event time 为 580、590、600 的三条输入构成，并在 610 输出，那么论文口径下的窗口输出延迟是 <code>610 - 600 = 10</code>。这样不会把窗口本身等待数据凑齐的完整跨度全部算成引擎处理延迟。</p>
<p>这个定义也有明确语义边界：它衡量“最后一个必要事件之后多久产出窗口结果”，不是每一条早到事件的等待时间。如果业务关心第一条事件进入窗口后等待了多久，还需要另加指标。</p>
<p>论文进一步定义 Sustainable Throughput：</p>
<blockquote>
<p>系统能够长期处理、且不会出现持续背压或 Event-time Latency 持续增长的最高输入速率。</p>
</blockquote>
<p>寻找方法不是让 Source 无限制拉取并记录瞬时峰值，而是：</p>
<ol>
<li>先给出明显高于系统能力的固定生成速率；</li>
<li>观察队列长度和 Event-time Latency 是否持续增长；</li>
<li>逐步降低生成速率；</li>
<li>找到系统可以长期维持、backlog 不再单调增长的最高速率。</li>
</ol>
<h4 id="它怎样论证-2">它怎样论证</h4>
<p>作者使用游戏业务启发的窗口聚合和窗口 Join，评估当时版本的 Storm 1.0.2、Spark 2.0.1 与 Flink 1.1.3，并改变：</p>
<ul>
<li>集群节点数；</li>
<li>窗口大小与滑动步长；</li>
<li>数据倾斜；</li>
<li>输入速率波动；</li>
<li>聚合与 Join 工作负载。</li>
</ul>
<p>最有说服力的证据不是三个系统的历史排行榜，而是过载实验中的指标分裂：</p>
<ul>
<li>SUT 启动背压后，摄入速率下降；</li>
<li>Processing-time Latency 可以保持相对稳定；</li>
<li>但事件继续堆积在外部队列中，Event-time Latency 持续上升。</li>
</ul>
<p>如果只看 SUT 内部处理时间，会误判系统仍然健康；外部时间戳和 backlog 才揭示真实的容量不足。论文也展示了不同系统在数据倾斜、突发流量、窗口聚合和窗口 Join 下呈现不同瓶颈，说明单一的“每秒多少条”无法概括流系统性能。</p>
<h4 id="这篇论文的核心结论-2">这篇论文的核心结论</h4>
<p>第三篇论文把问题从“数字是否稳定”推进到“数字是否测对了东西”：</p>
<ol>
<li>Driver 必须独立于 SUT，且不能随 SUT 变慢而同步降速；</li>
<li>延迟起点应尽可能接近事件产生，而不是方便测量的 Source 内部；</li>
<li>吞吐必须与 backlog、Event-time Latency 一起解释；</li>
<li>最大瞬时摄入速率不等于可长期维持的处理能力；</li>
<li>窗口、Join、倾斜与突发流量必须作为独立工作负载。</li>
</ol>
<h4 id="它没有解决什么-2">它没有解决什么</h4>
<p>首先，论文为了隔离引擎能力，主动移除了 Kafka、Redis 等外部系统。因此它回答的是“引擎在特定 Driver 下的能力”，不是完整生产链路的端到端容量。若性能声明包含消息队列和 Sink，它们就必须重新进入 SUT 边界。</p>
<p>其次，论文作者把以下问题留作后续工作：</p>
<ul>
<li>Exactly-once 的性能代价；</li>
<li>Out-of-order 与 Late Event；</li>
<li>故障、恢复与状态一致性；</li>
<li>更丰富的并发查询组合。</li>
</ul>
<p>最后，这是 2018 年特定版本与特定硬件上的结果，不能用来给今天的产品排名。我的进一步判断是：它对跨独立集群运行的不确定性讨论也弱于前两篇论文。因此，现代流系统 Benchmark 应把 2007/2013 年的独立重复、分层方差和效应量区间重新加到 2018 年的正确测量边界之上。</p>
<h3 id="14-三篇论文真正连起来的主线">1.4 三篇论文真正连起来的主线</h3>
<p>三篇论文分别强调统计、实验预算和系统语义，但最终可以收敛成四个连续问题：</p>
<table>
	<thead>
			<tr>
					<th>顺序</th>
					<th>必须回答的问题</th>
					<th>如果答错会怎样</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>1</td>
					<td>我想证明什么：冷启动、稳定态、单方法开销、端到端延迟，还是可持续吞吐？</td>
					<td>用正确工具回答错误问题</td>
			</tr>
			<tr>
					<td>2</td>
					<td>哪个才是独立实验单位：调用、Iteration、Fork、Build，还是完整 Cluster Run？</td>
					<td>伪造巨大样本量，低估不确定性</td>
			</tr>
			<tr>
					<td>3</td>
					<td>主要随机性和系统偏差来自哪里？</td>
					<td>在错误层级重复，或把时间漂移当代码差异</td>
			</tr>
			<tr>
					<td>4</td>
					<td>差异有多大、区间多宽、是否达到工程意义？</td>
					<td>把“统计上可区分”误写成“工程上值得”</td>
			</tr>
	</tbody>
</table>
<p>第一篇论文解决了第 2、4 个问题的基础；第二篇把第 2、3、4 个问题做成有限预算下的方法；第三篇则提醒我们，第 1 个问题一旦错了，后面的严谨统计没有意义。</p>
<h2 id="二jmh-怎样实现以及四个典型使用场景">二、JMH 怎样实现，以及四个典型使用场景</h2>
<p>JMH 是 OpenJDK 提供的 JVM Benchmark Harness。它最擅长测量单 JVM 内的 nano、micro、milli 级代码路径，也可以承载较大的方法级测试。</p>
<p>它能帮助解决：</p>
<ul>
<li>方法调用循环与计时；</li>
<li>JVM Fork、Warmup、Measurement；</li>
<li>多线程协同与 State 生命周期；</li>
<li>死代码消除、返回值消费；</li>
<li>参数组合与结果输出。</li>
</ul>
<p>它不能自动解决：</p>
<ul>
<li>被测代码是否代表真实业务；</li>
<li>Warmup 是否真的达到稳定且独立的状态；</li>
<li>多个 Iteration 是否可以当作独立实验；</li>
<li>baseline 与 candidate 是否受到机器漂移和执行顺序影响；</li>
<li>微基准改善能否转化成端到端吞吐或延迟改善。</li>
</ul>
<p>因此，JMH 是严谨实验的执行工具，不是结论生成器。</p>
<h3 id="21-jmh-不是用反射调用一次方法而是生成专用-harness">2.1 JMH 不是用反射调用一次方法，而是生成专用 Harness</h3>
<p>JMH 的关键实现位于官方仓库的几个部分：</p>
<ul>
<li><code>jmh-generator-annprocess</code> 中的 <code>BenchmarkProcessor</code> 是注解处理器；</li>
<li><code>BenchmarkGenerator</code> 根据 <code>@Benchmark</code>、<code>@State</code>、<code>@Setup</code> 等生成测试代码；</li>
<li><code>Runner</code> 解析配置并启动 Fork；</li>
<li><code>ForkedMain</code> 是被 Fork JVM 的入口；</li>
<li><code>ThroughputResult</code>、<code>AverageTimeResult</code> 等将操作数和时间转换成 Score。</li>
</ul>
<p>整体流程如下：</p>
<div class="mermaid">flowchart TB
    A["编写 @Benchmark 与 @State"] --> B["编译期注解处理器"]
    B --> C["生成专用 Harness<br/>以及 BenchmarkList"]
    C --> D["Host Runner 解析参数"]
    D --> E["启动独立 Forked JVM"]
    E --> F["执行 Warmup Iterations"]
    F --> G["执行 Measurement Iterations"]
    G --> H["聚合线程与 Iteration 结果<br/>输出文本或 JSON"]
</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#66d9ef">while</span> (control.<span style="color:#a6e22e">warmupShouldWait</span>) {
</span></span><span style="display:flex;"><span>    benchmark(state);
</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">long</span> start <span style="color:#f92672">=</span> System.<span style="color:#a6e22e">nanoTime</span>();
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">long</span> operations <span style="color:#f92672">=</span> 0;
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">do</span> {
</span></span><span style="display:flex;"><span>    blackhole.<span style="color:#a6e22e">consume</span>(benchmark(state));
</span></span><span style="display:flex;"><span>    operations<span style="color:#f92672">++</span>;
</span></span><span style="display:flex;"><span>} <span style="color:#66d9ef">while</span> (<span style="color:#f92672">!</span>control.<span style="color:#a6e22e">isDone</span>);
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">long</span> elapsed <span style="color:#f92672">=</span> System.<span style="color:#a6e22e">nanoTime</span>() <span style="color:#f92672">-</span> start;
</span></span></code></pre></div><p>真实生成代码还会处理：</p>
<ul>
<li>多线程开始与结束屏障；</li>
<li><code>@Setup</code>、<code>@TearDown</code> 的 Trial、Iteration、Invocation 生命周期；</li>
<li><code>@OperationsPerInvocation</code> 与 Batch Size；</li>
<li>异常传播；</li>
<li>AuxCounters；</li>
<li>不同 Benchmark Mode 的专用循环。</li>
</ul>
<p>这种编译期生成有两个价值。</p>
<p>第一，测试方法位于紧凑循环内，不需要每次通过反射调用。第二，JMH 可以根据声明提前生成 State 获取、返回值消费和生命周期代码，并在编译期发现一部分非法组合。</p>
<p>如果 <code>@Benchmark</code> 返回非 <code>void</code> 值，生成代码会把返回值交给 <code>Blackhole.consume(...)</code>。所以最简单的防止死代码消除方式通常不是在业务代码里手写 Blackhole，而是直接返回计算结果。</p>
<h3 id="22-forkwarmupmeasurement-分别代表什么">2.2 Fork、Warmup、Measurement 分别代表什么</h3>
<p>一次典型 JMH 运行可以这样理解：</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>一个 Benchmark 参数组合
</span></span><span style="display:flex;"><span>  ├─ Fork 1：全新的 JVM
</span></span><span style="display:flex;"><span>  │    ├─ Warmup Iteration 1..W
</span></span><span style="display:flex;"><span>  │    └─ Measurement Iteration 1..M
</span></span><span style="display:flex;"><span>  ├─ Fork 2：全新的 JVM
</span></span><span style="display:flex;"><span>  │    ├─ Warmup Iteration 1..W
</span></span><span style="display:flex;"><span>  │    └─ Measurement Iteration 1..M
</span></span><span style="display:flex;"><span>  └─ Fork F：...
</span></span></code></pre></div><ul>
<li>Fork：新的 JVM 进程，拥有新的 JIT Profile、堆、地址空间和运行时历史；</li>
<li>Warmup Iteration：执行负载但不进入最终 Score，用于把 JVM 推到目标生命周期位置；</li>
<li>Measurement Iteration：在固定时间窗口内反复调用 Benchmark 方法并形成一个 Iteration Score；</li>
<li>Invocation：Benchmark 方法的一次调用，通常数量极大，但不是独立 JVM 实验。</li>
</ul>
<p>JMH 的四种常用 Mode 回答不同问题：</p>
<table>
	<thead>
			<tr>
					<th>Mode</th>
					<th>Score 含义</th>
					<th>适合场景</th>
					<th>主要风险</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Throughput</td>
					<td>单位时间完成的操作数</td>
					<td>队列、计数器、解析器的处理能力</td>
					<td>高吞吐不代表低尾延迟</td>
			</tr>
			<tr>
					<td>AverageTime</td>
					<td>每次操作的平均时间</td>
					<td>稳定热路径的平均成本</td>
					<td>平均值会隐藏长尾</td>
			</tr>
			<tr>
					<td>SampleTime</td>
					<td>对部分调用采样并形成分布</td>
					<td>观察方法级延迟分布</td>
					<td>仍不是外部固定到达率下的系统延迟</td>
			</tr>
			<tr>
					<td>SingleShotTime</td>
					<td>单次调用或一个 Batch 的时间</td>
					<td>冷启动、一次性初始化、较长操作</td>
					<td>必须通过多个 Fork 获得独立重复</td>
			</tr>
	</tbody>
</table>
<p>Throughput 与 AverageTime 的核心换算非常简单：</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>Throughput  = N / T
</span></span><span style="display:flex;"><span>AverageTime = T / N
</span></span></code></pre></div><p>其中：</p>
<ul>
<li><code>N</code> 是 Measurement 时间内完成的操作数；</li>
<li><code>T</code> 是 Measurement 的有效计时时间；</li>
<li>多线程 Throughput 会先聚合线程结果；</li>
<li><code>@OperationsPerInvocation</code> 会改变一次 Benchmark invocation 代表的逻辑操作数。</li>
</ul>
<p>不要只因为 <code>ns/op</code> 看起来直观，就默认选择 AverageTime；Mode 必须服从问题。如果想知道 8 个线程争用一个计数器时每秒能完成多少次更新，Throughput 更自然。如果想测一次冷初始化，应该考虑 <code>SingleShotTime</code>、关闭 Warmup，并增加独立 Fork。</p>
<h3 id="23-建立项目运行与读懂输出">2.3 建立项目、运行与读懂输出</h3>
<p>JMH 官方推荐使用独立 Maven Benchmark 模块，而不是像 JUnit 一样只添加一个依赖后直接从 IDE 运行。下面使用当前稳定版 1.37 创建项目：</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-bash" data-lang="bash"><span style="display:flex;"><span>mvn archetype:generate <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -DinteractiveMode<span style="color:#f92672">=</span>false <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -DarchetypeGroupId<span style="color:#f92672">=</span>org.openjdk.jmh <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -DarchetypeArtifactId<span style="color:#f92672">=</span>jmh-java-benchmark-archetype <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -DarchetypeVersion<span style="color:#f92672">=</span>1.37 <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -DgroupId<span style="color:#f92672">=</span>org.example <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -DartifactId<span style="color:#f92672">=</span>benchmark <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -Dversion<span style="color:#f92672">=</span>1.0
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>cd benchmark
</span></span><span style="display:flex;"><span>mvn clean verify
</span></span><span style="display:flex;"><span>java -jar target/benchmarks.jar -l
</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-bash" data-lang="bash"><span style="display:flex;"><span>java -jar target/benchmarks.jar <span style="color:#e6db74">&#39;.*HashBenchmark.*&#39;</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -f <span style="color:#ae81ff">5</span> <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -wi <span style="color:#ae81ff">5</span> -w 1s <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -i <span style="color:#ae81ff">10</span> -r 1s <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -rf json <span style="color:#ae81ff">\
</span></span></span><span style="display:flex;"><span>  -rff target/jmh-result.json
</span></span></code></pre></div><p>参数含义如下：</p>
<table>
	<thead>
			<tr>
					<th>参数</th>
					<th>含义</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>-f 5</code></td>
					<td>运行 5 个独立 Fork</td>
			</tr>
			<tr>
					<td><code>-wi 5</code></td>
					<td>每个 Fork 运行 5 个 Warmup Iteration</td>
			</tr>
			<tr>
					<td><code>-w 1s</code></td>
					<td>每个 Warmup Iteration 运行 1 秒</td>
			</tr>
			<tr>
					<td><code>-i 10</code></td>
					<td>每个 Fork 运行 10 个 Measurement Iteration</td>
			</tr>
			<tr>
					<td><code>-r 1s</code></td>
					<td>每个 Measurement Iteration 运行 1 秒</td>
			</tr>
			<tr>
					<td><code>-rf json</code></td>
					<td>输出机器可读 JSON</td>
			</tr>
			<tr>
					<td><code>-rff ...</code></td>
					<td>指定结果文件</td>
			</tr>
	</tbody>
</table>
<p>这些数字只是第一次观察时间序列和方差的起点，不是三篇论文推导出的通用最优值。正式比较前，应根据 Benchmark、JDK、机器与目标精度调整。</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>Benchmark          Mode  Cnt   Score   Error  Units
</span></span><span style="display:flex;"><span>HashBenchmark.mix  avgt   50  37.210 ± 0.880  ns/op
</span></span></code></pre></div><p>这是一行教学输出，不是本文实测结论：</p>
<ul>
<li><code>Mode=avgt</code> 表示 AverageTime；</li>
<li><code>Cnt=50</code> 通常来自 5 Fork × 10 Measurement Iteration，不是只调用了 50 次；</li>
<li><code>Score=37.210 ns/op</code> 是聚合后的平均操作时间；</li>
<li><code>Error=0.880</code> 是 JMH 展示的 99.9% 均值误差范围；</li>
<li>JMH 的扩展输出会明确写出该区间假设正态分布。</li>
</ul>
<p>这里必须保持克制：JMH 展示的 <code>Score ± Error</code> 不是 baseline 与 candidate 的效应量区间，也不会证明 50 个 Iteration 全部独立。做版本回归判断时，应保存 JSON 原始结果，查看每个 Fork 的分布，并计算 candidate/baseline 的变化幅度与区间。</p>
<h3 id="24-场景一纯计算防止死代码消除与常量折叠">2.4 场景一：纯计算——防止死代码消除与常量折叠</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#f92672">package</span> org.example;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> java.util.concurrent.TimeUnit;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Benchmark;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.BenchmarkMode;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Mode;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.OutputTimeUnit;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Param;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Scope;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.State;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@State</span>(Scope.<span style="color:#a6e22e">Thread</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@BenchmarkMode</span>(Mode.<span style="color:#a6e22e">AverageTime</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@OutputTimeUnit</span>(TimeUnit.<span style="color:#a6e22e">NANOSECONDS</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">HashBenchmark</span> {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">@Param</span>({<span style="color:#e6db74">&#34;7&#34;</span>, <span style="color:#e6db74">&#34;42&#34;</span>})
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> seed;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">@Param</span>({<span style="color:#e6db74">&#34;64&#34;</span>, <span style="color:#e6db74">&#34;1024&#34;</span>})
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> rounds;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">@Benchmark</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> <span style="color:#a6e22e">mix</span>() {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">int</span> value <span style="color:#f92672">=</span> seed;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">int</span> i <span style="color:#f92672">=</span> 0; i <span style="color:#f92672">&lt;</span> rounds; i<span style="color:#f92672">++</span>) {
</span></span><span style="display:flex;"><span>            value <span style="color:#f92672">=</span> Integer.<span style="color:#a6e22e">rotateLeft</span>(value <span style="color:#f92672">*</span> 31 <span style="color:#f92672">+</span> i, 5);
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> value;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>这里有三个关键点：</p>
<ol>
<li><code>seed</code> 和 <code>rounds</code> 来自非 final 的 State 字段，避免把整个输入写成编译期常量；</li>
<li>方法返回结果，JMH 生成代码会消费它，降低整个计算被当作无用代码删除的风险；</li>
<li><code>Scope.Thread</code> 让每个线程拥有自己的 State，避免无意中测到共享字段争用。</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#a6e22e">@Benchmark</span>
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> <span style="color:#a6e22e">wrong</span>() {
</span></span><span style="display:flex;"><span>    mixConstant(42, 1024);
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>输入固定、结果又没有被使用，JIT 可能进行常量折叠或死代码消除。最后测到的可能只是空 Harness 的成本。</p>
<p>这个场景适合纯算法、编码、哈希、序列化内部函数等 CPU 热路径。但如果生产输入有不同长度、字符集或分支分布，就必须用 <code>@Param</code> 或预生成数据覆盖这些形态，不能只测最容易优化的一种输入。</p>
<h3 id="25-场景二数据结构用-state-与生命周期隔离准备成本">2.5 场景二：数据结构——用 State 与生命周期隔离准备成本</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-java" data-lang="java"><span style="display:flex;"><span><span style="color:#f92672">package</span> org.example;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> java.util.Arrays;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> java.util.concurrent.TimeUnit;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Benchmark;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.BenchmarkMode;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Level;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Mode;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.OutputTimeUnit;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Param;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Scope;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Setup;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.State;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@State</span>(Scope.<span style="color:#a6e22e">Thread</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@BenchmarkMode</span>(Mode.<span style="color:#a6e22e">AverageTime</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@OutputTimeUnit</span>(TimeUnit.<span style="color:#a6e22e">NANOSECONDS</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">LookupBenchmark</span> {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">@Param</span>({<span style="color:#e6db74">&#34;16&#34;</span>, <span style="color:#e6db74">&#34;1024&#34;</span>, <span style="color:#e6db74">&#34;65536&#34;</span>})
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> size;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">int</span><span style="color:#f92672">[]</span> values;
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">int</span> key;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">@Setup</span>(Level.<span style="color:#a6e22e">Trial</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> <span style="color:#a6e22e">prepareDataset</span>() {
</span></span><span style="display:flex;"><span>        values <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> <span style="color:#66d9ef">int</span><span style="color:#f92672">[</span>size<span style="color:#f92672">]</span>;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">int</span> i <span style="color:#f92672">=</span> 0; i <span style="color:#f92672">&lt;</span> size; i<span style="color:#f92672">++</span>) {
</span></span><span style="display:flex;"><span>            values<span style="color:#f92672">[</span>i<span style="color:#f92672">]</span> <span style="color:#f92672">=</span> i <span style="color:#f92672">*</span> 2;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>        key <span style="color:#f92672">=</span> values<span style="color:#f92672">[</span>size <span style="color:#f92672">-</span> 1<span style="color:#f92672">]</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:#a6e22e">@Benchmark</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> <span style="color:#a6e22e">linearSearch</span>() {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">int</span> i <span style="color:#f92672">=</span> 0; i <span style="color:#f92672">&lt;</span> values.<span style="color:#a6e22e">length</span>; i<span style="color:#f92672">++</span>) {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">if</span> (values<span style="color:#f92672">[</span>i<span style="color:#f92672">]</span> <span style="color:#f92672">==</span> key) {
</span></span><span style="display:flex;"><span>                <span style="color:#66d9ef">return</span> i;
</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">return</span> <span style="color:#f92672">-</span>1;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">@Benchmark</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> <span style="color:#a6e22e">binarySearch</span>() {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">return</span> Arrays.<span style="color:#a6e22e">binarySearch</span>(values, key);
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p><code>@Setup(Level.Trial)</code> 在一个 Benchmark Trial 开始时准备一次数组，数组构造不进入查询的计时结果。这样测到的是“已有数据结构上的查找成本”。</p>
<p>如果真正的问题是“构造并查询一次需要多久”，那就应该另写一个 Benchmark，把构造放进 <code>@Benchmark</code>。不要为了得到更小的数字，把生产路径必需的工作偷偷移到 Setup。</p>
<p>三个常见生命周期分别是：</p>
<table>
	<thead>
			<tr>
					<th>Level</th>
					<th>调用时机</th>
					<th>适合内容</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Trial</td>
					<td>每个完整 Benchmark Trial 一次</td>
					<td>大数据集、连接、共享结构的初始化</td>
			</tr>
			<tr>
					<td>Iteration</td>
					<td>每个 Warmup/Measurement Iteration 一次</td>
					<td>每个时间窗口要重置的计数或批次状态</td>
			</tr>
			<tr>
					<td>Invocation</td>
					<td>每次 Benchmark 调用前后</td>
					<td>必须逐操作恢复的状态，谨慎使用</td>
			</tr>
	</tbody>
</table>
<p><code>Level.Invocation</code> 的 Setup 时间可以从有效计时中扣除，但它仍会改变缓存、分支预测和对象状态，而且每次调用都有 Harness 成本。除非被测语义确实要求逐次重置，否则优先批量准备输入或使用多个预生成样本。</p>
<h3 id="26-场景三并发争用scope-选错测量问题就变了">2.6 场景三：并发争用——Scope 选错，测量问题就变了</h3>
<p>下面比较共享 <code>AtomicLong</code> 与 <code>LongAdder</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:#f92672">package</span> org.example;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> java.util.concurrent.TimeUnit;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> java.util.concurrent.atomic.AtomicLong;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> java.util.concurrent.atomic.LongAdder;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Benchmark;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.BenchmarkMode;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Mode;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.OutputTimeUnit;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Scope;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.State;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@State</span>(Scope.<span style="color:#a6e22e">Benchmark</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@BenchmarkMode</span>(Mode.<span style="color:#a6e22e">Throughput</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@OutputTimeUnit</span>(TimeUnit.<span style="color:#a6e22e">SECONDS</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ContendedCounterBenchmark</span> {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">final</span> AtomicLong atomic <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> AtomicLong();
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">final</span> LongAdder adder <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> LongAdder();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">@Benchmark</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> <span style="color:#a6e22e">atomicIncrement</span>() {
</span></span><span style="display:flex;"><span>        atomic.<span style="color:#a6e22e">incrementAndGet</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:#a6e22e">@Benchmark</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> <span style="color:#a6e22e">adderIncrement</span>() {
</span></span><span style="display:flex;"><span>        adder.<span style="color:#a6e22e">increment</span>();
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</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-bash" data-lang="bash"><span style="display:flex;"><span>java -jar target/benchmarks.jar ContendedCounterBenchmark -t <span style="color:#ae81ff">1</span> -f <span style="color:#ae81ff">5</span>
</span></span><span style="display:flex;"><span>java -jar target/benchmarks.jar ContendedCounterBenchmark -t <span style="color:#ae81ff">8</span> -f <span style="color:#ae81ff">5</span>
</span></span></code></pre></div><p><code>Scope.Benchmark</code> 使同一个 Trial 中的所有工作线程共享一个 State，因此多线程运行确实会争用同一计数器。如果改成 <code>Scope.Thread</code>，8 个线程会更新 8 份独立 State，测到的是并行独享吞吐，而不是共享争用。</p>
<p>这个例子还有一个比性能更重要的语义提醒：</p>
<ul>
<li><code>AtomicLong.incrementAndGet()</code> 可以为每次更新提供一个线性化后的返回值；</li>
<li><code>LongAdder</code> 通过分散竞争提高高并发更新能力，但读取总和不是同一种强一致语义。</li>
</ul>
<p>因此，不能根据 LongAdder 的 ops/s 更高就宣布它“全面替代” AtomicLong。Benchmark 只有在候选实现满足同一业务契约时，才有比较意义。</p>
<h3 id="27-场景四快慢路径用-auxcounters-解释主-score">2.7 场景四：快慢路径——用 AuxCounters 解释主 Score</h3>
<p>平均吞吐可能掩盖输入分布变化。下面让 0%、1% 或 10% 的输入触发异常解析路径，并用 AuxCounters 同时报告成功与失败速率：</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:#f92672">package</span> org.example;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> java.util.concurrent.TimeUnit;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.AuxCounters;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Benchmark;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.BenchmarkMode;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Level;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Mode;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.OutputTimeUnit;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Param;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Scope;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.Setup;
</span></span><span style="display:flex;"><span><span style="color:#f92672">import</span> org.openjdk.jmh.annotations.State;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@BenchmarkMode</span>(Mode.<span style="color:#a6e22e">Throughput</span>)
</span></span><span style="display:flex;"><span><span style="color:#a6e22e">@OutputTimeUnit</span>(TimeUnit.<span style="color:#a6e22e">SECONDS</span>)
</span></span><span style="display:flex;"><span><span style="color:#66d9ef">public</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">ParserBenchmark</span> {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">@State</span>(Scope.<span style="color:#a6e22e">Thread</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Inputs</span> {
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">@Param</span>({<span style="color:#e6db74">&#34;0&#34;</span>, <span style="color:#e6db74">&#34;1&#34;</span>, <span style="color:#e6db74">&#34;10&#34;</span>})
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> invalidPercent;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> String<span style="color:#f92672">[]</span> values;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">private</span> <span style="color:#66d9ef">int</span> cursor;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>        <span style="color:#a6e22e">@Setup</span>(Level.<span style="color:#a6e22e">Trial</span>)
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">void</span> <span style="color:#a6e22e">prepare</span>() {
</span></span><span style="display:flex;"><span>            values <span style="color:#f92672">=</span> <span style="color:#66d9ef">new</span> String<span style="color:#f92672">[</span>1024<span style="color:#f92672">]</span>;
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">for</span> (<span style="color:#66d9ef">int</span> i <span style="color:#f92672">=</span> 0; i <span style="color:#f92672">&lt;</span> values.<span style="color:#a6e22e">length</span>; i<span style="color:#f92672">++</span>) {
</span></span><span style="display:flex;"><span>                values<span style="color:#f92672">[</span>i<span style="color:#f92672">]</span> <span style="color:#f92672">=</span> i <span style="color:#f92672">%</span> 100 <span style="color:#f92672">&lt;</span> invalidPercent
</span></span><span style="display:flex;"><span>                        <span style="color:#f92672">?</span> <span style="color:#e6db74">&#34;invalid&#34;</span>
</span></span><span style="display:flex;"><span>                        : Integer.<span style="color:#a6e22e">toString</span>(i);
</span></span><span style="display:flex;"><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">public</span> String <span style="color:#a6e22e">next</span>() {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> values<span style="color:#f92672">[</span>cursor<span style="color:#f92672">++</span> <span style="color:#f92672">&amp;</span> (values.<span style="color:#a6e22e">length</span> <span style="color:#f92672">-</span> 1)<span style="color:#f92672">]</span>;
</span></span><span style="display:flex;"><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:#a6e22e">@State</span>(Scope.<span style="color:#a6e22e">Thread</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">@AuxCounters</span>(AuxCounters.<span style="color:#a6e22e">Type</span>.<span style="color:#a6e22e">OPERATIONS</span>)
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">static</span> <span style="color:#66d9ef">class</span> <span style="color:#a6e22e">Outcomes</span> {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">long</span> valid;
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">long</span> invalid;
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>    <span style="color:#a6e22e">@Benchmark</span>
</span></span><span style="display:flex;"><span>    <span style="color:#66d9ef">public</span> <span style="color:#66d9ef">int</span> <span style="color:#a6e22e">parse</span>(Inputs inputs, Outcomes outcomes) {
</span></span><span style="display:flex;"><span>        <span style="color:#66d9ef">try</span> {
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">int</span> value <span style="color:#f92672">=</span> Integer.<span style="color:#a6e22e">parseInt</span>(inputs.<span style="color:#a6e22e">next</span>());
</span></span><span style="display:flex;"><span>            outcomes.<span style="color:#a6e22e">valid</span><span style="color:#f92672">++</span>;
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> value;
</span></span><span style="display:flex;"><span>        } <span style="color:#66d9ef">catch</span> (NumberFormatException e) {
</span></span><span style="display:flex;"><span>            outcomes.<span style="color:#a6e22e">invalid</span><span style="color:#f92672">++</span>;
</span></span><span style="display:flex;"><span>            <span style="color:#66d9ef">return</span> <span style="color:#f92672">-</span>1;
</span></span><span style="display:flex;"><span>        }
</span></span><span style="display:flex;"><span>    }
</span></span><span style="display:flex;"><span>}
</span></span></code></pre></div><p>运行后，除了主 Benchmark 的总吞吐，还会看到 <code>valid</code> 与 <code>invalid</code> 两个 Secondary Result。这样可以回答：</p>
<ul>
<li>总吞吐下降是不是因为失败比例提高；</li>
<li>慢路径究竟占多少操作；</li>
<li>两个版本是否处理了相同输入分布。</li>
</ul>
<p><code>@AuxCounters</code> 的边界必须讲清楚：</p>
<ul>
<li>它只能标注 <code>Scope.Thread</code> 的 State；</li>
<li>只有 public 数值字段或返回数值的 public 方法会成为指标；</li>
<li>public 字段会在每个 Iteration 开始前重置，并在结束时读取；</li>
<li><code>Type.OPERATIONS</code> 会按时间归一化，适合成功/失败吞吐；</li>
<li><code>Type.EVENTS</code> 报告未按时间归一化的事件总数；</li>
<li>它是实验性 API，适合解释分支构成，不应代替主性能指标与正确性断言。</li>
</ul>
<h3 id="28-怎样从-jmh-输出得到版本结论">2.8 怎样从 JMH 输出得到版本结论</h3>
<p>假设 AverageTime 模式下定义：</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>R = candidate time / baseline time
</span></span></code></pre></div><p>其中：</p>
<ul>
<li><code>R &lt; 1</code> 表示 candidate 更快；</li>
<li><code>R &gt; 1</code> 表示 candidate 更慢；</li>
<li><code>R = 1</code> 表示两者平均时间相同。</li>
</ul>
<p>正式比较至少应做到：</p>
<ol>
<li>使用相同硬件、JDK、JVM 参数和 Benchmark 参数；</li>
<li>运行多个 Fork，保留每个 Fork/Iteration 的 JSON 结果；</li>
<li>交替或随机化 baseline 与 candidate 的执行顺序，避免时间漂移总偏向一方；</li>
<li>报告 <code>R</code> 的区间，而不是只比较两行 Score；</li>
<li>预先定义工程阈值，例如小于 1% 的变化不做优化结论；</li>
<li>同时检查正确性、分配率、GC 与其他可能被转移的成本。</li>
</ol>
<p>以“1% 是最小关注变化”为例：</p>
<table>
	<thead>
			<tr>
					<th>比值区间</th>
					<th>合理结论</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>整个区间低于 0.99</td>
					<td>有证据表明 candidate 达到关注阈值以上的改善</td>
			</tr>
			<tr>
					<td>整个区间高于 1.01</td>
					<td>有证据表明出现达到关注阈值以上的回归</td>
			</tr>
			<tr>
					<td>整个区间位于 0.99～1.01</td>
					<td>在当前精度与阈值下可认为工程上近似等价</td>
			</tr>
			<tr>
					<td>区间跨越这些边界</td>
					<td>证据不足，应增加独立重复或检查噪声来源</td>
			</tr>
	</tbody>
</table>
<p>如果使用 Throughput，方向要反过来定义，或者改为 <code>baseline throughput / candidate throughput</code>，让“小于 1 表示改善”的语义保持一致。无论怎样定义，都必须在报告中写清分子、分母和“越大越好还是越小越好”。</p>
<p>JMH 的 profiler 可以帮助解释结果：</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-bash" data-lang="bash"><span style="display:flex;"><span>java -jar target/benchmarks.jar LookupBenchmark -prof gc
</span></span></code></pre></div><p>例如一个实现 ns/op 下降，却显著增加 <code>gc.alloc.rate.norm</code>，它可能只是把 CPU 成本换成了分配和 GC 成本。Profiler 是归因线索，不是自动因果证明；发现异常后仍需要结合代码、汇编、硬件计数器或更高层测试验证。</p>
<h2 id="三总结与感悟">三、总结与感悟</h2>
<p>读完三篇论文再回看 JMH，我最大的感受是：性能测试的难点从来不是让程序多执行几遍，而是知道每一个数字究竟代表什么。</p>
<p>第一篇论文最重要的提醒是，不要把偶然的最好值当作能力，也不要把同一 JVM 中的大量调用当作大量独立证据。Java 的动态运行时决定了 Fork 层级不可忽略。</p>
<p>第二篇论文进一步说明，严谨并不等于无限增加运行时间。真正有效的方法是先找出方差来自 Build、Fork 还是 Iteration，再把重复预算花到正确层级。跑得更多不一定知道得更多；在错误层级重复，只会更精确地低估不确定性。</p>
<p>第三篇论文把视线从统计拉回系统语义。一个指标可以测得非常稳定，却从错误的时间点开始计时。流处理系统尤其如此：如果没有固定到达率、外部时间戳、backlog 与 Event-time Latency，背压完全可能把容量不足伪装成低延迟。</p>
<p>JMH 的价值，是把 JVM 内部微基准中最容易犯的一批 Harness 错误标准化处理：生成专用循环、启动 Fork、组织 Warmup 与 Measurement、管理 State、消费返回值、输出机器可读结果。但它无法替我们决定测量边界，也不会自动证明 Warmup 已经稳定，更不会把一个 5 ns 的方法级改善自动转换成系统吞吐提升。</p>
<p>因此，我现在更愿意用下面这句话定义一份好的 Benchmark：</p>
<blockquote>
<p>它不是给出一个漂亮数字，而是让别人能够明确知道这个数字在什么条件下成立、误差有多大、什么证据可以推翻它。</p>
</blockquote>
<p>真正开始写 Benchmark 之前，可以先写出性能声明：</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>在固定的硬件、JDK、参数和输入分布下，
</span></span><span style="display:flex;"><span>candidate 相对 baseline 改善哪一个指标，
</span></span><span style="display:flex;"><span>改善幅度至少是多少，
</span></span><span style="display:flex;"><span>并且没有破坏哪一种正确性与资源约束。
</span></span></code></pre></div><p>如果这句话写不清楚，代码暂时也不用急着写。因为 Benchmark 的顺序应该是：</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></code></pre></div><p>而不是先得到一个数字，再回头为它寻找故事。</p>
<h3 id="参考资料">参考资料</h3>
<ol>
<li>Andy Georges, Dries Buytaert, Lieven Eeckhout, <a href="https://dri.es/files/oopsla07-georges.pdf">Statistically Rigorous Java Performance Evaluation</a>, OOPSLA 2007.</li>
<li>Tomas Kalibera, Richard Jones, <a href="https://dl.acm.org/doi/10.1145/2491894.2464160">Rigorous Benchmarking in Reasonable Time</a>, ISMM 2013；<a href="https://www.researchgate.net/publication/257193308_Rigorous_Benchmarking_in_Reasonable_Time">可访问全文</a>.</li>
<li>Jeyhun Karimov et al., <a href="https://arxiv.org/pdf/1802.08496">Benchmarking Distributed Stream Data Processing Systems</a>, ICDE 2018.</li>
<li>OpenJDK, <a href="https://github.com/openjdk/jmh">Java Microbenchmark Harness</a>.</li>
<li>OpenJDK JMH, <a href="https://github.com/openjdk/jmh/tree/master/jmh-samples/src/main/java/org/openjdk/jmh/samples">官方示例目录</a>.</li>
<li>OpenJDK JMH 源码：<a href="https://github.com/openjdk/jmh/blob/master/jmh-generator-annprocess/src/main/java/org/openjdk/jmh/generators/BenchmarkProcessor.java">BenchmarkProcessor</a>、<a href="https://github.com/openjdk/jmh/blob/master/jmh-core/src/main/java/org/openjdk/jmh/generators/core/BenchmarkGenerator.java">BenchmarkGenerator</a>、<a href="https://github.com/openjdk/jmh/blob/master/jmh-core/src/main/java/org/openjdk/jmh/runner/Runner.java">Runner</a>、<a href="https://github.com/openjdk/jmh/blob/master/jmh-core/src/main/java/org/openjdk/jmh/results/Result.java">Result</a>.</li>
</ol>
]]></content:encoded></item><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>