// loadtest/read-feed.js —— L1/L2 观众读路径压测场景(P5-prep Task 3)。 // // 模拟 N 名观众(VU=观众,ramp 阶段爬坡-稳态-退坡)在 ov 域公开相册上的四类 // 行为混合:持续分页读最新(latest)/ 热门(hot)切换 / 大图请求(presigned // 直链打到对象存储,单独 tag 区分)/ 点赞批次 / 浏览上报(每 VU 一次, // 30min 去重窗口内重复上报 counted=false 属正常语义)。 // // 用法(README.md 有完整参数表): // k6 run --env BASE=https://photo.example.com --env ALBUM_TOKEN=xxx \ // --env VUS=100 --env STEADY=5m read-feed.js // // 阈值(thresholds)为占位注释:D05(数量并发与网络目标)未定,候选口径 // 对齐项目计划 §6 候选指标,确认后在 options.thresholds 取消注释启用。 import http from 'k6/http'; import { check, sleep } from 'k6'; import { Counter, Trend } from 'k6/metrics'; import { envFloat, envInt, envStr, fetchFeedPage, likeBatch, reportVisit, uniqId, } from './lib/helpers.js'; // ── 参数(全部可 --env 覆盖;缺省 = L1 基础档)── const BASE = envStr('BASE', ''); const ALBUM_TOKEN = envStr('ALBUM_TOKEN', ''); const VUS = envInt('VUS', 100); // L1=100 / L2=1000(在线观众) const RAMP_UP = envStr('RAMP_UP', '1m'); const STEADY = envStr('STEADY', '5m'); const RAMP_DOWN = envStr('RAMP_DOWN', '1m'); const PAGES = envInt('PAGES', 3); // 每迭代翻页深度 const PAGE_LIMIT = envInt('PAGE_LIMIT', 30); // 契约上限 60 const HOT_SHARE = envFloat('HOT_SHARE', 0.5); // 迭代内切热门排序概率 const BIGIMG_SHARE = envFloat('BIGIMG_SHARE', 0.3); // 迭代内取大图概率 const LIKE_SHARE = envFloat('LIKE_SHARE', 0.4); // 迭代内点赞批次概率 const THINK_MIN = envFloat('THINK_MIN', 0.5); // 观众思考时间(秒) const THINK_MAX = envFloat('THINK_MAX', 3); // ── 指标(按操作分组,输出解读见 README)── const listLatency = new Trend('feed_list_duration'); // latest 分页请求 const hotLatency = new Trend('feed_hot_duration'); // hot 排序页 const imageLatency = new Trend('image_get_duration'); // 对象存储直链大图(外部依赖) const likeLatency = new Trend('like_confirm_duration'); // 点赞批次确认 const visitLatency = new Trend('visit_report_duration'); // 浏览上报 const likeAccepted = new Counter('like_accepted_total'); const likeRejected = new Counter('like_rejected_total'); // 429 限速 / batchAccepted=false const listBroken = new Counter('feed_list_error_total'); // 非 200 / 解析失败 export const options = { scenarios: { viewers: { executor: 'ramping-vus', startVUs: 0, stages: [ { duration: RAMP_UP, target: VUS }, { duration: STEADY, target: VUS }, { duration: RAMP_DOWN, target: 0 }, ], gracefulRampDown: '30s', }, }, // thresholds 占位 —— D05 确认后取消注释(候选口径 = 项目计划 §6): // 普通列表 API P95 < 500ms;点赞确认 P95 < 2s(广播链路另由前端观测)。 // thresholds: { // feed_list_duration: ['p(95)<500'], // feed_hot_duration: ['p(95)<500'], // like_confirm_duration: ['p(95)<2000'], // visit_report_duration: ['p(95)<500'], // 'checks': ['rate>0.99'], // http_req_failed: ['rate<0.01'], // }, }; // k6 每个 VU 独立初始化模块:以下模块级状态天然按 VU 隔离(观众身份)。 // 注意:身份串延迟到 default 内首次使用时生成——init 上下文里 exec.vu.idInTest // 恒为 0,不能用于区分 VU;uniqId 自带时间戳+随机已保证跨 VU 唯一。 let visitorId = ''; let likeSessionId = ''; let visitReported = false; export function setup() { if (!BASE || !ALBUM_TOKEN) { throw new Error('缺少 --env BASE= 与 --env ALBUM_TOKEN=<公开相册 token>'); } const res = http.get(`${BASE}/v1/public/albums/${ALBUM_TOKEN}`); if (res.status !== 200) { throw new Error(`相册不可访问(HTTP ${res.status}):核对 BASE/ALBUM_TOKEN`); } return { albumTitle: res.json('title') }; } export default function () { // ① 浏览上报(每观众会话一次;窗口语义见文件头) if (!visitReported) { visitReported = true; if (!visitorId) { visitorId = uniqId('visitor'); } const res = reportVisit(BASE, ALBUM_TOKEN, visitorId, uniqId('vr')); visitLatency.add(res.timings.duration); check(res, { 'visit report 200': (r) => r.status === 200 }); } // ② 最新分页(游标推进至 nextCursor=null 或 PAGES 页) const items = []; let cursor; for (let page = 0; page < PAGES && cursor !== null; page += 1) { const res = fetchFeedPage(BASE, ALBUM_TOKEN, { cursor, limit: PAGE_LIMIT, tag: 'list_public_photos', }); listLatency.add(res.timings.duration); if ( !check(res, { 'list 200': (r) => r.status === 200, 'list has items': (r) => Array.isArray(r.json('items')), }) ) { listBroken.add(1); break; } const body = res.json(); for (const item of body.items) { items.push(item); } cursor = body.nextCursor; // null = 末页,循环条件止 } // ③ 热门排序一页(sort=hot 仅 ov 实现) if (Math.random() < HOT_SHARE) { const res = fetchFeedPage(BASE, ALBUM_TOKEN, { limit: PAGE_LIMIT, sort: 'hot', tag: 'list_public_photos_hot', }); hotLatency.add(res.timings.duration); check(res, { 'hot list 200': (r) => r.status === 200 }); } // ④ 大图请求(presigned 直链 → 对象存储/CDN,非被测 API;tag 区分解读) if (items.length > 0 && Math.random() < BIGIMG_SHARE) { const item = items[Math.floor(Math.random() * items.length)]; const url = item.browse && item.browse.url; if (url) { const res = http.get(url, { tags: { name: 'oss_image_get' } }); imageLatency.add(res.timings.duration); check(res, { 'image 200': (r) => r.status === 200 }); } } // ⑤ 点赞批次(匿名端点;限速口径见 README——压测会话请注意 LIKE_IP_WINDOW_DELTA) if (items.length > 0 && Math.random() < LIKE_SHARE) { if (!likeSessionId) { likeSessionId = uniqId('sess'); } const item = items[Math.floor(Math.random() * items.length)]; const delta = 1 + Math.floor(Math.random() * 3); // 契约 1..20,取保守值 const res = likeBatch( BASE, ALBUM_TOKEN, item.photoId, likeSessionId, uniqId('batch'), delta, ); likeLatency.add(res.timings.duration); if (res.status === 200 && res.json('batchAccepted') === true) { likeAccepted.add(1); } else { likeRejected.add(1); // 429=限速触发(压测口径问题,见 README);非 200=链路异常 } } sleep(THINK_MIN + Math.random() * (THINK_MAX - THINK_MIN)); }