
k6 v0.38.2 补丁发布解读子指标阈值 NaN 与摘要归属错误的修复原理【免费下载链接】k6A modern load testing tool, using Go and JavaScript项目地址: https://gitcode.com/GitHub_Trending/k6/k6k6 v0.38.2 是针对 v0.38 系列的一次补丁发布patch release修复了负载测试引擎中与子指标sub-metric阈值求值相关的两个真实缺陷其一对尚未产生任何样本的子指标应用abortOnFail阈值时阈值结果会被错误地计算为NaN其二没有任何样本的子指标会在测试摘要中被渲染到错误的父指标名下。本文以 release notes/v0.38.2.md 为骨架结合仓库中阈值引擎、指标注入器ingester与摘要输出的源码实现逐条还原问题根因、复现脚本与修复行为帮助读者深入理解 k6 中阈值与子指标从采样、聚合到求值、渲染的完整链路。1. 发布背景一次针对阈值引擎的定点修复v0.38.2 是 v0.38.x 系列中的一个小版本不引入新功能只针对上一轮引擎内阈值处理方式变更recent changes to how we handle thresholds in the k6 engine所引入的回归问题进行修正。两个问题均由社区用户efdknittlfrank报告并协助定位仓库在 release notes/v0.38.2.md 中专门致谢。要理解这两个 Bug需要先建立三个基础概念子指标sub-metric通过指标名{标签键:标签值}语法定义的、基于某个父指标过滤出的数据子集。例如checks{type:read}就是checks的子指标只有带type:read标签的样本才会计入其中。其数据结构定义在 metrics/metric.go// A Submetric represents a filtered dataset based on a parent metric. type Submetric struct { Name string json:name Suffix string json:suffix // TODO: rename? Tags *TagSet json:tags Metric *Metric json:- Parent *Metric json:- }阈值threshold在测试配置中声明的断言表达式如rate0.9支持abortOnFail失败即中止测试与delayAbortEval宽限期等选项。Sink每种指标类型在引擎内部持有的聚合器负责把样本聚合成count、rate、avg、p(95)等可供阈值表达式求值的数值。两个 Bug 的共同触发前提都是某个子指标在整个测试期间没有收到任何样本。2. 缺陷一无样本子指标阈值被求值为 NaN2.1 复现脚本import { check, sleep } from k6; import http from k6/http; export const options { scenarios: { iWillFail: { exec: iWillFail, executor: constant-vus, startTime: 2s, vus: 1, duration: 30s, }, }, thresholds: { checks{type:read}: [{ threshold: rate0.9, abortOnFail: true }], }, }; export function iWillFail() { let res http.get(https://test-api.k6.io/); check(res, { read status is 200: (r) r.status 200, }, { type: read }); sleep(1); }该脚本的核心设定是使用constant-vus执行器1 个 VU 持续 30 秒但场景在startTime: 2s之后才启动即测试的前 2 秒内没有任何迭代执行、也没有任何样本产出阈值checks{type:read}带abortOnFail: true按理说读接口成功率低于 0.9 就立刻中止测试。2.2 缺陷症状在 v0.38.2 之前运行上述脚本会产生如下输出✗ { type:read }...: NaN% ✓ 0 ✗ 0 vus...............: 0 min0 max0 vus_max...........: 1 min1 max1阈值结果被渲染成了NaN%成功 0、失败 0而不是一个数值。这意味着阈值表达式rate0.9的左值并不是0/00而是一个非数值。2.3 根因分析源码级问题出在指标引擎对空 Sink的处理上。在 internal/metrics/engine/engine.go 中evaluateThresholds会周期性每 2 秒见thresholdsRate常量对带阈值指标求值for _, m : range me.metricsWithThresholds { // If either the metric has no thresholds defined, or its sinks // are empty, lets ignore its thresholds execution at this point. if len(m.Thresholds.Thresholds) 0 || (ignoreEmptySinks m.Sink.IsEmpty()) { continue } ... succ, err : m.Thresholds.Run(m.Sink, t) ... }在测试开始的最初几秒内子指标checks{type:read}的 Sink 还是空的本应被Sink.IsEmpty()跳过。但在 v0.38.2 修复前abortOnFail路径上的求值逻辑没有严格走这条空 Sink 即跳过的保护分支导致阈值对空 RateSink直接求值。关键漏洞位于 metrics/thresholds.go 中Thresholds.Run对RateSink的处理case *RateSink: // We want to avoid division by zero, which // would lead to https://github.com/grafana/k6/issues/2520 if sinkImpl.Total 0 { ts.sinked[rate] float64(sinkImpl.Trues) / float64(sinkImpl.Total) }RateSink的Total为 0没有任何样本时rate键根本不会写入sinked映射。随后 metrics/thresholds.go 的runNoTaint在sinks中查找不到rate键时会返回(true, nil)视为通过——这本是设计上的保护。但旧的求值路径绕过了这一层空值保护直接用0/0或未初始化值参与比较最终得到NaN。仓库代码中保留的注释直接点明了这一因果链We want to avoid division by zero, which would lead to #2520即本小节所讨论的 issue [2520]。2.4 修复后的行为v0.38.2 的修复确保在子指标尚未产生任何样本时绝不提前对abortOnFail阈值做会失败的判断。阈值只有在获得了足够样本、Sink 非空之后才会被真正求值从而杜绝了测试刚起步就被一个 NaN 结果误中止的错误行为也保证最终结果一定是数值如0%而非NaN%。从代码路径看internal/metrics/engine/engine.go 中的Sink.IsEmpty()检查是这道防线而每种 Sink 的空判定在 metrics/sink.go 中定义Sink 类型空判定逻辑源码位置CounterSinkFirst.IsZero()metrics/sink.goGaugeSink!g.minSetmetrics/sink.goTrendSinkt.count 0metrics/sink.goRateSinkr.Total 0metrics/sink.go3. 缺陷二无样本子指标被渲染到错误的父指标下3.1 复现脚本import { Counter } from k6/metrics; const counter1 new Counter(one); const counter2 new Counter(two); export const options { thresholds: { one{tag:xyz}: [], }, }; export default function() { console.log(not submitting metric1); counter2.add(42); }脚本中声明了两个自定义 Counter 指标one和two但对one{tag:xyz}定义了空的阈值列表且测试体从不提交one的任何样本只往two里写入 42。3.2 缺陷症状在 v0.38.2 之前摘要输出错误地把{ tag:xyz }子指标渲染到了iterations下面data_received........: 0 B 0 B/s data_sent............: 0 B 0 B/s iteration_duration...: avg0s min0s med0s max0s p(90)0s p(95)0s iterations...........: 1 499.950005/s { tag:xyz }........: 0 0/s two..................: 42 20997.90021/s而正确结果应是把子指标挂在其真正的父指标one之下one..................: 0 0/s { tag:xyz }........: 0 0/s two..................: 423.3 根因分析源码级问题的根源在于没有样本的子指标如何进入摘要渲染这一路径。v0.38.2 之前摘要输出依赖样本驱动的方式收集指标只有收到过样本的指标及与之匹配的子指标才会被记录进摘要数据模型。以 internal/output/summary/summary.go 的storeSample为例if _, exists : o.dataModel.aggregatedMetrics[sample.Metric.Name]; !exists { o.dataModel.aggregatedMetrics[sample.Metric.Name] relayAggregatedMetricFrom(sample.Metric) o.dataModel.storeThresholdsFor(sample.Metric) for _, sub : range sample.Metric.Submetrics { o.dataModel.storeThresholdsFor(sub.Metric) } }当某个指标第一次收到样本时它及其全部子指标会被登记。但如果子指标one{tag:xyz}一个样本都没收到它的父指标one也从未出现在样本流中此时子指标就只能靠其他渠道被登记——而旧实现中这个其他渠道发生了错位把子指标登记到了错误的父节点示例中表现为挂在iterations下。修复对应 PR [2519]的思路在引擎初始化阶段就能看到子指标与父指标必须在测试启动前就建立正确的归属关系。参见 internal/metrics/engine/engine.go 的InitSubMetricsAndThresholds// Mark the metric (and the parent metric, if were dealing with a // submetric) as observed, so they are shown in the end-of-test summary, // even if they dont have any metric samples during the test run me.markObserved(metric) if metric.Sub ! nil { me.markObserved(metric.Sub.Parent) }注释明确写道把子指标及其父指标都标记为 observed即使整个测试期间没有任何样本也要在总结中显示。摘要侧则配套新增了 internal/output/summary/summary.go 的processObservedMetrics// processObservedMetrics is responsible for ensuring that we have collected // all metrics, even those that have no samples, so that we can render them in the summary. func (o *Output) processObservedMetrics(observedMetrics map[string]*metrics.Metric) { for _, m : range observedMetrics { if _, exists : o.dataModel.aggregatedMetrics[m.Name]; !exists { o.dataModel.aggregatedMetrics[m.Name] relayAggregatedMetricFrom(m) o.dataModel.storeThresholdsFor(m) } } }这条观测指标兜底收集路径会在生成摘要前补齐所有被引擎标记为 observed 的指标包括零样本的子指标并且由于子指标在注册时已通过Submetric.Parent指回真实父指标见 metrics/metric.go 的AddSubmetric渲染时便能正确地把{ tag:xyz }挂到one之下。3.4 回归测试保护仓库为第二个缺陷专门保留了回归测试脚本 internal/cmd/testdata/thresholds/thresholds_on_submetric_without_samples.js// Thresholds over submetrics without any values should still // be displayed under their proper parent metrics in the summary. // // Protects from #2518 regressions. import { Counter } from k6/metrics; const counter1 new Counter(one); const counter2 new Counter(two); export const options { thresholds: { one{tag:xyz}: [], }, }; export default function () { console.log(not submitting metric1); counter2.add(42); }文件头注释写明该脚本专门用于防止 issue [#2518] 回归与 internal/cmd/tests/cmd_run_test.go 等端到端测试配合确保零样本子指标仍归属正确父指标这一行为不会再次被破坏。4. 补充知识子指标的定义与阈值求值全链路结合上述两个缺陷可以完整梳理 k6 中阈值 子指标从定义到展示的链路解析ParseMetricNamemetrics/metric.go把one{tag:xyz}拆成父指标名one与标签表达式tag:xyz并校验花括号配对、标签格式等语法。注册引擎初始化时getThresholdMetricOrSubmetricinternal/metrics/engine/engine.go通过AddSubmetric创建Submetric结构子指标内部生成一个独立 Metric 实例并通过Parent字段指回父指标。阈值校验Thresholds.Validatemetrics/thresholds.go确认表达式聚合方法与指标类型匹配如 Trend 支持avg/min/max/med/p(95)Counter 支持count/rate不合法则报InvalidConfig退出码。采样与聚合测试运行时OutputIngester.flushMetricsinternal/metrics/engine/ingester.go对每条样本先写入父指标 Sink再遍历父指标的全部子指标用sample.Tags.Contains(sm.Tags)判断标签是否匹配匹配则同样写入子指标 Sink。周期性求值引擎每 2 秒调用evaluateThresholdsinternal/metrics/engine/engine.go对 Sink 非空的指标执行Thresholds.RunSink 为空时跳过这正是缺陷一修复的关键防线。若阈值失败且开启abortOnFail则构造带exitcodes.ThresholdsHaveFailed退出码与AbortedByThreshold中止原因的错误并中止测试。汇总渲染测试结束时Output.Summary先调用processObservedMetrics兜底收集零样本指标再按父指标分组渲染子指标缩进显示在父指标之下这正是缺陷二修复的关键环节。5. 升级建议若你正在使用 v0.38.0 或 v0.38.1且脚本中对子指标配置了abortOnFail阈值或依赖零样本子指标在摘要中的正确归属建议升级到 v0.38.2 以规避NaN误判与摘要错位问题升级后可用上文两个复现脚本快速验证修复效果第一个脚本不应再出现NaN%第二个脚本的{ tag:xyz }应稳定渲染在one之下更完整的阈值语法聚合方法、比较运算符、abortOnFail、delayAbortEval等配置项可参阅 metrics/thresholds_parser.go 中的 BNF 定义与 lib/options.go 中阈值选项的解析实现。6. 小结v0.38.2 的两个修复虽然体量不大但精准地补齐了空子指标这一边界场景在阈值求值与摘要归属两条路径上的行为求值侧空 Sink 必须被跳过或安全兜底避免abortOnFail在数据就绪前被NaN误触发渲染侧零样本子指标必须在初始化阶段就与父指标建立归属并通过 observed 标记进入摘要兜底收集而非依赖样本流被动登记。这两个原则在 internal/metrics/engine/engine.go 与 internal/output/summary/summary.go 中至今仍是相关逻辑的设计基石理解它们有助于你在编写 k6 脚本时预判阈值与子指标在各种边界情况下的表现。【免费下载链接】k6A modern load testing tool, using Go and JavaScript项目地址: https://gitcode.com/GitHub_Trending/k6/k6创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考