d01cecd2f6
- docs/plans/2026-09-21-P5-真实环境接入.md 草案(头部标注待 G5 凭证评审 转正):T1-T12 任务序(每任务 产出/依赖用户输入/关口)——G1 OSS 冒烟→ 国内起栈→ov 部署+bootstrap 对账→域名证书→CDN(含图片加速三选一)→ 跨境 MySQL 通道定案(白名单+SSL/隧道/HTTP 化三选项,含改造量评估)→ G4 用户源→故障演练→压测 D05→G2 真机→G6 试运行→发布交接;用户输入 汇总表 + 决策 D03-D13 状态速查 + 风险表(跨境链路/双层签名/MySQL 8.0.30 门槛/镜像仓库通道等) - 总纲 P5 行注记:准备件完成 2026-09-21,实装待 G5 + G4 - P5-prep 计划全项勾选回填(评审项注明自审已修 + 独立 reviewer 由编排方追加) - 自审修正:monitoring 统计副本表名(无 stat_snapshots 表,副本位在 albums/photos 行)与 outbox_events.occurredAt/photo_jobs.updatedAt 列名、 jq 管道括号;cdn 失效多 URL 换行分隔;k6 helpers 请求体用 ArrayBuffer 原生形态、read-feed 身份串延迟生成(init 上下文 VU 编号恒 0) Co-Authored-By: Claude Code <noreply@anthropic.com>
180 lines
6.8 KiB
JavaScript
180 lines
6.8 KiB
JavaScript
// 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));
|
||
}
|