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
+96
View File
@@ -0,0 +1,96 @@
# loadtest —— k6 压测框架(P5-prep Task 3)
对齐项目计划 §6 首轮容量验证建议的 L1/L2 阶梯(L1 基础:相册 1000 照片 /
3 人上传 / 100 观众;L2 目标候选:10000 / 10 / 1000;L3 扩展探测不设默认门槛)。
**D05(数量并发与网络目标)未定——所有 thresholds 为占位注释**,确认后取消
注释启用(候选口径见 §3)。
## 文件
| 文件 | 用途 |
| --- | --- |
| `read-feed.js` | 观众读路径:N 观众(ramp 阶段)持续分页读最新 + 热门切换 + 大图请求 + 点赞批次 + 浏览上报四 mix |
| `upload-burst.js` | 工作人员 M 并发小图上传全链(presign → OSS PUT → complete),可选轮询发布可见 / ov 同步;兼作数据种子 |
| `lib/helpers.js` | 登录 / 建相册 / 上传全链 / 分页 / 点赞 / 浏览 / 轮询助手 + 内嵌 800x600 JPEG fixture(与 `scripts/prod-smoke.sh` 同源) |
安装:[k6](https://grafana.com/docs/k6/latest/set-up/install/) ≥ 0.45(Windows/
macOS/Linux 均有安装包)。脚本在仓库根 `loadtest/` 下直接 `k6 run`。
## 运行方式
```bash
# ① 种子:建时间戳相册并上传照片(L1 档:3 人 × 20 张;setup 会打印 ALBUM_TOKEN)
k6 run --env BASE_CN=https://photo.example.cn \
--env ACCOUNT=admin --env PASSWORD='…' \
--env VUS=3 --env ITERATIONS=20 upload-burst.js
# ② 观众读路径压测(L1 档:100 观众;用 ① 打印的 ALBUM_TOKEN)
k6 run --env BASE=https://photo.example.com --env ALBUM_TOKEN=<打印值> \
--env VUS=100 --env STEADY=5m read-feed.js
# ②' L2 档:观众 1000 / 上传 10 人(先种更大相册:10 × 100)
k6 run --env BASE=… --env ALBUM_TOKEN=… --env VUS=1000 --env STEADY=10m read-feed.js
# ③ 上传链含发布观测(传输完成→发布;ov 同步端到端可选)
k6 run --env BASE_CN=… --env ACCOUNT=… --env PASSWORD=… \
--env ALBUM_ID=<albumId> --env ALBUM_TOKEN=<token> \
--env WAIT_PUBLISH=1 --env WAIT_SYNC=1 --env BASE_OV=https://photo.example.com \
upload-burst.js
# 结果归档:--out json=results-$(date +%F-%H%M).json 或 --summary-export=summary.json
```
### 参数总表(--env KEY=VALUE)
| KEY | 脚本 | 缺省 | 说明 |
| --- | --- | --- | --- |
| `BASE` | 两者 | 必填(read)/ upload 的 BASE_CN 回退 | 被测 API 基址(read 用 ov 域,upload 用 cn 域),末尾不带 `/v1` |
| `ALBUM_TOKEN` | read 必填 / upload 可选 | — | 公开相册 token(种子模式首次运行打印) |
| `ACCOUNT` / `PASSWORD` | upload | 必填 | 工作人员(或 admin)账号 |
| `ALBUM_ID` | upload | 空=建新册 | 复用相册;配合 `ALBUM_TOKEN` 才有发布轮询 |
| `VUS` | 两者 | 100 / 3 | 观众数 / 上传并发人数 |
| `ITERATIONS` | upload | 20 | 每人上传张数(L1 合计 = VUS×ITERATIONS) |
| `STEADY` / `RAMP_UP` / `RAMP_DOWN` | read | 5m / 1m / 1m | ramp 阶段时长 |
| `PAGES` / `PAGE_LIMIT` | read | 3 / 30 | 翻页深度 / 页大小(契约上限 60) |
| `HOT_SHARE` / `BIGIMG_SHARE` / `LIKE_SHARE` | read | 0.5 / 0.3 / 0.4 | 迭代内行为混合概率 |
| `THINK_MIN` / `THINK_MAX` | read | 0.5 / 3 | 观众思考时间秒 |
| `WAIT_PUBLISH` / `WAIT_SYNC` / `BASE_OV` | upload | 0 / 0 / 空 | 公开可见轮询开关(cn 发布 / ov 同步) |
| `POLL_TIMEOUT` / `POLL_INTERVAL` | upload | 60 / 2 | 轮询上限/间隔秒 |
## 输出解读(P95 关注点 ←→ 项目计划 §6 候选指标)
| 候选指标(计划原文) | k6 指标名 | 说明 |
| --- | --- | --- |
| 普通列表 API P95 < 500ms | `feed_list_duration` / `feed_hot_duration` | 按 tag `list_public_photos(_hot)` 分组的 Trend;「首批照片 P95<3s」是浏览器端口径(含渲染/图载),k6 以首页列表请求近似 + §冷缓存另测 |
| 传输完成到发布 P95 < 10s | `publish_wait_duration` | `WAIT_PUBLISH=1` 时 complete → cn 公开列表可见的等待;上传耗时(`upload_oss_put_duration`)单独统计,不计入处理时延(计划注记) |
| (同上,跨境) | `ov_sync_wait_duration` | `WAIT_SYNC=1`:complete → ov 公开可见(含推送 2s 间隔) |
| 点赞确认及广播 P95 < 2s | `like_confirm_duration` | API 确认口径;WS 广播时延属前端观测面,k6 不覆盖 |
| — | `upload_presign_duration` / `upload_complete_duration` | 上传链分步;`upload_chain_duration` 为三步端到端 |
| — | `like_rejected_total` / `publish_timeout_total` | 非目标信号:前者多为限速触发(见下),后者=发布超时需查 worker |
通用看板项:`http_req_duration`(按 name tag 细分)、`http_req_failed`、
`checks` 通过率、VU 数与迭代吞吐(`iteration_duration` 倒数)。**冷缓存/地域
时延不在 k6 范围**——用 `infra/deploy/cdn-checklist.md` §6 的多地域 curl 记录表。
## 阈值占位(D05 确认后启用)
两脚本 `options.thresholds` 均为注释占位(候选值即上表)。确认 D05 后:
1. 取消注释并按定案值调整;
2. L2 档跑 `k6 run … --tag testid=l2-$(date +%F)` 便于结果对比归档;
3. 阈值失败会让 k6 以非零码退出——可直接作为 P5 容量报告的机器判定。
## 操作注意(如实阅读再跑)
- **限速会污染指标**:likes 有 Redis 滑窗限速(`LIKE_IP_WINDOW_DELTA=2000` /
24h / IP;`LIKE_SESSION_WINDOW_DELTA=500` / 会话)——高并发点赞从单一压测机
出发很快触发 429(计入 `like_rejected_total`,且账面不是真实服务容量)。
staging 压测调大对应 env 或 `LIKE_SHARE=0`;需要真实验证限速本身时反向利用。
visits 的键含 visitorId(每 VU 唯一),`VISIT_WINDOW_REQUESTS=60`/窗 通常够用。
- **反代后 IP 归并**:经 OpenResty 反代时服务端 req.ip 是网关地址,trust proxy
未配置前所有限速按网关 IP 聚合——压测读数要意识到这一点(登记为 P5 项,
likes.controller.ts 文件头同注)。
- **对象存储是外部依赖**:`oss_put` / `oss_image_get` 打到 MinIO/OSS/CDN 直链,
其时延与被测 API 分开解读(tag 已区分);生产桶上跑会产生真实流量费用。
- **不自动清理**:种子照片留在相册内(时间戳标题可辨认),清理=后台软删或
`DELETE /v1/photos/{id}`(prod-smoke 同款);相册无删除端点。
- 对生产环境执行前必须获得明确许可——上传/点赞/浏览写路径全部落真实数据。
+220
View File
@@ -0,0 +1,220 @@
// loadtest/lib/helpers.js —— k6 公共助手(P5-prep Task 3)。
// 被 read-feed.js / upload-burst.js 复用:登录 / 建相册 / 上传全链 / 公开读
// 分页 / 点赞批次 / 浏览上报 / 公开可见轮询。端点与请求体形态对齐
// scripts/prod-smoke.sh(契约权威 = packages/contracts/openapi.yaml)。
//
// 内置测试图与 prod-smoke.sh 同一张:800x600 JPEG(quality 55,3118 字节),
// base64 内嵌(下方 JPEG_B64 由 scripts/prod-smoke.sh 同步生成,勿手改),
// k6 侧用 k6/encoding 解码为二进制。刻意不用 1x1——避免变体流水线踩最小
// 尺寸边界。
import http from 'k6/http';
import { sleep } from 'k6';
import encoding from 'k6/encoding';
export const JPEG_B64 = '/9j/2wBDAA4KCw0LCQ4NDA0QDw4RFiQXFhQUFiwgIRokNC43NjMuMjI6QVNGOj1OPjIySGJJTlZYXV5dOEVmbWVabFNbXVn/2wBDAQ8QEBYTFioXFypZOzI7WVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVn/wAARCAJYAyADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAX/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCsAkKIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD//2Q==';
/** 解码一次,全脚本复用(Uint8Array 可直接作 http 请求体)。 */
export const JPEG_BYTES = new Uint8Array(encoding.b64decode(JPEG_B64, 'binary'));
// ── env 参数读取(--env KEY=VALUE → __ENV.KEY;缺省回退)──
export function envStr(name, fallback) {
const v = __ENV[name];
return v === undefined || v === '' ? fallback : v;
}
export function envInt(name, fallback) {
const raw = __ENV[name];
if (raw === undefined || raw === '') {
return fallback;
}
const n = Number.parseInt(raw, 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export function envFloat(name, fallback) {
const raw = __ENV[name];
if (raw === undefined || raw === '') {
return fallback;
}
const n = Number.parseFloat(raw);
return Number.isFinite(n) && n >= 0 ? n : fallback;
}
/** 压测内唯一键(clientTaskId/batchId/requestId 等幂等键;时间戳+随机足够)。 */
export function uniqId(prefix) {
return `${prefix}-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e9).toString(36)}`;
}
// ── 认证与相册 ──
export function authHeaders(token) {
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
}
/** 密码登录 → sessionToken(响应体 {sessionToken,...},同 prod-smoke ②)。 */
export function login(base, account, password) {
const res = http.post(
`${base}/v1/auth/password`,
JSON.stringify({ account, password }),
{ headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_password' } },
);
if (res.status !== 200) {
throw new Error(`登录失败 HTTP ${res.status}: ${res.body}`);
}
const token = res.json('sessionToken');
if (!token) {
throw new Error(`登录响应缺 sessionToken: ${res.body}`);
}
return token;
}
/** 建时间戳相册 → { albumId, publicToken }(同 prod-smoke ③)。 */
export function createAlbum(base, token, title, description) {
const res = http.post(
`${base}/v1/albums`,
JSON.stringify({
translations: [
{ locale: 'zh-CN', title, description: description || 'k6 loadtest fixture' },
],
defaultLocale: 'zh-CN',
startAt: new Date().toISOString(),
}),
{ headers: authHeaders(token), tags: { name: 'albums_create' } },
);
if (res.status !== 201) {
throw new Error(`建相册失败 HTTP ${res.status}: ${res.body}`);
}
return { albumId: res.json('id'), publicToken: res.json('publicToken') };
}
// ── 上传全链(presign → OSS PUT → complete)──
/**
* 单照片全链上传。返回逐步耗时(毫秒,k6 timings)与 id,供脚本各自打点:
* { photoId, uploadId, timings: { presign, put, complete, chainMs } }
* 任一步非预期状态码即抛错(k6 计一次 error 并终止单迭代)。
*/
export function uploadPhoto(base, token, albumId, clientTaskId) {
const started = Date.now();
const presign = http.post(
`${base}/v1/uploads`,
JSON.stringify({
albumId,
clientTaskId,
filename: 'k6.jpg',
bytes: JPEG_BYTES.byteLength,
contentType: 'image/jpeg',
}),
{ headers: authHeaders(token), tags: { name: 'uploads_presign' } },
);
if (presign.status !== 201) {
throw new Error(`presign 失败 HTTP ${presign.status}: ${presign.body}`);
}
const uploadId = presign.json('uploadId');
const photoId = presign.json('photoId');
const url = presign.json('url');
if (!uploadId || !url) {
throw new Error(`presign 响应缺 uploadId/url: ${presign.body}`);
}
// PUT 目标是 presigned 对象存储 URL(staging=MinIO / 生产=OSS),非被测 API 域。
const put = http.put(url, JPEG_BYTES, {
headers: { 'Content-Type': 'image/jpeg' },
tags: { name: 'oss_put' },
});
if (put.status < 200 || put.status > 299) {
throw new Error(`OSS PUT 失败 HTTP ${put.status}`);
}
const complete = http.post(
`${base}/v1/uploads/${uploadId}/complete`,
'',
{ headers: authHeaders(token), tags: { name: 'uploads_complete' } },
);
if (complete.status !== 200) {
throw new Error(`complete 失败 HTTP ${complete.status}: ${complete.body}`);
}
return {
photoId,
uploadId,
timings: {
presign: presign.timings.duration,
put: put.timings.duration,
complete: complete.timings.duration,
chainMs: Date.now() - started,
},
};
}
// ── 观众端四类操作(ov 域公开端点;亦可打 cn 做对照)──
/**
* 公开照片列表一页(契约:limit 1..60 缺省 30;cursor 不透明需 encode;
* sort=latest|hot 仅 ov 实现非法值 400)。tag 参数让脚本能按 name 分组统计。
*/
export function fetchFeedPage(base, albumToken, options) {
const opts = options || {};
let qs = `limit=${opts.limit || 30}`;
if (opts.cursor) {
qs += `&cursor=${encodeURIComponent(opts.cursor)}`;
}
if (opts.sort) {
qs += `&sort=${opts.sort}`;
}
return http.get(`${base}/v1/public/albums/${albumToken}/photos?${qs}`, {
tags: { name: opts.tag || 'list_public_photos' },
});
}
/** 浏览上报(匿名;visitorId 客户端持久 8..128 字符,requestId 幂等键)。 */
export function reportVisit(base, albumToken, visitorId, requestId) {
return http.post(
`${base}/v1/public/albums/${albumToken}/visits`,
JSON.stringify({ visitorId, requestId }),
{
headers: { 'Content-Type': 'application/json' },
tags: { name: 'visit_report' },
},
);
}
/** 点赞批次(匿名;sessionId 限速键 / batchId 幂等键 / delta 1..20 整数)。 */
export function likeBatch(base, albumToken, photoId, sessionId, batchId, delta) {
return http.post(
`${base}/v1/public/albums/${albumToken}/photos/${photoId}/likes`,
JSON.stringify({ sessionId, batchId, delta }),
{
headers: { 'Content-Type': 'application/json' },
tags: { name: 'like_batch' },
},
);
}
/**
* 轮询公开列表直到 photoId 可见(worker 发布事务异步 + cn→ov 推送 2s 间隔;
* 同 prod-smoke ④ 口径)。返回 { found, waitedMs, polls }。
*/
export function pollUntilVisible(
base,
albumToken,
photoId,
timeoutSec,
intervalSec,
tag,
) {
const started = Date.now();
let polls = 0;
for (;;) {
polls += 1;
const res = fetchFeedPage(base, albumToken, { limit: 30, tag: tag || 'public_read_poll' });
if (res.status === 200) {
const items = res.json('items');
if (Array.isArray(items) && items.some((it) => it.photoId === photoId)) {
return { found: true, waitedMs: Date.now() - started, polls };
}
}
if (Date.now() - started > timeoutSec * 1000) {
return { found: false, waitedMs: Date.now() - started, polls };
}
sleep(intervalSec);
}
}
+172
View File
@@ -0,0 +1,172 @@
// 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 exec from 'k6/execution';
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 隔离(观众身份)。
const visitorId = uniqId(`visitor-${exec.vu.idInTest}`);
const likeSessionId = uniqId(`sess-${exec.vu.idInTest}`);
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;
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) {
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));
}
+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}`);
}
}