// 协作存储 U7 压测(方案 §3 负载合同 / §5 U7):k6 覆盖非模型普通 API // 的吞吐与延迟阶梯;SSE 可见延迟 P95 由 Go 探针(../sse_probe)承担—— // k6 核心不支持 SSE 流式读取,两者分工记录在同一份结果目录。 // // 目标实例:run_load.sh 起的隔离栈(mock 模式 backend + 隔离 PG),绝不打 // 生产服务。预期限流/冲突(409/429)单列计数,不计入失败。 // C2 双实例:BASE_URLS=a,b 时 VU 粘滞到实例;热点会话跨实例写触发 409; // 另有跨实例读抽样(A 拓扑在 B 上 GET)。 // // 阶梯与环境变量(由 run_load.sh 注入): // BASE_URL / BASE_URLS 单址或逗号分隔双址 // ADMIN_* 引导管理员(run_load.sh 预建;setup 里登录后建压测用户) // USERS 压测用户数(默认 30;每 VU 绑定一个用户) // PROFILE smoke | ladder | sustained60m | sustained2h(默认 smoke) // // 阈值(负载合同,待实测后校准): // 读路径 p(95)<300ms、全量 p(99)<1s、非预期失败率<0.1%。 import http from 'k6/http'; import { check, group, sleep } from 'k6'; import { Counter, Trend } from 'k6/metrics'; import exec from 'k6/execution'; const BASE_SINGLE = __ENV.BASE_URL || 'http://127.0.0.1:18980'; const BASE_URLS = (__ENV.BASE_URLS || BASE_SINGLE) .split(',') .map((s) => s.trim()) .filter(Boolean); const ADMIN_OWNER = __ENV.ADMIN_OWNER || 'load_admin'; const ADMIN_PASS = __ENV.ADMIN_PASS || 'load-admin-pass'; const USERS = parseInt(__ENV.USERS || '30', 10); const PROFILE = __ENV.PROFILE || 'smoke'; // 预期拥塞(负载合同要求单列,不算失败):409 会话忙 / 429 队列满。 const expectedContention = new Counter('expected_contention'); const msgLatency = new Trend('message_roundtrip', true); const crossReadOk = new Counter('cross_instance_read_ok'); const stages = { smoke: [{ duration: '30s', target: 10 }], ladder: [ { duration: '2m', target: 10 }, { duration: '3m', target: 30 }, { duration: '5m', target: 100 }, { duration: '2m', target: 0 }, ], sustained60m: [ { duration: '2m', target: 30 }, { duration: '56m', target: 30 }, { duration: '2m', target: 0 }, ], sustained2h: [ { duration: '3m', target: 30 }, { duration: '114m', target: 30 }, { duration: '3m', target: 0 }, ], }; export const options = { stages: stages[PROFILE] || stages.smoke, thresholds: { // 非预期失败:2xx 之外;409/429 经 expectedStatuses 排除后进 expected_contention。 http_req_failed: ['rate<0.001'], 'http_req_duration{group:::read}': ['p(95)<300', 'p(99)<1000'], http_req_duration: ['p(99)<1000'], }, summaryTrendStats: ['avg', 'p(50)', 'p(95)', 'p(99)', 'max'], }; // 会话忙 / 队列满为负载合同预期拥塞,不计入 http_req_failed。 http.setResponseCallback(http.expectedStatuses(200, 201, 202, 409, 429)); function baseForVU() { // VU 粘滞:偶数 → 0,奇数 → 1(单实例时恒为 0)。 const idx = exec.vu.idInTest % BASE_URLS.length; return BASE_URLS[idx]; } function otherBase(mine) { if (BASE_URLS.length < 2) return mine; return BASE_URLS[0] === mine ? BASE_URLS[1] : BASE_URLS[0]; } const jsonHeaders = { headers: { 'Content-Type': 'application/json' } }; function parseJSON(res) { try { return res.json(); } catch (e) { return null; } } // 与现网一致:管理员建号 → 首登 must_change → POST /api/auth/password → 再登录。 // 压测口令保持不变(next=同一密码),只走完设密门,不另造后门清标志。 function loginReady(base, owner, password) { const login = http.post(`${base}/api/auth/login`, JSON.stringify({ owner, password }), jsonHeaders); if (login.status !== 200) return login; const body = parseJSON(login); if (body && body.mustChangePassword) { const ch = http.post(`${base}/api/auth/password`, JSON.stringify({ next: password }), jsonHeaders); if (ch.status !== 200) return ch; return http.post(`${base}/api/auth/login`, JSON.stringify({ owner, password }), jsonHeaders); } return login; } // ---- setup:管理员建压测用户;双实例时建共享热点会话 ---- export function setup() { const adminBase = BASE_URLS[0]; const adminLogin = loginReady(adminBase, ADMIN_OWNER, ADMIN_PASS); if (adminLogin.status !== 200) throw new Error(`admin login: ${adminLogin.status} ${adminLogin.body}`); const users = []; for (let i = 0; i < USERS; i++) { const owner = `load_u${i}`; const password = `load-user-pass-${i}-Aa9`; const created = http.post(`${adminBase}/api/admin/users`, JSON.stringify({ owner, name: `压测用户${i}`, password, isAdmin: false }), jsonHeaders); if (created.status !== 200 && created.status !== 409) throw new Error(`create user ${owner}: ${created.status} ${created.body}`); users.push({ owner, password }); } // 建号必须保持管理员会话;设密会换 cookie,只能在全员建完后逐个走门。 for (const u of users) { const gated = loginReady(adminBase, u.owner, u.password); if (gated.status !== 200) throw new Error(`activate ${u.owner}: ${gated.status} ${gated.body}`); } // 热点会话:多 VU 跨实例争抢同一会话(C2「互相不拖累」的争用源)。 // 建用户时 cookie 落在最后一位 load_u* 上,热点资源须用管理员会话。 const adminAgain = loginReady(adminBase, ADMIN_OWNER, ADMIN_PASS); if (adminAgain.status !== 200) throw new Error(`admin re-login: ${adminAgain.status} ${adminAgain.body}`); let hotspot = null; if (BASE_URLS.length >= 2) { const proj = http.post(`${adminBase}/api/projects`, JSON.stringify({ name: 'hotspot-shared' }), jsonHeaders); if (proj.status !== 200) throw new Error(`hotspot project: ${proj.status} ${proj.body}`); const projBody = parseJSON(proj); const conv = http.post(`${adminBase}/api/projects/${projBody.id}/conversations`, JSON.stringify({ title: '热点会话' }), jsonHeaders); if (conv.status !== 200) throw new Error(`hotspot conv: ${conv.status} ${conv.body}`); hotspot = { projectID: projBody.id, convID: parseJSON(conv).id, owner: ADMIN_OWNER, password: ADMIN_PASS }; } return { users, hotspot, bases: BASE_URLS }; } // ---- default:每 VU 一个用户身份,混合读写 ---- export default function (data) { const base = baseForVU(); const u = data.users[exec.scenario.iterationInTest % data.users.length]; const login = loginReady(base, u.owner, u.password); if (login.status !== 200) { check(null, { 'user login': () => false }); return; } if (!u.__ctx) { const proj = http.post(`${base}/api/projects`, JSON.stringify({ name: `压测-${u.owner}` }), jsonHeaders); const projBody = parseJSON(proj); if (proj.status !== 200 || !projBody || !projBody.id) { check(proj, { 'create project 2xx': () => false }); return; } const conv = http.post(`${base}/api/projects/${projBody.id}/conversations`, JSON.stringify({ title: '压测会话' }), jsonHeaders); const convBody = parseJSON(conv); if (conv.status !== 200 || !convBody || !convBody.id) { check(conv, { 'create conversation 2xx': () => false }); return; } u.__ctx = { projectID: projBody.id, convID: convBody.id }; } const { projectID, convID } = u.__ctx; group('read', () => { const projs = http.get(`${base}/api/projects`); check(projs, { 'list projects 2xx': (r) => r.status === 200 }); const detail = http.get(`${base}/api/conversations/${convID}`); check(detail, { 'conversation detail 2xx': (r) => r.status === 200 }); const cards = http.get(`${base}/api/conversations/${convID}/cards`); check(cards, { 'cards 2xx': (r) => r.status === 200 }); const settings = http.get(`${base}/api/settings`); check(settings, { 'settings 2xx': (r) => r.status === 200 }); }); // 跨实例读:本 VU 会话在另一实例上应立即可见(共享权威)。 if (BASE_URLS.length >= 2 && exec.scenario.iterationInTest % 5 === 0) { const other = otherBase(base); // 另一实例需同用户登录(cookie jar 按 VU;换 host 需重新登录)。 const loginB = loginReady(other, u.owner, u.password); if (loginB.status === 200) { const cross = http.get(`${other}/api/conversations/${convID}`); if (cross.status === 200) crossReadOk.add(1); check(cross, { 'cross-instance conversation 2xx': (r) => r.status === 200 }); } } group('write', () => { // ~20% 迭代打热点会话(双实例时经本 VU 粘滞实例提交 → 预期 409 上升)。 let targetConv = convID; let writeBase = base; if (data.hotspot && exec.scenario.iterationInTest % 5 === 0) { targetConv = data.hotspot.convID; // 热点会话属 admin:先以 admin 登录再写。 const hl = loginReady(writeBase, data.hotspot.owner, data.hotspot.password); if (hl.status !== 200) { check(null, { 'hotspot admin login': () => false }); return; } } const requestId = `req-${u.owner}-${exec.scenario.iterationInTest}-${exec.vu.idInTest}`; const body = JSON.stringify({ text: `压测消息 ${Date.now()}`, requestId }); const t0 = Date.now(); const sent = http.post(`${writeBase}/api/conversations/${targetConv}/messages`, body, { headers: { 'Content-Type': 'application/json' } }); if (sent.status === 200) { msgLatency.add(Date.now() - t0); const id = sent.json().message && sent.json().message.id; check(sent, { 'message has id': () => !!id }); if (exec.scenario.iterationInTest % 10 === 0 && targetConv === convID) { const replay = http.post(`${writeBase}/api/conversations/${targetConv}/messages`, body, { headers: { 'Content-Type': 'application/json' } }); check(replay, { 'requestId replay same message': (r) => r.status === 200 && r.json().message && r.json().message.id === id, }); } } else if (sent.status === 409 || sent.status === 429) { expectedContention.add(1); } else { check(sent, { 'message send 2xx': () => false }); } }); sleep(1); }