task-99: 全链路压测记录 CPU、内存、GC、DB、Redis、RustFS、网络结果

This commit is contained in:
2026-08-30 23:17:02 +08:00
parent e79f1015cb
commit 2fb56632c9
2 changed files with 355 additions and 0 deletions
@@ -0,0 +1,180 @@
/**
* 全链路压测记录器(Task 99)。
*
* 执行压测请求并记录 CPU、内存、GC、DB、Redis、RustFS、网络结果:
* metric(i) 产生单次请求结果(ok/延迟),sample() 周期采集系统指标,
* 输出请求统计 + 各指标的平均/最大/min/95 分位汇总。
*
* 语义:
* - 并发有界:concurrency 个并发槽执行 requests 个请求,不无界扩张;
* - 失败有界:maxFailures 设定失败容忍上限,超过后 ok=false,但仍完成
* 全部请求并输出完整报告;
* - 采样失败(依赖故障)不中断压测:计入 sampleFailures,统计从有效
* 采样计算;依赖恢复后同一 recorder 重跑即全量恢复;
* - 空请求数安全返回;单请求不依赖批量路径;同一输入重复执行幂等,
* 无残留状态;
* - 校验失败 fail-fastmetric/sample 非函数、requests 非非负整数、
* concurrency/maxFailures 非法、latencyMs 非法均抛错。
*/
export interface LoadMetric {
ok: boolean
output: string
latencyMs: number
}
export interface LoadSample {
cpuPercent: number
heapBytes: number
gcCount: number
dbQps: number
redisQps: number
rustfsQps: number
networkBytesPerSec: number
}
export interface LoadTestRecorderOptions {
/** 单次请求执行器;抛错按失败计入 */
metric: (i: number) => Promise<LoadMetric>
/** 系统指标采样器;抛错计入 sampleFailures 不中断压测 */
sample: () => Promise<LoadSample>
/** 请求总数,必须为非负整数,默认 100 */
requests?: number
/** 并发槽数量,必须为正数,默认 1 */
concurrency?: number
/** 失败容忍上限,必须为非负整数,默认 0 */
maxFailures?: number
/** 采样间隔(每 N 个请求采样一次),必须为正数,默认 1 */
sampleEvery?: number
}
export interface MetricSummary {
avg: number
max: number
min: number
p95: number
}
export interface LoadTestSummary {
ok: boolean
requests: number
failures: number
samples: Record<string, MetricSummary> & { avgCount: number }
}
export interface LoadTestResult {
ok: boolean
summary: LoadTestSummary
metrics: Array<LoadMetric & { index: number }>
sampleFailures: number
}
export interface LoadTestRecorder {
run: () => Promise<LoadTestResult>
}
const SAMPLE_KEYS: Array<keyof LoadSample> = [
'cpuPercent',
'heapBytes',
'gcCount',
'dbQps',
'redisQps',
'rustfsQps',
'networkBytesPerSec',
]
function p95(values: number[]): number {
if (values.length === 0) return 0
const sorted = [...values].sort((a, b) => a - b)
const index = Math.min(sorted.length - 1, Math.ceil(sorted.length * 0.95) - 1)
return sorted[index]
}
function summarize(values: number[]): MetricSummary {
return {
avg: values.length ? values.reduce((sum, v) => sum + v, 0) / values.length : 0,
max: values.length ? Math.max(...values) : 0,
min: values.length ? Math.min(...values) : 0,
p95: p95(values),
}
}
export function createLoadTestRecorder(options: LoadTestRecorderOptions): LoadTestRecorder {
if (typeof options.metric !== 'function') {
throw new Error('metric 必须是函数')
}
if (typeof options.sample !== 'function') {
throw new Error('sample 必须是函数')
}
const requests = options.requests ?? 100
const concurrency = options.concurrency ?? 1
const maxFailures = options.maxFailures ?? 0
const sampleEvery = options.sampleEvery ?? 1
if (!Number.isInteger(requests) || requests < 0) {
throw new Error('requests 必须是非负整数: ' + requests)
}
if (!(concurrency > 0)) {
throw new Error('concurrency 必须为正数: ' + concurrency)
}
if (!Number.isInteger(maxFailures) || maxFailures < 0) {
throw new Error('maxFailures 必须是非负整数: ' + maxFailures)
}
if (!(sampleEvery > 0)) {
throw new Error('sampleEvery 必须为正数: ' + sampleEvery)
}
async function run(): Promise<LoadTestResult> {
const metrics: Array<LoadMetric & { index: number }> = []
const samplesByKey: Record<string, number[]> = {}
for (const key of SAMPLE_KEYS) samplesByKey[key] = []
let sampleFailures = 0
const runRequest = async (i: number) => {
let metric: LoadMetric
try {
metric = await options.metric(i)
} catch (error) {
metric = { ok: false, output: error instanceof Error ? error.message : String(error), latencyMs: 0 }
}
if (typeof metric.latencyMs !== 'number' || !Number.isFinite(metric.latencyMs) || metric.latencyMs < 0) {
throw new Error('latencyMs 必须为非负数值: ' + metric.latencyMs)
}
metrics.push({ ...metric, index: i })
if (i % sampleEvery === 0) {
try {
const sample = await options.sample()
for (const key of SAMPLE_KEYS) {
samplesByKey[key].push(sample[key])
}
} catch {
sampleFailures += 1
}
}
}
for (let offset = 0; offset < requests; offset += concurrency) {
const batch = []
for (let i = offset; i < Math.min(offset + concurrency, requests); i++) {
batch.push(runRequest(i))
}
await Promise.all(batch)
}
metrics.sort((a, b) => a.index - b.index)
const failures = metrics.filter((m) => !m.ok).length
const summary: LoadTestSummary = {
ok: failures <= maxFailures,
requests: metrics.length,
failures,
samples: {
avgCount: samplesByKey.cpuPercent.length,
} as LoadTestSummary['samples'],
}
for (const key of SAMPLE_KEYS) {
;(summary.samples as Record<string, MetricSummary>)[key] = summarize(samplesByKey[key])
}
return { ok: failures <= maxFailures, summary, metrics, sampleFailures }
}
return { run }
}