352 lines
13 KiB
JavaScript
352 lines
13 KiB
JavaScript
|
|
/**
|
|||
|
|
* 用户管理系统 (UMS) - k6 全场景性能测试套件
|
|||
|
|
*
|
|||
|
|
* 测试策略:
|
|||
|
|
* Stage 1 - 预热阶段 (2min): 从 0 → 10 VU,验证系统基线
|
|||
|
|
* Stage 2 - 正常负载 (5min): 50 VU,验证日常运营能力
|
|||
|
|
* Stage 3 - 峰值负载 (3min): 100 VU,验证高峰时段
|
|||
|
|
* Stage 4 - 持续峰值 (5min): 100 VU,验证耐久性
|
|||
|
|
* Stage 5 - 压力测试 (2min): 200 VU,寻找系统断点
|
|||
|
|
* Stage 6 - 尖峰测试 (1min): 500 VU,模拟流量骤增
|
|||
|
|
* Stage 7 - 冷却阶段 (2min): 200 → 0 VU
|
|||
|
|
*
|
|||
|
|
* 运行命令:
|
|||
|
|
* k6 run --env BASE_URL=http://localhost:8080 docs/performance/k6_load_test.js
|
|||
|
|
* k6 run --env BASE_URL=http://localhost:8080 --env SCENARIO=smoke docs/performance/k6_load_test.js
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import http from 'k6/http';
|
|||
|
|
import { check, sleep, group } from 'k6';
|
|||
|
|
import { Rate, Trend, Counter, Gauge } from 'k6/metrics';
|
|||
|
|
import { SharedArray } from 'k6/data';
|
|||
|
|
import exec from 'k6/execution';
|
|||
|
|
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
// 自定义指标
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
const loginErrorRate = new Rate('login_errors');
|
|||
|
|
const apiErrorRate = new Rate('api_errors');
|
|||
|
|
const loginLatency = new Trend('login_latency_ms', true);
|
|||
|
|
const userQueryLatency = new Trend('user_query_latency_ms', true);
|
|||
|
|
const tokenRefreshLatency = new Trend('token_refresh_latency_ms', true);
|
|||
|
|
const authRequests = new Counter('authenticated_requests');
|
|||
|
|
const activeSessionGauge = new Gauge('active_sessions');
|
|||
|
|
|
|||
|
|
const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
|
|||
|
|
const SCENARIO = __ENV.SCENARIO || 'full';
|
|||
|
|
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
// 测试场景配置
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
const scenarios = {
|
|||
|
|
smoke: {
|
|||
|
|
stages: [
|
|||
|
|
{ duration: '30s', target: 5 },
|
|||
|
|
{ duration: '1m', target: 5 },
|
|||
|
|
{ duration: '30s', target: 0 },
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
full: {
|
|||
|
|
stages: [
|
|||
|
|
{ duration: '2m', target: 10 }, // 预热
|
|||
|
|
{ duration: '5m', target: 50 }, // 正常负载
|
|||
|
|
{ duration: '3m', target: 100 }, // 峰值负载
|
|||
|
|
{ duration: '5m', target: 100 }, // 持续峰值(耐久)
|
|||
|
|
{ duration: '2m', target: 200 }, // 压力测试
|
|||
|
|
{ duration: '1m', target: 500 }, // 尖峰测试
|
|||
|
|
{ duration: '2m', target: 0 }, // 冷却
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
stress: {
|
|||
|
|
stages: [
|
|||
|
|
{ duration: '2m', target: 200 },
|
|||
|
|
{ duration: '5m', target: 200 },
|
|||
|
|
{ duration: '2m', target: 400 },
|
|||
|
|
{ duration: '5m', target: 400 },
|
|||
|
|
{ duration: '2m', target: 0 },
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
soak: {
|
|||
|
|
stages: [
|
|||
|
|
{ duration: '2m', target: 50 },
|
|||
|
|
{ duration: '30m', target: 50 }, // 耐力测试 30 分钟
|
|||
|
|
{ duration: '2m', target: 0 },
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
export const options = {
|
|||
|
|
stages: scenarios[SCENARIO]?.stages || scenarios.full.stages,
|
|||
|
|
thresholds: {
|
|||
|
|
// HTTP 级别 SLA
|
|||
|
|
http_req_duration: ['p(95)<500', 'p(99)<1000'],
|
|||
|
|
http_req_failed: ['rate<0.01'], // 错误率 < 1%
|
|||
|
|
|
|||
|
|
// 业务级别 SLA
|
|||
|
|
login_latency_ms: ['p(95)<300', 'p(99)<800'],
|
|||
|
|
user_query_latency_ms: ['p(95)<200', 'p(99)<500'],
|
|||
|
|
token_refresh_latency_ms: ['p(95)<150', 'p(99)<400'],
|
|||
|
|
|
|||
|
|
// 错误率
|
|||
|
|
login_errors: ['rate<0.02'], // 登录错误率 < 2%
|
|||
|
|
api_errors: ['rate<0.01'], // API 错误率 < 1%
|
|||
|
|
},
|
|||
|
|
summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(90)', 'p(95)', 'p(99)', 'count'],
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
// 辅助函数
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
function getCsrfToken() {
|
|||
|
|
const res = http.get(`${BASE_URL}/api/v1/auth/csrf-token`, {
|
|||
|
|
headers: { 'Content-Type': 'application/json' },
|
|||
|
|
});
|
|||
|
|
if (res.status === 200) {
|
|||
|
|
try {
|
|||
|
|
return res.json('csrf_token') || res.json('data.csrf_token') || '';
|
|||
|
|
} catch (_) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function login(username, password, csrfToken) {
|
|||
|
|
const start = Date.now();
|
|||
|
|
const payload = JSON.stringify({
|
|||
|
|
account: username,
|
|||
|
|
password: password,
|
|||
|
|
device_id: `load-test-device-${exec.vu.idInTest}`,
|
|||
|
|
device_name: 'k6-load-tester',
|
|||
|
|
device_browser: 'k6',
|
|||
|
|
device_os: 'linux',
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const res = http.post(`${BASE_URL}/api/v1/auth/login`, payload, {
|
|||
|
|
headers: {
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'X-CSRF-Token': csrfToken,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const latencyMs = Date.now() - start;
|
|||
|
|
loginLatency.add(latencyMs);
|
|||
|
|
|
|||
|
|
const success = check(res, {
|
|||
|
|
'登录状态200': (r) => r.status === 200,
|
|||
|
|
'返回access_token': (r) => {
|
|||
|
|
try {
|
|||
|
|
const body = r.json();
|
|||
|
|
return !!(body.access_token || (body.data && body.data.access_token));
|
|||
|
|
} catch (_) { return false; }
|
|||
|
|
},
|
|||
|
|
'登录延迟<800ms': (_) => latencyMs < 800,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
loginErrorRate.add(!success);
|
|||
|
|
return res.status === 200 ? res : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getAccessToken(loginRes) {
|
|||
|
|
if (!loginRes) return null;
|
|||
|
|
try {
|
|||
|
|
const body = loginRes.json();
|
|||
|
|
return body.access_token || (body.data && body.data.access_token) || null;
|
|||
|
|
} catch (_) { return null; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function authHeaders(token, csrfToken) {
|
|||
|
|
return {
|
|||
|
|
headers: {
|
|||
|
|
'Authorization': `Bearer ${token}`,
|
|||
|
|
'Content-Type': 'application/json',
|
|||
|
|
'X-CSRF-Token': csrfToken || '',
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
// 测试场景主函数
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
export default function () {
|
|||
|
|
const csrfToken = getCsrfToken();
|
|||
|
|
sleep(0.1);
|
|||
|
|
|
|||
|
|
// ── 场景1: 认证流程 (权重 30%) ──────────────
|
|||
|
|
group('认证流程', function () {
|
|||
|
|
const loginRes = login('admin', 'Admin@123456', csrfToken);
|
|||
|
|
if (!loginRes) { sleep(1); return; }
|
|||
|
|
|
|||
|
|
const token = getAccessToken(loginRes);
|
|||
|
|
if (!token) { sleep(1); return; }
|
|||
|
|
|
|||
|
|
activeSessionGauge.add(1);
|
|||
|
|
authRequests.add(1);
|
|||
|
|
|
|||
|
|
// 获取用户信息
|
|||
|
|
const userInfoRes = http.get(`${BASE_URL}/api/v1/auth/userinfo`, authHeaders(token, csrfToken));
|
|||
|
|
check(userInfoRes, {
|
|||
|
|
'用户信息200': (r) => r.status === 200,
|
|||
|
|
'包含用户名': (r) => {
|
|||
|
|
try { return !!r.json('username'); } catch (_) { return false; }
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
apiErrorRate.add(userInfoRes.status !== 200);
|
|||
|
|
|
|||
|
|
sleep(0.5 + Math.random() * 0.5);
|
|||
|
|
|
|||
|
|
// ── 场景2: 用户管理操作 (权重 40%) ──────────
|
|||
|
|
group('用户管理', function () {
|
|||
|
|
const start = Date.now();
|
|||
|
|
const listRes = http.get(
|
|||
|
|
`${BASE_URL}/api/v1/users?page=1&page_size=20`,
|
|||
|
|
authHeaders(token, csrfToken)
|
|||
|
|
);
|
|||
|
|
const latencyMs = Date.now() - start;
|
|||
|
|
userQueryLatency.add(latencyMs);
|
|||
|
|
|
|||
|
|
const listOk = check(listRes, {
|
|||
|
|
'用户列表200': (r) => r.status === 200,
|
|||
|
|
'返回数据数组': (r) => {
|
|||
|
|
try {
|
|||
|
|
const body = r.json();
|
|||
|
|
return Array.isArray(body.data) || Array.isArray(body.items) ||
|
|||
|
|
(body.data && Array.isArray(body.data.list));
|
|||
|
|
} catch (_) { return false; }
|
|||
|
|
},
|
|||
|
|
'查询延迟<500ms': (_) => latencyMs < 500,
|
|||
|
|
});
|
|||
|
|
apiErrorRate.add(!listOk);
|
|||
|
|
|
|||
|
|
sleep(0.2 + Math.random() * 0.3);
|
|||
|
|
|
|||
|
|
// 角色列表查询
|
|||
|
|
const rolesRes = http.get(`${BASE_URL}/api/v1/roles`, authHeaders(token, csrfToken));
|
|||
|
|
check(rolesRes, {
|
|||
|
|
'角色列表200': (r) => r.status === 200,
|
|||
|
|
});
|
|||
|
|
apiErrorRate.add(rolesRes.status !== 200);
|
|||
|
|
|
|||
|
|
sleep(0.2);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ── 场景3: 日志查询(分页)──────────────────
|
|||
|
|
group('日志查询', function () {
|
|||
|
|
// offset 分页
|
|||
|
|
const logRes = http.get(
|
|||
|
|
`${BASE_URL}/api/v1/logs/login?page=1&page_size=20`,
|
|||
|
|
authHeaders(token, csrfToken)
|
|||
|
|
);
|
|||
|
|
check(logRes, {
|
|||
|
|
'日志列表200': (r) => r.status === 200,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
sleep(0.3 + Math.random() * 0.2);
|
|||
|
|
|
|||
|
|
// cursor 分页(深翻)
|
|||
|
|
const cursorRes = http.get(
|
|||
|
|
`${BASE_URL}/api/v1/logs/login?size=20`,
|
|||
|
|
authHeaders(token, csrfToken)
|
|||
|
|
);
|
|||
|
|
check(cursorRes, {
|
|||
|
|
'cursor分页200': (r) => r.status === 200,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
sleep(0.2);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
// ── 场景4: Token 刷新 (每10次请求模拟一次) ──
|
|||
|
|
if (exec.vu.iterationInScenario % 10 === 0) {
|
|||
|
|
group('Token刷新', function () {
|
|||
|
|
const start = Date.now();
|
|||
|
|
const refreshRes = http.post(
|
|||
|
|
`${BASE_URL}/api/v1/auth/refresh`,
|
|||
|
|
null,
|
|||
|
|
authHeaders(token, csrfToken)
|
|||
|
|
);
|
|||
|
|
const latencyMs = Date.now() - start;
|
|||
|
|
tokenRefreshLatency.add(latencyMs);
|
|||
|
|
|
|||
|
|
check(refreshRes, {
|
|||
|
|
'刷新成功200或401': (r) => r.status === 200 || r.status === 401,
|
|||
|
|
});
|
|||
|
|
sleep(0.1);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
activeSessionGauge.add(-1);
|
|||
|
|
sleep(1 + Math.random() * 1);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
// 测试结束汇总
|
|||
|
|
// ─────────────────────────────────────────────
|
|||
|
|
export function handleSummary(data) {
|
|||
|
|
const now = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
|||
|
|
|
|||
|
|
function formatMetric(metric) {
|
|||
|
|
if (!metric || !metric.values) return 'N/A';
|
|||
|
|
const v = metric.values;
|
|||
|
|
if (v.rate !== undefined) return `${(v.rate * 100).toFixed(2)}%`;
|
|||
|
|
if (v['p(99)'] !== undefined) {
|
|||
|
|
return `avg=${v.avg?.toFixed(1)}ms p50=${v.med?.toFixed(1)}ms p95=${v['p(95)']?.toFixed(1)}ms p99=${v['p(99)']?.toFixed(1)}ms max=${v.max?.toFixed(1)}ms`;
|
|||
|
|
}
|
|||
|
|
return JSON.stringify(v);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const report = {
|
|||
|
|
summary: {
|
|||
|
|
test_time: now,
|
|||
|
|
scenario: SCENARIO,
|
|||
|
|
base_url: BASE_URL,
|
|||
|
|
total_requests: data.metrics.http_reqs?.values?.count,
|
|||
|
|
total_duration: data.state?.testRunDurationMs,
|
|||
|
|
peak_vus: data.metrics.vus_max?.values?.max,
|
|||
|
|
},
|
|||
|
|
sla_results: {
|
|||
|
|
http_req_duration_p99: formatMetric(data.metrics.http_req_duration),
|
|||
|
|
http_req_failed_rate: formatMetric(data.metrics.http_req_failed),
|
|||
|
|
login_latency_p99: formatMetric(data.metrics.login_latency_ms),
|
|||
|
|
user_query_latency_p99: formatMetric(data.metrics.user_query_latency_ms),
|
|||
|
|
token_refresh_latency_p99: formatMetric(data.metrics.token_refresh_latency_ms),
|
|||
|
|
login_error_rate: formatMetric(data.metrics.login_errors),
|
|||
|
|
api_error_rate: formatMetric(data.metrics.api_errors),
|
|||
|
|
},
|
|||
|
|
raw_metrics: data.metrics,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
[`docs/performance/results/k6_result_${now}.json`]: JSON.stringify(report, null, 2),
|
|||
|
|
stdout: generateTextSummary(data, report),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function generateTextSummary(data, report) {
|
|||
|
|
const thresholds = data.metrics;
|
|||
|
|
const passed = Object.entries(data.metrics)
|
|||
|
|
.filter(([, m]) => m.thresholds)
|
|||
|
|
.every(([, m]) => Object.values(m.thresholds).every(t => !t.ok === false));
|
|||
|
|
|
|||
|
|
return `
|
|||
|
|
╔══════════════════════════════════════════════════════════════════╗
|
|||
|
|
║ UMS 性能测试报告 (k6) ║
|
|||
|
|
╚══════════════════════════════════════════════════════════════════╝
|
|||
|
|
|
|||
|
|
📊 测试概要
|
|||
|
|
场景: ${report.summary.scenario}
|
|||
|
|
目标地址: ${report.summary.base_url}
|
|||
|
|
总请求数: ${report.summary.total_requests?.toLocaleString() || 'N/A'}
|
|||
|
|
峰值 VU: ${report.summary.peak_vus || 'N/A'}
|
|||
|
|
|
|||
|
|
⚡ SLA 结果
|
|||
|
|
HTTP P99: ${report.sla_results.http_req_duration_p99}
|
|||
|
|
HTTP 错误率: ${report.sla_results.http_req_failed_rate}
|
|||
|
|
登录 P99: ${report.sla_results.login_latency_p99}
|
|||
|
|
用户查询 P99: ${report.sla_results.user_query_latency_p99}
|
|||
|
|
Token刷新 P99: ${report.sla_results.token_refresh_latency_p99}
|
|||
|
|
|
|||
|
|
📝 详细结果已写入 docs/performance/results/
|
|||
|
|
`;
|
|||
|
|
}
|