From 1bc86f0447adb45c6802d05b9cb40ae310fb47ef Mon Sep 17 00:00:00 2001 From: zerosaturation Date: Mon, 15 Jun 2026 20:10:56 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E4=BF=AE=E6=94=B9=E5=8E=8B=E6=B5=8B?= =?UTF-8?q?=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/Makefile | 36 +- backend/reports/S1.json | 23 + backend/reports/S2.json | 23 + backend/reports/S4.json | 45 ++ backend/reports/baseline.csv | 4 + backend/reports/final-report.md | 227 ++++++++ backend/reports/run-metadata.json | 12 + backend/reports/s1.png | Bin 0 -> 13043 bytes backend/reports/s2.png | Bin 0 -> 13692 bytes backend/reports/s4.png | Bin 0 -> 14933 bytes backend/scripts/loadgen/README.md | 262 ++++++++-- backend/scripts/loadgen/REPORT_GUIDE.md | 266 ++++++++++ backend/scripts/loadgen/RUNBOOK.md | 366 +++++++++++++ backend/scripts/loadgen/loadgen/lib/hdr.go | 95 ++++ backend/scripts/loadgen/loadgen/main.go | 137 ++++- .../scripts/loadgen/loadgen/reporter/json.go | 85 +-- .../scripts/loadgen/loadgen/reporter/knee.go | 33 ++ .../loadgen/loadgen/reporter/markdown.go | 492 +++++++++++++++++- .../scripts/loadgen/loadgen/reporter/meta.go | 156 ++++++ .../loadgen/loadgen/scenarios/common.go | 9 +- .../loadgen/loadgen/scenarios/s1_login.go | 4 + .../loadgen/loadgen/scenarios/s2_read.go | 4 + .../loadgen/loadgen/scenarios/s3_like.go | 4 + .../loadgen/loadgen/scenarios/s4_mint.go | 11 +- backend/scripts/loadgen/scripts/prod_seed.sh | 35 ++ backend/scripts/loadgen/seed/README.md | 201 +++++-- .../pages/square/components/CreationGrid.vue | 2 +- .../square/components/HotCategoryBlock.vue | 3 + 28 files changed, 2354 insertions(+), 181 deletions(-) create mode 100644 backend/reports/S1.json create mode 100644 backend/reports/S2.json create mode 100644 backend/reports/S4.json create mode 100644 backend/reports/baseline.csv create mode 100644 backend/reports/final-report.md create mode 100644 backend/reports/run-metadata.json create mode 100644 backend/reports/s1.png create mode 100644 backend/reports/s2.png create mode 100644 backend/reports/s4.png create mode 100644 backend/scripts/loadgen/REPORT_GUIDE.md create mode 100644 backend/scripts/loadgen/RUNBOOK.md create mode 100644 backend/scripts/loadgen/loadgen/reporter/knee.go create mode 100644 backend/scripts/loadgen/loadgen/reporter/meta.go create mode 100644 backend/scripts/loadgen/scripts/prod_seed.sh diff --git a/backend/Makefile b/backend/Makefile index fdfc464..0bb73a1 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -1,7 +1,7 @@ # TopFans Backend Makefile # 用于简化开发流程 -.PHONY: help install-swagger gen-swagger update-swagger start-swagger start-all stop-all clean build run all +.PHONY: help install-swagger gen-swagger update-swagger start-swagger start-all stop-all clean build run all loadgen-build loadgen-test loadgen-vet loadgen-ci # 默认目标 help: @@ -23,6 +23,11 @@ help: @echo " make run - 运行 Gateway" @echo " make all - 安装依赖 + 生成文档 + 构建" @echo "" + @echo "压测工具:" + @echo " make loadgen-build - 编译 seed + loadgen 到 bin/" + @echo " make loadgen-test - 运行 loadgen 单元测试" + @echo " make loadgen-vet - go vet 静态检查" + @echo "" @echo "清理:" @echo " make clean - 清理生成的文件" @echo "" @@ -37,6 +42,11 @@ help: @echo " make run - 运行 Gateway" @echo " make all - 安装依赖 + 生成文档 + 构建" @echo "" + @echo "压测工具:" + @echo " make loadgen-build - 编译 seed + loadgen 到 bin/" + @echo " make loadgen-test - 运行 loadgen 单元测试" + @echo " make loadgen-vet - go vet 静态检查" + @echo "" @echo "清理:" @echo " make clean - 清理生成的文件" @@ -92,8 +102,32 @@ clean: @rm -rf backend/gateway/docs/*.go @rm -rf backend/gateway/docs/*.json @rm -rf backend/gateway/docs/*.yaml + @rm -rf backend/bin/ @echo "✅ 清理完成" +# ==================== Loadgen / 压测工具 ==================== + +# 编译 seed 和 loadgen 二进制到 bin/ +loadgen-build: + @echo "编译 loadgen 工具..." + @mkdir -p bin + @go build -ldflags="-s -w" -o bin/seed ./scripts/loadgen/seed/ + @go build -ldflags="-s -w" -o bin/loadgen ./scripts/loadgen/loadgen/ + @echo "✅ seed + loadgen → bin/" + +# 运行 loadgen 单元测试 (当前 23 个测试, 应全过) +loadgen-test: + @echo "运行 loadgen 单元测试..." + @go test -count=1 ./scripts/loadgen/... + +# go vet 静态检查 +loadgen-vet: + @echo "go vet loadgen..." + @go vet ./scripts/loadgen/... + +# loadgen 完整 CI 入口: vet + test + build +loadgen-ci: loadgen-vet loadgen-test loadgen-build + # 全部:安装依赖 + 生成文档 + 构建 all: install-swagger gen-swagger build @echo "" diff --git a/backend/reports/S1.json b/backend/reports/S1.json new file mode 100644 index 0000000..c0d0b5c --- /dev/null +++ b/backend/reports/S1.json @@ -0,0 +1,23 @@ +{ + "scenario": "S1", + "total_requests": 8, + "errors": 0, + "five_xx": 0, + "p50_us": 73919, + "p95_us": 83071, + "p99_us": 83071, + "max_us": 83071, + "stages": [ + { + "stage_idx": 1, + "target_rps": 1, + "total_requests": 8, + "errors": 0, + "five_xx": 0, + "p50_us": 73919, + "p95_us": 83071, + "p99_us": 83071, + "max_us": 83071 + } + ] +} diff --git a/backend/reports/S2.json b/backend/reports/S2.json new file mode 100644 index 0000000..7bac804 --- /dev/null +++ b/backend/reports/S2.json @@ -0,0 +1,23 @@ +{ + "scenario": "S2", + "total_requests": 8, + "errors": 8, + "five_xx": 0, + "p50_us": 1552, + "p95_us": 2909, + "p99_us": 2909, + "max_us": 2909, + "stages": [ + { + "stage_idx": 1, + "target_rps": 1, + "total_requests": 8, + "errors": 8, + "five_xx": 0, + "p50_us": 1552, + "p95_us": 2909, + "p99_us": 2909, + "max_us": 2909 + } + ] +} diff --git a/backend/reports/S4.json b/backend/reports/S4.json new file mode 100644 index 0000000..d9e0110 --- /dev/null +++ b/backend/reports/S4.json @@ -0,0 +1,45 @@ +{ + "scenario": "S4", + "total_requests": 18, + "errors": 18, + "five_xx": 0, + "p50_us": 1210, + "p95_us": 2161, + "p99_us": 2161, + "max_us": 2161, + "stages": [ + { + "stage_idx": 1, + "target_rps": 1, + "total_requests": 3, + "errors": 3, + "five_xx": 0, + "p50_us": 4143, + "p95_us": 8943, + "p99_us": 8943, + "max_us": 8943 + }, + { + "stage_idx": 2, + "target_rps": 2, + "total_requests": 6, + "errors": 6, + "five_xx": 0, + "p50_us": 1314, + "p95_us": 2044, + "p99_us": 2044, + "max_us": 2044 + }, + { + "stage_idx": 3, + "target_rps": 3, + "total_requests": 9, + "errors": 9, + "five_xx": 0, + "p50_us": 1210, + "p95_us": 2161, + "p99_us": 2161, + "max_us": 2161 + } + ] +} diff --git a/backend/reports/baseline.csv b/backend/reports/baseline.csv new file mode 100644 index 0000000..bcb4015 --- /dev/null +++ b/backend/reports/baseline.csv @@ -0,0 +1,4 @@ +scenario,total,errors,five_xx,p50_ms,p95_ms,p99_ms,max_ms,stages +S1,8,0,0,73.91,83.07,83.07,83.07,1 +S2,8,8,0,1.55,2.90,2.90,2.90,1 +S4,18,18,0,1.20,2.16,2.16,2.16,3 diff --git a/backend/reports/final-report.md b/backend/reports/final-report.md new file mode 100644 index 0000000..8502670 --- /dev/null +++ b/backend/reports/final-report.md @@ -0,0 +1,227 @@ +# TopFans 压测报告 + +## 📋 运行信息 + +| 项 | 值 | +|---|---| +| **生成时间** | 2026-06-15 20:05:56 CST | +| **压测开始** | 2026-06-15 20:05:47 CST | +| **压测结束** | 2026-06-15 20:05:56 CST | +| **总耗时** | 9s | +| **目标地址** | `http://localhost:8080` | +| **测试场景** | S4 | +| **阶梯模式** | step (`1,2,3`) | +| **JWT 签名密钥** | `topfans-***` (前 8 位) | +| **监控模式** | off | +| **总请求数** | 34 | +| **总错误数** | 26 (76.47%) | +| **5xx 数** | 0 (0.00%) | + +--- + +## 🎯 执行摘要 + +**总览**: ✅ 1 健康 / ⚠️ 0 警告 / 🚨 2 严重 (共 3) + +🚨 **关键问题** (2 个): + +- **S2 (浏览资产详情)**: 错误率 100.00% +- **S4 (资产铸造 (mint))**: 错误率 100.00% + +**场景速览**: + +- ✅ **S1 用户登录** — p99=83ms, err 0.00% +- 🚨 **S2 浏览资产详情** — p99=3ms, err 100.00% +- 🚨 **S4 资产铸造 (mint)** — p99=2ms, err 100.00% + +--- + +## 📊 总览表 + +| 场景 | 描述 | Total | Err | 5xx | P50ms | P95ms | P99ms | Maxms | 拐点 RPS | 状态 | +|------|------|-------|-----|-----|-------|-------|-------|-------|---------|------| +| **S1** | 用户登录 | 8 | 0 (0.00%) | 0 (0.00%) | 74 | 83 | 83 | 83 | — | ✅ | +| **S2** | 浏览资产详情 | 8 | 8 (100.00%) | 0 (0.00%) | 2 | 3 | 3 | 3 | — | 🚨 | +| **S4** | 资产铸造 (mint) | 18 | 18 (100.00%) | 0 (0.00%) | 1 | 2 | 2 | 2 | — | 🚨 | + +> 说明: Err 包含 4xx + 5xx,5xx 是子集。错误率 = Err / Total。 + +## 🔬 跨场景瓶颈分析 + +✅ **无明显瓶颈**,所有场景 P99 都在阈值内。 + +**P99 / 阈值 比率** (从高到低): + +- S1: 0.08x (83ms) +- S2: 0.01x (3ms) +- S4: 0.00x (2ms) + +--- + +## ✅ S1 用户登录 + +### 📌 测试说明 + +| 项 | 值 | +|---|---| +| **API** | `POST /api/v1/auth/login` | +| **负载类型** | ✏️ 轻写 | +| **业务说明** | 用户身份认证,签发 JWT | +| **影响范围** | 🔴 所有用户必经路径,失败 = 用户进不来 | + +### 📈 性能指标 vs 健康阈值 + +| 指标 | 实测 | 阈值 | 判定 | +|------|------|------|------| +| P50ms | 74 | ≤100 | ✅ | +| P95ms | 83 | ≤300 | ✅ | +| P99ms | 83 | ≤1000 | ✅ | +| Maxms | 83 | — | ℹ️ 参考 | +| 错误率 | 0.00% | ≤1.00% | ✅ | +| 5xx 率 | 0.00% | ≤0.10% | ✅ | + +### 📍 拐点分析 + +ℹ️ 仅 1 个 stage,未做阶梯测试,无法判断拐点。 + +### 🔢 阶梯结果 + +| Stage | TargetRPS | Total | Err | 5xx | P50ms | P95ms | P99ms | Maxms | 涨幅 | +|-------|-----------|-------|-----|-----|-------|-------|-------|-------|------| +| 1 | 1 | 8 | 0 | 0 | 74 | 83 | 83 | 83 | | + +### 🎯 行动项 + +✅ 无需行动项 — 所有指标在阈值内。 + +### 📉 图表 + +![S1 RPS / P99 / Error](.//s1.png) + +--- + +## 🚨 S2 浏览资产详情 + +### 📌 测试说明 + +| 项 | 值 | +|---|---| +| **API** | `GET /api/v1/assets/{id}` | +| **负载类型** | 📖 读 | +| **业务说明** | 高频读路径,典型缓存命中场景 | +| **影响范围** | 🟢 单用户最高频操作,影响页面加载体验 | + +### 📈 性能指标 vs 健康阈值 + +| 指标 | 实测 | 阈值 | 判定 | +|------|------|------|------| +| P50ms | 2 | ≤50 | ✅ | +| P95ms | 3 | ≤150 | ✅ | +| P99ms | 3 | ≤500 | ✅ | +| Maxms | 3 | — | ℹ️ 参考 | +| 错误率 | 100.00% | ≤1.00% | 🚨 | +| 5xx 率 | 0.00% | ≤0.10% | ✅ | + +### 📍 拐点分析 + +ℹ️ 仅 1 个 stage,未做阶梯测试,无法判断拐点。 + +### 🔢 阶梯结果 + +| Stage | TargetRPS | Total | Err | 5xx | P50ms | P95ms | P99ms | Maxms | 涨幅 | +|-------|-----------|-------|-----|-----|-------|-------|-------|-------|------| +| 1 | 1 | 8 | 8 | 0 | 2 | 3 | 3 | 3 | | + +### 🎯 行动项 + +- [ ] **🟡 P1**: 错误率 100.00% — 检查 4xx 错误码,看是否 JWT 过期 / 数据缺失 + +### 📉 图表 + +![S2 RPS / P99 / Error](.//s2.png) + +--- + +## 🚨 S4 资产铸造 (mint) + +### 📌 测试说明 + +| 项 | 值 | +|---|---| +| **API** | `POST /api/v1/assets/mints/precreate` | +| **负载类型** | 🛠️ 重写 | +| **业务说明** | 写重路径:OSS 上传 + 签名 + 事务落库 | +| **影响范围** | 🟡 核心交易,影响创作者产出节奏 | + +### 📈 性能指标 vs 健康阈值 + +| 指标 | 实测 | 阈值 | 判定 | +|------|------|------|------| +| P50ms | 1 | ≤300 | ✅ | +| P95ms | 2 | ≤800 | ✅ | +| P99ms | 2 | ≤2000 | ✅ | +| Maxms | 2 | — | ℹ️ 参考 | +| 错误率 | 100.00% | ≤1.00% | 🚨 | +| 5xx 率 | 0.00% | ≤0.10% | ✅ | + +### 📍 拐点分析 + +✅ **拐点未触发** — 全程 3 个 stage 健康运行,最高 3 RPS p99=2ms。 + +### 🔢 阶梯结果 + +| Stage | TargetRPS | Total | Err | 5xx | P50ms | P95ms | P99ms | Maxms | 涨幅 | +|-------|-----------|-------|-----|-----|-------|-------|-------|-------|------| +| 1 | 1 | 3 | 3 | 0 | 4 | 9 | 9 | 9 | | +| 2 | 2 | 6 | 6 | 0 | 1 | 2 | 2 | 2 | -77% | +| 3 | 3 | 9 | 9 | 0 | 1 | 2 | 2 | 2 | +6% | + +### 🎯 行动项 + +- [ ] **🟡 P1**: 错误率 100.00% — 检查 4xx 错误码,看是否 JWT 过期 / 数据缺失 + +### 📉 图表 + +![S4 RPS / P99 / Error](.//s4.png) + +--- + +## 📎 附录 + +### 健康阈值说明 + +- **P50/P95/P99**: 百分位延迟 (毫秒),值越小越好 +- **错误率**: 4xx+5xx 请求占比,健康 < 1% +- **5xx 率**: 服务端错误率,健康 < 0.1% +- **拐点**: 阶梯测试中,p99 相对前一 stage 涨幅 > 50% 的第一个 stage + +### 文件清单 + +``` +reports/ +├── final-report.md (本文件) +├── baseline.csv (Excel 可打开的汇总) +├── s1.json +├── s1.png +├── s2.json +├── s2.png +├── s3.json +├── s3.png +├── s4.json +├── s4.png +├── s5.json +├── s5.png +├── s6.json +├── s6.png +├── s7.json +├── s7.png +``` + +### 如何复现 + +```bash +cd /opt/topfans/loadtest +./loadgen --cmd=run --scenarios=S4 --stage=step --step-schedule='1,2,3' \ + --target=http://localhost:8080 \ + --monitor=off \ +``` diff --git a/backend/reports/run-metadata.json b/backend/reports/run-metadata.json new file mode 100644 index 0000000..281e7b3 --- /dev/null +++ b/backend/reports/run-metadata.json @@ -0,0 +1,12 @@ +{ + "start_time": "2026-06-15T20:05:47.357522+08:00", + "end_time": "2026-06-15T20:05:56.380495+08:00", + "target": "http://localhost:8080", + "scenarios": [ + "S4" + ], + "step_schedule": "1,2,3", + "jwt_secret_hint": "topfans-", + "monitor_mode": "off", + "stage_mode": "step" +} \ No newline at end of file diff --git a/backend/reports/s1.png b/backend/reports/s1.png new file mode 100644 index 0000000000000000000000000000000000000000..6c822643faa8ee337fac54d309cea1c766299085 GIT binary patch literal 13043 zcmeHudpMMP+xB3UN@cZ7`=XU(iYSuowUJe=Y*L|Oq$DM>$(~WImW&oFDiu+q(ndBR zWHZ{xE@V>(GeXF2jNJ^z%zNJTJny@{<9Uzg`;Pbd{&~OeUB^0BYt7tq-}mqLyRP#( z&-1!gsF{iJoLNg|kw~ODJAc`}mqhv@m_!mcoFPskk^F|}7f2+v7dy9a+2<46*W&H7 z?<{LjBr{{($XX+(^Wx|2#iSOH?;Sp$HSJ>Xob6lYHKosBMEEt?)X$$MwN}Y5J(pa) z|3~wEKg>V7=GFOG_IGFO-KO~S&o|j;)rCXHEXGp0AV$;oZ^Ct peMX=35?iYdKq=d8$6yBGJT`b6ef9@=;=( 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 literal 0 HcmV?d00001 diff --git a/backend/reports/s2.png b/backend/reports/s2.png new file mode 100644 index 0000000000000000000000000000000000000000..17e495a13a275091332827af90a3fa1a02abf631 GIT binary patch literal 13692 zcmeHud05Ts`|b+uWC|@ZRqUi_CyC}&rX@tF5YnI!8WokM6}D-ihzd<)2t{Q|nrV=R zRhm|s=h8e6Yqi$7U-rKC`CaGyzJHuQ&h`79?{T@ZcWbTB=kvbr^W4w<-1oDt?$*

g;*3OE;buys_V4%epzbe_WX}gFScI;YSgQt0Jb{nY�$^vP&>~FKL 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 F{|6QZ literal 0 HcmV?d00001 diff --git a/backend/reports/s4.png b/backend/reports/s4.png new file mode 100644 index 0000000000000000000000000000000000000000..e837a74187fcdc01ff0e0de48ffedcad2874f3ac GIT binary patch literal 14933 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 给阿里云单机 (4G/2C) TopFans 后端微服务用的压测 + 数据准备工具集。 +> 凌晨 02:00-06:00 业务低峰执行,数据物理隔离 `star_id=999900`。 -## 目录 +--- + +## 📚 文档地图 + +| 文档 | 用途 | 谁要看 | +|------|------|--------| +| **README.md** (本文) | 工具集概览 + 5 分钟入门 | 所有人 | +| [RUNBOOK.md](RUNBOOK.md) | 凌晨压测**一步一步**操作手册 | on-call 工程师 | +| [REPORT_GUIDE.md](REPORT_GUIDE.md) | 压测报告**怎么读** + 瓶颈定位 + 行动项模板 | 看报告的工程师 / TL | +| [seed/README.md](seed/README.md) | seed 工具细节 (数据准备) | 第一次跑压测的人 | + +--- + +## 🧰 工具集概览 ``` -backend/scripts/loadgen/ -├── seed/ # 数据准备工具(CLI) -│ ├── main.go # seed CLI 入口 -│ ├── stars.go users.go profiles.go assets.go -│ ├── slots_and_exhibits.go friendships.go -│ ├── tokens.go sequences.go cleanup.go -│ ├── seed_test.go # 单元测试 -│ └── README.md -├── loadgen/ # 压测主程序 -│ ├── main.go # loadgen CLI 入口 -│ ├── preflight.go verify.go # 7 项开压前检查 + 压后验证 -│ ├── lib/ # 核心库(16 个测试全过) -│ │ ├── csv.go client.go hdr.go log.go ramp.go -│ │ ├── circuit.go ssh_metrics.go config.go -│ │ └── *_test.go -│ ├── scenarios/ # 7 个场景(已注册) -│ │ ├── s1_login.go s2_read.go s3_like.go s4_mint.go -│ │ ├── s5_dashboard.go s6_ranking.go s7_place.go -│ │ ├── common.go scenarios.go -│ │ └── scenarios_test.go -│ └── reporter/ # 报告生成 -│ ├── json.go csv.go plot.go markdown.go -├── monitor/ # 监控栈 -│ ├── sample.sh # 后台采样(写到 metrics-feed.jsonl) -│ ├── docker-compose.monitor.yml -│ ├── prometheus.yml -│ └── grafana-dashboards/ # 4 个预置面板 -├── recover/ # 一键灭火 + 备份还原 -│ ├── emergency-stop.sh -│ └── restore-from-backup.sh -├── scripts/ # 部署到 prod -│ └── mint_reset.sh -└── reports/ # 跑测产出(gitignore) +loadgen/ +├── seed/ # 数据准备 CLI (生成 1000 个测试用户 + 资产 + JWT) +├── loadgen/ # 压测主程序 (7 个场景,6 维熔断,带 reporter) +├── monitor/ # 监控栈 (Prometheus + Grafana,可选) +├── recover/ # 紧急灭火 (一键停 + 数据库恢复) +├── scripts/ # 部署到 prod 的辅助脚本 +└── reports/ # 跑测产出 (gitignore,scp 拉回本地) ``` -## 编译 +### 核心 CLI: `bin/seed` + `bin/loadgen` + +| 命令 | 作用 | +|------|------| +| `./bin/seed` | 灌测试数据 → `users.csv` + 数据库 | +| `./bin/seed --cleanup` | 清理测试数据 (保留 1000 用户) | +| `./bin/seed --cleanup --full` | 全部删掉 (账号本身) | +| `./bin/seed --reset-tokens` | 只重签 JWT (跨周压测用) | +| `./bin/loadgen --cmd=preflight` | 7 项开压前检查 | +| `./bin/loadgen --cmd=run --scenarios=S1` | 跑场景 | +| `./bin/loadgen --cmd=report` | 生成 markdown 报告 + PNG 图表 | + +### 7 个场景 + +| ID | 场景 | 默认 RPS | 写/读 | 关键 API | +|----|------|---------|------|---------| +| S1 | Login | 15 | 写(轻) | `POST /api/v1/auth/login` | +| S2 | Read | 250 | 读 | `GET /api/v1/assets/{id}` | +| S3 | Like | 50 | 写(轻) | `POST/DELETE /api/v1/social/assets/{id}/like` | +| S4 | Mint | 1-5 | **写(重)** | `POST /api/v1/assets/mints/precreate` | +| S5 | Dashboard | — | 读聚合 | (dashboard 聚合) | +| S6 | Ranking | 300 | 读 | `GET /api/v1/rankings/hot` | +| S7 | Place | 1-5 | **写(重)** | (摆展事务) | + +--- + +## 🚀 5 分钟入门 (本地 docker) + +```bash +# 1. 编译 (Linux prod 部署用,本地 darwin 直接 go build) +cd backend +make loadgen-build + +# 2. 准备数据 (需要本地 docker postgres) +cd scripts/loadgen/seed +# 生成 bcrypt 哈希 (与 tokens.go 硬编码的 "Test@123" 匹配) +python3 -c "import bcrypt; print(bcrypt.hashpw(b'Test@123', bcrypt.gensalt(rounds=10)).decode())" \ + > loadtest_bcrypt.txt +# 跑 seed (用本地 docker 的 env) +DB_PASSWORD=123456 \ +JWT_SECRET=topfans-secret-key-local-dev-only \ +/Users/liulujian/Documents/code/TopFansByGithub/backend/bin/seed \ + --db-name=top-fans --db-host=localhost --db-port=15432 --db-user=postgres + +# 3. 复制 users.csv 到 backend 目录 +cp users.csv ../../../users.csv + +# 4. 开压前检查 +cd ../../../ # = backend +JWT_SECRET=topfans-secret-key-local-dev-only \ + ./bin/loadgen --cmd=preflight --target=http://localhost:8080 + +# 5. 烟雾测试 (30 秒,1 RPS) +JWT_SECRET=topfans-secret-key-local-dev-only \ + ./bin/loadgen --cmd=run --scenarios=S1 --stage=baseline --rps=1 --duration=30s \ + --target=http://localhost:8080 --monitor=off + +# 6. 生成报告 +JWT_SECRET=topfans-secret-key-local-dev-only \ + ./bin/loadgen --cmd=report --input=./reports --output=./reports/final-report.md +open reports/final-report.md # macOS +``` + +--- + +## 🔨 编译 ```bash cd backend +make loadgen-build # 编译 seed + loadgen 到 bin/ +make loadgen-test # 单元测试 (23 个) +make loadgen-vet # go vet +make loadgen-ci # vet + test + build (CI 单步) +``` + +手动编译 (Linux prod): +```bash GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/seed ./scripts/loadgen/seed/ GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o bin/loadgen ./scripts/loadgen/loadgen/ ``` -## 测试 +--- -```bash -cd backend -go test ./scripts/loadgen/... -``` +## 🛡️ 安全设计 -**当前测试状态** (截至 Phase 7 完结): -- `seed` 包: 5/5 PASS -- `loadgen/lib` 包: 16/16 PASS -- `loadgen/scenarios` 包: 2/2 PASS -- 共 23 个测试全过 +### 数据隔离 +所有测试数据用 `star_id = 999900` 物理隔离,**不影响**真实业务 star_id (87, 88, 91, 93, 94, 95)。 -## 关键特性 +### CLAUDE.md 序列重置 +seed 工具末尾自动同步所有相关表的 PG 序列(避免后续 GORM 插入报 duplicate key)。 -### 1. 6 维红线判停(自动熔断) +### 凌晨窗口 +执行窗口:**02:00 - 06:00** 业务低峰。 +紧急灭火: `recover/emergency-stop.sh` 一键停 + `restore-from-backup.sh` 5-8min 还原。 + +### 6 维红线熔断 (自动停) | # | 红线 | 阈值 | 数据源 | |---|------|------|--------| @@ -74,20 +134,108 @@ go test ./scripts/loadgen/... | R5 | 磁盘空闲 | < 5GB 持续 30s | metrics-feed | | R6 | OOM 事件 | 瞬时触发 | metrics-feed | -### 2. CLAUDE.md 序列重置 +--- -seed 工具自动同步所有相关表的 PG 序列(避免后续 GORM 插入报 duplicate key)。 +## 📊 报告产出 -### 3. 数据隔离 +跑完 + `--cmd=report` 后,`reports/` 下: -所有测试数据用 `star_id = 999900` 物理隔离,不影响真实业务 star_id (87, 88, 91, 93, 94, 95)。 +``` +reports/ +├── S1.json # 原始数据 (含 stages) +├── S2.json +├── S4.json +├── baseline.csv # Excel 友好的汇总 +├── s1.png # RPS / P99 / Error 曲线 +├── s2.png +├── s4.png +└── final-report.md # ← 主要看这个 +``` -### 4. 凌晨窗口 +`final-report.md` 包含: +1. **总览表** (所有场景一行一个,7 列) +2. **每个场景的 ⚠️ 拐点 RPS** (自动算:第一个 p99 涨 >50% 的 stage) +3. **阶梯结果表** (每 stage 的 RPS / p50 / p95 / p99 / err / 5xx) +4. **PNG 曲线图** (RPS / P99 / Error 三条线) -执行窗口:凌晨 02:00-06:00 业务低峰。emergency-stop 一键回滚,restore-from-backup.sh 5-8min 还原。 +详细读法见 [REPORT_GUIDE.md](REPORT_GUIDE.md)。 -## 详细文档 +--- + +## 🧪 测试状态 + +``` +seed: 5/5 PASS +loadgen/lib: 16/16 PASS +scenarios: 2/2 PASS +TOTAL: 23/23 PASS +``` + +--- + +## 📁 完整目录 + +``` +backend/scripts/loadgen/ +├── README.md # ← 你在这里 +├── RUNBOOK.md # ← 凌晨压测操作手册 +├── REPORT_GUIDE.md # ← 报告怎么读 +├── seed/ # 数据准备工具 +│ ├── main.go # CLI 入口 +│ ├── stars.go users.go profiles.go assets.go +│ ├── slots_and_exhibits.go friendships.go +│ ├── tokens.go sequences.go cleanup.go +│ ├── seed_test.go # 单元测试 +│ ├── loadtest_bcrypt.txt # Test@123 哈希 (与 tokens.go 匹配) +│ └── README.md +├── loadgen/ # 压测主程序 +│ ├── main.go # CLI 入口 +│ ├── preflight.go verify.go # 7 项开压前检查 + 压后验证 +│ ├── lib/ # 核心库 +│ │ ├── csv.go # users.csv 解析 +│ │ ├── client.go # HTTP client +│ │ ├── hdr.go # 延迟直方图 + per-stage 计数 +│ │ ├── log.go ramp.go # 日志 + 阶梯调度 +│ │ ├── circuit.go # 6 维熔断 +│ │ ├── ssh_metrics.go # prod server metrics 抓取 +│ │ ├── config.go +│ │ └── *_test.go # 16 个测试 +│ ├── scenarios/ # 7 个场景 +│ │ ├── s1_login.go +│ │ ├── s2_read.go +│ │ ├── s3_like.go +│ │ ├── s4_mint.go # 支持多 stage +│ │ ├── s5_dashboard.go +│ │ ├── s6_ranking.go +│ │ ├── s7_place.go +│ │ ├── common.go # doRequest + DefaultBaseURL +│ │ ├── scenarios.go # 注册表 +│ │ ├── helpers.go +│ │ └── scenarios_test.go +│ └── reporter/ # 报告生成 +│ ├── json.go # RunReport + StageReport +│ ├── csv.go # baseline.csv +│ ├── plot.go # PNG 曲线 (gonum) +│ ├── markdown.go # final-report.md +│ └── knee.go # KneeRPS 自动算 +├── monitor/ # 监控栈 (可选) +│ ├── sample.sh # 后台采样到 metrics-feed.jsonl +│ ├── docker-compose.monitor.yml +│ ├── prometheus.yml +│ └── grafana-dashboards/ # 4 个预置面板 +├── recover/ # 紧急灭火 +│ ├── emergency-stop.sh +│ └── restore-from-backup.sh +├── scripts/ # prod 辅助 +│ ├── mint_reset.sh # S4 之间的 mint 数据清理 +│ └── prod_seed.sh # 一键跑 seed (读 prod env) +└── reports/ # 跑测产出 (gitignore) +``` + +--- + +## 详细设计 - **设计文档**: `docs/superpowers/specs/2026-06-12-load-testing-design.md` - **实施计划**: `docs/superpowers/plans/2026-06-12-load-testing.md` -- **seed 工具说明**: `seed/README.md` +- **seed 工具说明**: [seed/README.md](seed/README.md) diff --git a/backend/scripts/loadgen/REPORT_GUIDE.md b/backend/scripts/loadgen/REPORT_GUIDE.md new file mode 100644 index 0000000..30c3b29 --- /dev/null +++ b/backend/scripts/loadgen/REPORT_GUIDE.md @@ -0,0 +1,266 @@ +# REPORT_GUIDE — 压测报告怎么读 + +> **目标读者**:看完压测报告后,需要判断"系统能扛住吗"+"哪里是瓶颈"+"下一步改什么"的工程师 +> **报告路径**:`reports/final-report.md` (主) + `reports/{scenario}.json` (原始) + `reports/{scenario}.png` (图) + +--- + +## 1. 报告目录结构 + +``` +reports/ +├── S1.json # 场景 1 原始数据 (程序读) +├── S2.json # 场景 2 +├── S4.json # 场景 4 +├── baseline.csv # Excel 可打开的汇总表 +├── s1.png # 场景 1 曲线图 (RPS / P99 / Error) +├── s2.png +├── s4.png +└── final-report.md # ← 你要看的总报告 +``` + +--- + +## 2. 三步读完报告 + +### 第 1 步:看汇总表 (1 分钟) + +```markdown +| Scenario | Total | Err | 5xx | P50ms | P95ms | P99ms | Maxms | Stages | +|----------|-------|-----|-----|-------|-------|-------|-------|--------| +| S1 | 12500 | 0 | 0 | 86.59 | 119.23 | 200.50 | 450 | 5 | +| S2 | 25000 | 5 | 0 | 12.30 | 35.00 | 88.00 | 250 | 5 | +| S4 | 600 | 12 | 2 | 200.00 | 500.00 | 850.00 | 1200 | 4 | +``` + +**每个字段的含义**: + +| 字段 | 含义 | 健康参考 (4G/2C prod) | +|------|------|----------------------| +| `Scenario` | 场景 ID (S1=登录, S2=读, S3=点赞, S4=铸造, ...) | — | +| `Total` | 该场景总请求数 | 越大越好,代表你扛住了 | +| `Err` | 客户端+服务端错误总和 | **< 1%** | +| `5xx` | 服务端错误 (500-599) | **< 0.1%** (1‰) | +| `P50ms` | 50% 请求在这个时间内 | < 100ms | +| `P95ms` | 95% 请求在这个时间内 | < 300ms | +| `P99ms` | 99% 请求在这个时间内 | < 1000ms (S4 写重可放宽到 2000ms) | +| `Maxms` | 最慢的一次请求 | 一般 3-5x P99 | +| `Stages` | 阶梯测试的阶段数 | = step-schedule 的元素数 | + +**判断模板**: +- ✅ 全绿 → 系统扛得住,准备上线 +- ⚠️ 某个 S* Err > 1% → 优先看那个场景 +- 🚨 某个 S* 5xx > 1% → 服务端有问题,看 §3 定位 + +--- + +### 第 2 步:看拐点 (KneeRPS) (2 分钟) + +每个 scenario 标题下会出现一行: + +```markdown +**⚠️ 拐点**: stage 3 @ 3 RPS (p99 暴涨 514%) +``` + +**含义**: 当 RPS 升到 3 时,p99 延迟比 stage 2 暴涨 514% (5.14 倍)。 + +**判定逻辑** (在 `reporter/knee.go`): +- 逐 stage 比 p99 +- 第一次涨幅 > 50% 时,标记为拐点 +- 全程没涨 > 50% → 显示 "✅ 拐点未触发" + +**怎么用这个数字**: +- **S1 拐点 RPS = 15** → 你的登录服务,超过 15 QPS 就开始劣化。生产预估峰值 10 QPS,留 50% buffer +- **S4 拐点 RPS = 2** → 铸造接口很重,2 QPS 就劣化了。要么优化,要么限流 + +**举例**: +| 拐点 RPS | 业务含义 | 行动项 | +|---------|---------|--------| +| ≥ 期望峰值的 2x | ✅ 健康 | 上线,加监控 | +| ≈ 期望峰值 | ⚠️ 临界 | 加缓存 / 异步化 / 限流 | +| < 期望峰值 | 🚨 不达标 | 重构 + 复测 | + +--- + +### 第 3 步:看阶梯表 + 曲线图 (5 分钟) + +**阶梯表** (md 里每个场景下): + +```markdown +### 阶梯结果 +| Stage | TargetRPS | Total | Err | 5xx | P50ms | P95ms | P99ms | Maxms | +|-------|-----------|-------|-----|-----|-------|-------|-------|-------| +| 1 | 2 | 600 | 0 | 0 | 80 | 100 | 110 | 130 | +| 2 | 5 | 1500 | 0 | 0 | 82 | 105 | 115 | 140 | +| 3 | 10 | 3000 | 0 | 0 | 85 | 110 | 130 | 180 | +| 4 | 15 | 4500 | 0 | 0 | 95 | 130 | 200 | 350 | +| 5 | 20 | 6000 | 5 | 0 | 120 | 200 | 450 | 800 | +``` + +**怎么读**: + +- **Total** 应该是 `TargetRPS × Duration` (近似,因为有误差) +- **P99ms** 应该随 TargetRPS 上升**平滑增加** (10-30% 涨幅/stage 是正常) +- **Err / 5xx** 应该全程 < 1% +- **如果某 stage 突然 P99 翻倍** → 拐点,看上面 KneeRPS + +**曲线图** (`s1.png` 等): + +- **X 轴**: Stage 编号 (1, 2, 3, ...) +- **Y 轴**: 三个值 — RPS (蓝)、P99ms (绿)、Error% (红) +- **怎么看**: + - 三条线**平稳上升** = 正常 + - **P99 突然陡升** = 拐点 + - **Error% 突然跳起来** = 服务挂了 + +--- + +## 3. 定位瓶颈 — 常见模式 + +### 模式 1: P99 阶梯上升,但 Error 一直 0 +**含义**: 系统扛得住,但在变慢。 +**原因**: GC 抖动 / DB 慢查询 / 锁竞争。 +**行动**: +1. 看 PG 慢查询日志: `pg_stat_statements` ORDER BY `mean_exec_time` DESC +2. 看应用层 profile: `pprof` heap + cpu +3. 检查连接池配置: 可能太小 + +### 模式 2: P99 阶梯上升 + Error 也开始涨 +**含义**: 系统到极限。 +**原因**: 资源耗尽 (CPU 100%, 连接池满, DB 锁)。 +**行动**: +1. 看 server metrics feed: `tail -f metrics-feed.jsonl` +2. `top` 看 CPU/内存,`iostat` 看 IO +3. 检查是否有连接泄漏 (`netstat | grep TIME_WAIT`) + +### 模式 3: 阶梯早期就 5xx > 5% +**含义**: 系统本身有问题,不是负载问题。 +**原因**: 代码 bug / 配置错误 / 依赖缺失。 +**行动**: +1. 看 5xx 的具体响应体 (在 log 里) +2. 检查 error 码,对照业务错误码定义 +3. 看是不是 auth/JWT 过期 + +### 模式 4: 第一个 stage P99 很高,后续反而低 +**含义**: 热身不够 / 缓存没预热。 +**原因**: Redis 冷启动 / JIT 编译 / DB 连接池启动慢。 +**行动**: +1. 第一次 stage 加长 (例如先 2min 预热) +2. 或者用 `--rps=1` 先跑 1-2min 预热,再开阶梯 + +### 模式 5: S4 (Mint) 在很低的 RPS 就拐 +**含义**: 写路径太重。 +**原因**: 铸造涉及事务 / 签名 / OSS 上传,本身就是慢操作。 +**行动**: +1. 检查 mint 是不是同步阻塞 (能不能异步化?) +2. 看 mint 数据是否需要落库 (能否用 append-only?) +3. 考虑限流: 服务端拒绝 > 2 QPS 的 mint 请求 + +--- + +## 4. 怎么写出行动项 + +读完报告,应该能回答三个问题: + +### Q1: 系统能扛住业务预期峰值吗? +- 业务预期峰值 → 比对拐点 RPS +- 拐点 ≥ 2x 峰值 → ✅ 可以上线 +- 拐点 ≈ 1x 峰值 → ⚠️ 加监控告警,谨慎上线 +- 拐点 < 峰值 → 🚨 必须先优化 + +### Q2: 拐点在哪里?为什么? +看哪个 stage 拐的,然后: +- **CPU 100%** → 计算密集,优化算法或加机器 +- **DB CPU 100%** → 慢查询,加索引或读写分离 +- **PG 连接数满** → 连接池配置 / 服务降级 +- **PG 锁等待** → 事务设计问题 +- **磁盘 IO 满** → 加 SSD 或缓存 + +### Q3: 下一步改什么? + +行动项模板: + +```markdown +## [Loadtest 2026-06-15] 行动项 + +### P0 (上线前必修) +- [ ] **S2 Read 拐点 100 RPS < 业务预期 150 RPS** + - 根因: PG `assets` 表全表扫描,10 万行 + - 修复: 加 `idx_assets_star_id_status` 索引 + - Owner: @dba + +### P1 (1 周内修) +- [ ] **S4 Mint 拐点 2 RPS** + - 根因: 同步写 OSS + 同步落库 + - 修复: mint 流程拆成 precreate + 后台 worker + - Owner: @backend + +### P2 (技术债) +- [ ] 压测期间 CPU 持续 80%,考虑扩容到 4C +``` + +--- + +## 5. JSON 原始数据怎么读 (高级) + +`reports/S1.json` 长这样: + +```json +{ + "scenario": "S1", + "total_requests": 12500, + "errors": 5, + "five_xx": 0, + "p50_us": 86591, + "p95_us": 119231, + "p99_us": 200502, + "max_us": 450000, + "stages": [ + { + "stage_idx": 1, + "target_rps": 2, + "total_requests": 600, + "errors": 0, + "five_xx": 0, + "p50_us": 80000, + "p95_us": 100000, + "p99_us": 110000, + "max_us": 130000 + }, + ... + ] +} +``` + +**单位说明**: +- 所有 `_us` 后缀 = microseconds (微秒,1ms = 1000us) +- 例: `p99_us: 200502` = 200.5 ms + +**怎么用**: +- 画自己的图 (用 Excel/Google Sheets 打开 baseline.csv 最方便) +- 跟历史报告对比 (跨版本性能回归) +- CI 集成: 解析 JSON,断言 P99 < 某个阈值 + +--- + +## 6. 常见问题 + +### Q: "5xx=0 但 Err=5" 是什么意思? +A: 5xx 是服务端错,Err 是总错 (含 4xx)。Err > 5xx 表示有客户端错 (一般是 401/403/404)。看 log 里具体错误码。 + +### Q: 为什么 P50 很低但 P99 很高? +A: 正常 — 长尾效应。99% 都快但 1% 慢。如果 P99 太高说明有少数请求卡住,看是不是 GC / 锁 / IO 抖动。 + +### Q: Max 比 P99 高很多,是不是异常? +A: 可能是单个网络抖动,正常。Max / P99 < 5x 都是健康。 + +### Q: 同一个场景不同次跑,数据差很多? +A: 检查 prod 是否有其他流量在跑 (业务)。压测应在凌晨,业务低峰。 + +--- + +## 7. 进一步 + +- 想优化场景,见 `seed/README.md` +- 想加新场景,在 `scenarios/` 新建 `s8_xxx.go`,模仿 s1_login.go 的 BeginStage/EndStage 模式 +- 想加新的红线指标,见 `lib/circuit.go` diff --git a/backend/scripts/loadgen/RUNBOOK.md b/backend/scripts/loadgen/RUNBOOK.md new file mode 100644 index 0000000..ae18133 --- /dev/null +++ b/backend/scripts/loadgen/RUNBOOK.md @@ -0,0 +1,366 @@ +# RUNBOOK — 凌晨压测执行手册 + +> **目标读者**:负责 prod 凌晨压测的 on-call 工程师 +> **执行窗口**:02:00 - 06:00 (业务低峰) +> **预计总耗时**:1.5 - 4 小时 (按场景数) +> **风险等级**:🟡 中 (会写 23k+ 测试数据,但物理隔离 star_id=999900) + +--- + +## 0. 前置检查 (T-1 天) + +### 0.1 确认 prod 状态 +```bash +# SSH 到 prod +ssh root@101.132.250.62 + +# 确认 prod 网关正常 +curl -sS http://localhost:8080/health +# 期望: {"service":"top-fans-gateway","status":"ok"} + +# 确认磁盘空间 > 10GB (R5 红线需要) +df -h /opt +# 期望: Avail > 10G +``` + +### 0.2 确认阿里云快照 < 24h +- 登录 ECS 控制台 → 实例 → 磁盘与镜像 → 快照 +- 必须有 < 24h 的快照,**否则不要开压** +- 没有的话先手动触发:实例 → 更多 → 磁盘和镜像 → 创建快照 + +### 0.3 备份数据库 +```bash +ssh root@101.132.250.62 +mkdir -p /opt/topfans/backups +pg_dump -h localhost -U postgres topfans > /opt/topfans/backups/pre-loadtest-$(date +%Y%m%d-%H%M).sql +ls -lh /opt/topfans/backups/pre-loadtest-*.sql +# 期望: 文件 > 50MB +``` + +--- + +## 1. 上传/确认工具 (T-30min) + +### 1.1 确认工具已上传到 prod +```bash +ssh root@101.132.250.62 +ls -la /opt/topfans/loadtest/ +# 必须看到: +# seed (二进制) +# loadgen (二进制) +# loadtest_bcrypt.txt +# scripts/prod_seed.sh +# README.md +# reports/ (空目录) +``` + +如果文件缺失,本地重新上传: +```bash +# 本地 (从 backend 目录) +cd /Users/liulujian/Documents/code/TopFansByGithub/backend + +# 重新编译 +make loadgen-build + +# 上传 +scp bin/seed bin/loadgen root@101.132.250.62:/opt/topfans/loadtest/ +scp scripts/loadgen/seed/loadtest_bcrypt.txt root@101.132.250.62:/opt/topfans/loadtest/ +scp scripts/loadgen/scripts/prod_seed.sh root@101.132.250.62:/opt/topfans/loadtest/scripts/ +ssh root@101.132.250.62 "chmod +x /opt/topfans/loadtest/{seed,loadgen} /opt/topfans/loadtest/scripts/prod_seed.sh" +``` + +### 1.2 重新生成 bcrypt 哈希 (如果你改了密码策略) +```bash +# 本地 +cd backend/scripts/loadgen/seed + +# 生成与 tokens.go 硬编码密码 (默认 "Test@123") 匹配的哈希 +python3 -c "import bcrypt; print(bcrypt.hashpw(b'Test@123', bcrypt.gensalt(rounds=10)).decode())" \ + > loadtest_bcrypt.txt + +# 上传覆盖 +scp loadtest_bcrypt.txt root@101.132.250.62:/opt/topfans/loadtest/ +``` + +--- + +## 2. 数据准备 (T0 = 02:00) + +### 2.1 SSH 到 prod +```bash +ssh root@101.132.250.62 +``` + +### 2.2 一键跑 seed (生产数据灌入) +```bash +cd /opt/topfans/loadtest +bash scripts/prod_seed.sh +``` + +**这一步骤会做什么**: +- 读 `/opt/topfans/docker/.env.prod` 拿 DB_PASSWORD + JWT_SECRET +- 插入 star_id=999900 测试明星 (1 行) +- 插入 1000 个测试用户 (mobile 19900000001 - 19900001000) +- 插入 1000 个 fan_profile + crystal +- 插入 5000 个 assets +- 插入 3000 个 booth_slots + 2000 个 exhibitions +- 插入 10000 个 friendships +- **重置所有相关表的 PG 序列** (CLAUDE.md 规范,避免后续 GORM 插入报 duplicate key) +- 签 1000 个 JWT,写到 `users.csv` + +**预计耗时**:30 - 60 秒 + +**预期输出**: +``` +✓ stars seeded +✓ 1000 users seeded +✓ 1000 fan_profiles + crystal seeded +✓ 5000 assets seeded +✓ 3000 booth_slots + 2000 exhibitions seeded +✓ 10000 friendships seeded +✓ sequences reset +✅ users.csv written: 1000 rows +✅ seed + tokens completed +``` + +--- + +## 3. 开压前 7 项检查 (T0+1min) + +```bash +cd /opt/topfans/loadtest +./loadgen --cmd=preflight --target=http://localhost:8080 +``` + +**预期全部 PASS**: +``` +✓ ① Gateway /health HTTP 200 +✓ ② SSH to prod (省略,如不需要 server metrics) +✓ ③ pg_dump backup > 50MB (你的备份) +✓ ④ 阿里云快照 < 24h (人工确认) +✓ ⑤ prod 磁盘空闲 > 10GB free > 10G +✓ ⑥ users.csv 1000 rows rows=1000 +✓ ⑦ JWT_SECRET set set + +ALL CHECKS PASSED — 可以开压 +``` + +**如果有 FAIL**:见 "附录 A: 故障排查" + +--- + +## 4. 烟雾测试 (T0+2min) — 强烈推荐 + +> 这一步只花 30 秒,但能提前发现 90% 的集成问题,省后面 1 小时排错 + +```bash +cd /opt/topfans/loadtest +JWT_SECRET=$(grep '^JWT_SECRET=' /opt/topfans/docker/.env.prod | cut -d= -f2) \ + ./loadgen --cmd=run --scenarios=S1 --stage=baseline --rps=1 --duration=30s \ + --target=http://localhost:8080 --monitor=off 2>&1 | tee reports/smoke-s1.log +``` + +**预期**: +``` +📊 S1: total=30 err=0 5xx=0 p99=200ms stages=1 +✅ loadgen done. total=30 err=0 fiveXX=0 +``` + +**判定**: +- ✅ total=30, err=0 → 进入正式压测 +- ❌ total < 30 → 跑挂了,查 `reports/smoke-s1.log` +- ❌ err > 0 → auth/JWT 问题,检查 `users.csv` 和 JWT_SECRET + +--- + +## 5. 正式压测 (T0+3min) + +### 5.1 选择策略 + +**Plan B 推荐** (S1 + S2 + S4,~1.5 小时): +```bash +cd /opt/topfans/loadtest +export JWT_SECRET=$(grep '^JWT_SECRET=' /opt/topfans/docker/.env.prod | cut -d= -f2) +export PROD_SSH=root@101.132.250.62 + +# === 场景 1: Login (02:05-02:30, 25min) === +./loadgen --cmd=run --scenarios=S1 \ + --stage=step --step-schedule='2,5,10,15,20' \ + --duration=5m --target=http://localhost:8080 \ + --monitor=full --prod-ssh=$PROD_SSH \ + --inter-scenario-pause=0s 2>&1 | tee reports/s1.log +# 预期: 5 个 stage,每 stage 5min,p99 应随 RPS 阶梯上升 + +# === 场景 2: Read (02:35-03:00, 25min) === +./loadgen --cmd=run --scenarios=S2 \ + --stage=step --step-schedule='10,30,60,100,150' \ + --duration=5m --target=http://localhost:8080 \ + --monitor=full --prod-ssh=$PROD_SSH \ + --inter-scenario-pause=0s 2>&1 | tee reports/s2.log + +# === 场景 4: Mint (03:05-03:30, 25min, 写重,保守) === +./loadgen --cmd=run --scenarios=S4 \ + --stage=step --step-schedule='1,2,3,5' \ + --duration=5m --target=http://localhost:8080 \ + --monitor=full --prod-ssh=$PROD_SSH \ + --inter-scenario-pause=0s 2>&1 | tee reports/s4.log +``` + +**Plan A 全量** (S1-S7,~3.5 小时): +```bash +# S1-S7 全部跑,S4/S7 写重场景保守 +SCENARIOS="S1,S2,S3,S4,S5,S6,S7" +SCHEDULES_BY_SCENARIO='{"S1":"2,5,10,15,20","S2":"10,30,60,100,150","S3":"5,15,30,50","S4":"1,2,3,5","S5":"5,10,20,40","S6":"20,50,100,150","S7":"1,2,3,5"}' +# (目前 loadgen 一次只支持一个 schedule,需要跑 7 次) +``` + +### 5.2 每个场景跑完后做什么 +1. 检查 `reports/{scenario}.log` 末尾的 `📊` 行 +2. 记录 total / err / 5xx / p99 / stages +3. 如果 `🚨 circuit breaker tripped` 触发,**立即停**,见附录 B + +--- + +## 6. 生成报告 (T+1min) + +```bash +cd /opt/topfans/loadtest +./loadgen --cmd=report --input=./reports --output=./reports/final-report.md +``` + +**产出**: +``` +reports/ +├── S1.json +├── S2.json +├── S4.json +├── baseline.csv # Excel 可直接打开 +├── s1.png # RPS/P99/Error 曲线图 +├── s2.png +├── s4.png +└── final-report.md # 人看的报告 +``` + +--- + +## 7. 收尾 (T+2min) + +### 7.1 拉报告到本地 +```bash +# 本地 +mkdir -p ~/Desktop/loadtest-report-$(date +%Y%m%d) +scp -r root@101.132.250.62:/opt/topfans/loadtest/reports/* ~/Desktop/loadtest-report-$(date +%Y%m%d)/ +``` + +### 7.2 决定是否清理测试数据 + +| 情况 | 动作 | +|------|------| +| 数据分析完,后续不需要 | `./seed --cleanup --full` | +| 数据还要保留做下一轮 | `./seed --cleanup` (保留 1000 用户,清理关联数据) | +| 只是 JWT 过期 | `./seed --reset-tokens --jwt-secret=$JWT_SECRET` | +| **生产事故** | `./seed --cleanup --full` + 立即回滚,见附录 C | + +### 7.3 (可选) 关闭监控后台采样 +```bash +# 如果你启动了 monitor/sample.sh,杀掉 +ssh root@101.132.250.62 "pkill -f 'monitor/sample.sh'" +``` + +--- + +## 8. 报告分析 (T+30min,白天) + +见 `REPORT_GUIDE.md` — 教你怎么读 `final-report.md`,定位瓶颈,写行动项。 + +--- + +## 附录 A: 故障排查 + +### A.1 preflight FAIL: users.csv 不存在 +**原因**: 上次 seed 没跑成功 +**修复**: `cd /opt/topfans/loadtest && bash scripts/prod_seed.sh` + +### A.2 preflight FAIL: 阿里云快照 < 24h +**原因**: 没备份 +**修复**: 在 ECS 控制台手动建快照,等就绪后重跑 preflight + +### A.3 烟雾测试 FAIL: 大量 4xx +**原因**: JWT_SECRET 不匹配 / users.csv 过期 +**修复**: +```bash +# 1. 确认 JWT_SECRET +grep '^JWT_SECRET=' /opt/topfans/docker/.env.prod + +# 2. 重签 token (数据保留) +./seed --reset-tokens --jwt-secret=$JWT_SECRET + +# 3. 重跑 +./loadgen --cmd=run --scenarios=S1 --stage=baseline --rps=1 --duration=30s \ + --target=http://localhost:8080 --monitor=off +``` + +### A.4 烟雾测试 FAIL: 大量 5xx +**原因**: 网关/服务挂了 +**修复**: 先看 `docker ps` 确认服务在,`curl /health` 确认网关活 + +--- + +## 附录 B: Circuit Breaker 触发 (🚨) + +如果出现 `🚨 circuit breaker tripped!`,**立即**: +1. **Ctrl+C** 停止当前 loadgen (会 graceful shutdown,等待当前请求完成) +2. 立即判断: + - 5xx > 10% 持续 10s → 服务有问题,见附录 C + - 仅客户端错率高 → 测试问题,可能是 step 跳太猛 +3. **降低 RPS 重跑** 或 **改天再试** + +--- + +## 附录 C: 紧急灭火 (production 被打挂了) + +**判定**: 服务真实报错(不是测试客户端问题),prod 用户受影响。 + +**立即执行** (按顺序,每步 30s 内): +```bash +ssh root@101.132.250.62 + +# 1. 停 loadgen + 监控 +pkill -f 'bin/loadgen' +pkill -f 'monitor/sample.sh' + +# 2. 清测试数据 (1 秒) +cd /opt/topfans/loadtest +./seed --cleanup --full + +# 3. 重启服务 (让 prod 回到 baseline) +cd /opt/topfans/docker +docker-compose -f docker-compose.prod.yml --profile prod restart + +# 4. (最严重情况) 从备份还原 +bash /opt/topfans/loadtest/recover/restore-from-backup.sh +# 输入 backup 文件路径,预计 5-8 分钟 +``` + +**事后**: +- 写事故复盘 +- 修压测发现的 bug +- 调整 step schedule (下一次更保守) + +--- + +## 附录 D: 常用 cheat sheet + +```bash +# 查看 loadtest 进程 +ssh root@101.132.250.62 "ps aux | grep -E '(loadgen|sample)' | grep -v grep" + +# 看实时日志 +ssh root@101.132.250.62 "tail -f /opt/topfans/loadtest/reports/*.log" + +# 看 metrics feed +ssh root@101.132.250.62 "tail -f /opt/topfans/loadtest/metrics-feed.jsonl" + +# 测一下网关还活着 +ssh root@101.132.250.62 "curl -sS http://localhost:8080/health" +``` diff --git a/backend/scripts/loadgen/loadgen/lib/hdr.go b/backend/scripts/loadgen/loadgen/lib/hdr.go index b379c52..22084fd 100644 --- a/backend/scripts/loadgen/loadgen/lib/hdr.go +++ b/backend/scripts/loadgen/loadgen/lib/hdr.go @@ -2,13 +2,36 @@ package lib import ( "sync" + "sync/atomic" "github.com/HdrHistogram/hdrhistogram-go" ) +// LatencyRecorder tracks latency histogram + per-stage counters. +// +// Concurrency model: a single LatencyRecorder is shared across all scenarios. +// Per-scenario isolation: callers MUST call Reset() at scenario boundaries. +// Per-stage isolation: callers MUST call BeginStage() at stage boundaries +// (which clears histogram + zero stage counters). type LatencyRecorder struct { mu sync.Mutex h *hdrhistogram.Histogram + + stageTotal atomic.Int64 + stageErrors atomic.Int64 + stageFiveXX atomic.Int64 + + stages []StageSnapshot +} + +// StageSnapshot is the per-stage data captured by EndStage. +type StageSnapshot struct { + StageIdx int + TargetRPS int + Histogram *hdrhistogram.Histogram + TotalRequests int64 + Errors int64 + FiveXX int64 } func NewLatencyRecorder() *LatencyRecorder { @@ -17,6 +40,7 @@ func NewLatencyRecorder() *LatencyRecorder { } } +// Record stores a latency sample (in microseconds). func (r *LatencyRecorder) Record(latencyUs int64) { r.mu.Lock() defer r.mu.Unlock() @@ -26,8 +50,79 @@ func (r *LatencyRecorder) Record(latencyUs int64) { _ = r.h.RecordValue(latencyUs) } +// RecordResult increments per-stage error/5xx counters based on HTTP status code. +// isError: status >= 400 or transport error +// is5xx: status >= 500 +func (r *LatencyRecorder) RecordResult(isError, is5xx bool) { + if isError { + r.stageErrors.Add(1) + } + if is5xx { + r.stageFiveXX.Add(1) + } + r.stageTotal.Add(1) +} + +// Snapshot returns a copy of the current histogram (for use by circuit-breaker). +// Does NOT affect per-stage counters. func (r *LatencyRecorder) Snapshot() *hdrhistogram.Histogram { r.mu.Lock() defer r.mu.Unlock() return hdrhistogram.Import(r.h.Export()) } + +// Reset clears the histogram, per-stage counters, AND accumulated stages. +// Call between scenarios. +func (r *LatencyRecorder) Reset() { + r.mu.Lock() + defer r.mu.Unlock() + r.h = hdrhistogram.New(1, 30_000_000, 3) + r.stages = nil + r.stageTotal.Store(0) + r.stageErrors.Store(0) + r.stageFiveXX.Store(0) +} + +// ClearStages drops accumulated stage data but keeps the current histogram and counters. +// Use when you want stages to remain but accumulated list to be discarded. +func (r *LatencyRecorder) ClearStages() { + r.mu.Lock() + defer r.mu.Unlock() + r.stages = nil +} + +// BeginStage marks the start of a new stage at TargetRPS RPS. +// Resets histogram AND per-stage counters. Stages slice gains a new entry. +func (r *LatencyRecorder) BeginStage(idx, targetRPS int) { + r.mu.Lock() + defer r.mu.Unlock() + r.h = hdrhistogram.New(1, 30_000_000, 3) + r.stageTotal.Store(0) + r.stageErrors.Store(0) + r.stageFiveXX.Store(0) + r.stages = append(r.stages, StageSnapshot{StageIdx: idx, TargetRPS: targetRPS}) +} + +// EndStage freezes the histogram + per-stage counters into the latest stage entry. +// Must be called after BeginStage and after the stage has produced some traffic. +func (r *LatencyRecorder) EndStage() { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.stages) == 0 { + return + } + last := &r.stages[len(r.stages)-1] + last.Histogram = hdrhistogram.Import(r.h.Export()) + last.TotalRequests = r.stageTotal.Load() + last.Errors = r.stageErrors.Load() + last.FiveXX = r.stageFiveXX.Load() +} + +// Stages returns a copy of accumulated stage snapshots. +func (r *LatencyRecorder) Stages() []StageSnapshot { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]StageSnapshot, len(r.stages)) + copy(out, r.stages) + return out +} diff --git a/backend/scripts/loadgen/loadgen/main.go b/backend/scripts/loadgen/loadgen/main.go index 1d52685..38a58ef 100644 --- a/backend/scripts/loadgen/loadgen/main.go +++ b/backend/scripts/loadgen/loadgen/main.go @@ -66,6 +66,31 @@ func runLoadgen(target, scenarioIDs, stage, stepSchedule string, rps, vus int, d // 让 scenarios 用 --target 而不是写死的 prod IP scenarios.DefaultBaseURL = target + // 写 run-metadata.json (供 --cmd=report 使用) + runStart := time.Now() + defer func() { + meta := reporter.RunMetadata{ + StartTime: runStart, + EndTime: time.Now(), + Target: target, + Scenarios: strings.Split(scenarioIDs, ","), + StepSchedule: stepSchedule, + StageMode: stage, + RPSOverride: rps, + MonitorMode: monitorMode, + ProdSSH: prodSSH, + } + // 取 JWT_SECRET 前 8 位作为 hint + if jwtSecret := os.Getenv("JWT_SECRET"); len(jwtSecret) >= 8 { + meta.JWTSecretHint = jwtSecret[:8] + } + if err := os.MkdirAll("reports", 0o755); err == nil { + if data, err := json.MarshalIndent(meta, "", " "); err == nil { + _ = os.WriteFile(filepath.Join("reports", "run-metadata.json"), data, 0o644) + } + } + }() + users, err := lib.LoadUsers("users.csv") if err != nil { return fmt.Errorf("load users.csv: %w (先跑 `seed` 生成 users.csv)", err) @@ -126,6 +151,14 @@ func runLoadgen(target, scenarioIDs, stage, stepSchedule string, rps, vus int, d continue } log.Printf("=== scenario %d/%d: %s ===", idx+1, len(ids), id) + + // 场景开始:快照 delta 基线,清空 stage 累积 + recorder.ClearStages() + recorder.Reset() + prevTotal := totalCount.Load() + prevErr := errCount.Load() + prev5xx := fiveXXCount.Load() + s, err := scenarios.Get(id, client, users, &errCount, &totalCount, &fiveXXCount, recorder, breaker, prodSSH) if err != nil { return fmt.Errorf("scenario %s: %w", id, err) @@ -133,6 +166,38 @@ func runLoadgen(target, scenarioIDs, stage, stepSchedule string, rps, vus int, d if err := s.Run(ctx, rps, duration, dashboard, breaker, stages); err != nil { return fmt.Errorf("run scenario %s: %w", id, err) } + + // 场景结束:写 per-scenario JSON (含 stages) + scenarioTotal := totalCount.Load() - prevTotal + scenarioErr := errCount.Load() - prevErr + scenario5xx := fiveXXCount.Load() - prev5xx + scenarioStages := recorder.Stages() + + stageReports := make([]reporter.StageReport, 0, len(scenarioStages)) + for _, ss := range scenarioStages { + stageReports = append(stageReports, reporter.MakeStageReport( + ss.StageIdx, ss.TargetRPS, ss.Histogram, + ss.TotalRequests, ss.Errors, ss.FiveXX, + )) + } + rr := reporter.RunReport{ + Scenario: id, + TotalRequests: scenarioTotal, + Errors: scenarioErr, + FiveXX: scenario5xx, + P50Us: recorder.Snapshot().ValueAtPercentile(50), + P95Us: recorder.Snapshot().ValueAtPercentile(95), + P99Us: recorder.Snapshot().ValueAtPercentile(99), + MaxUs: recorder.Snapshot().Max(), + Stages: stageReports, + } + scenarioPath := filepath.Join("reports", id+".json") + if err := reporter.WriteJSON(scenarioPath, rr); err != nil { + return fmt.Errorf("write %s: %w", scenarioPath, err) + } + log.Printf("📊 %s: total=%d err=%d 5xx=%d p99=%dms stages=%d", + id, scenarioTotal, scenarioErr, scenario5xx, rr.P99Us/1000, len(stageReports)) + if breaker.State() == lib.CircuitTripped { log.Printf("⚠️ circuit tripped, stopping") break @@ -143,11 +208,8 @@ func runLoadgen(target, scenarioIDs, stage, stepSchedule string, rps, vus int, d } } - // write final report - if err := reporter.WriteJSON("report.json", scenarioIDs, recorder.Snapshot(), totalCount.Load(), errCount.Load(), fiveXXCount.Load()); err != nil { - return fmt.Errorf("write report: %w", err) - } log.Printf("✅ loadgen done. total=%d err=%d fiveXX=%d", totalCount.Load(), errCount.Load(), fiveXXCount.Load()) + log.Printf("💡 下一步: ./loadgen --cmd=report --input=./reports --output=./reports/final-report.md") return nil } @@ -186,20 +248,33 @@ func runReport(inputDir, output string) error { return fmt.Errorf("--input required for cmd=report") } - // 1. 收集 reports/run-*/ 下的 *.json + // 1. 递归收集 reports/ 下的所有 *.json (filepath.Glob 不支持 **, 用 WalkDir) var scenarioReports []reporter.RunReport - matches, _ := filepath.Glob(filepath.Join(inputDir, "**", "*.json")) - for _, m := range matches { - data, err := os.ReadFile(m) + err := filepath.WalkDir(inputDir, func(path string, d os.DirEntry, walkErr error) error { + if walkErr != nil { + return nil + } + if d.IsDir() || !strings.HasSuffix(path, ".json") { + return nil + } + // 跳过元数据文件 (它是 RunMetadata 不是 RunReport) + if strings.HasSuffix(path, "run-metadata.json") { + return nil + } + data, err := os.ReadFile(path) if err != nil { - continue + return nil } var rr reporter.RunReport if err := json.Unmarshal(data, &rr); err != nil { - log.Printf("skip %s: %v", m, err) - continue + log.Printf("skip %s: %v", path, err) + return nil } scenarioReports = append(scenarioReports, rr) + return nil + }) + if err != nil { + return fmt.Errorf("walk %s: %w", inputDir, err) } if len(scenarioReports) == 0 { return fmt.Errorf("no JSON reports found in %s", inputDir) @@ -213,17 +288,41 @@ func runReport(inputDir, output string) error { } log.Printf("wrote %s", baselinePath) - // 3. 转 ScenarioReport (供 markdown 用) - scenarioMarkdownReports := make([]reporter.ScenarioReport, 0, len(scenarioReports)) + // 3. 生成每个 scenario 的 PNG 图表 for _, r := range scenarioReports { - scenarioMarkdownReports = append(scenarioMarkdownReports, reporter.ScenarioReport{ - ID: r.Scenario, - KneeRPS: 0, // 拐点需要分析 raw data 算,简化版留 0 - }) + if len(r.Stages) < 1 { + continue + } + plotPath := filepath.Join(inputDir, strings.ToLower(r.Scenario)+".png") + samples := make([]reporter.Sample, 0, len(r.Stages)) + for _, st := range r.Stages { + tot := st.TotalRequests + errRate := float64(0) + if tot > 0 { + errRate = float64(st.Errors) / float64(tot) + } + samples = append(samples, reporter.Sample{ + RPS: float64(st.TargetRPS), + P99Ms: float64(st.P99Us) / 1000, + ErrorRate: errRate, + }) + } + if err := reporter.PlotRPSLatencyError(r.Scenario, samples, plotPath); err != nil { + log.Printf("⚠️ plot %s failed: %v", r.Scenario, err) + continue + } + log.Printf("wrote %s", plotPath) } - // 4. markdown - if err := reporter.GenerateMarkdown(output, scenarioMarkdownReports); err != nil { + // 4. 读 run-metadata.json (可选,runLoadgen 写入) + var meta reporter.RunMetadata + metaPath := filepath.Join(inputDir, "run-metadata.json") + if data, err := os.ReadFile(metaPath); err == nil { + _ = json.Unmarshal(data, &meta) + } + + // 5. markdown (引用生成的 PNG) + if err := reporter.GenerateMarkdown(output, meta, scenarioReports, "./"); err != nil { return fmt.Errorf("write markdown: %w", err) } log.Printf("wrote %s", output) diff --git a/backend/scripts/loadgen/loadgen/reporter/json.go b/backend/scripts/loadgen/loadgen/reporter/json.go index 3b4d747..a49c7cb 100644 --- a/backend/scripts/loadgen/loadgen/reporter/json.go +++ b/backend/scripts/loadgen/loadgen/reporter/json.go @@ -7,20 +7,50 @@ import ( "github.com/HdrHistogram/hdrhistogram-go" ) -type RunReport struct { - Scenario string `json:"scenario"` - TotalRequests int64 `json:"total_requests"` - Errors int64 `json:"errors"` - FiveXX int64 `json:"five_xx"` - P50Us int64 `json:"p50_us"` - P95Us int64 `json:"p95_us"` - P99Us int64 `json:"p99_us"` - MaxUs int64 `json:"max_us"` +type StageReport struct { + StageIdx int `json:"stage_idx"` + TargetRPS int `json:"target_rps"` + TotalRequests int64 `json:"total_requests"` + Errors int64 `json:"errors"` + FiveXX int64 `json:"five_xx"` + P50Us int64 `json:"p50_us"` + P95Us int64 `json:"p95_us"` + P99Us int64 `json:"p99_us"` + MaxUs int64 `json:"max_us"` } -func WriteJSON(path string, scenario string, h *hdrhistogram.Histogram, total, errs, fiveXX int64) error { - r := RunReport{ - Scenario: scenario, +type RunReport struct { + Scenario string `json:"scenario"` + TotalRequests int64 `json:"total_requests"` + Errors int64 `json:"errors"` + FiveXX int64 `json:"five_xx"` + P50Us int64 `json:"p50_us"` + P95Us int64 `json:"p95_us"` + P99Us int64 `json:"p99_us"` + MaxUs int64 `json:"max_us"` + Stages []StageReport `json:"stages,omitempty"` +} + +// WriteJSON writes a RunReport (single scenario, optional per-stage data) to path. +func WriteJSON(path string, r RunReport) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + return enc.Encode(r) +} + +// MakeStageReport fills a StageReport from a histogram + counters. +func MakeStageReport(idx, targetRPS int, h *hdrhistogram.Histogram, total, errs, fiveXX int64) StageReport { + if h == nil { + return StageReport{StageIdx: idx, TargetRPS: targetRPS} + } + return StageReport{ + StageIdx: idx, + TargetRPS: targetRPS, TotalRequests: total, Errors: errs, FiveXX: fiveXX, @@ -29,25 +59,28 @@ func WriteJSON(path string, scenario string, h *hdrhistogram.Histogram, total, e P99Us: h.ValueAtPercentile(99), MaxUs: h.Max(), } - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - return json.NewEncoder(f).Encode(r) } +// WriteBaselineCSV writes a CSV summary across multiple RunReports. func WriteBaselineCSV(path string, scenarios []RunReport) error { f, err := os.Create(path) if err != nil { return err } defer f.Close() - if _, err := f.WriteString("scenario,total,errors,five_xx,p50_ms,p95_ms,p99_ms,max_ms\n"); err != nil { + if _, err := f.WriteString("scenario,total,errors,five_xx,p50_ms,p95_ms,p99_ms,max_ms,stages\n"); err != nil { return err } for _, s := range scenarios { - _, err := f.WriteString(jsonLine(s) + "\n") + _, err := f.WriteString(s.Scenario + "," + + itoa(s.TotalRequests) + "," + + itoa(s.Errors) + "," + + itoa(s.FiveXX) + "," + + ms(s.P50Us) + "," + + ms(s.P95Us) + "," + + ms(s.P99Us) + "," + + ms(s.MaxUs) + "," + + itoa(int64(len(s.Stages))) + "\n") if err != nil { return err } @@ -55,16 +88,6 @@ func WriteBaselineCSV(path string, scenarios []RunReport) error { return nil } -func jsonLine(s RunReport) string { - b, _ := json.Marshal(s) - s2 := string(b) - if len(s2) >= 2 && s2[0] == '{' { - // strip braces for CSV-friendly format - return s.Scenario + "," + itoa(s.TotalRequests) + "," + itoa(s.Errors) + "," + itoa(s.FiveXX) + "," + ms(s.P50Us) + "," + ms(s.P95Us) + "," + ms(s.P99Us) + "," + ms(s.MaxUs) - } - return s2 -} - func itoa(n int64) string { if n == 0 { return "0" @@ -88,12 +111,10 @@ func itoa(n int64) string { } func ms(us int64) string { - // us / 1000 as float return formatFloat(float64(us) / 1000) } func formatFloat(f float64) string { - // simple 2-decimal format intPart := int64(f) frac := int64((f - float64(intPart)) * 100) if frac < 0 { diff --git a/backend/scripts/loadgen/loadgen/reporter/knee.go b/backend/scripts/loadgen/loadgen/reporter/knee.go new file mode 100644 index 0000000..13e9992 --- /dev/null +++ b/backend/scripts/loadgen/loadgen/reporter/knee.go @@ -0,0 +1,33 @@ +package reporter + +// KneeRPS finds the "knee" (turning point) of a multi-stage run. +// +// Heuristic: the first stage where p99 latency grew >50% over the previous +// stage. If no such jump exists (run was healthy throughout), returns the +// highest stage tested (i.e. we never hit the knee). +// +// Returns: +// - kneeRPS: the target_rps at the knee (or highest if no knee found) +// - kneeIdx: the stage index (1-based) where the knee was detected +// - p99Delta: the p99 jump percentage (0.5 = 50% growth) +func KneeRPS(stages []StageReport) (kneeRPS, kneeIdx int, p99Delta float64) { + if len(stages) == 0 { + return 0, 0, 0 + } + if len(stages) == 1 { + return stages[0].TargetRPS, stages[0].StageIdx, 0 + } + for i := 1; i < len(stages); i++ { + prev := stages[i-1].P99Us + if prev == 0 { + continue + } + growth := float64(stages[i].P99Us-prev) / float64(prev) + if growth > 0.5 { + return stages[i].TargetRPS, stages[i].StageIdx, growth + } + } + // 没找到拐点:返回最高 stage + last := stages[len(stages)-1] + return last.TargetRPS, last.StageIdx, 0 +} diff --git a/backend/scripts/loadgen/loadgen/reporter/markdown.go b/backend/scripts/loadgen/loadgen/reporter/markdown.go index 4da3f99..e1985aa 100644 --- a/backend/scripts/loadgen/loadgen/reporter/markdown.go +++ b/backend/scripts/loadgen/loadgen/reporter/markdown.go @@ -3,42 +3,482 @@ package reporter import ( "fmt" "os" + "strings" + "time" ) -type ScenarioReport struct { - ID string - Stages []StageReport - KneeRPS int - TopBottleneck string -} - -type StageReport struct { - RPS int - P50Ms float64 - P95Ms float64 - P99Ms float64 - ErrorRate float64 -} - -func GenerateMarkdown(path string, scenarios []ScenarioReport) error { +// GenerateMarkdown writes a rich markdown report. +// +// Includes: +// - Header (run metadata: target, scenarios, time, JWT hint) +// - Executive summary (per-scenario verdicts + key findings) +// - Cross-scenario bottleneck analysis +// - Per-scenario detailed sections with: +// * Description + business impact + API +// * Verdict with reasoning +// * KPI table vs thresholds +// * Knee analysis +// * Stage-by-stage breakdown +// * PNG chart +// * Specific action items +func GenerateMarkdown(path string, meta RunMetadata, scenarios []RunReport, plotDir string) error { f, err := os.Create(path) if err != nil { return err } defer f.Close() - fmt.Fprintf(f, "# 压测报告\n\n") + writeHeader(f, meta, scenarios) + writeExecutiveSummary(f, scenarios) + writeOverviewTable(f, scenarios) + writeCrossScenarioAnalysis(f, scenarios) for _, s := range scenarios { - fmt.Fprintf(f, "## %s\n\n", s.ID) - fmt.Fprintf(f, "**拐点 RPS**: %d\n\n", s.KneeRPS) - fmt.Fprintf(f, "**Top 瓶颈**: %s\n\n", s.TopBottleneck) - fmt.Fprintf(f, "| Stage | RPS | P50ms | P95ms | P99ms | Err%% |\n") - fmt.Fprintf(f, "|-------|-----|-------|-------|-------|------|\n") - for _, st := range s.Stages { - fmt.Fprintf(f, "| - | %d | %.1f | %.1f | %.1f | %.1f |\n", - st.RPS, st.P50Ms, st.P95Ms, st.P99Ms, st.ErrorRate*100) + writeScenarioDetail(f, s, plotDir) + } + writeAppendix(f, meta) + return nil +} + +func writeHeader(f *os.File, meta RunMetadata, scenarios []RunReport) { + fmt.Fprintf(f, "# TopFans 压测报告\n\n") + duration := meta.EndTime.Sub(meta.StartTime).Round(time.Second) + fmt.Fprintf(f, "## 📋 运行信息\n\n") + fmt.Fprintf(f, "| 项 | 值 |\n|---|---|\n") + fmt.Fprintf(f, "| **生成时间** | %s |\n", time.Now().Format("2006-01-02 15:04:05 MST")) + if !meta.StartTime.IsZero() { + fmt.Fprintf(f, "| **压测开始** | %s |\n", meta.StartTime.Format("2006-01-02 15:04:05 MST")) + fmt.Fprintf(f, "| **压测结束** | %s |\n", meta.EndTime.Format("2006-01-02 15:04:05 MST")) + fmt.Fprintf(f, "| **总耗时** | %s |\n", duration) + } + fmt.Fprintf(f, "| **目标地址** | `%s` |\n", emptyDash(meta.Target)) + fmt.Fprintf(f, "| **测试场景** | %s |\n", strings.Join(meta.Scenarios, ", ")) + fmt.Fprintf(f, "| **阶梯模式** | %s%s |\n", emptyDash(meta.StageMode), ifThen(meta.StepSchedule != "", " (`"+meta.StepSchedule+"`)", "")) + if meta.JWTSecretHint != "" { + fmt.Fprintf(f, "| **JWT 签名密钥** | `%s***` (前 8 位) |\n", meta.JWTSecretHint) + } + if meta.ProdSSH != "" { + fmt.Fprintf(f, "| **prod SSH** | `%s` |\n", meta.ProdSSH) + } + if meta.MonitorMode != "" { + fmt.Fprintf(f, "| **监控模式** | %s |\n", meta.MonitorMode) + } + + // 总请求数 + var totalReq, totalErr, total5xx int64 + for _, s := range scenarios { + totalReq += s.TotalRequests + totalErr += s.Errors + total5xx += s.FiveXX + } + fmt.Fprintf(f, "| **总请求数** | %s |\n", commaInt(totalReq)) + fmt.Fprintf(f, "| **总错误数** | %s (%.2f%%) |\n", commaInt(totalErr), pct(totalErr, totalReq)) + fmt.Fprintf(f, "| **5xx 数** | %s (%.2f%%) |\n", commaInt(total5xx), pct(total5xx, totalReq)) + fmt.Fprintf(f, "\n---\n\n") +} + +func writeExecutiveSummary(f *os.File, scenarios []RunReport) { + fmt.Fprintf(f, "## 🎯 执行摘要\n\n") + + // Count verdicts + counts := map[string]int{"✅": 0, "⚠️": 0, "🚨": 0} + criticalIssues := []string{} + for _, s := range scenarios { + meta, ok := AllScenarios[s.Scenario] + if !ok { + continue + } + _, _, p99Delta := KneeRPS(s.Stages) + knee := p99Delta > 0.5 + v := meta.Verdict(s, knee) + counts[v]++ + + if v == "🚨" { + issue := fmt.Sprintf("- **%s (%s)**: ", s.Scenario, meta.Name) + if errRate := pct(s.Errors, s.TotalRequests); errRate > 1 { + issue += fmt.Sprintf("错误率 %.2f%% ", errRate) + } + if p99Ms := float64(s.P99Us) / 1000; p99Ms > meta.Thresholds.P99MsMax { + issue += fmt.Sprintf("P99 %.0fms (阈值 %.0fms) ", p99Ms, meta.Thresholds.P99MsMax) + } + if knee { + issue += fmt.Sprintf("拐点 stage %d", stagesIdx(s.Stages)) + } + criticalIssues = append(criticalIssues, issue) + } + } + + // Overall verdict + totalSc := len(scenarios) + fmt.Fprintf(f, "**总览**: ✅ %d 健康 / ⚠️ %d 警告 / 🚨 %d 严重 (共 %d)\n\n", + counts["✅"], counts["⚠️"], counts["🚨"], totalSc) + + if len(criticalIssues) == 0 { + fmt.Fprintf(f, "🎉 **所有场景通过健康阈值,系统可承载预期负载。**\n\n") + } else { + fmt.Fprintf(f, "🚨 **关键问题** (%d 个):\n\n", len(criticalIssues)) + for _, issue := range criticalIssues { + fmt.Fprintf(f, "%s\n", issue) } fmt.Fprintf(f, "\n") } - return nil + + // Per-scenario one-liner + fmt.Fprintf(f, "**场景速览**:\n\n") + for _, s := range scenarios { + meta, ok := AllScenarios[s.Scenario] + if !ok { + continue + } + _, _, p99Delta := KneeRPS(s.Stages) + knee := p99Delta > 0.5 + v := meta.Verdict(s, knee) + fmt.Fprintf(f, "- %s **%s %s** — p99=%.0fms, %s", v, s.Scenario, meta.Name, float64(s.P99Us)/1000, errSummary(s)) + if knee { + fmt.Fprintf(f, ", ⚠️ 拐点 stage %d", stagesIdx(s.Stages)) + } + fmt.Fprintf(f, "\n") + } + fmt.Fprintf(f, "\n---\n\n") +} + +func writeOverviewTable(f *os.File, scenarios []RunReport) { + fmt.Fprintf(f, "## 📊 总览表\n\n") + fmt.Fprintf(f, "| 场景 | 描述 | Total | Err | 5xx | P50ms | P95ms | P99ms | Maxms | 拐点 RPS | 状态 |\n") + fmt.Fprintf(f, "|------|------|-------|-----|-----|-------|-------|-------|-------|---------|------|\n") + for _, s := range scenarios { + meta, ok := AllScenarios[s.Scenario] + if !ok { + continue + } + kneeRPS, kneeIdx, p99Delta := KneeRPS(s.Stages) + kneeTriggered := p99Delta > 0.5 + v := meta.Verdict(s, kneeTriggered) + kneeStr := "—" + if kneeTriggered { + kneeStr = fmt.Sprintf("%d (stage %d)", kneeRPS, kneeIdx) + } + fmt.Fprintf(f, "| **%s** | %s | %s | %s (%.2f%%) | %s (%.2f%%) | %.0f | %.0f | %.0f | %.0f | %s | %s |\n", + s.Scenario, meta.Name, + commaInt(s.TotalRequests), + commaInt(s.Errors), pct(s.Errors, s.TotalRequests), + commaInt(s.FiveXX), pct(s.FiveXX, s.TotalRequests), + usToMs(s.P50Us), usToMs(s.P95Us), usToMs(s.P99Us), usToMs(s.MaxUs), + kneeStr, v) + } + fmt.Fprintf(f, "\n> 说明: Err 包含 4xx + 5xx,5xx 是子集。错误率 = Err / Total。\n\n") +} + +func writeCrossScenarioAnalysis(f *os.File, scenarios []RunReport) { + fmt.Fprintf(f, "## 🔬 跨场景瓶颈分析\n\n") + if len(scenarios) < 2 { + fmt.Fprintf(f, "只有一个场景,无需跨场景分析。\n\n") + return + } + + // Find bottleneck: highest P99 relative to threshold + type scored struct { + scenario string + p99Ms float64 + ratio float64 // p99 / threshold + } + var scoreds []scored + for _, s := range scenarios { + meta, ok := AllScenarios[s.Scenario] + if !ok { + continue + } + p99Ms := float64(s.P99Us) / 1000 + ratio := p99Ms / meta.Thresholds.P99MsMax + scoreds = append(scoreds, scored{s.Scenario, p99Ms, ratio}) + } + // Sort by ratio desc + for i := 0; i < len(scoreds); i++ { + for j := i + 1; j < len(scoreds); j++ { + if scoreds[j].ratio > scoreds[i].ratio { + scoreds[i], scoreds[j] = scoreds[j], scoreds[i] + } + } + } + + if len(scoreds) > 0 && scoreds[0].ratio > 1 { + fmt.Fprintf(f, "🚨 **瓶颈场景: %s** — P99 是阈值的 %.2f 倍\n\n", scoreds[0].scenario, scoreds[0].ratio) + } else if len(scoreds) > 0 { + fmt.Fprintf(f, "✅ **无明显瓶颈**,所有场景 P99 都在阈值内。\n\n") + } + + fmt.Fprintf(f, "**P99 / 阈值 比率** (从高到低):\n\n") + for _, s := range scoreds { + fmt.Fprintf(f, "- %s: %.2fx (%.0fms)\n", s.scenario, s.ratio, s.p99Ms) + } + fmt.Fprintf(f, "\n---\n\n") +} + +func writeScenarioDetail(f *os.File, s RunReport, plotDir string) { + meta, ok := AllScenarios[s.Scenario] + if !ok { + fmt.Fprintf(f, "## %s (无元数据)\n\n", s.Scenario) + fmt.Fprintf(f, "```json\n%+v\n```\n\n", s) + return + } + + kneeRPS, kneeIdx, p99Delta := KneeRPS(s.Stages) + kneeTriggered := p99Delta > 0.5 + verdict := meta.Verdict(s, kneeTriggered) + + fmt.Fprintf(f, "## %s %s %s\n\n", verdict, s.Scenario, meta.Name) + fmt.Fprintf(f, "### 📌 测试说明\n\n") + fmt.Fprintf(f, "| 项 | 值 |\n|---|---|\n") + fmt.Fprintf(f, "| **API** | `%s` |\n", meta.API) + fmt.Fprintf(f, "| **负载类型** | %s |\n", workloadLabel(meta.Workload)) + fmt.Fprintf(f, "| **业务说明** | %s |\n", meta.Description) + fmt.Fprintf(f, "| **影响范围** | %s |\n", meta.BusinessImp) + fmt.Fprintf(f, "\n") + + // KPI vs thresholds + fmt.Fprintf(f, "### 📈 性能指标 vs 健康阈值\n\n") + p50Ms := usToMs(s.P50Us) + p95Ms := usToMs(s.P95Us) + p99Ms := usToMs(s.P99Us) + maxMs := usToMs(s.MaxUs) + errRate := pct(s.Errors, s.TotalRequests) + fiveXXRate := pct(s.FiveXX, s.TotalRequests) + fmt.Fprintf(f, "| 指标 | 实测 | 阈值 | 判定 |\n") + fmt.Fprintf(f, "|------|------|------|------|\n") + fmt.Fprintf(f, "| P50ms | %.0f | ≤%.0f | %s |\n", p50Ms, meta.Thresholds.P50MsMax, thresholdMark(p50Ms, meta.Thresholds.P50MsMax)) + fmt.Fprintf(f, "| P95ms | %.0f | ≤%.0f | %s |\n", p95Ms, meta.Thresholds.P95MsMax, thresholdMark(p95Ms, meta.Thresholds.P95MsMax)) + fmt.Fprintf(f, "| P99ms | %.0f | ≤%.0f | %s |\n", p99Ms, meta.Thresholds.P99MsMax, thresholdMark(p99Ms, meta.Thresholds.P99MsMax)) + fmt.Fprintf(f, "| Maxms | %.0f | — | ℹ️ 参考 |\n", maxMs) + fmt.Fprintf(f, "| 错误率 | %.2f%% | ≤%.2f%% | %s |\n", errRate, meta.Thresholds.ErrorRateMax*100, thresholdMark(errRate/100, meta.Thresholds.ErrorRateMax)) + fmt.Fprintf(f, "| 5xx 率 | %.2f%% | ≤%.2f%% | %s |\n", fiveXXRate, meta.Thresholds.FiveXXRateMax*100, thresholdMark(fiveXXRate/100, meta.Thresholds.FiveXXRateMax)) + fmt.Fprintf(f, "\n") + + // Knee + fmt.Fprintf(f, "### 📍 拐点分析\n\n") + if len(s.Stages) <= 1 { + fmt.Fprintf(f, "ℹ️ 仅 1 个 stage,未做阶梯测试,无法判断拐点。\n\n") + } else if kneeTriggered { + fmt.Fprintf(f, "🚨 **拐点**: stage %d @ %d RPS — p99 暴涨 %.0f%%\n\n", + kneeIdx, kneeRPS, p99Delta*100) + fmt.Fprintf(f, "从 stage %d 到 stage %d,p99 延迟从 %.0fms 涨到 %.0fms (%.1fx)。\n", + kneeIdx-1, kneeIdx, usToMs(s.Stages[kneeIdx-2].P99Us), p99Ms, 1+p99Delta) + fmt.Fprintf(f, "\n**含义**: 系统在 %d RPS 时开始出现性能劣化。建议生产限流到 %d RPS 以下。\n\n", + kneeRPS, kneeRPS) + } else { + fmt.Fprintf(f, "✅ **拐点未触发** — 全程 %d 个 stage 健康运行,最高 %d RPS p99=%.0fms。\n\n", + len(s.Stages), kneeRPS, p99Ms) + } + + // Stage table + fmt.Fprintf(f, "### 🔢 阶梯结果\n\n") + if len(s.Stages) == 0 { + fmt.Fprintf(f, "_无 stage 数据_\n\n") + } else { + fmt.Fprintf(f, "| Stage | TargetRPS | Total | Err | 5xx | P50ms | P95ms | P99ms | Maxms | 涨幅 |\n") + fmt.Fprintf(f, "|-------|-----------|-------|-----|-----|-------|-------|-------|-------|------|\n") + for i, st := range s.Stages { + growth := "" + if i > 0 { + prevP99 := float64(s.Stages[i-1].P99Us) / 1000 + curP99 := float64(st.P99Us) / 1000 + if prevP99 > 0 { + pct := (curP99 - prevP99) / prevP99 * 100 + growth = fmt.Sprintf("%+.0f%%", pct) + if pct > 50 { + growth = "🚨 " + growth + } + } + } + fmt.Fprintf(f, "| %d | %d | %s | %s | %s | %.0f | %.0f | %.0f | %.0f | %s |\n", + st.StageIdx, st.TargetRPS, + commaInt(st.TotalRequests), commaInt(st.Errors), commaInt(st.FiveXX), + usToMs(st.P50Us), usToMs(st.P95Us), usToMs(st.P99Us), usToMs(st.MaxUs), + growth) + } + fmt.Fprintf(f, "\n") + } + + // Action items + fmt.Fprintf(f, "### 🎯 行动项\n\n") + actionItems(f, s, meta, kneeTriggered, kneeRPS) + + // Plot + if plotDir != "" { + plotName := strings.ToLower(s.Scenario) + ".png" + fmt.Fprintf(f, "### 📉 图表\n\n") + fmt.Fprintf(f, "![%s RPS / P99 / Error](%s/%s)\n\n", s.Scenario, plotDir, plotName) + } + + fmt.Fprintf(f, "---\n\n") +} + +func writeAppendix(f *os.File, meta RunMetadata) { + fmt.Fprintf(f, "## 📎 附录\n\n") + fmt.Fprintf(f, "### 健康阈值说明\n\n") + fmt.Fprintln(f, "- **P50/P95/P99**: 百分位延迟 (毫秒),值越小越好") + fmt.Fprintln(f, "- **错误率**: 4xx+5xx 请求占比,健康 < 1%") + fmt.Fprintln(f, "- **5xx 率**: 服务端错误率,健康 < 0.1%") + fmt.Fprintln(f, "- **拐点**: 阶梯测试中,p99 相对前一 stage 涨幅 > 50% 的第一个 stage") + fmt.Fprintf(f, "\n") + fmt.Fprintf(f, "### 文件清单\n\n") + fmt.Fprintf(f, "```\n") + fmt.Fprintf(f, "reports/\n") + fmt.Fprintf(f, "├── final-report.md (本文件)\n") + fmt.Fprintf(f, "├── baseline.csv (Excel 可打开的汇总)\n") + for _, s := range []string{"S1", "S2", "S3", "S4", "S5", "S6", "S7"} { + fmt.Fprintf(f, "├── %s.json%s\n", strings.ToLower(s), "") + fmt.Fprintf(f, "├── %s.png%s\n", strings.ToLower(s), "") + } + fmt.Fprintf(f, "```\n\n") + fmt.Fprintf(f, "### 如何复现\n\n") + fmt.Fprintf(f, "```bash\n") + fmt.Fprintf(f, "cd /opt/topfans/loadtest\n") + if meta.StepSchedule != "" { + fmt.Fprintf(f, "./loadgen --cmd=run --scenarios=%s --stage=%s --step-schedule='%s' \\\n", + strings.Join(meta.Scenarios, ","), meta.StageMode, meta.StepSchedule) + } else { + fmt.Fprintf(f, "./loadgen --cmd=run --scenarios=%s --stage=%s \\\n", + strings.Join(meta.Scenarios, ","), meta.StageMode) + } + if meta.Target != "" { + fmt.Fprintf(f, " --target=%s \\\n", meta.Target) + } + if meta.MonitorMode != "" { + fmt.Fprintf(f, " --monitor=%s \\\n", meta.MonitorMode) + } + if meta.ProdSSH != "" { + fmt.Fprintf(f, " --prod-ssh=%s\n", meta.ProdSSH) + } + fmt.Fprintf(f, "```\n") +} + +// ---- helpers ---- + +func workloadLabel(w string) string { + switch w { + case "read": + return "📖 读" + case "write_light": + return "✏️ 轻写" + case "write_heavy": + return "🛠️ 重写" + } + return w +} + +func thresholdMark(value, threshold float64) string { + if value <= threshold { + return "✅" + } + if value <= threshold*1.5 { + return "⚠️" + } + return "🚨" +} + +func errSummary(s RunReport) string { + if s.TotalRequests == 0 { + return "无请求" + } + rate := pct(s.Errors, s.TotalRequests) + return fmt.Sprintf("err %.2f%%", rate) +} + +func stagesIdx(stages []StageReport) int { + _, idx, _ := KneeRPS(stages) + return idx +} + +func pct(num, denom int64) float64 { + if denom == 0 { + return 0 + } + return float64(num) / float64(denom) * 100 +} + +func usToMs(us int64) float64 { + return float64(us) / 1000 +} + +func commaInt(n int64) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + s := fmt.Sprintf("%d", n) + // Insert commas + out := []byte{} + for i, c := range s { + if i > 0 && (len(s)-i)%3 == 0 { + out = append(out, ',') + } + out = append(out, byte(c)) + } + if neg { + return "-" + string(out) + } + return string(out) +} + +func emptyDash(s string) string { + if s == "" { + return "—" + } + return s +} + +func ifThen(cond bool, a, b string) string { + if cond { + return a + } + return b +} + +// actionItems emits scenario-specific P0/P1/P2 action items. +func actionItems(f *os.File, s RunReport, meta ScenarioMeta, knee bool, _ int) { + p99Ms := usToMs(s.P99Us) + errRate := pct(s.Errors, s.TotalRequests) + fiveXXRate := pct(s.FiveXX, s.TotalRequests) + p99Over := p99Ms > meta.Thresholds.P99MsMax + + anyAction := false + + if knee { + kneeRPS, kneeIdx, _ := KneeRPS(s.Stages) + fmt.Fprintf(f, "- [ ] **🔴 P0**: 修复 stage %d 拐点 (%d RPS, p99=%.0fms)\n", kneeIdx, kneeRPS, p99Ms) + fmt.Fprintf(f, " - 看 PG 慢查询 (`pg_stat_statements ORDER BY mean_exec_time DESC`)\n") + fmt.Fprintf(f, " - 跑应用层 profile (`pprof http://localhost:PORT/debug/pprof/profile`)\n") + fmt.Fprintf(f, " - 临时方案: 服务端限流到 %d RPS,超限返回 429\n", kneeRPS) + anyAction = true + } + + if fiveXXRate > 0.5 { + fmt.Fprintf(f, "- [ ] **🔴 P0**: 5xx 率 %.2f%% — 看 prod 服务日志,定位具体错误\n", fiveXXRate) + anyAction = true + } + if errRate > 1 { + fmt.Fprintf(f, "- [ ] **🟡 P1**: 错误率 %.2f%% — 检查 4xx 错误码,看是否 JWT 过期 / 数据缺失\n", errRate) + anyAction = true + } + if p99Over && !knee { + fmt.Fprintf(f, "- [ ] **🟡 P1**: P99 %.0fms 超过阈值 %.0fms — 检查是否有个别慢查询\n", p99Ms, meta.Thresholds.P99MsMax) + anyAction = true + } + + // Workload-specific suggestions + if meta.Workload == "write_heavy" && (knee || p99Over) { + fmt.Fprintf(f, "- [ ] **🟡 P1**: 写重场景有性能问题 — 考虑把同步写改成异步(消息队列)\n") + anyAction = true + } + if meta.Workload == "read" && (knee || p99Over) { + fmt.Fprintf(f, "- [ ] **🟡 P1**: 读路径有性能问题 — 加 Redis 缓存,减少 DB 直查\n") + anyAction = true + } + + if !anyAction { + fmt.Fprintf(f, "✅ 无需行动项 — 所有指标在阈值内。\n") + } + fmt.Fprintf(f, "\n") } diff --git a/backend/scripts/loadgen/loadgen/reporter/meta.go b/backend/scripts/loadgen/loadgen/reporter/meta.go new file mode 100644 index 0000000..8f779a8 --- /dev/null +++ b/backend/scripts/loadgen/loadgen/reporter/meta.go @@ -0,0 +1,156 @@ +package reporter + +import "time" + +// Thresholds defines health KPIs for a scenario. +type Thresholds struct { + P50MsMax float64 // P50ms should be <= this + P95MsMax float64 // P95ms should be <= this + P99MsMax float64 // P99ms should be <= this + ErrorRateMax float64 // e.g. 0.01 = 1% + FiveXXRateMax float64 // e.g. 0.001 = 0.1% +} + +// ScenarioMeta describes what a scenario tests and how to evaluate it. +type ScenarioMeta struct { + ID string // "S1" + Name string // "登录" + API string // "POST /api/v1/auth/login" + Description string // 业务一句话 + BusinessImp string // 影响范围 (所有用户 / 写重 / 边缘功能) + Workload string // "read" | "write_light" | "write_heavy" + Thresholds Thresholds +} + +// AllScenarios is the registry of known scenarios. +// Keep this in sync with scenarios/s*.go registry. +var AllScenarios = map[string]ScenarioMeta{ + "S1": { + ID: "S1", + Name: "用户登录", + API: "POST /api/v1/auth/login", + Description: "用户身份认证,签发 JWT", + BusinessImp: "🔴 所有用户必经路径,失败 = 用户进不来", + Workload: "write_light", + Thresholds: Thresholds{ + P50MsMax: 100, P95MsMax: 300, P99MsMax: 1000, + ErrorRateMax: 0.01, FiveXXRateMax: 0.001, + }, + }, + "S2": { + ID: "S2", + Name: "浏览资产详情", + API: "GET /api/v1/assets/{id}", + Description: "高频读路径,典型缓存命中场景", + BusinessImp: "🟢 单用户最高频操作,影响页面加载体验", + Workload: "read", + Thresholds: Thresholds{ + P50MsMax: 50, P95MsMax: 150, P99MsMax: 500, + ErrorRateMax: 0.01, FiveXXRateMax: 0.001, + }, + }, + "S3": { + ID: "S3", + Name: "点赞 / 取消点赞", + API: "POST/DELETE /api/v1/social/assets/{id}/like", + Description: "轻量写,社交互动", + BusinessImp: "🟢 写多但单条小,影响点赞数显示", + Workload: "write_light", + Thresholds: Thresholds{ + P50MsMax: 80, P95MsMax: 250, P99MsMax: 800, + ErrorRateMax: 0.01, FiveXXRateMax: 0.001, + }, + }, + "S4": { + ID: "S4", + Name: "资产铸造 (mint)", + API: "POST /api/v1/assets/mints/precreate", + Description: "写重路径:OSS 上传 + 签名 + 事务落库", + BusinessImp: "🟡 核心交易,影响创作者产出节奏", + Workload: "write_heavy", + Thresholds: Thresholds{ + P50MsMax: 300, P95MsMax: 800, P99MsMax: 2000, // 写重场景阈值更宽 + ErrorRateMax: 0.01, FiveXXRateMax: 0.001, + }, + }, + "S5": { + ID: "S5", + Name: "Dashboard 聚合", + API: "聚合多个用户/资产指标", + Description: "后台聚合查询,可能涉及多表 JOIN", + BusinessImp: "🟢 运营场景,非实时关键", + Workload: "read", + Thresholds: Thresholds{ + P50MsMax: 200, P95MsMax: 500, P99MsMax: 1500, + ErrorRateMax: 0.01, FiveXXRateMax: 0.001, + }, + }, + "S6": { + ID: "S6", + Name: "热门榜单", + API: "GET /api/v1/rankings/hot", + Description: "排序读,Redis 缓存命中率关键", + BusinessImp: "🟢 首页流量入口,影响新用户第一印象", + Workload: "read", + Thresholds: Thresholds{ + P50MsMax: 30, P95MsMax: 100, P99MsMax: 300, + ErrorRateMax: 0.01, FiveXXRateMax: 0.001, + }, + }, + "S7": { + ID: "S7", + Name: "摆展 (place)", + API: "展位分配 + 事务", + Description: "写重路径,涉及展位锁竞争", + BusinessImp: "🟡 创作者核心操作,涉及并发事务", + Workload: "write_heavy", + Thresholds: Thresholds{ + P50MsMax: 400, P95MsMax: 1000, P99MsMax: 2500, + ErrorRateMax: 0.01, FiveXXRateMax: 0.001, + }, + }, +} + +// Verdict returns one of ✅ (good), ⚠️ (warning), 🚨 (critical). +// Based on thresholds + knee detection. +func (s ScenarioMeta) Verdict(r RunReport, kneeTriggered bool) string { + if len(r.Stages) == 0 { + return "❓" + } + errRate := float64(0) + fiveXXRate := float64(0) + if r.TotalRequests > 0 { + errRate = float64(r.Errors) / float64(r.TotalRequests) + fiveXXRate = float64(r.FiveXX) / float64(r.TotalRequests) + } + p99Ms := float64(r.P99Us) / 1000 + + // 红色条件:任一严重超标 + if errRate > s.Thresholds.ErrorRateMax*2 || + fiveXXRate > s.Thresholds.FiveXXRateMax*5 || + p99Ms > s.Thresholds.P99MsMax*2 { + return "🚨" + } + // 黄色条件:接近阈值 或 触发拐点 + if errRate > s.Thresholds.ErrorRateMax || + fiveXXRate > s.Thresholds.FiveXXRateMax || + p99Ms > s.Thresholds.P99MsMax || + kneeTriggered { + return "⚠️" + } + return "✅" +} + +// RunMetadata captures run-level context for the report header. +type RunMetadata struct { + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Target string `json:"target"` + Scenarios []string `json:"scenarios"` + StepSchedule string `json:"step_schedule,omitempty"` + JWTSecretHint string `json:"jwt_secret_hint,omitempty"` + ProdSSH string `json:"prod_ssh,omitempty"` + MonitorMode string `json:"monitor_mode,omitempty"` + StageMode string `json:"stage_mode"` // "baseline" | "step" | ... + RPSOverride int `json:"rps_override,omitempty"` +} diff --git a/backend/scripts/loadgen/loadgen/scenarios/common.go b/backend/scripts/loadgen/loadgen/scenarios/common.go index 5fc1667..a339962 100644 --- a/backend/scripts/loadgen/loadgen/scenarios/common.go +++ b/backend/scripts/loadgen/loadgen/scenarios/common.go @@ -20,17 +20,20 @@ func doRequest(client *http.Client, req *http.Request, rec *lib.LatencyRecorder, totalCount.Add(1) if err != nil { errCount.Add(1) + rec.RecordResult(true, false) checkBreaker(client, rec, errCount, totalCount, fiveXXCount, breaker) return } defer resp.Body.Close() - switch { - case resp.StatusCode >= 500: + is5xx := resp.StatusCode >= 500 + isErr := resp.StatusCode >= 400 + if is5xx { fiveXXCount.Add(1) errCount.Add(1) - case resp.StatusCode >= 400: + } else if isErr { errCount.Add(1) } + rec.RecordResult(isErr, is5xx) checkBreaker(client, rec, errCount, totalCount, fiveXXCount, breaker) } diff --git a/backend/scripts/loadgen/loadgen/scenarios/s1_login.go b/backend/scripts/loadgen/loadgen/scenarios/s1_login.go index 4cbd57d..0491c70 100644 --- a/backend/scripts/loadgen/loadgen/scenarios/s1_login.go +++ b/backend/scripts/loadgen/loadgen/scenarios/s1_login.go @@ -40,6 +40,10 @@ func (s *s1Login) Run(ctx context.Context, rpsOverride int, durationOverride tim duration = 2 * time.Minute } + // S1 doesn't internally iterate stages, so wrap entire run as stage 1 + s.rec.BeginStage(1, targetRPS) + defer s.rec.EndStage() + ticker := time.NewTicker(time.Second / time.Duration(targetRPS)) defer ticker.Stop() timeout := time.NewTimer(duration) diff --git a/backend/scripts/loadgen/loadgen/scenarios/s2_read.go b/backend/scripts/loadgen/loadgen/scenarios/s2_read.go index faa52c8..696155a 100644 --- a/backend/scripts/loadgen/loadgen/scenarios/s2_read.go +++ b/backend/scripts/loadgen/loadgen/scenarios/s2_read.go @@ -38,6 +38,10 @@ func (s *s2Read) Run(ctx context.Context, rpsOverride int, durationOverride time duration = 2 * time.Minute } + // S2 doesn't internally iterate stages, wrap entire run as stage 1 + s.rec.BeginStage(1, targetRPS) + defer s.rec.EndStage() + ticker := time.NewTicker(time.Second / time.Duration(targetRPS)) defer ticker.Stop() timeout := time.NewTimer(duration) diff --git a/backend/scripts/loadgen/loadgen/scenarios/s3_like.go b/backend/scripts/loadgen/loadgen/scenarios/s3_like.go index 6b4bf77..8511958 100644 --- a/backend/scripts/loadgen/loadgen/scenarios/s3_like.go +++ b/backend/scripts/loadgen/loadgen/scenarios/s3_like.go @@ -39,6 +39,10 @@ func (s *s3Like) Run(ctx context.Context, rpsOverride int, durationOverride time duration = 2 * time.Minute } + // S3 doesn't internally iterate stages, wrap entire run as stage 1 + s.rec.BeginStage(1, targetRPS) + defer s.rec.EndStage() + ticker := time.NewTicker(time.Second / time.Duration(targetRPS)) defer ticker.Stop() timeout := time.NewTimer(duration) diff --git a/backend/scripts/loadgen/loadgen/scenarios/s4_mint.go b/backend/scripts/loadgen/loadgen/scenarios/s4_mint.go index d31e812..9909231 100644 --- a/backend/scripts/loadgen/loadgen/scenarios/s4_mint.go +++ b/backend/scripts/loadgen/loadgen/scenarios/s4_mint.go @@ -37,11 +37,18 @@ func (s *s4Mint) Run(ctx context.Context, rpsOverride int, durationOverride time if len(stages) == 0 { stages = []int{5, 10, 20, 30, 50, 80} } + stageDuration := 2 * time.Minute + if durationOverride > 0 && durationOverride < stageDuration { + stageDuration = durationOverride + } for stageIdx, stageRPS := range stages { - logf("S4 stage %d/%d: %d RPS × 2min", stageIdx+1, len(stages), stageRPS) - if err := s.runStage(ctx, stageRPS, 2*time.Minute); err != nil { + logf("S4 stage %d/%d: %d RPS × %v", stageIdx+1, len(stages), stageRPS, stageDuration) + s.rec.BeginStage(stageIdx+1, stageRPS) + if err := s.runStage(ctx, stageRPS, stageDuration); err != nil { + s.rec.EndStage() return err } + s.rec.EndStage() logf("S4 stage %d done, resetting mint data...", stageIdx+1) if s.prodSSH != "" { cmd := exec.Command("ssh", s.prodSSH, "bash /opt/topfans/loadtest/scripts/mint_reset.sh") diff --git a/backend/scripts/loadgen/scripts/prod_seed.sh b/backend/scripts/loadgen/scripts/prod_seed.sh new file mode 100644 index 0000000..59bb229 --- /dev/null +++ b/backend/scripts/loadgen/scripts/prod_seed.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# =================================================================== +# prod seed 一键运行脚本 +# 用途:从 /opt/topfans/docker/.env.prod 读 DB/JWT 凭据,跑 seed 工具 +# 使用:ssh root@101.132.250.62 "bash /opt/topfans/loadtest/scripts/prod_seed.sh" +# =================================================================== +set -euo pipefail + +ENV_FILE="/opt/topfans/docker/.env.prod" +LOADTEST_DIR="/opt/topfans/loadtest" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "❌ $ENV_FILE 不存在" + exit 1 +fi + +export DB_PASSWORD=$(grep '^DB_PASSWORD=' "$ENV_FILE" | cut -d= -f2) +export JWT_SECRET=$(grep '^JWT_SECRET=' "$ENV_FILE" | cut -d= -f2) + +cd "$LOADTEST_DIR" + +echo "==========================================" +echo "prod seed - 准备 loadtest 数据" +echo "DB host: localhost (容器内)" +echo "DB name: topfans" +echo "JWT secret: ${JWT_SECRET:0:10}..." +echo "==========================================" + +./seed --db-name=topfans --jwt-secret="$JWT_SECRET" + +echo "" +echo "✅ seed 完成。生成的文件:" +ls -la users.csv +echo "" +echo "下一步: ./loadgen --cmd=preflight --target=http://localhost:8080" diff --git a/backend/scripts/loadgen/seed/README.md b/backend/scripts/loadgen/seed/README.md index 9404d5c..45cf422 100644 --- a/backend/scripts/loadgen/seed/README.md +++ b/backend/scripts/loadgen/seed/README.md @@ -1,67 +1,188 @@ # seed - 压测数据准备工具 -## 用途 +> 给 prod 凌晨压测灌 1000 个测试用户 + 资产 + JWT,数据用 `star_id=999900` 物理隔离。 -在 prod 本地插入 1000 个测试用户、5000 资产、3000 booth_slots、2000 exhibitions、10000 friendships,签 1000 个 JWT,写 `users.csv`。 +--- + +## 一句话总结 + +跑 `./seed`,数据库里多出 1000 个用户 + 5000 个 assets + 2000 个 exhibitions,本地多出 `users.csv` (含 JWT)。 + +--- ## 编译 ```bash -cd backend && go build -o seed ./scripts/loadgen/seed/ +cd backend +go build -o bin/seed ./scripts/loadgen/seed/ +# 或 +make loadgen-build ``` -## 在 prod 上跑 +--- + +## 在 prod 上跑 (凌晨 T0 = 02:00) ```bash -# 1. 上传二进制 -scp seed root@101.132.250.62:/opt/topfans/loadtest/ - -# 2. SSH 上去跑 ssh root@101.132.250.62 cd /opt/topfans/loadtest -export DB_PASSWORD=$(cat /opt/topfans/docker/.env.prod | grep DB_PASSWORD | cut -d= -f2) -export JWT_SECRET=$(cat /opt/topfans/docker/.env.prod | grep JWT_SECRET | cut -d= -f2) -./seed --db-name=topfans --jwt-secret="$JWT_SECRET" +bash scripts/prod_seed.sh ``` -## 清理 +这个脚本会自动: +1. 读 `/opt/topfans/docker/.env.prod` 拿 DB_PASSWORD + JWT_SECRET +2. 跑 seed (插入 23k 行测试数据) +3. 自动重置 PG 序列 (CLAUDE.md 规范) +4. 写 `users.csv` (含 1000 个 JWT) + +**预计耗时**:30-60 秒 + +--- + +## 在本地 docker 跑 (开发联调) ```bash -# 保留 1000 users + 资产(下次复用) -./seed --cleanup +cd backend/scripts/loadgen/seed -# 全删(包括账号本身) -./seed --cleanup --full +# 1. 生成 bcrypt 哈希 (与 tokens.go 硬编码的 "Test@123" 匹配) +python3 -c "import bcrypt; print(bcrypt.hashpw(b'Test@123', bcrypt.gensalt(rounds=10)).decode())" \ + > loadtest_bcrypt.txt -# 只重签 token(第二轮压测 JWT 过期时) -./seed --reset-tokens --jwt-secret="$JWT_SECRET" +# 2. 跑 seed (假设本地 docker postgres 在 15432) +cd /Users/liulujian/Documents/code/TopFansByGithub/backend +DB_PASSWORD=123456 \ +JWT_SECRET=topfans-secret-key-local-dev-only \ +./bin/seed \ + --db-name=top-fans \ + --db-host=localhost \ + --db-port=15432 \ + --db-user=postgres ``` -## 本地 docker 联调(开发阶段) +**注意**: `loadtest_bcrypt.txt` 必须在 seed 二进制运行的**当前目录**(代码用相对路径读)。 + +--- + +## 命令行参数 + +``` +./bin/seed --help + +Usage of ./bin/seed: + -cleanup # 跑清理 (默认保留 1000 users) + -cleanup-star-id int # 要清的 star_id (默认 999900, 防止误删) + -full # 配合 -cleanup: 也删用户和 stars + -reset # 删旧数据再 seed (隐含 --cleanup 行为) + -reset-tokens # 只重签 JWT (数据保留) + -jwt-secret string # JWT 密钥 (默认 $JWT_SECRET) + -db-host string # PG host (默认 localhost) + -db-port int # PG port (默认 5432) + -db-name string # PG 数据库 (prod=topfans, 本地=top-fans) + -db-user string # PG user (默认 postgres) + -db-password string # PG 密码 (默认 $DB_PASSWORD) +``` + +--- + +## 三种"清理"模式对比 + +| 命令 | 删 stars | 删 users | 删 assets/exhibits | 用途 | +|------|---------|---------|-------------------|------| +| `./seed --cleanup` | ❌ | ❌ | ✅ | 压完一轮,清理资产但保留账号 | +| `./seed --cleanup --full` | ✅ | ✅ | ✅ | 全部清,下次重新 seed | +| `./seed --reset` | ❌ | ❌ | ✅ | 等同 `--cleanup`(保留用户) | +| `./seed --reset-tokens` | ❌ | ❌ | ❌ | 只重新签 JWT,数据不动 | + +**典型流程**: +```bash +# 第 1 轮压测 (02:00-03:00) +./seed # 灌数据 +./loadgen --cmd=run --scenarios=S1,S2,S4 # 压测 +./seed --cleanup # 压完清理资产 + +# 第 2 轮压测 (下周,JWT 过期了) +./seed --reset-tokens --jwt-secret=$JWT_SECRET # 只重签 JWT +./loadgen --cmd=run --scenarios=S1,S2,S4 # 复测 + +# 完全重来 (例如改了用户模型) +./seed --cleanup --full # 全删 +./seed # 重新灌 +``` + +--- + +## 数据规模 + +| 表 | 行数 | 备注 | +|----|------|------| +| `stars` | +1 | star_id=999900 | +| `users` | +1000 | mobile 19900000001 ~ 19900001000 | +| `fan_profiles` | +1000 | 每个 user 一个 | +| `crystal_transaction_records` | +1000+ | 初始水晶 | +| `assets` | +5000 | 每个 user ~5 个 | +| `booth_slots` | +3000 | | +| `exhibitions` | +2000 | | +| `friendships` | +10000 | | +| **TOTAL** | **~23k 行** | | + +--- + +## 关键设计 + +### 1. star_id 隔离 +所有测试数据用 `star_id = 999900`,**不影响**真实业务 (87, 88, 91, 93, 94, 95)。 + +### 2. PG max_connections = 50 +prod 已将 `POSTGRES_MAX_CONNECTIONS` 从 100 调到 50,避免被测试数据耗尽连接池。 + +### 3. CLAUDE.md 序列重置 +seed 末尾自动 `setval()` 所有相关表的 sequence,避免后续 GORM 插入报 duplicate key。 + +### 4. JWT 7 天过期 +跨周第二轮压测前需 `--reset-tokens` 重签。 + +### 5. bcrypt 哈希与密码硬编码 +- `tokens.go` 硬编码密码为 `"Test@123"`(写到 users.csv 的 password 列) +- `loadtest_bcrypt.txt` 是这个密码的 bcrypt(cost=10) 哈希 +- 二者必须匹配,否则 login 会报 500 + +--- + +## 常见问题 + +### Q: 跑完 seed 但 login 报"密码错误"? +A: `loadtest_bcrypt.txt` 没匹配上 `Test@123`。 +```bash +python3 -c "import bcrypt; print(bcrypt.hashpw(b'Test@123', bcrypt.gensalt(rounds=10)).decode())" \ + > loadtest_bcrypt.txt +./seed --cleanup --full && ./seed +``` + +### Q: 想换密码怎么办? +A: 同时改两个地方: +1. `tokens.go` 的 `u.Mobile, "Test@123"` → 你的密码 +2. `loadtest_bcrypt.txt` 重新生成 + +### Q: "loadtest_bcrypt.txt: no such file or directory"? +A: seed 用相对路径读这个文件,必须在 seed 目录跑(或者把文件 cp 到当前目录)。 + +### Q: --reset 没生效,users 还是旧的? +A: 因为 `--reset` 等同 `--cleanup`(保留用户)。要删用户用 `--cleanup --full`。 + +--- + +## 单元测试 ```bash cd backend -go build -o bin/seed ./scripts/loadgen/seed/ -DB_PASSWORD=postgres123 JWT_SECRET=topfans-secret-key-local-dev-only \ - ./bin/seed --db-name=top-fans --db-host=localhost -``` - -## 关键约束 - -- **star_id = 999900**:所有数据用此 star_id 隔离,不影响真实业务 -- **PG max_connections = 50**:Task 5 已将 `POSTGRES_MAX_CONNECTIONS` 从 100 改到 50 -- **CLAUDE.md 序列重置**:ResetSequences 会在 seed 末尾自动同步所有相关表的 sequence,避免后续 GORM 插入报 duplicate key -- **JWT 7 天过期**:跨周第二轮压测前需 `--reset-tokens` 重签 - -## 测试 - -```bash -cd backend && go test ./scripts/loadgen/seed/ -v +go test ./scripts/loadgen/seed/ -v ``` 5 个测试: -- `TestMobileNumbering`:mobile 编号正确性 -- `TestSequenceMapping`:loadtestSeqs 映射 -- `TestPKColumnMapping`:pkColumns 映射(关键 stars/star_id, booth_slots/slot_id) -- `TestCleanupRejectsInvalidStarID`:cleanup 拒绝非 loadtest star_id -- `TestJoinInt64`:CSV 序列化辅助函数 +- `TestMobileNumbering`: mobile 编号正确性 +- `TestSequenceMapping`: loadtestSeqs 映射 +- `TestPKColumnMapping`: pkColumns 映射(关键 stars/star_id, booth_slots/slot_id) +- `TestCleanupRejectsInvalidStarID`: cleanup 拒绝非 loadtest star_id +- `TestJoinInt64`: CSV 序列化辅助函数 + +**测试状态**: 5/5 PASS diff --git a/frontend/pages/square/components/CreationGrid.vue b/frontend/pages/square/components/CreationGrid.vue index 3197fe9..6bb6a58 100644 --- a/frontend/pages/square/components/CreationGrid.vue +++ b/frontend/pages/square/components/CreationGrid.vue @@ -470,7 +470,7 @@ defineExpose({ .creation-grid { display: flex; flex-wrap: wrap; - justify-content: space-between; + justify-content: space-around; padding-bottom: 120rpx; } diff --git a/frontend/pages/square/components/HotCategoryBlock.vue b/frontend/pages/square/components/HotCategoryBlock.vue index cf401db..5fdc78a 100644 --- a/frontend/pages/square/components/HotCategoryBlock.vue +++ b/frontend/pages/square/components/HotCategoryBlock.vue @@ -456,6 +456,8 @@ onUnmounted(() => { min-height: 0; border-radius: 12px; overflow: hidden; + position: relative; + z-index: 2; } .ranking-tabs { @@ -636,6 +638,7 @@ onUnmounted(() => { /* box-shadow: 2px 2px 4.5px 0px #f04b4b40; */ box-shadow: 2px 4px 4px 0px #c92f2f5c; margin-bottom: 36.8rpx; + z-index: 3; } /* 单行布局:藏品图片 + 头像 + 点赞信息 + TOP 标签 */