Files
photo-live/loadtest/read-feed.js
T

180 lines
6.8 KiB
JavaScript
Raw Normal View History

// 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=<ov 基址> 与 --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));
}