test(loadtest),docs: monitoring, cdn checklist and k6 framework

- monitoring.md: 指标→实现映射(1Panel+云监控+结构化日志,不引入额外监控
  系统);pino 字段表如实列自代码(访问日志 req.id/res.statusCode/
  responseTime + sync/worker 自定义 msg 字段);六类告警 jq 检索语句与
  阈值建议总表;日志保留(compose json-file 10m×3 已设 + 每日归档建议)
- cdn-checklist.md: 全步骤待凭证执行;图片加速三选一架构核对(双层签名
  冲突分析,推荐 c 起步=CDN 仅加速 viewer 静态、presigned 直链不变,
  图片收益后评估 b=CDN 鉴权+私有桶回源授权);缓存规则对齐源站 immutable;
  失效/预热 CLI 样例与多地域冷缓存测试点模板
- loadtest/: k6 框架——read-feed.js(N 观众四 mix:latest/hot 分页+大图+
  点赞+浏览,ramp 阶段)、upload-burst.js(M 并发上传全链含 complete 与
  发布/同步可选轮询,兼作数据种子)、lib/helpers.js(登录/建册/上传全链
  +内嵌 JPEG fixture 与 prod-smoke 同源)、README(运行方式/参数表/
  P95 输出解读对齐计划 §6 候选指标;D05 未定→阈值占位注释)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-21 01:52:47 +08:00
parent a79fff5b9c
commit f71a887f17
6 changed files with 924 additions and 0 deletions
+144
View File
@@ -0,0 +1,144 @@
// loadtest/upload-burst.js —— L1/L2 工作人员并发上传压测场景(P5-prep Task 3)。
//
// M 名工作人员(VU)并发执行小图上传全链:presign → 对象存储 PUT → complete,
// 并可选轮询「公开可见」(传输完成→发布,worker 变体流水线 + 发布事务异步)
// 与「ov 侧可见」(cn→ov 同步端到端)。数据面与 scripts/prod-smoke.sh ③④ 同径。
//
// 兼作 read-feed.js 的数据种子:ALBUM_ID 缺省时 setup 建时间戳相册并打印
// albumId/publicToken——用打印出的 ALBUM_TOKEN 跑 read-feed.js 即得固定测试相册。
//
// 用法(README.md 有完整参数表):
// k6 run --env BASE_CN=https://photo.example.cn --env ACCOUNT=a --env PASSWORD=p \
// --env VUS=3 --env ITERATIONS=20 --env WAIT_PUBLISH=1 upload-burst.js
//
// 注意:fixtures 用内嵌 800x600 小图(3118 字节)——只压「链路与并发正确性」;
// 真实尺寸图片的吞吐/处理时延另用真实样本按计划 §6 注记单独验证。
import { Counter, Trend } from 'k6/metrics';
import { check } from 'k6';
import {
createAlbum,
envInt,
envStr,
login,
pollUntilVisible,
uniqId,
uploadPhoto,
} from './lib/helpers.js';
// ── 参数 ──
const BASE_CN = envStr('BASE_CN', envStr('BASE', ''));
const BASE_OV = envStr('BASE_OV', ''); // 留空 = 跳过 ov 同步观测
const ACCOUNT = envStr('ACCOUNT', '');
const PASSWORD = envStr('PASSWORD', '');
const ALBUM_ID = envStr('ALBUM_ID', ''); // 空 = setup 建新相册(种子模式)
const VUS = envInt('VUS', 3); // L1=3 / L2=10(同时上传人数)
const ITERATIONS = envInt('ITERATIONS', 20); // 每人上传张数
const WAIT_PUBLISH = envStr('WAIT_PUBLISH', '0') === '1'; // 轮询 cn 公开可见
const WAIT_SYNC = envStr('WAIT_SYNC', '0') === '1'; // 轮询 ov 公开可见(需 BASE_OV)
const POLL_TIMEOUT = envInt('POLL_TIMEOUT', 60); // 轮询上限秒
const POLL_INTERVAL = envInt('POLL_INTERVAL', 2); // 轮询间隔秒
// ── 指标 ──
const presignLatency = new Trend('upload_presign_duration');
const putLatency = new Trend('upload_oss_put_duration'); // 对象存储外部依赖
const completeLatency = new Trend('upload_complete_duration');
const chainLatency = new Trend('upload_chain_duration'); // 三步端到端
const publishWait = new Trend('publish_wait_duration'); // complete → cn 公开可见
const syncWait = new Trend('ov_sync_wait_duration'); // complete → ov 公开可见
const publishTimeout = new Counter('publish_timeout_total');
const syncTimeout = new Counter('ov_sync_timeout_total');
const uploadFailed = new Counter('upload_failed_total');
export const options = {
scenarios: {
uploaders: {
executor: 'per-vu-iterations',
vus: VUS,
iterations: ITERATIONS,
maxDuration: '30m',
},
},
// thresholds 占位 —— D05 确认后取消注释(候选口径 = 项目计划 §6:
// 传输完成到发布 P95 < 10s,上传耗时单独统计不计入处理时延):
// thresholds: {
// upload_presign_duration: ['p(95)<500'],
// upload_complete_duration: ['p(95)<2000'],
// publish_wait_duration: ['p(95)<10000'],
// http_req_failed: ['rate<0.01'],
// },
};
export function setup() {
if (!BASE_CN || !ACCOUNT || !PASSWORD) {
throw new Error('缺少 --env BASE_CN=<cn 基址> --env ACCOUNT=… --env PASSWORD=…');
}
if (WAIT_SYNC && !BASE_OV) {
throw new Error('WAIT_SYNC=1 需要 --env BASE_OV=<ov 基址>');
}
const token = login(BASE_CN, ACCOUNT, PASSWORD);
let albumId = ALBUM_ID;
let publicToken = '';
if (!albumId) {
const album = createAlbum(BASE_CN, token, `k6-upload-${Date.now()}`);
albumId = album.albumId;
publicToken = album.publicToken;
console.log(
`[seed] 已建相册 albumId=${albumId} ALBUM_TOKEN=${publicToken}` +
'(read-feed.js 用此 token;照片不会自动清理,软删见 README)',
);
} else {
// 复用既有相册:publicToken 由调用方提供(种子模式首次运行会打印);
// 未提供则 WAIT_PUBLISH/WAIT_SYNC 轮询自动跳过(默认函数内按空值守护)。
publicToken = envStr('ALBUM_TOKEN', '');
console.log(
`[seed] 复用相册 albumId=${albumId}` +
(publicToken ? ` ALBUM_TOKEN=${publicToken}` : '(未传 ALBUM_TOKEN,跳过公开可见轮询)'),
);
}
return { token, albumId, publicToken };
}
export default function (data) {
try {
const result = uploadPhoto(BASE_CN, data.token, data.albumId, uniqId('k6up'));
presignLatency.add(result.timings.presign);
putLatency.add(result.timings.put);
completeLatency.add(result.timings.complete);
chainLatency.add(result.timings.chainMs);
check(result, { 'upload chain ok': (r) => Boolean(r.photoId) });
if (WAIT_PUBLISH && data.publicToken && result.photoId) {
const poll = pollUntilVisible(
BASE_CN,
data.publicToken,
result.photoId,
POLL_TIMEOUT,
POLL_INTERVAL,
'publish_poll_cn',
);
if (poll.found) {
publishWait.add(poll.waitedMs);
} else {
publishTimeout.add(1);
}
}
if (WAIT_SYNC && BASE_OV && data.publicToken && result.photoId) {
const poll = pollUntilVisible(
BASE_OV,
data.publicToken,
result.photoId,
POLL_TIMEOUT,
POLL_INTERVAL,
'publish_poll_ov',
);
if (poll.found) {
syncWait.add(poll.waitedMs);
} else {
syncTimeout.add(1);
}
}
} catch (err) {
uploadFailed.add(1);
console.error(`upload chain failed: ${err.message}`);
}
}