同一批请求,本地报表的 P95 是 190 毫秒,流水线却算出另一个数,先别归因于云端 Mac 的性能。两端可能分别用了最近秩和线性插值。在 VMFly 物理节点上运行统计验收时,应先固定算法、样本范围和空集合语义,再讨论接口是否变慢。本文给出不依赖网络服务的 Swift 脚本,适合放进 iOS 工程的统计模块回归流程。
固定分位数的计算契约
本文只接受整数百分位 1 至 100。将非负、有限的耗时从小到大排序,百分位为 q、样本数为 n,最近秩是 ceil(q × n / 100),从一开始编号。返回该位置的实际样本,不做相邻值插值。
例如,20 个递增样本从 10 到 200 毫秒,P50、P95、P99 分别是 100、190、200 毫秒。只有一个样本时,三个结果都等于它。这是算法定义,不代表尾部延迟已经得到充分观测。
空集合没有分位数。把它写成零,会让没有成功请求的窗口看起来比正常窗口更快。
输入为负数、无穷值或 NaN 时,本次计算失败。不要悄悄过滤异常值,否则采集错误会被包装成正常报表。生产系统若采用隔离策略,必须另外累计被拒绝的样本数。
实现不依赖浮点乘法的秩计算
保存以下内容为 percentile-check.swift。脚本使用完整 Xcode 工具链提供的 Swift,不要求修改应用工程。耗时统一使用毫秒;函数无法从裸数值判断单位,调用侧必须完成转换。
import Foundation
enum MetricError: Error, Equatable {
case invalidPercentile
case invalidDuration
}
func percentile(_ values: [Double], q: Int) throws -> Double? {
guard (1...100).contains(q) else {
throw MetricError.invalidPercentile
}
guard values.allSatisfy({ $0.isFinite && $0 >= 0 }) else {
throw MetricError.invalidDuration
}
guard !values.isEmpty else {
return nil
}
let sorted = values.sorted()
let n = sorted.count
let rank = (n / 100) * q + ((n % 100) * q + 99) / 100
return sorted[rank - 1]
}
func expectError(_ expected: MetricError,
_ operation: () throws -> Void) {
do {
try operation()
preconditionFailure("Expected metric error")
} catch let error as MetricError {
precondition(error == expected)
} catch {
preconditionFailure("Unexpected error type")
}
}
let samples = (1...20).map { Double($0 * 10) }
let p50 = try percentile(samples, q: 50)
let p95 = try percentile(samples, q: 95)
let p99 = try percentile(samples, q: 99)
precondition(p50 == 100 && p95 == 190 && p99 == 200)
let empty = try percentile([], q: 95)
let single = try percentile([7], q: 99)
let repeated = try percentile([4, 4, 4], q: 95)
let zero = try percentile([0], q: 50)
let reversed = try percentile(Array(samples.reversed()), q: 95)
precondition(empty == nil && single == 7)
precondition(repeated == 4 && zero == 0 && reversed == p95)
for q in [0, 101] {
expectError(.invalidPercentile) {
_ = try percentile(samples, q: q)
}
}
for value in [-1.0, Double.nan, Double.infinity] {
expectError(.invalidDuration) {
_ = try percentile([value], q: 95)
}
}
秩计算把 n 拆成整百部分与余数,避免 Double 乘法在整数边界附近产生舍入偏差;也没有直接计算可能溢出的 n × q。由于 q 不超过 100,余数部分的中间结果有界,最终秩不超过样本数。
在节点上运行确定性验收
从代码仓库根目录执行下面的命令。脚本通过时没有输出,失败时进程返回非零状态;流水线直接使用退出状态判定,不必解析本地化日志。
set -eu
xcrun swiftc percentile-check.swift -o /tmp/percentile-check
/tmp/percentile-check
示例使用固定临时路径,适合单任务演示。并发流水线应把可执行文件放进各任务独立的工作目录,避免互相覆盖。不要添加 -Ounchecked,该模式会移除这里用作门禁的前置条件检查。
独立脚本验证的是示例实现。接入项目后,应将同一组向量迁入测试目标,直接调用生产统计函数,而不是长期维护一份只供测试的算法副本。固定随机种子补充排列测试时,还要保留原始输入,方便重放失败。
补齐窗口与样本边界
上线前至少检查四类变化:
- 打乱输入顺序,结果不变;输入数组本身不应被统计函数改写。
- 加入重复耗时后仍按样本个数排名,不能先去重。
- 在 99、100、101 个样本处检查秩,防止整百边界偏移。
- 扩大百分位时结果不下降,并且结果始终属于原样本集合。
所有百分位应来自同一份不可变快照。若采集器一边追加数组,一边分别计算 P50 和 P99,两个结果可能对应不同窗口。先在采集器的同步边界内复制样本,再离开该边界排序,避免排序占用采集路径。
超时请求不能作为零耗时加入数组。若仅统计成功请求,应明确标记“成功请求延迟”,同时输出失败数和超时数;否则成功率下降时,延迟反而可能看起来改善。
让回归报告能够复核
每次报告保存算法标识、单位、窗口起止、成功样本数、失败数、超时数及三个百分位。固定夹具可以保存完整向量;真实请求只保存完成排障所需的数据,不附带请求凭据或正文。
不要平均多个窗口的 P95 来冒充总窗口 P95。需要合并时,应重新计算合并样本,或使用明确支持合并且标注误差的统计结构。本文的排序实现适合精确验收,不适合无限增长的数据流。
最后把“统计实现正确”与“性能达标”分成两道门禁:前者用固定向量精确比较,后者在足够样本和一致窗口下比较业务阈值。这样一次失败才能明确指向算法回归、采集异常或真实延迟变化。
常见问题
P95 应该使用最近秩还是线性插值?
两种方法都有用途,但结果不能直接混比。本文选择最近秩,返回排序后实际存在的样本值;若现有报表使用插值,应保留原口径,或更新算法标识并重新建立基线。
没有成功请求时,可以把 P95 记成零吗?
不可以。空集合应返回无结果,并记录成功样本数为零;超时和失败另行计数。零耗时是有效测量值,不能兼作没有数据的标记。