feat(perf): 增加大数据压测脚手架
This commit is contained in:
251
scripts/perf-check.mjs
Normal file
251
scripts/perf-check.mjs
Normal file
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env node
|
||||
import { performance } from 'node:perf_hooks';
|
||||
|
||||
const DEFAULT_THRESHOLDS = {
|
||||
healthVersion: 500,
|
||||
requirementPool: 1000,
|
||||
versionDetail: 1500,
|
||||
workspace: 1200,
|
||||
xiaobaoWarning: 1000,
|
||||
};
|
||||
|
||||
const DEFAULTS = {
|
||||
baseUrl: 'http://localhost:3001/api/v1',
|
||||
productId: 'perf-product-001',
|
||||
versionId: 'perf-version-001-001-001',
|
||||
userId: 'perf-user-dev-01',
|
||||
iterations: 5,
|
||||
warmup: 1,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { ...DEFAULTS };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--dry-run') {
|
||||
args.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--base-url') {
|
||||
args.baseUrl = argv[++index] ?? args.baseUrl;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--base-url=')) {
|
||||
args.baseUrl = arg.slice('--base-url='.length);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--product-id') {
|
||||
args.productId = argv[++index] ?? args.productId;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--version-id') {
|
||||
args.versionId = argv[++index] ?? args.versionId;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--user-id') {
|
||||
args.userId = argv[++index] ?? args.userId;
|
||||
continue;
|
||||
}
|
||||
if (arg === '--iterations') {
|
||||
args.iterations = parsePositiveInt(argv[++index], args.iterations);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--warmup') {
|
||||
args.warmup = parsePositiveInt(argv[++index], args.warmup, 0);
|
||||
continue;
|
||||
}
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
args.baseUrl = args.baseUrl.replace(/\/+$/, '');
|
||||
return args;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/perf-check.mjs [--base-url URL] [--dry-run]
|
||||
|
||||
Runs the V2.6 hot API performance harness.
|
||||
|
||||
Options:
|
||||
--base-url API root. Defaults to ${DEFAULTS.baseUrl}
|
||||
--product-id Requirement pool product id. Defaults to ${DEFAULTS.productId}
|
||||
--version-id Version detail id. Defaults to ${DEFAULTS.versionId}
|
||||
--user-id Workspace user id. Defaults to ${DEFAULTS.userId}
|
||||
--iterations Timed iterations per probe. Defaults to ${DEFAULTS.iterations}
|
||||
--warmup Warmup iterations per probe. Defaults to ${DEFAULTS.warmup}
|
||||
--dry-run Print probes without making HTTP requests.
|
||||
`);
|
||||
}
|
||||
|
||||
function parsePositiveInt(raw, fallback, min = 1) {
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isFinite(parsed)) return fallback;
|
||||
return Math.max(min, Math.floor(parsed));
|
||||
}
|
||||
|
||||
function buildProbes(args) {
|
||||
const requirementQuery = new URLSearchParams({
|
||||
productId: args.productId,
|
||||
q: 'REQ',
|
||||
limit: '50',
|
||||
});
|
||||
const workspaceQuery = new URLSearchParams({ userId: args.userId });
|
||||
const xiaobaoQuery = new URLSearchParams({ manager: 'true' });
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'healthVersion',
|
||||
label: 'health/version',
|
||||
method: 'GET',
|
||||
path: '/health/version',
|
||||
thresholdMs: DEFAULT_THRESHOLDS.healthVersion,
|
||||
},
|
||||
{
|
||||
key: 'requirementPool',
|
||||
label: 'v2.2 requirement query',
|
||||
method: 'GET',
|
||||
path: `/v2.2/requirements?${requirementQuery.toString()}`,
|
||||
thresholdMs: DEFAULT_THRESHOLDS.requirementPool,
|
||||
},
|
||||
{
|
||||
key: 'versionDetail',
|
||||
label: 'v2.2 version detail',
|
||||
method: 'GET',
|
||||
path: `/v2.2/versions/${encodeURIComponent(args.versionId)}/detail-data`,
|
||||
thresholdMs: DEFAULT_THRESHOLDS.versionDetail,
|
||||
},
|
||||
{
|
||||
key: 'workspace',
|
||||
label: 'v2.2 workspace',
|
||||
method: 'GET',
|
||||
path: `/v2.2/workspace?${workspaceQuery.toString()}`,
|
||||
thresholdMs: DEFAULT_THRESHOLDS.workspace,
|
||||
},
|
||||
{
|
||||
key: 'xiaobaoWarning',
|
||||
label: 'v2.2 xiaobao warning',
|
||||
method: 'GET',
|
||||
path: `/v2.2/xiaobao-warning?${xiaobaoQuery.toString()}`,
|
||||
thresholdMs: DEFAULT_THRESHOLDS.xiaobaoWarning,
|
||||
},
|
||||
].map((probe) => ({
|
||||
...probe,
|
||||
url: `${args.baseUrl}${probe.path}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async function measureProbe(probe, args) {
|
||||
for (let index = 0; index < args.warmup; index += 1) {
|
||||
await requestOnce(probe);
|
||||
}
|
||||
|
||||
const samples = [];
|
||||
const statuses = new Map();
|
||||
const errors = [];
|
||||
for (let index = 0; index < args.iterations; index += 1) {
|
||||
const result = await requestOnce(probe);
|
||||
samples.push(result.durationMs);
|
||||
statuses.set(result.status, (statuses.get(result.status) ?? 0) + 1);
|
||||
if (result.error) errors.push(result.error);
|
||||
if (result.status < 200 || result.status >= 300) {
|
||||
errors.push(`HTTP ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
const p50 = percentile(samples, 50);
|
||||
const p95 = percentile(samples, 95);
|
||||
if (p95 > probe.thresholdMs) {
|
||||
errors.push(`p95 ${p95.toFixed(1)}ms > budget ${probe.thresholdMs}ms`);
|
||||
}
|
||||
|
||||
return {
|
||||
...probe,
|
||||
count: samples.length,
|
||||
p50,
|
||||
p95,
|
||||
statuses: [...statuses.entries()].sort(([a], [b]) => a - b),
|
||||
ok: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestOnce(probe) {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const response = await fetch(probe.url, { method: probe.method });
|
||||
await response.arrayBuffer();
|
||||
return {
|
||||
status: response.status,
|
||||
durationMs: performance.now() - startedAt,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 0,
|
||||
durationMs: performance.now() - startedAt,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function percentile(values, pct) {
|
||||
if (values.length === 0) return 0;
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const index = Math.ceil((pct / 100) * sorted.length) - 1;
|
||||
return sorted[Math.max(0, Math.min(sorted.length - 1, index))];
|
||||
}
|
||||
|
||||
function formatStatuses(statuses) {
|
||||
return statuses.map(([status, count]) => `${status}:${count}`).join(', ');
|
||||
}
|
||||
|
||||
function printProbePlan(probes) {
|
||||
console.log('Performance harness dry-run');
|
||||
for (const probe of probes) {
|
||||
console.log(`${probe.key}\t${probe.method}\t${probe.url}\tbudget_p95_ms=${probe.thresholdMs}`);
|
||||
}
|
||||
}
|
||||
|
||||
function printResults(results) {
|
||||
console.log('probe\tstatus\tp50_ms\tp95_ms\tbudget_p95_ms\tok');
|
||||
for (const result of results) {
|
||||
console.log([
|
||||
result.key,
|
||||
formatStatuses(result.statuses),
|
||||
result.p50.toFixed(1),
|
||||
result.p95.toFixed(1),
|
||||
result.thresholdMs,
|
||||
result.ok ? 'yes' : 'no',
|
||||
].join('\t'));
|
||||
if (result.errors.length > 0) {
|
||||
for (const error of result.errors) console.error(`${result.key}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const probes = buildProbes(args);
|
||||
if (args.dryRun) {
|
||||
printProbePlan(probes);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = [];
|
||||
for (const probe of probes) {
|
||||
results.push(await measureProbe(probe, args));
|
||||
}
|
||||
printResults(results);
|
||||
if (results.some((result) => !result.ok)) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user