56 KiB
后端服务压力测试设计
状态:设计已确认 ✅ 创建时间:2026-06-12 作者:Claude 目标:为部署在阿里云单机(
101.132.250.62,4G/2C,docker-compose)的 TopFans 后端微服务,设计一套可执行、可恢复、可重复的压力测试方案。覆盖容量评估、SLA 基线、稳定性验证、破坏性测试四类目标。
目录
- 背景与目标
- 关键约束与已验证事实
- 总体方案与架构
- 测试数据准备
- 场景设计与 RPS 梯度
- 执行计划与时间盒
- 监控指标与判停红线
- 风险控制与回滚
- 产出物清单
- 不在范围(YAGNI)
- 后续步骤
- 附录 A:术语表
- 附录 B:被测接口路径速查
1. 背景与目标
1.1 背景
TopFans 平台当前部署在阿里云单机 101.132.250.62,使用 docker/docker-compose.prod.yml 启动 11 个容器(gateway + 9 个 Dubbo 微服务 + PostgreSQL + Redis)。
项目目前处于早期阶段(生产数据规模:89 users / 91 fan_profiles / 223 assets),尚未做过任何系统化的压力测试。团队对系统在当前 4G/2C 资源配置下的真实承载能力、接口性能瓶颈、长时间运行的稳定性均缺乏量化数据。
1.2 目标(四象全要)
| 目标 | 含义 | 产出 |
|---|---|---|
| G1 容量评估 | 找出每个核心接口的拐点 RPS(错误率/P99 突破阈值前的最大稳态 RPS) | 每场景一个"拐点 RPS"数字 + 瓶颈分析 |
| G2 SLA 基线 | 建立每个接口的 P50/P95/P99 延迟基线,供后续回归对比 | baseline.csv + HDR 直方图 |
| G3 稳定性 | 在安全水位下跑 30 分钟,验证无内存/连接池/goroutine 泄漏 | 时序图 + 资源增长率 |
| G4 破坏性 | 推至拐点 2-3 倍 RPS,验证降级与自动恢复 | 恢复时间 + 异常日志摘录 |
1.3 非目标(明确不做)
- 不做端到端业务正确性测试(这是 E2E 的工作)
- 不做安全/渗透测试
- 不做前端性能测试
- 不做单元/集成测试
- 不做 AI Chat WebSocket 压测(独立场景,本轮跳过)
- 不做活动榜单/星册等次要接口压测(本轮聚焦核心 7 场景)
2. 关键约束与已验证事实
2.1 环境约束
| 项 | 现状 |
|---|---|
| 部署位置 | 阿里云 ECS 101.132.250.62(华东 1 / 杭州,101.132.x.x 段;root SSH) |
| 资源 | 4G RAM / 2 CPU(生产单机,无 staging) |
| 容器资源限额 | gateway 300M/0.5C,各微服务 100-200M/0.5C,PG 400M(max 100 connections),Redis 256M |
| 入口 | 公网 http://101.132.250.62:8080(无 nginx 反代,gateway 直接对外) |
| 部署脚本 | docker/deploy.sh(参考用法见脚本头部注释) |
⚠️ PG 内存配置冲突(必须在 preflight 阶段处理):
- PG 容器限额 400M,但
POSTGRES_MAX_CONNECTIONS=100 - 每个 PG backend 进程典型占用 5-10MB(work_mem + stack + plan cache,不含 shared_buffers)
- 100 connections × 7MB ≈ 700MB > 400M 容器限额
- → R4 红线 85 connections 之前,PG 进程很可能已被 cgroup OOM Killer 杀掉
- → R6 OOM 红线如果检测不准(详见 §7.3 修复说明),会出现"PG 突然消失但红线没触发"
- 处置方案(任选其一):
- 方案 A(推荐):第一轮压测前手动把
POSTGRES_MAX_CONNECTIONS降到 50(preflight 仅做检测+报错+给出 SQL 命令,不自动改 PG 配置,因为max_connections修改需要重启 PG 才生效,不能 reload)。手动步骤:# 改 docker-compose.prod.yml POSTGRES_MAX_CONNECTIONS=50 # 重启 postgres 容器(约 30s 停机) docker-compose -f docker-compose.prod.yml restart postgres # 验证 docker exec "$PG_CONTAINER" psql -U postgres -c "SHOW max_connections;" - 方案 B:把 PG 容器 limits.memory 提到 1024M,max_connections 不变(同样需重启 PG)
- 方案 C:接受现状,把 R4 阈值从 85 调到 50(无停机,但风险还在)
- 方案 A(推荐):第一轮压测前手动把
2.2 数据库约束(来自本地 top-fans 库 schema 调研)
库名差异(重要):
- 本地 docker:
top-fans(带横线) - prod docker-compose 配置:
topfans(无横线) - → seed/loadgen 工具的 DSN 必须参数化,开工前先 ssh 到 prod 跑
\d+ <key_tables>验证 schema 与本地一致
核心表与隐含约束:
| 表 | 关键约束 |
|---|---|
users |
mobile 唯一(仅 deleted_at IS NULL);id BIGSERIAL |
stars |
star_id BIGSERIAL;identity_id 唯一 |
fan_profiles |
(user_id, star_id) 唯一;(star_id, nickname) 唯一;多了 experience/revenue_boost_bps 字段(vs GORM model) |
assets |
id BIGSERIAL;外键 owner_uid → users.id, star_id → stars.star_id |
asset_likes |
exhibition_id NOT NULL;唯一约束 (user_id, asset_id, exhibition_id);点赞必须依附 exhibition |
exhibitions |
uk_asset WHERE deleted_at IS NULL:每个 asset 同一时刻只能上一个展位 |
booth_slots |
is_enabled 默认 false,seed 时必须显式置 true |
mint_orders |
OrderID 是 UUID(字符串主键),不需序列重置 |
auto_users 表(避坑):
存在一张 auto_users 表(主键序列名 auto_like_users_id_seq 暴露原始用途:自动点赞机器人元数据表)。压测数据绝不写入 auto_users,避免被业务侧的自动点赞调度器扫到产生不可控背景流量。
2.3 业务约束
铸造成本指数翻倍(关键约束):
mint_cost_config 当前配置:
第1次:2 第2次:4 第3次:8 第4次:16 第5次:32
第6次:64 第7次:128 第8次:256 第9次:512 第10次:1024
累计 = 2046 水晶/用户
含义:
- 单测试账号最多压 10 次铸造(之后
mint_cost_config查空报错) - → 铸造场景采用 轮转重置策略:每 200 秒一轮,跑完 reset 水晶+次数+订单,再开下一轮(详见 §5.4)
JWT secret:
- 本地:
topfans-secret-key-local-dev-only - prod:
topfans-secret-key-please-change-in-production - seed 工具复用
backend/pkg/jwt.GenerateToken(),secret 通过--jwt-secret参数注入,不要 commit 到 repo
2.4 已确认决策汇总
| 决策项 | 选择 |
|---|---|
| 压源位置 | 同地域阿里云 ECS(4G/2C 按量付费,~5 元/天) |
| 监控形态 | 可配置三档:--monitor=off/lite/full,默认 lite |
| 工具栈 | 方案 D:自研 Go 二进制 loadgen + seed |
| 业务规模预期 | 早期项目摸底,无业务量预设 |
| 核心场景 | S1 登录、S2 资产读、S3 点赞、S4 铸造、S5 看板、S6 多维榜单、S7 上架 |
| seed 运行位置 | prod 服务器本地(避免 PG 公网暴露) |
| schema 验证 | 开工前 ssh prod 跑 diff |
| 铸造场景节奏 | 轮转重置 |
| RPS 阶梯策略 | 每场景独立阶梯(详见 §5.6) |
| 压测分轮 | 分两轮:探索 ~2h + 修复 + 验证 ~4h |
| 第一轮混合场景 | 不做(数据驱动后再定比例) |
| 6 维红线阈值 | 维持设计原值(详见 §7.3) |
| 实时仪表 | stderr 行模式(不做 TUI) |
| Grafana 面板 | 4 个(整机/容器/PG/业务) |
3. 总体方案与架构
3.1 架构总览
┌──────────────────────────┐ ┌────────────────────────────────────┐
│ 压力机 (同地域 ECS) │ 公网 HTTP │ prod 101.132.250.62 (4G/2C) │
│ │ ─────────► │ │
│ loadgen (Go binary) │ │ docker-compose.prod.yml │
│ ├ scenarios/*.go │ │ ├ gateway:8080 (REST) │
│ ├ lib/{ramp,circuit, │ │ ├ userservice/socialservice/... │
│ │ hdr,csv,client} │ │ └ postgres + redis │
│ ├ users.csv (1000 行) │ │ │
│ └ reports/run-*/ │ │ /opt/topfans/loadtest/ │
│ │ ssh tunnel │ ├ seed binary │
│ loadgen-watcher (ssh) │ ─────────► │ ├ sample.sh (后台) │
│ │ │ ├ metrics-feed.jsonl │
│ │ │ ├ emergency-stop.sh │
│ │ │ └ restore-from-backup.sh │
└──────────────────────────┘ └────────────────────────────────────┘
▲ ▲
│ 报告生成(事后) │ 可选: docker-compose.monitor.yml
▼ ▼
./reports/run-YYYYMMDD-HHMMSS/ cAdvisor + node/pg/redis-exporter
*.json *.csv *.md *.svg + Prometheus + Grafana (端口 3000)
3.2 为什么选这个架构
| 决策 | 理由 |
|---|---|
| 不在被压机器跑压源 | 资源争抢:loadgen 在 200-1000 goroutine 并发下吃 0.5-1 CPU、300-800MB,会污染容量数据 30-50%;localhost 走 lo 不经过物理 NIC,路径与真实用户不同;docker stats 自己也会被压慢,监控失真。注意区分:spec §4.2 的"1000 测试用户"是用户池规模(数据),loadgen 实际并发 goroutine 数由 RPS × 平均 RT 决定(200 RPS × 100ms RT = 20 并发 goroutine)。 |
| 同地域 ECS | 跨公网压会把家宽延迟/丢包混进 P99,污染结论;压源 ECS 与 prod 同在华东 1(杭州),内网/同骨干 RTT 2-5ms |
| 不绕过 gateway | 真实业务路径就从 gateway 入,绕过测不出 gateway 自身瓶颈 |
| 可配置监控(off/lite/full) | 摸容量拐点时 lite 干扰最小(< 30M);定位慢查询时 full(~400M/0.3C)值得开 |
| 方案 D 自研 Go 工具 | Go 后端团队熟,可复用 pkg/jwt/pkg/types;单二进制部署;逻辑可灵活定制(如铸造轮转) |
3.3 工程目录结构
backend/scripts/loadgen/
├── seed/
│ ├── main.go # 入口,解析 --jwt-secret/--db-dsn/--reset
│ ├── stars.go # INSERT star_id=999900
│ ├── users.go # INSERT 1000 users (bcrypt 'Test@123')
│ ├── profiles.go # INSERT fan_profiles + 充值 2200 水晶
│ ├── slots_and_exhibits.go # INSERT booth_slots (is_enabled=true) + exhibitions
│ ├── assets.go # INSERT 5000 assets (status=1)
│ ├── friendships.go # INSERT 10000 friendships
│ ├── tokens.go # 复用 pkg/jwt 预签 token,写 users.csv
│ ├── sequences.go # 重置所有相关表的序列(CLAUDE.md 规范)
│ ├── cleanup.go # DELETE WHERE star_id=999900(强校验)
│ └── README.md
├── loadgen/
│ ├── main.go # 入口,解析 --scenario/--rps/--vus/--duration/--monitor
│ ├── lib/
│ │ ├── ramp.go # 阶梯调度器:[]Stage{rps, duration}
│ │ ├── circuit.go # 6 维红线判停(详见 §7.3)
│ │ ├── hdr.go # HdrHistogram-go 封装
│ │ ├── csv.go # users.csv 加载到内存
│ │ ├── client.go # http.Transport(MaxConnsPerHost=500)
│ │ ├── ssh_metrics.go # ssh tail metrics-feed.jsonl
│ │ └── log.go # stderr 行仪表 + 全量 events.jsonl
│ ├── scenarios/
│ │ ├── s1_login.go
│ │ ├── s2_read.go
│ │ ├── s3_like.go
│ │ ├── s4_mint.go # 含 reset SQL 调度
│ │ ├── s5_dashboard.go
│ │ ├── s6_ranking.go
│ │ └── s7_place.go
│ ├── reporter/
│ │ ├── json.go # raw 时序
│ │ ├── csv.go # 聚合表
│ │ ├── plot.go # gonum/plot 三联图
│ │ └── markdown.go # report.md 生成
│ ├── preflight.go # §8 7 项 sanity check
│ └── verify.go # 压测后数据完整性校验
├── monitor/
│ ├── sample.sh # 被压机后台采样
│ ├── docker-compose.monitor.yml # cAdvisor+exporters+Prom+Grafana
│ └── grafana-dashboards/ # 4 个预置面板 JSON
├── recover/
│ ├── emergency-stop.sh # 一键熔断
│ └── restore-from-backup.sh # pg_dump 还原
└── reports/ # gitignore,跑测产出
└── run-YYYYMMDD-HHMMSS/
3.4 loadgen CLI 完整命令与 flag 表(终审修复 D3)
散落在 §4.5/§5.6/§6.1/§7.5/§8.5 的命令在此处统一定义。实施时严格按这张表生成 CLI。
| 子命令 | 用途 | flag | 默认值 |
|---|---|---|---|
loadgen seed --prod |
在 prod 本地跑 seed | --jwt-secret <secret> 必填 |
- |
--db-host <host> |
localhost |
||
--db-name <name> |
topfans |
||
--db-password <pw> 或读 $DB_PASSWORD |
- | ||
--reset 删旧测试数据后重新 seed |
false | ||
--reset-tokens 只重签 token,不动数据 |
false | ||
loadgen seed cleanup |
清理测试数据 | --keep-baseline 保留 1000 testers + 资产 |
false |
--full 全删(含 stars/users/profiles) |
false | ||
loadgen run |
跑压测主流程 | --scenarios <S1,S2,...> 复数 ,逗号分隔 |
- |
--stage <baseline|step|soak|stress> |
step |
||
--rps <N> 单 RPS 模式 |
- | ||
--vus <N> 最大 VU 数 |
自动 | ||
--duration <30m> 单阶段时长 |
按 §5.3 | ||
--inter-scenario-pause <15m> |
15m |
||
--monitor <off|lite|full> |
lite |
||
--prod-ssh <user@host> |
- | ||
--target <url> 被压 gateway 入口 |
http://101.132.250.62:8080 |
||
loadgen preflight |
开压前 7 项检查 | --target <url> |
- |
--prod-ssh <user@host> |
- | ||
loadgen verify |
压后数据完整性校验 | --prod-ssh <user@host> |
- |
loadgen report |
从 raw data 生成 Markdown 报告 | --input <dir> |
- |
--output <md path> |
./report.md |
命名约定(实施时强制):
- 多场景一律用
--scenarios=S1,S2,...(复数,逗号分隔) - 单场景用
--scenarios=S4(不再设--scenario单数 flag,避免歧义)
4. 测试数据准备
4.1 数据隔离策略("压测沙盒")
所有压测数据归到一个独立的 star_id,物理隔离真实业务:
| 维度 | 隔离值 | 业务真实值 |
|---|---|---|
| star_id | 999900 | 87, 88, 91, 93, 94, 95(6 个) |
| 测试手机号前缀 | 199000xxxxx(11 位) | 13x/15x/17x... |
| 测试 user_id 区间 | 30000001 ~ 30001000 | max=110 |
| 测试 asset_id 起点 | MAX(assets.id) + 1000 动态分配 |
max=303 |
| 测试昵称 | loadtest_<n> |
(任意中文/英文) |
| 测试资产名 | loadtest_asset_<userId>_<n> |
(任意) |
4.2 数据规模
| 资源 | 数量 | 设计依据 |
|---|---|---|
| 测试用户 | 1000 | 200 并发 × 5 倍余量 |
| 每用户 crystal_balance 初始 | 2200 | ≥ 2046(10 次铸造累计)+ 缓冲 |
| 每用户预铸资产 | 5 | asset_id 从 MAX+1000 起。用途分配:资产 1-2 用于 seed 预上架(S3 点赞依赖),资产 3-5 留作未上架库存供 S7 上架/卸下轮转使用 |
| 每用户 booth_slots | 3(is_enabled=true) | 默认配额;slot_index=1,2 给 seed 预上架,slot_index=3 留给 S7 压测 |
| 每用户 exhibitions | 2(用 slot 1, 2) | "可点赞资产池"(S3 依赖) |
| 每用户测试好友 | 10 | S2/S6 部分查询走好友关系 |
| 总 INSERT 行数 | ~25,000 | users 1k + profiles 1k + assets 5k + slots 3k + exhibits 2k + friendships 1w + stars 1 |
4.3 Seed 执行流程
BEGIN;
-- 时间戳占位:ts = extract(epoch from now())*1000 (毫秒,与项目时间戳约定一致)
-- 实际 seed 程序中由 Go 侧 time.Now().UnixMilli() 计算
-- 1. 测试明星
INSERT INTO stars (star_id, name, identity_id, is_active, created_at, updated_at)
VALUES (999900, 'loadtest_star', 'loadtest_star', true, ts, ts)
ON CONFLICT (star_id) DO NOTHING;
-- 2. 1000 测试用户(password_hash 用 bcrypt('Test@123') 离线生成一次复用)
INSERT INTO users (id, mobile, password_hash, is_active, created_at, updated_at) VALUES
(30000001, '19900000001', '$2a$10$<bcrypt_hash>', true, ts, ts),
...
ON CONFLICT (id) DO NOTHING;
-- 3. fan_profiles + 充值
INSERT INTO fan_profiles (user_id, star_id, nickname, crystal_balance, slot_limit, is_active, created_at, updated_at) VALUES
(30000001, 999900, 'loadtest_1', 2200, 3, true, ts, ts), ...;
-- 4. assets(从 MAX(id)+1000 起步,避免与真实数据撞主键)
WITH base AS (SELECT COALESCE(MAX(id), 0) + 1000 AS start FROM assets)
INSERT INTO assets (id, owner_uid, star_id, name, cover_url, info, status, like_count, is_active, created_at, updated_at, grade)
SELECT start + n, owner_uid, 999900, 'loadtest_asset_'||owner_uid||'_'||idx, '<PLACEHOLDER_OSS_URL>', 'loadtest', 1, 0, true, ts, ts, 1
FROM base, generate_series(1, 5000) n, ...;
-- 5. booth_slots(每 user 3 个,is_enabled=true)
INSERT INTO booth_slots (host_profile_id, user_id, star_id, slot_index, is_enabled, created_at, updated_at) ...;
-- 6. exhibitions(每 user 把 asset 1,2 上架到 slot 1,2)
INSERT INTO exhibitions (asset_id, slot_id, host_profile_id, occupier_uid, occupier_star_id, start_time, expire_at, created_at, updated_at)
SELECT a.id, s.slot_id, fp.id, a.owner_uid, 999900, ts, ts + 4*3600*1000, ts, ts
FROM assets a
JOIN fan_profiles fp ON a.owner_uid=fp.user_id AND fp.star_id=999900
JOIN booth_slots s ON s.host_profile_id=fp.id AND s.slot_index IN (1,2)
WHERE a.star_id=999900 AND a.name LIKE 'loadtest_%';
-- 7. friendships(双向;status 默认 'accepted',唯一约束 (user_id, friend_id, star_id))
INSERT INTO friendships (user_id, friend_id, star_id, status, intimacy, created_at, updated_at)
SELECT a.id, b.id, 999900, 'accepted', 0, ts, ts
FROM users a, users b
WHERE a.id BETWEEN 30000001 AND 30001000
AND b.id BETWEEN 30000001 AND 30001000
AND a.id != b.id
AND ((a.id - 30000000) + 1) % 10 = ((b.id - 30000000) % 10)
ON CONFLICT (user_id, friend_id, star_id) DO NOTHING;
-- 上述 JOIN 条件生成约 10000 行双向好友关系
COMMIT;
-- 8. ⚠️ CLAUDE.md 强制:重置所有相关表的序列
SELECT setval('users_id_seq', (SELECT MAX(id) FROM users));
SELECT setval('fan_profiles_id_seq', (SELECT MAX(id) FROM fan_profiles));
SELECT setval('assets_id_seq', (SELECT MAX(id) FROM assets));
SELECT setval('booth_slots_slot_id_seq', (SELECT MAX(slot_id) FROM booth_slots));
SELECT setval('exhibitions_id_seq', (SELECT MAX(id) FROM exhibitions));
SELECT setval('stars_star_id_seq', (SELECT MAX(star_id) FROM stars));
SELECT setval('asset_likes_id_seq', (SELECT COALESCE(MAX(id), 0) FROM asset_likes));
SELECT setval('friendships_id_seq', (SELECT MAX(id) FROM friendships));
SELECT setval('crystal_transaction_records_id_seq', (SELECT COALESCE(MAX(id), 0) FROM crystal_transaction_records));
4.4 JWT Token 预签发
// backend/scripts/loadgen/seed/tokens.go
import "github.com/topfans/backend/pkg/jwt"
func GenerateTokensForLoadtest(users []TestUser, jwtSecret string) error {
jwt.SetSecret(jwtSecret) // 从命令行参数注入
csvFile, _ := os.Create("users.csv")
defer csvFile.Close()
writer := csv.NewWriter(csvFile)
writer.Write([]string{"phone", "password", "user_id", "star_id", "jwt_token", "asset_ids", "exhibition_ids"})
for _, u := range users {
token, err := jwt.GenerateToken(u.UserID, 999900, time.Now().UnixMilli())
if err != nil { return err }
writer.Write([]string{
u.Mobile, "Test@123",
strconv.FormatInt(u.UserID, 10),
"999900", token,
joinInt64Slice(u.AssetIDs, ";"), // 工具函数:strings.Builder + strconv.FormatInt
joinInt64Slice(u.ExhibitionIDs, ";"),
})
}
return writer.Flush()
}
⚠️ users.csv 加入 .gitignore,包含 token 不能 commit。
⚠️ <PLACEHOLDER_OSS_URL> 需要替换为 OSS 上真实存在的占位图 URL(seed 执行前先上传一张 loadtest-placeholder.png 到 OSS 并填入)。上传方式:用项目现有接口 POST /api/v1/assets/oss/upload-signature 获取签名 → PUT 文件到 OSS 的 loadtest/loadtest-placeholder.png 路径 → 拷贝返回的可访问 URL 填入 seed 工具的 LOADTEST_PLACEHOLDER_URL 常量。
4.5 数据清理策略
# 删压测产生的写入(保留 1000 个测试账号 + 资产,下次复用)
loadgen seed cleanup --keep-baseline
# 全删(包括账号本身)
loadgen seed cleanup --full
cleanup 安全校验:
const LoadtestStarID = 999900
func cleanup(db *sql.DB, starID int64) error {
if starID != LoadtestStarID {
return errors.New("safety: cleanup only accepts loadtest star_id 999900")
}
queries := []string{
"DELETE FROM asset_likes WHERE star_id = $1",
"DELETE FROM exhibitions USING fan_profiles fp WHERE exhibitions.host_profile_id=fp.id AND fp.star_id = $1",
"DELETE FROM booth_slots WHERE star_id = $1",
"DELETE FROM mint_orders WHERE star_id = $1",
"DELETE FROM crystal_transaction_records WHERE star_id = $1",
// assets / fan_profiles / users / stars: 按 --keep-baseline / --full 决定
}
for _, q := range queries {
if _, err := db.Exec(q, starID); err != nil { return err }
}
return nil
}
5. 场景设计与 RPS 梯度
5.1 7 个场景一览
| ID | 场景 | 接口 | 依赖 | 预期瓶颈 | 基线 P95 目标 |
|---|---|---|---|---|---|
| S1 | 登录+鉴权 | POST /auth/login + GET /me/profile |
mobile + password | UserService bcrypt 验密 / PG users 查询 / JWT 签发 CPU | < 200ms |
| S2 | 资产读 | GET /assets/me/items?page=1 + GET /assets/:id 随机 |
预签 token + asset_ids | PG idx_assets_owner_star 索引 / Gateway→Dubbo→Asset 三跳 |
< 100ms |
| S3 | 点赞 | POST /social/assets/:id/like + DELETE |
必须用 seed 上架的 asset_id(依附 exhibition) | PG asset_likes 唯一约束 / assets.like_count 行锁 |
< 150ms |
| S4 | 资产铸造 | POST /assets/mints/precreate → POST /assets/mints |
水晶 ≥ cost + 未达 10 次硬上限 | PG 事务(扣余额+写订单) / crystal_transaction_records 流水 |
< 500ms |
| S5 | 数据看板 | 7 个 GET /dashboard/* 串行 |
当前用户 token | PG 物化视图 / 多表 JOIN / Statistic 服务 | < 800ms(用户会话端到端) |
| S6 | 多维榜单 | GET /rankings/{hot,original}?dimension={displaying,month,total}&star_id={87,88,93,999900} |
24 种参数组合循环(2 接口 × 3 dimension × 4 star_id) | PG 排序+LIMIT / Asset 服务 Redis 缓存命中率 | < 250ms |
| S7 | 上架展位 | POST /galleries/place + DELETE /galleries/slots/:slot_id/asset |
slot_index=3 留作压测槽位 | PG exhibitions.uk_asset 唯一 / booth_slots 状态机 |
< 200ms |
5.2 度量定义(避免歧义)
RPS 在本文档中的统一含义:除非特别说明,"RPS" = 后端请求/秒(backend QPS),即 loadgen 实际发出的 HTTP 请求计数。
S5 看板场景特例:S5 一次"用户会话" = 7 个 /dashboard/* 串行请求。S5 的 RPS 阶梯(§5.3)以用户会话视角给出,对应后端 QPS = 阶梯值 × 7。例如 S5 阶梯 "20 RPS" 表示20 个并发用户会话/秒,对应 140 backend QPS。S5 的 P95/P99 也按"用户会话端到端"度量(即 7 个串行请求的总耗时)。
S6 榜单场景特例:S6 一次"用户行为" = 1 次请求(不串行)。S6 的 RPS = 后端 QPS。
所有红线判定基于"后端 QPS 视角":§7.3 R1/R2/R3 的错误率和延迟红线统计的是 events.jsonl 单请求事件,不分场景类型一律按原子请求计。这意味着 S5 在 20 RPS(会话)= 140 QPS 时如果有 1.4 QPS 错误(5%),R1 触发。
5.3 4 阶段 RPS 梯度
⚠️ 两轮拆分(P0-1 修复):本节描述 4 阶段完整 cycle 模型。第一轮(探索)只跑阶段 1+2(baseline + step,225 min),目标是找到拐点;阶段 3 稳定性 + 阶段 4 破坏性 + §5.5 混合场景全部推到第二轮(验证)。第二轮窗口独立计算(详见 §6.3)。
阶段 第一轮 第二轮 1. Baseline ✅ 跑 (已有数据,跳过) 2. Step Ramp-up ✅ 跑 (已有拐点,跳过;如修复后想对比,可重跑) 3. Soak(30min × N) ❌ 不跑 ✅ 跑(在已知拐点的 60% 安全水位) 4. Stress(5min × N) ❌ 不跑 ✅ 跑(拐点 × 2-3 倍) 混合场景 ❌ 不跑 ✅ 跑(按数据驱动比例)
阶段 1 · 基线 (Baseline)
- 目标:拿到"无并发干扰"的 P50/P95/P99
- 执行:每场景 1 VU × 1 RPS × 3 min
- 产出:
baseline.csv
阶段 2 · 容量阶梯 (Step Ramp-up) ← 找拐点
- 目标:每场景独立 6 阶爬升,找到错误率/P99 突破阈值的 RPS
- 每阶 2 分钟
每场景独立的 RPS 阶梯:
| 场景 | RPS 阶梯(每阶 2min) | 预估拐点 |
|---|---|---|
| S1 登录 | 2 → 5 → 10 → 15 → 25 → 40 | ~15 |
| S2 资产读 | 20 → 50 → 100 → 200 → 400 → 700 | ~250 |
| S3 点赞 | 5 → 10 → 20 → 40 → 80 → 150 | ~50 |
| S4 铸造 | 5 → 10 → 20 → 30 → 50 → 80 | ~30 |
| S5 看板 | 2 → 5 → 10 → 20 → 35 → 60 | ~20 |
| S6 榜单 | 20 → 50 → 100 → 200 → 400 → 700 | ~300 |
| S7 上架 | 5 → 10 → 20 → 40 → 80 → 150 | ~50 |
每场景跑到红线(错误率>5% 持续 30s 或 P99>3s 持续 30s)自动停止。
阶段 3 · 稳定性 (Soak) ← 找泄漏
- 目标:取阶段 2 拐点的 60% 作为"安全水位",跑 30 分钟
- 关注:goroutine / 内存增长 / PG connections / 慢查询累积
- S1/S2/S3/S5/S6/S7 各跑一次 30min;S4 见 §5.4
阶段 4 · 破坏性 (Stress) ← 验证降级
- 目标:拐点 × 2-3 倍,跑 5 分钟
- 观察:Gateway 503 / Dubbo 超时熔断 / PG 连接拒绝 / OOM Kill / 撤压后恢复时间
5.4 铸造场景特殊处理:每阶 reset 轮转
铸造单用户硬上限 10 次(mint_cost_config 只有 10 行):1000 用户共 10,000 次配额。
为什么不能裸跑阶梯(B1 自审发现):
S4 阶梯 5→10→20→30→50→80 RPS(每阶 120s),累计请求量 = (5+10+20+30+50+80) × 120 = 23,400 次 >> 10,000 配额。
如果不每阶 reset,第 50 RPS 阶(累计 8400 次 + 120s × 50 = 14,400 次)就会大面积"用户已达 10 次"业务报错;80 RPS 阶(累计 16,800 次起)几乎 100% 失败 → R3 5xx 红线触发,但实际不是系统拐点,是配额耗尽,拐点测不出来。
正确节奏(每阶单独 reset):
S4 阶梯每阶 = "压 120s → 暂停 → reset → 下一阶"
阶段 1: 5 RPS × 120s = 600 req → reset
阶段 2: 10 RPS × 120s = 1200 req → reset
阶段 3: 20 RPS × 120s = 2400 req → reset
阶段 4: 30 RPS × 120s = 3600 req → reset
阶段 5: 50 RPS × 120s = 6000 req → reset
阶段 6: 80 RPS × 120s = 9600 req → reset
(每阶都在 10000 配额内,余量充足)
稳定性阶段同样轮转(取 50 RPS 安全水位):
[T+0] 开始压 50 RPS
[T+200s] loadgen 暂停(1000 用户 × 10 次 = 10000 / 50 RPS = 200s)
[T+201s] ssh prod 执行 reset SQL(约 5-10s)
[T+210s] 继续下一轮
...
循环 9 轮 ≈ 30 分钟
Reset SQL(P0-2 修复:包装成可执行脚本 + PGPASSWORD):
scenarios/s4_mint.go 在每阶/每轮结束后通过 ssh 远程触发 prod 上的 mint_reset.sh:
# /opt/topfans/loadtest/scripts/mint_reset.sh(部署到 prod)
#!/bin/bash
set -e
PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
[ -z "$PG_CONTAINER" ] && { echo "❌ 找不到 postgres 容器"; exit 1; }
export PGPASSWORD="${DB_PASSWORD:-postgres123}"
docker exec -i -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans <<'EOF'
BEGIN;
DELETE FROM user_mint_count WHERE star_id = 999900;
DELETE FROM mint_orders WHERE star_id = 999900;
UPDATE fan_profiles SET crystal_balance = 2200 WHERE star_id = 999900;
DELETE FROM crystal_transaction_records WHERE star_id = 999900;
DELETE FROM assets WHERE star_id = 999900 AND name LIKE 'loadtest_mint_%';
COMMIT;
-- 序列不重置(mint_orders 主键是 UUID;assets 新建的会被上面 DELETE 清掉,序列空洞 OK)
EOF
echo "✅ mint reset 完成"
scenarios/s4_mint.go 调用方式(伪代码):
func resetMintData() error {
cmd := exec.Command("ssh", prodSSH, "bash /opt/topfans/loadtest/scripts/mint_reset.sh")
return cmd.Run()
}
铸造场景资产命名约定(重要):
S4 压测调用 POST /assets/mints 时,请求 body 的 name 字段必须以 loadtest_mint_ 开头(如 loadtest_mint_30000001_round3_42),这样 reset SQL 的 name LIKE 'loadtest_mint_%' 才能精准清理,不会误删 seed 阶段预铸的 loadtest_asset_ 资产。
scenarios/s4_mint.go 构造请求时强制按此前缀生成 name。
5.5 不做的(YAGNI)
- 混合场景:第一轮不做。理由:早期项目业务比例还没成型,凭直觉给比例会产出"看似科学但实际是猜的"数字。等单场景拐点出齐,按业务相对调用频率回推 DAU 上限。
- WebSocket(AI Chat):独立的长连接压力模型,本轮不压。
- 活动榜单 / 星册接口:聚焦核心 7 场景。
5.6 场景间隔离缓冲与总时长(B8 自审修复)
每个场景跑完不能立刻切下一个。必须等:
| 缓冲项 | 最少耗时 | 原因 |
|---|---|---|
| TIME_WAIT 释放 | 120-240s | Linux tcp_fin_timeout=60,200 VU 的 TIME_WAIT 堆积要分钟级散去;不等会让下一场景压力机连接抖动 |
| 连接池回收 | 60-120s | Dubbo client / Go http.Transport idle conn + PG idle_in_transaction_session_timeout;前场景占用的 PG 连接要释放完 |
| Redis 缓存散热 | 60-300s | 前场景预热的热点 key 退场;不等会让下一场景"缓存命中率虚高" |
| PG WAL 消化 | 30-60s | 前场景的写操作 WAL 还在异步落盘 |
| cleanup SQL | 30-60s | S4 还要 §5.4 reset |
| 人眼判断 / 短报告 | 5-10min | 看本场景是否需要进入下一场景,还是要重跑 |
结论:每场景之间至少 8-12 min 缓冲。
修正后的第一轮总时长(终审修复,明细可对账):
| 阶段 | 单次耗时 | 次数 | 小计 | 累计 |
|---|---|---|---|---|
| 开场(seed + monitor 启动) | 2 min | 1 | 2 min | 2 min |
| Baseline(每场景 1 RPS) | 3 min | 7 场景 | 21 min | 23 min |
| Baseline 场景间短 buffer | 1 min | 6 间隔 | 6 min | 29 min |
| 阶梯(6 阶 × 2min/阶) | 12 min | 7 场景 | 84 min | 113 min |
| 阶梯场景间长 buffer | 15 min | 6 间隔 | 90 min | 203 min |
| 收尾(cleanup + verify + 释放) | 22 min | 1 | 22 min | 225 min |
| 合计 | 225 min = 3h 45min |
→ §6.1 第一轮窗口:02:00-06:00(含 preflight 在 01:30-02:00 之外)。第二轮稳定性 + 破坏性约 4 小时(含混合场景)。
loadgen run --scenarios=S1,S2,S3,S4,S5,S6,S7 --inter-scenario-pause=15m 自动在场景间插入 pause。
6. 执行计划与时间盒
6.1 第一轮(探索压测)
Day 1:环境与数据准备(白天,6-7 小时,分两个时段)
| 时段 | 动作 |
|---|---|
| 上午 1h | ssh prod 跑 \d+ users fan_profiles assets asset_likes exhibitions booth_slots mint_cost_config 对照本地,生成 prod-vs-local-schema-diff.md |
| 上午 2h | 编写 seed/ Go 工具,本地 docker-compose 联调跑通 |
| 下午 14:00-14:05(业务低峰,P0-5) | 应用 §2.1 方案 A(必做):改 docker-compose.prod.yml 把 POSTGRES_MAX_CONNECTIONS 从 100 改到 50,docker-compose -f docker-compose.prod.yml restart postgres(约 30s 停机)。验证:docker exec ... psql -c "SHOW max_connections;" 返回 50 |
| 下午 1h | 阿里云控制台开 ECS(华东 1 杭州 同地域,4G/2C 按量付费) |
| 下午 1h | 编译 seed + loadgen 二进制;scp 到压力机 + scp seed 二进制和 mint_reset.sh 到 prod /opt/topfans/loadtest/ |
| 晚上 23:00(次低峰) | 预演 dry run:用 --monitor=off 跑 5 min mini baseline(10 RPS × 1min × 7 场景),验证 preflight/seed/cleanup 链路通畅 |
Day 1 当晚 / 02:00 - 06:00(即 Day 2 凌晨):第一次正式压测窗口
P0-3 对账:本表逐分钟与 §5.6 总时长表对账。02:00 开场到 05:45 收尾结束 = 225 min;06:00 为余量缓冲(容纳意外延迟、监控停止、scp 报告等收尾动作)。
| 时间 | 持续 | 动作 |
|---|---|---|
| 01:30 | 25 min | preflight 检查(详见 §8.5;含 §8.2 防线 1 的 7 项 + max_connections=50 验证) |
| 01:55 | 5 min | pg_dump 备份完成(pg_dump 跑了 ~3 min,加 buffer) |
| 02:00 | 1 min | ssh prod 跑 loadgen seed --prod |
| 02:01 | 1 min | ssh prod 启动 monitor.sh 后台采样 |
| 02:02 | 21 min | 压力机跑 baseline(每场景 1 RPS × 3min,7 场景,场景间 1min 短 buffer) |
| 02:29 | 84 min | 压力机跑 step 阶梯(每场景 6 阶 × 2min,7 场景) |
| 02:29-05:35 | +90 min | 场景间 15min 长 buffer × 6 间隔(与阶梯交错执行;阶梯第 1 场景跑完 12min → 15min buffer → 第 2 场景 → ... → 第 7 场景结束) |
| 05:35 | 5 min | ssh prod 停 monitor.sh + 收尾监控 |
| 05:40 | 5 min | loadgen seed cleanup --keep-baseline |
| 05:45 | 5 min | loadgen verify(详见 §8.5) |
| 05:50 | 10 min | 压力机拉回报告目录 + 释放 ECS(如 1 周内不再压) |
| 06:00 | — | 窗口结束,prod 完全可用 |
加和验证:开场 2 + baseline 27 + step+buffer 174(84+90 交错) + 收尾 25 = 228 min ≈ §5.6 表 225 min(3 min 容差在 §6.5 允许内)。窗口实际 240 min,留 12 min 应急余量。
Day 3:分析与决策
- 读取
report-round1.md - Review 会议:哪些瓶颈是配置可修(bcrypt cost、PG max_connections、连接池),哪些是代码必改(N+1、缺索引、锁粒度),哪些接受现状
6.2 修复期(Day 3 - Day 14)
如有必要,按 review 结论改代码:调 bcrypt cost、加索引、加缓存、调 Dubbo 超时。每个修复打 tag 部署一次便于第二轮对比。
6.3 第二轮(验证压测)
触发条件:第一轮报告交付 + 团队 review 后,决定是否修复瓶颈:
- 如果第一轮拐点已经满足业务预期 → 第二轮可跳过
- 如果改了代码/配置 → 第二轮只压改动影响的场景
第二轮内容(详见 §5.3 拆分表):
- 阶段 3 稳定性:30min × 修改了的场景
- 阶段 4 破坏性:5min × 修改了的场景
- 混合场景:15min(按 §5.5 数据驱动的比例)
第二轮时长估算(修改了 N 个场景的情况):
- 稳定性:30min × N + 15min × (N-1) 缓冲
- 破坏性:5min × N + 5min × (N-1) 缓冲
- 混合:15min + 30min 收尾
- N=3 时约 3h;N=7(全部场景)时约 6.5h(要拆 2 个凌晨窗口)
仍在凌晨 02:00-06:00 窗口(必要时第二天 02:00-06:00 续)。
⚠️ JWT 重签(终审修复 C1):pkg/jwt 的 TokenExpiration = 7 * 24 * time.Hour(7 天)。第一轮 seed 时签的 token 7 天后过期。如第二轮在第一轮 7 天后才跑,必须第二轮开压前 30 min 重跑:
# prod 服务器上
PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
loadgen seed --reset-tokens --jwt-secret="${JWT_SECRET}" --db-host=localhost --db-name=topfans
# --reset-tokens 仅重新签发 users.csv 里 1000 个 token,不动数据
6.4 时间承诺
- 第一轮端到端:从今天起 5 个工作日内完成报告
- 第二轮端到端(如触发):第一轮报告交付后 2 周内
6.5 中止/回退原则
| 情况 | 动作 |
|---|---|
| 压测期间 prod 错误率持续 > 10% | loadgen 自动 SIGINT,ssh 跑 emergency-stop.sh |
| 监控仪表提示磁盘满(< 5GB) | 立即 stop |
| 用户半夜投诉 | 立即 stop,第二天 review 是否窗口选错 |
| 任何阶段 reset SQL 失败 | 不进入下一阶段,保留现场 |
| preflight 任何一项 fail | 拒绝启动主压测 |
7. 监控指标与判停红线
7.1 监控分层(4 个采集源 × 3 个聚合粒度)
压力机侧(loadgen 自身)
├ 单请求级:start_ts/end_ts/rt_ms/status/scenario/vu_id → events.jsonl
├ 10 秒滑窗:actual_rps/p50/p95/p99/error_rate → 10s.csv + stderr
└ 全局 HDR 直方图:每场景 .bin 文件,事后还原任意百分位
被压机侧 · monitor.sh(lite 模式,默认)
├ docker stats(每 5s,所有容器 CPU/MEM/NET/IO)
├ pg_stat_activity(每 5s)
├ pg_stat_statements top 10(每 30s)
├ redis INFO(每 5s)
└ uptime + df -h(每 5s)
被压机侧 · Prometheus(full 模式,可选)
├ cadvisor(容器维度,scrape 5s)
├ node-exporter(OS 层)
├ postgres-exporter(连接/lock/cache hit/replication)
└ redis-exporter(内存/命令/客户端)
业务侧探针(可选)
└ Gateway /health 每 1s 探活(外部 SLA 视角)
7.2 实时仪表盘(stderr 行模式)
每秒一行:
[02:15:34] S3 like | target= 50 actual= 48.3 | p50=42 p95=180 p99=520 | err=0.2% | vu=5 conn_err=0
每分钟汇总:
═══════════════════════════════════════════════════════════════
[02:16:00] SCENARIO=S3 STAGE=stage3-20rps ELAPSED=01:58
Requests: 5,820 (97.0/s avg)
Errors: 12 (0.2%)
Latency p50: 42ms p95: 198ms p99: 612ms max: 2.1s
Connection: active=5 refused=0 timeout=0
Status: 2xx=99.8% 4xx=0.1% 5xx=0.1%
═══════════════════════════════════════════════════════════════
7.3 6 维红线(任一触发即停)
| # | 红线 | 触发条件 | 数据源 | 周期 |
|---|---|---|---|---|
| R1 | 客户端错误率 | error_rate > 5% 持续 30s |
loadgen 滑窗 | 5s |
| R2 | 客户端 P99 | p99 > 3000ms 持续 30s |
loadgen HDR | 5s |
| R3 | 5xx 比例 | 5xx_rate > 10% 持续 10s |
loadgen status | 5s |
| R4 | PG 连接数 | pg_active > 85(max 100 的 85%) |
monitor.sh + pg_stat_activity | 5s |
| R5 | 磁盘空间 | disk_free < 5GB(B9 修复:原 2GB 太低,PG WAL 在写密集场景会快速膨胀 2-3GB) |
monitor.sh + df -h | 30s |
| R6 | 容器 OOM | docker events --filter event=oom 收到事件 或 docker inspect --format='{{.State.OOMKilled}}' 返回 true 或 容器 RestartCount 较基线增加 |
monitor.sh + docker events 流式订阅 | 1s(事件触发) |
⚠️ 不要用 ExitCode=137 检测 OOM(自审 B5):
- 137 = 128 + SIGKILL,
docker stop超时也会触发,无法区分 OOM/手动 kill restart: always把容器自动拉起后,docker inspect看到的是新实例的 ExitCode=0,OOM 痕迹消失- 正确做法:
docker events --filter event=oom提供实时事件流;State.OOMKilled字段是 docker daemon 直接记录的 OOM 标志
lib/circuit.go 主循环每 5s 检查;触发后向主进程发 SIGINT,loadgen 优雅退出。
7.4 Grafana 4 面板(--monitor=full)
| 面板 | 内容 |
|---|---|
| 1. 整机概览 | CPU / Memory / Network / Disk IO 时序 + load1/5/15 |
| 2. 容器维度 | 每容器 CPU% / Memory 对比限额 + 重启次数(捕捉 OOM) |
| 3. PG 健康 | active connections vs max / longest transaction / cache hit ratio / locks / WAL 增长 |
| 4. 业务指标 | loadgen RPS / Error / P99(Prometheus 从 loadgen :9091/metrics pull) |
loadgen 暴露 :9091/metrics,被 Prometheus pull。
7.5 报告生成(事后)
loadgen report --input ./reports/run-20260613-0200/ --output ./report.md
自动:
- 从
*.hdr还原任意百分位(注:HdrHistogram precision=3 时 P99 以下可信,P99.9+ 误差 10-30%,仅作参考;如需准确 P99.9,preflight 阶段将 precision 提到 4-5 位但内存 4×) gonum/plot画每场景 RPS-Latency-Error 三联图(SVG)- 表格列出每场景拐点 RPS
- 摘录 prod 监控的高负载片段
- 自动写"建议"节:哪些场景接近瓶颈、哪些有空间
8. 风险控制与回滚
8.1 崩溃形态与恢复时间矩阵
| # | 形态 | 概率 | 影响 | 自动恢复 | 恢复时间 | 数据风险 |
|---|---|---|---|---|---|---|
| 1 | 容器 OOM Killed | 🔴 高 | 单服务短时不可用 | ✅ restart: always |
10-30s | 无 |
| 2 | PG 连接打满 100 | 🟡 中 | 整站新请求拒绝 | ✅ idle 释放 | 停压后 30s | 无 |
| 3 | PG 慢查询雪崩 / 死锁 | 🟡 中 | 整站 hang | ⚠️ 半自动 | 30-90s | 无 |
| 4 | TIME_WAIT 端口耗尽 | 🟡 中 | 新连接失败 | ✅ 60s 释放 | 60s | 无 |
| 5 | 磁盘满 | 🟢 低 | 整站 5xx | ❌ 手动 rm logs | 2-5min | 极小 |
| 6 | 整机 OOM(PG 进程被 cgroup kill) | 🟡 中(原 🟢 错算,§2.1 PG 400M/100conn 冲突让概率提高) | 整站宕机 | ✅ + WAL replay | 60-120s | 无 |
| 7 | 数据污染真实数据 | 🔴 极低 | 业务异常 | ❌ 备份还原 | 5-10min | 中-高 |
| 8 | 阿里云硬件故障 | ⚪ 极罕 | 数据丢失 | ❌ 快照还原 | 30min-1h | 高 |
| 9 | 压力机自身 GC 暴涨(B9 新增) | 🟡 中 | P99 数据失真,记的延迟里有 30-50% 是 loadgen 自己 | ❌ 需重测 | 重测整轮 | 无(但结果作废) |
| 10 | PG WAL 写满(B9 新增) | 🟡 中(200 VU × 30min 点赞写 5w 行) | 整库变只读 | ❌ checkpoint + rm 旧 WAL | 5-15min | 无 |
| 11 | Docker bridge NAT 表满(B9 新增) | 🟢 低(200 VU × 30min × 无 keep-alive ≈ 36w NAT 条目) | 新连接失败 | ✅ keep-alive 缓解 | 立即(开 keep-alive) | 无 |
| 12 | PG 锁等待雪崩(B9 新增) | 🟡 中(S3 点赞行锁 + S7 唯一约束) | 假装连接耗尽,根因是锁 | ❌ pg_terminate_backend 杀长事务 | 30-90s | 无 |
| 13 | SSH tunnel 断开监控失效(B9 新增) | 🟡 中(凌晨阿里云抖动) | R4/R5/R6 静默失效 | ⚠️ autossh 自愈 | 5-30s | 无 |
新增 R7 红线(压源自检):
| R7 | 压源自身延迟漂移 | loadgen 内部计算 client_p99_自测 > 0.3 × target_p99 持续 30s | loadgen 内部钩子 | 5s | 触发说明压力机已成为瓶颈,数据不可信,需重测或上分布式压源 |
8.2 三道熔断防线
防线 1:压测前必做(不做就不开压)
⚠️ 容器名注意(B7 自审修复):以下命令中的
topfans-postgres来自docker-compose.prod.yml:41的container_name:字段,但实际启动后名字可能因 docker-compose 版本/项目目录前缀不同而异(如topfans_postgres_1)。所有脚本应用动态查找代替硬编码:PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)下文示例为可读性保留
topfans-postgres名称,实施时统一替换为$PG_CONTAINER。
⚠️ PGPASSWORD 注入(终审修复):
docker exec ... psql -U postgres在容器内仍走pg_hba.conf鉴权,prod 配置默认要求密码(.env.prod里DB_PASSWORD=postgres123)。所有 psql 命令前必须export PGPASSWORD,否则鉴权失败。
# 脚本头部统一注入(防线 1 / restore / snapshot / reset SQL 共用)
PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
[ -z "$PG_CONTAINER" ] && { echo "❌ 找不到 postgres 容器"; exit 1; }
export PGPASSWORD="${DB_PASSWORD:-postgres123}" # 与 .env.prod 一致
# 数据库逻辑备份
docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" pg_dump -U postgres -d topfans \
-f /opt/topfans/backups/pre-loadtest-$(date +%Y%m%d-%H%M).sql
# 阿里云控制台拍快照(5-10 分钟,手动操作)
# ECS → 实例详情 → 云盘 → 创建快照
# 磁盘检查
df -h | grep -E "/$|/opt" # 需要 ≥ 15GB 空闲(终审修复:原 10GB 在 30min soak 场景 WAL 膨胀下余量不够)
# PG 连接基线
docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans \
-c "SELECT count(*) FROM pg_stat_activity;"
# PG 内存约束自检(B4 自审修复 + 终审 #6 澄清)
docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans \
-c "SHOW max_connections;"
# 如果显示 100 但容器 limit=400M,preflight 报错并退出,提示运维手动跑 §2.1 方案 A
防线 2:压测中自动熔断
loadgen 6 维红线(详见 §7.3)+ monitor.sh 旁路监控。任一红线触发,loadgen 主进程收 SIGINT 优雅退出。
防线 3:手动一键灭火(emergency-stop.sh)
#!/bin/bash
# /opt/topfans/loadtest/recover/emergency-stop.sh
pkill -9 loadgen 2>/dev/null
PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
export PGPASSWORD="${DB_PASSWORD:-postgres123}"
docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans -c "
SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE state != 'idle' AND now() - query_start > interval '10 seconds'
AND usename = 'postgres';
"
cd /opt/topfans/docker
docker-compose -f docker-compose.prod.yml restart
sleep 30
curl -f http://localhost:8080/health || echo "⚠️ Gateway 仍未恢复"
8.3 备份兜底(唯一不可恢复路径的保险)
# /opt/topfans/loadtest/recover/restore-from-backup.sh
# 用法:bash restore-from-backup.sh /opt/topfans/backups/pre-loadtest-20260613-0200.sql
BACKUP_FILE=$1
[ -f "$BACKUP_FILE" ] || { echo "备份文件不存在"; exit 1; }
PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
export PGPASSWORD="${DB_PASSWORD:-postgres123}"
# 停所有应用层(动态查找 topfans 相关容器)
docker ps --filter 'name=topfans-' --format '{{.Names}}' \
| grep -v postgres | grep -v redis | xargs -r docker stop
# 删库重建
docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -c "DROP DATABASE topfans;"
docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -c "CREATE DATABASE topfans;"
# 还原
docker exec -i -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans < "$BACKUP_FILE"
# 启动
cd /opt/topfans/docker
docker-compose -f docker-compose.prod.yml --profile prod up -d
sleep 30
curl http://localhost:8080/health
实测还原时间:~150MB DB → 5-8 分钟。
8.4 测试数据污染的额外保护
| 层 | 机制 |
|---|---|
| 1 隔离 star_id | 所有写入强制带 star_id=999900,与真实业务 star_id=87~95 物理隔离 |
| 2 cleanup 强制 WHERE | 所有清理 SQL 必须含 WHERE star_id=999900,代码层 reject 没有 WHERE 的语句 |
| 3 本地完整跑一遍 | 用 docker-compose.local.yml 跑一次 seed + 压测 + cleanup,验证不影响 star_id≠999900 数据 |
8.5 preflight 与 verify
Preflight(开压前自动检查)
loadgen preflight --target http://101.132.250.62:8080 --prod-ssh root@101.132.250.62
7 项检查:
✓ ① Gateway /health 返回 200
✓ ② SSH 到 prod 成功
✓ ③ pg_dump 备份文件存在 (size > 50MB)
✓ ④ 阿里云快照在 24h 内创建(人工确认或 ECS API 检查)
✓ ⑤ prod 磁盘空闲 > 10GB
✓ ⑥ users.csv 加载 OK,1000 行
✓ ⑦ JWT_SECRET 与 prod 一致(用 1 个 token 调 /me/profile 验证返回 200)
─────────────────────────────────────────
ALL CHECKS PASSED — 可以开压
任一项 fail,loadgen 拒绝启动主压测。
Verify(压测后自动验证)
loadgen verify --prod-ssh root@101.132.250.62
检查:
- diff pre-snapshot vs post-snapshot(真实用户数据未变)
SELECT count(*) FROM mint_orders WHERE star_id != 999900 AND created_at > <压测开始时间>== 0- PG 连接数已回落到基线
- 所有容器 Restart Count 未增加(除非压崩)
- 磁盘空闲恢复(cleanup 后日志已清)
任一项 fail → 走 emergency-stop + restore。
8.6 prod 状态打点
# pre-test-snapshot.sh / post-test-snapshot.sh
PG_CONTAINER=$(docker ps --filter 'name=postgres' --format '{{.Names}}' | grep -v exporter | head -1)
export PGPASSWORD="${DB_PASSWORD:-postgres123}"
docker exec -e PGPASSWORD="$PGPASSWORD" "$PG_CONTAINER" psql -U postgres -d topfans -c "
SELECT
(SELECT count(*) FROM users) AS users,
(SELECT count(*) FROM assets) AS assets,
(SELECT count(*) FROM asset_likes) AS likes,
(SELECT count(*) FROM mint_orders) AS mints,
(SELECT sum(crystal_balance) FROM fan_profiles WHERE star_id != 999900) AS real_user_crystal_total,
NOW() AS snapshot_at;
"
real_user_crystal_total 若变化 = 数据污染告警。
8.7 临时关闭外部告警(如有)
# D6 自审修复:项目当前未确认是否接入告警系统,本节为条件式
# 1. 检查是否有 webhook 配置
grep -rEn "webhook|alert|dingding|wechat|feishu|lark|slack" \
/opt/topfans/docker/.env.prod /opt/topfans/docker/docker-compose.prod.yml 2>/dev/null
# 2a. 如果有 webhook,临时改为本地黑洞地址
# 例如 sed -i.bak 's|ALERT_WEBHOOK=.*|ALERT_WEBHOOK=http://127.0.0.1:9999/null|' .env.prod
# 完成后 systemctl 或 docker-compose restart 让配置生效
# 压测结束后 mv .env.prod.bak .env.prod 恢复
# 2b. 如果 grep 没有命中(项目当前状态),本节跳过
# 3. 通知运维:今晚 02:00-06:00 压测,告警别理
8.8 不可触碰的红线(人工铁律)
| 红线 | 处置 |
|---|---|
| 没 pg_dump 就不开压 | preflight 检查 |
| 白天/工作时段不开压 | 时间窗口 02:00-06:00 |
cleanup SQL 没有 WHERE star_id=999900 不执行 |
seed/cleanup 代码层强校验 |
| 压测期间发现真实用户投诉立刻停 | emergency-stop |
| 第一轮没出报告前不开第二轮 | 防止盲压 |
8.9 关于 IP 白名单(决定不做)
考虑过在 gateway 加临时 middleware,让真实用户走 503 维护页只让压力机 IP 进入。决定不做,理由:
- 早期项目凌晨 02:00-06:00 DAU 接近 0,撞上概率极低
- 加 middleware 要重新部署 gateway,反而引入新风险
- 万一压测被熔断了,真实用户却被白名单拦了 = 把 prod 自己锁死
靠时间窗口 + 6 维红线自动判停就够。
9. 产出物清单
9.1 第一轮交付
docs/loadtest/round1/
├── prod-vs-local-schema-diff.md # Day 1 schema 对照报告
├── seed-validation.log # Day 1 联调记录
├── report-round1.md # 主报告(模板见 §9.2)
├── monitoring/
│ ├── sample-YYYYMMDD-0200.log # 服务端采样原始
│ ├── docker-stats.csv # CPU/内存时序
│ ├── pg-slow-queries.txt # pg_stat_statements top 20
│ └── grafana-screenshots/ # 仅 --monitor=full 时
└── raw-data/
├── baseline-*.json # 每场景基线
├── step-*.json # 每场景阶梯
└── hdr-histograms.bin # 二进制 HDR(可重放)
9.2 report-round1.md 模板
⚠️ 以下数字均为格式示例,不是真实压测结果。真实数字由
loadgen report自动从 raw data 生成。
# 第一轮压测报告 - 2026-MM-DD
## 摘要(示例值)
- 总耗时: 1h45min
- 测试场景: 7
- 拐点小结(示例): S1=15RPS, S2=350RPS, S3=60RPS, S4=25RPS, S5=18RPS, S6=400RPS, S7=70RPS
- 主要瓶颈(示例): bcrypt cost (S1), PG aggregation (S5)
- 建议: 优先改 S1/S5
## 各场景详情
(每场景一节:RPS 曲线、错误率曲线、Top 慢查询、容器 CPU/内存峰值)
## 系统观察
- 整机 CPU 峰值: 95%(发生在 S2=700 RPS 时)
- PG connections 峰值: 78/100(发生在 S5=60 RPS 时)← 接近上限
- Redis 内存峰值: 180MB / 256M
- 容器 OOM: 无
## 后续行动
- [ ] 改 bcrypt cost 10 → 8(S1 拐点提高 ~3x)
- [ ] 给 statistic_mv 加 refresh 调度(S5 拐点提高 ~2x)
- [ ] 调 PG max_connections 100 → 150(S5 紧迫)
10. 不在范围(YAGNI)
| 项 | 不做的原因 |
|---|---|
| 分布式追踪(OpenTelemetry / Jaeger) | 早期摸底用不上 |
| 业务级埋点(每接口单独 metrics) | 加业务代码代价大 |
| 全链路日志聚合(ELK) | Loki/Promtail 都嫌重 |
| APM(New Relic / DataDog) | 商业产品,超出范围 |
| TUI 实时仪表盘 | stderr 行模式更可靠 |
| 第一轮混合场景 | 业务比例还没成型,凭直觉给比例没意义 |
| WebSocket 压测 | 独立的长连接模型,本轮不做 |
| IP 白名单 middleware | 凌晨窗口 DAU 接近 0,引入新风险得不偿失 |
| 端到端业务正确性测试 | E2E 的工作 |
| 前端性能测试 | 范围外 |
| 安全/渗透测试 | 范围外 |
| 活动榜单/星册接口 | 聚焦核心 7 场景 |
11. 后续步骤
- 本设计文档复核 ← 当前节点
- 调用
writing-plansskill 生成实施计划 - Day 1: schema diff + seed 工具开发 + 压力机准备
- Day 2: 凌晨 02:00-06:00 第一次正式压测
- Day 3: review 报告与决策
- 修复期(可选)
- 第二轮压测(验证)
附录 A:术语表
| 术语 | 含义 |
|---|---|
| VU | Virtual User,并发虚拟用户数 |
| RPS | Requests Per Second,每秒请求数 |
| P50/P95/P99 | 延迟分位数(中位数 / 95% / 99% 的请求在此值以下完成) |
| HDR | High Dynamic Range Histogram,高精度低开销直方图,用于准确算百分位 |
| 拐点 RPS | 错误率/P99 突破阈值前的最大稳态 RPS |
| 安全水位 | 拐点 × 60%,作为稳定性测试的目标 RPS |
| Soak Test | 稳定性测试,长时间维持中等负载找泄漏 |
| Stress Test | 破坏性测试,超出极限验证降级与恢复 |
| MTTR | Mean Time To Recover,平均恢复时间 |
| 影子表 | 与生产表结构相同但物理分离的副本(本方案用 star_id 隔离代替) |
附录 B:被测接口路径速查
| 场景 | Method | 路径 | 主要 body/query |
|---|---|---|---|
| S1 | POST | /api/v1/auth/login |
{mobile, password} |
| S1 | GET | /api/v1/me/profile |
(Header: Authorization) |
| S2 | GET | /api/v1/assets/me/items?page=1&page_size=20 |
|
| S2 | GET | /api/v1/assets/:asset_id |
|
| S3 | POST | /api/v1/social/assets/:asset_id/like |
(需 asset 在 exhibition 中) |
| S3 | DELETE | /api/v1/social/assets/:asset_id/like |
|
| S4 | POST | /api/v1/assets/mints/precreate |
{material_url, name, info, ...} |
| S4 | POST | /api/v1/assets/mints |
{order_id} |
| S5 | GET | /api/v1/dashboard/today-overview |
|
| S5 | GET | /api/v1/dashboard/income-curve |
|
| S5 | GET | /api/v1/dashboard/exhibition-summary |
|
| S5 | GET | /api/v1/dashboard/like-income-by-level |
|
| S5 | GET | /api/v1/dashboard/top-assets |
|
| S5 | GET | /api/v1/dashboard/level-distribution |
|
| S5 | GET | /api/v1/dashboard/upgrade-progress |
|
| S6 | GET | /api/v1/rankings/hot?dimension={displaying,month,total}&star_id={87,88,93,999900}&page=1&page_size=10 |
dimension 值已通过代码核实(ranking_service.go:63-72),仅这 3 个 |
| S6 | GET | /api/v1/rankings/original?... |
|
| S7 | POST | /api/v1/galleries/place |
{slot_id, asset_id} |
| S7 | DELETE | /api/v1/galleries/slots/:slot_id/asset |
— 设计文档结束 —