Files
photo-live/loadtest/read-feed.js
T
浅念 d01cecd2f6 docs: p5 preparation complete and implementation draft
- 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>
2026-09-21 01:56:56 +08:00

180 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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));
}