作者 | Niu Zhiwei
图 1:同一轮运行中的 JMH 汇总,包括完整 Pipeline 和 SeaTunnelRow 热点操作
图 2:五条 Pipeline 在相同负载下的吞吐、延迟、增长比例与有效样本
一、先读懂这组结果
这轮测试使用 Java 8、4 GiB 堆、4 个 JVM 可见处理器和 4 个 Pipeline 并行度;每次作业处理 1,000,000 行,计划输入速率为 600,000 行/秒,Payload 为 256 个字符。
结果可以压缩成三点:
- 五个场景的 Sink 吞吐都在 58.8 万到 59.1 万行/秒,P99 为 100 到 101 ms,Growth 为 0.99 到 1.00,75/75 个样本全部有效,说明当前负载下没有持续积累 backlog;
- Pipeline JMH Score 的最大差距约为 0.87%,而本轮 Error 为 0.88% 到 1.38%,因此只能说没有观察到可区分的功能开销,不能说某个场景更快或"零开销";
- 60 万行/秒是本轮固定负载,不是容量上限。真正的容量评估还需要提高输入速率并持续观察吞吐、P99 和延迟增长。
因此,这组数字首先证明的是基准链路完整、当前负载可持续,并且能够对不同引擎能力进行同口径比较,而不是给 Zeta 下一个脱离环境的绝对性能结论。
二、为什么要同时做微基准和完整 Pipeline
SeaTunnel 的基准测试分为两层,两者回答的问题不同。
SeaTunnelRow 微基准:定位热点
SeaTunnelRowBenchmark 关注 Source、Transform 和 Sink 热路径中的基础操作,包括 Row 创建、字段读取、复制、投影、Options 和大小计算。
| 操作 | Score(ops/ms) | CV | 主要含义 |
|---|---|---|---|
copyPlainRow |
22,328.74 | 1.01% | 完整复制普通 Row |
copyProjectedPlainRow |
59,769.89 | 1.57% | 只复制 8 个字段中的 4 个投影字段 |
copyProjectedRowWithOptions |
49,282.32 | 0.59% | 投影复制,同时处理 Options |
copyRowWithOptions |
20,789.92 | 0.22% | 完整复制带 Options 的 Row |
copyRowWithTracePayload |
20,499.68 | 0.27% | 完整复制带 Trace Payload 的 Row |
copyThenMutateCopiedOptions |
7,305.63 | 1.01% | 复制后修改 Options |
createRowAndGetBytesSize |
5,837.52 | 0.68% | 创建 Row 并计算大小 |
createRowWithSetField |
7,668.90 | 0.59% | 逐字段构建 Row |
getBytesSizeCached |
254,677.57 | 1.13% | 读取已经缓存的大小 |
readFields |
28,347.47 | 0.47% | 读取并消费全部字段 |
ops/ms 表示每毫秒完成多少次操作,越大越好。例如 copyPlainRow 的 22,328.74 ops/ms 约等于每秒 2,233 万次操作。
这些数字主要用于同一方法跨版本比较,或者比较语义相近的操作。不能因为 getBytesSizeCached 比 createRowAndGetBytesSize 高很多,就直接得出某段完整业务链路快了相同比例:前者只是读取缓存,后者同时包含对象创建和首次大小计算,测量对象并不相同。
在语义接近的复制场景中可以看到,带 Options 的完整复制比普通复制低约 6.9%,加入 Trace Payload 后比普通复制低约 8.2%。这类差异能够提示优化方向,但最终是否影响真实任务,仍要回到完整 Pipeline 验证。
Zeta Pipeline 基准:验证整体效果
完整 Pipeline 启动真实的单节点 Zeta Master 和 Worker,并通过正常的 Client、配置解析、任务提交和调度链路执行有界作业。
集群在 Trial 的 Setup 阶段启动,不进入计时区间。每次 Benchmark invocation 都会提交一个包含 1,000,000 行数据的完整作业,作业配置生成、提交、调度、Source、可选 Transform、Sink 和完成等待都在测量范围内。
这种设计比只调用一个内部方法更接近真实引擎,又通过内存 Source 和黑洞 Sink 去掉了外部系统波动,适合回答"引擎核心链路是否发生变化"。
三、设计背后的理论依据
基准测试的可信度包含两个层面:一是结果是否具有统计上的可靠性,二是指标是否真正测量了我们关心的性能问题。前者要求正确处理 JVM 波动、独立样本和实验误差,后者要求明确吞吐与延迟的测量边界。
这套设计主要参考了三项性能评估研究:
| 研究 | 核心问题 | 主要结论 |
|---|---|---|
| Statistically Rigorous Java Performance Evaluation,OOPSLA 2007 | Java 程序每次运行都可能不同,怎样得到可信结论? | 区分启动与稳态,以独立 JVM 运行作为重要实验单位,同时报告估计值和不确定性 |
| Rigorous Benchmarking in Reasonable Time,ISMM 2013 | Build、JVM 和 Iteration 都存在波动,有限的实验时间应该怎样分配? | 识别不同层级的方差,通过定标实验把重复预算用于主要的不确定性来源 |
| Benchmarking Distributed Stream Data Processing Systems,ICDE 2018 | 流处理系统中的吞吐和延迟应该从哪里开始测量? | 使用开环负载和事件生成时间,结合 backlog 与尾延迟判断可持续吞吐 |
三项研究解决的不是三个孤立场景,而是基准测试中的三个连续问题:样本是否可信、实验预算是否有效,以及测量对象是否正确。
JVM 性能评估:从重复运行到可信样本
Java 程序的性能不是一个固定常数。即使代码、参数和机器完全相同,JIT 编译时机、GC、线程调度、堆布局和操作系统扰动仍会让每次运行产生差异。
因此,只取多次运行中的最好值并不能代表程序的典型性能。best of N 实际回答的是"这 N 次运行中最幸运的一次有多快"。随着 N 增大,抽到极端好运样本的概率也会增加,最终结果会系统性地偏向波动更大的实现。
论文进一步区分了启动性能和稳定态性能。启动性能关注 JVM 启动、类加载、初始化和任务执行的总成本;稳定态性能关注程序完成初始化和主要 JIT 编译后的长期执行能力。两者测量的对象不同,不能混在同一个数字中。
同一个 JVM 内的 Iteration 共享 JIT Profile、已编译代码、堆状态和 GC 历史,因此不能把它们全部当成彼此独立的 JVM 实验。独立 JVM invocation,也就是 JMH 中的 Fork,是 Java 性能评估中不能忽略的实验层级。可信结果还需要同时报告估计值和不确定性,而不是只展示一个最好值或平均值。
实验预算:重复应该放在哪一层
增加运行次数并不一定能够等比例提高结论的可信度。性能实验通常包含多个嵌套层级:
text
Build
└─ JVM Execution / Fork
└─ Measurement Iteration
└─ Pipeline Invocation
每个层级都可能引入不同的变化。重新 Build 可能改变代码布局,重新启动 JVM 会重新经历 JIT 和堆状态变化,同一个 JVM 内的 Iteration 则主要受到局部运行时扰动。
如果主要方差来自不同 Fork,那么把每个 Fork 中的 Iteration 从 5 次增加到 50 次,也无法消除 Fork 之间的差异;增加独立 Fork 可能更有价值。如果 Build 本身也会显著影响结果,就还需要把重复提升到 Build 层级。
论文提出先做定标实验:保留各层原始样本,估计 Build、Fork 和 Iteration 的方差贡献与时间成本,再把有限预算分配给主要的不确定性来源。因此,固定的 Fork 和 Iteration 次数只能作为起点,不能被当成适用于所有 Benchmark 的永久配置。
这里还需要区分随机误差和系统偏差。增加重复可以帮助估计随机误差,却不能修复测试顺序、机器状态或环境差异造成的系统偏差。如果 baseline 总是在机器空闲时运行,而 candidate 总是在机器繁忙时运行,再多样本也只会更精确地描述一个有偏实验。
流处理测量:从"测得稳定"到"测量正确"
前两项研究关注怎样可靠地估计一个指标,第三项研究则进一步追问:这个指标是否真正代表了流处理系统的性能。
传统闭环负载生成器通常在上一批数据处理完成后再发送下一批。当系统变慢时,生成器也会跟着降低发送速度,尚未进入系统的数据等待时间不会被记录。最终可能出现一种反直觉现象:系统已经严重过载,报告中的内部处理延迟却仍然很低。这就是 Coordinated Omission。
为避免这个问题,输入速率必须独立于系统的完成速度,延迟也应从事件原本计划产生的时间开始计算。否则,Source 之前的排队会被排除,测量结果只能反映引擎摄入数据之后的处理时间,无法表达事件从应该产生到最终完成的真实等待。
这也引出了可持续吞吐与瞬时峰值的区别。可持续吞吐不是系统短时间内达到的最大速度,而是系统能够长期处理,同时不持续积累 backlog 和 Event-time Latency 的最高输入速率。吞吐、数据完整性、尾延迟和延迟增长必须一起解释,任何一个单独使用都不足以证明系统能够稳定承载当前负载。
三项研究形成的方法论
三项研究共同形成了一条完整的论证链:先定义要评估的是启动、稳态、热点方法还是完整系统;再识别真正的独立实验单位和主要方差来源;最后确认吞吐与延迟的起止边界是否代表用户真正关心的问题。
统计方法无法修复错误的测量边界,正确的测量边界也不能代替独立重复和方差分析。只有两者同时成立,Benchmark 才能从一组运行数字变成可以支持工程判断的性能证据。
四、这些理论如何落到 Zeta Pipeline
前一节确定了三项基本原则:保留独立 JVM 样本、把重复预算放在正确层级,并使用开环负载测量真实排队时间。本节不再重复这些概念,只说明它们怎样落实到 Zeta Pipeline 基准中。
BenchmarkSource 记录计划生成时间
Source 根据绝对时间安排每条记录的计划生成时间:
text
scheduledTime = startTime
+ wholeSeconds × 1000
+ remainder × 1000 / rate
计划时间不会因为上一条记录处理变慢而暂停。记录到达 Sink 后计算:
text
event-time latency = Sink 接收时间 - 计划生成时间
因此,引擎暂时无法及时处理所产生的排队,会直接进入 P50、P95 和 P99,而不会被 Source 自身降速隐藏。
确定性组件隔离引擎开销
测试组件尽量不引入随机业务行为:
- Source 生成固定数量、固定 Payload 的内存数据;
- Transform 执行固定次数的 hash 工作,并输出可校验 checksum;
- Sink 不访问外部系统,只统计行数、吞吐、延迟分布和 checksum;
- 每条 Pipeline 都保持相同的数据量、并行度和资源配置,只切换目标能力。
五个场景分别覆盖基础数据链路、Transform、实时忙碌度观测、StainTrace 以及两者组合。这样可以在工作负载保持一致的前提下,观察不同引擎能力带来的增量开销。
正确性校验决定样本是否有效
性能很快但少处理了数据,是无效结果。每次完整作业都会检查:
processed_rows是否等于expected_rows;- 基础 Source → Sink 场景的 checksum 是否为 0;
- 含 Transform 的场景 checksum 是否非 0;
- 延迟百分位是否落入统计范围,而不是被 overflow bucket 截断;
- P99 和延迟增长是否满足当前基准的保护条件。
只有数据完整、Transform 确实执行并且延迟可解释,样本才会进入最终报告。
Fork、预热与机器可读结果
公共 JMH 配置使用 3 个 Fork、每个 Fork 3 次预热和 5 次测量。Fork 提供相互独立的 JVM 进程,Warmup 结果不参与最终聚合,Measurement 才用于形成报告。
每个 Measurement Iteration 内可以完成多次完整 Pipeline invocation,所以报告中的 75/75 不是 75 行数据,也不等于 15 个 JMH Iteration,而是 Measurement 阶段实际完成的 75 个 Pipeline 作业样本。
除了 Markdown 汇总,测试还保留原始 JMH JSON、每次 Pipeline JSON、标准化指标和环境元数据。Commit、JDK、JVM 参数、运行器镜像、CPU、内核、内存和负载参数共同决定两组结果是否具有可比性。
3 Fork × 5 Measurement 是当前阶段的初始预算,而不是永久配置。托管运行器上的结果首先用于观察;后续还需要在固定机器上积累历史分布,再根据真实方差决定重复次数、重跑规则和性能回归阈值。
五、怎样读懂两套数字
JMH Score 和 Pipeline Throughput 为什么不同
Pipeline 类声明每次 invocation 包含 1,000,000 个逻辑操作,因此 JMH 会把完整作业耗时换算为 ops/s:
text
JMH Score ≈ 1,000,000 / 完整作业耗时
这里的完整作业耗时包括配置生成、提交、调度、Source、Transform、Sink 和结束等待。
Pipeline JSON 的吞吐边界更窄:
text
Pipeline Throughput
= processed_rows / (Sink 最后一条接收时间 - 第一条接收时间)
它只观察 Sink 实际接收数据的区间,不包含作业提交和收尾固定成本。因此图中 JMH Score 约为 45.4 万到 45.8 万 ops/s,而 Pipeline Throughput 约为 58.8 万到 59.1 万行/秒。这不是两套结果矛盾,而是测量边界不同。
Score、Error、CV 和 Unit
| 字段 | 含义 | 使用方式 |
|---|---|---|
Score |
JMH 对当前方法吞吐的估计值 | 吞吐模式下越大越好 |
Error |
当前运行内部置信区间半宽占 Score 的比例 | 区间可近似理解为 Score × (1 ± Error%) |
CV |
样本标准差除以样本均值 | 越低表示本轮样本越集中 |
Unit |
指标单位 | Pipeline 为 ops/s,Row 微基准为 ops/ms |
Error 和 CV 只能描述本次运行内部的波动,不包含不同宿主机、不同 CPU 调度状态和不同系统负载之间的漂移。比较两个版本时,不能只因为 Score 相差 1% 就认定发生了回归。
P50、P95、P99、Max、Growth 和 Valid
- P50:一半记录的延迟不超过该值,代表典型体验;
- P95 / P99:观察尾部记录,能更早暴露排队、暂停和抖动;
- Max:最慢记录,但对单个异常值更敏感,应和百分位一起看;
- Growth:比较前后半段 P99,判断 backlog 是否持续累积;
- Valid:Measurement 阶段完整、可持续且没有百分位溢出的样本数量。
其中 Growth 的计算方式是:
text
Growth = (后半段 P99 + 1) / (前半段 P99 + 1)
吞吐告诉我们单位时间完成多少工作,延迟告诉我们每条记录等了多久,Growth 告诉我们这种等待是否还在恶化。三者必须结合,任何一个单独使用都可能产生错误结论。
六、从基准结果看 Zeta 的性能表现
在 4 个 JVM 可见处理器和 4 GiB 堆的资源条件下,Zeta 启动真实的单节点 Master 和 Worker,经过作业提交、调度以及完整的 Source -> Transform -> Sink 链路后,Sink 吞吐仍然保持在每秒 58.8 万到 59.1 万行,P99 约为 100 ms,75/75 个测量样本全部有效。
五条 Pipeline 的 Growth 都接近 1,说明在每秒 60 万行的计划输入下,系统没有持续积累延迟。加入 Transform、实时可观测性和 StainTrace 后,性能差异也没有超出本轮测量误差。它不能证明这些能力"零开销",但至少说明当前开销没有对核心链路造成可识别的性能下降。
这组结果不是 Zeta 的容量上限,也不包含真实 Connector、多节点网络和 Checkpoint 等成本。不过在当前测量范围内,Zeta 已经表现出较高的吞吐能力、稳定的尾延迟和良好的功能开销控制。对于数据库、消息队列、数据仓库和数据湖之间的批流一体数据同步,SeaTunnel Zeta 是一款值得优先考虑的高性能引擎。
七、思考与总结
设计基准测试的第一步不是立即编写 JMH 方法,而是先理解基准测试究竟要证明什么,并通过成熟研究掌握启动与稳态、独立样本、分层方差、开环负载和可持续吞吐等基本概念。理论的作用不是让测试看起来更复杂,而是帮助我们避免从错误的测量方式中得到精确但无意义的数字。
明确问题之后,需要固定计时边界、工作负载和资源条件,并使用确定性数据验证处理结果是否完整。只有先证明数据没有丢失、目标逻辑确实执行,吞吐和延迟才有讨论价值。
最后,应当分离预热与正式测量,保留独立重复,同时观察吞吐、尾延迟、Growth 和不确定性。托管运行器上的单次结果适合发现信号,不适合立即成为性能门禁;只有积累固定环境下的历史分布,才能进一步定义可靠的回归阈值。
基准测试的价值不在于展示一次漂亮的数字,而在于建立一套可以重复运行、解释性能变化并长期发现回归的证据体系。