2026-09-21 01:52:47 +08:00
|
|
|
|
// 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'],
|
|
|
|
|
|
// },
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-09-21 01:56:56 +08:00
|
|
|
|
// k6 每个 VU 独立初始化模块:以下模块级状态天然按 VU 隔离(观众身份)。
|
|
|
|
|
|
// 注意:身份串延迟到 default 内首次使用时生成——init 上下文里 exec.vu.idInTest
|
|
|
|
|
|
// 恒为 0,不能用于区分 VU;uniqId 自带时间戳+随机已保证跨 VU 唯一。
|
|
|
|
|
|
let visitorId = '';
|
|
|
|
|
|
let likeSessionId = '';
|
2026-09-21 01:52:47 +08:00
|
|
|
|
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;
|
2026-09-21 01:56:56 +08:00
|
|
|
|
if (!visitorId) {
|
|
|
|
|
|
visitorId = uniqId('visitor');
|
|
|
|
|
|
}
|
2026-09-21 01:52:47 +08:00
|
|
|
|
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) {
|
2026-09-21 01:56:56 +08:00
|
|
|
|
if (!likeSessionId) {
|
|
|
|
|
|
likeSessionId = uniqId('sess');
|
|
|
|
|
|
}
|
2026-09-21 01:52:47 +08:00
|
|
|
|
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));
|
|
|
|
|
|
}
|