topfans/docs/superpowers/specs/2026-06-12-load-testing-design.md
2026-06-12 17:00:46 +08:00

56 KiB
Raw Blame History

后端服务压力测试设计

状态:设计已确认 创建时间2026-06-12 作者Claude 目标:为部署在阿里云单机(101.132.250.624G/2Cdocker-compose的 TopFans 后端微服务设计一套可执行、可恢复、可重复的压力测试方案。覆盖容量评估、SLA 基线、稳定性验证、破坏性测试四类目标。


目录

  1. 背景与目标
  2. 关键约束与已验证事实
  3. 总体方案与架构
  4. 测试数据准备
  5. 场景设计与 RPS 梯度
  6. 执行计划与时间盒
  7. 监控指标与判停红线
  8. 风险控制与回滚
  9. 产出物清单
  10. 不在范围YAGNI
  11. 后续步骤
  12. 附录 A术语表
  13. 附录 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.5CPG 400Mmax 100 connectionsRedis 256M
入口 公网 http://101.132.250.62:8080(无 nginx 反代,gateway 直接对外)
部署脚本 docker/deploy.sh(参考用法见脚本头部注释)

⚠️ PG 内存配置冲突(必须在 preflight 阶段处理)

  • PG 容器限额 400MPOSTGRES_MAX_CONNECTIONS=100
  • 每个 PG backend 进程典型占用 5-10MBwork_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 降到 50preflight 仅做检测+报错+给出 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 提到 1024Mmax_connections 不变(同样需重启 PG
    • 方案 C接受现状把 R4 阈值从 85 调到 50无停机但风险还在

2.2 数据库约束(来自本地 top-fans 库 schema 调研)

库名差异(重要)

  • 本地 dockertop-fans(带横线)
  • prod docker-compose 配置:topfans(无横线)
  • → seed/loadgen 工具的 DSN 必须参数化,开工前先 ssh 到 prod 跑 \d+ <key_tables> 验证 schema 与本地一致

核心表与隐含约束

关键约束
users mobile 唯一(仅 deleted_at IS NULLid BIGSERIAL
stars star_id BIGSERIALidentity_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 默认 falseseed 时必须显式置 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
  • prodtopfans-secret-key-please-change-in-production
  • seed 工具复用 backend/pkg/jwt.GenerateToken()secret 通过 --jwt-secret 参数注入,不要 commit 到 repo

2.4 已确认决策汇总

决策项 选择
压源位置 同地域阿里云 ECS4G/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.TransportMaxConnsPerHost=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, 956 个)
测试手机号前缀 199000xxxxx11 位) 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 ≥ 204610 次铸造累计)+ 缓冲
每用户预铸资产 5 asset_id 从 MAX+1000 起。用途分配:资产 1-2 用于 seed 预上架S3 点赞依赖),资产 3-5 留作未上架库存供 S7 上架/卸下轮转使用
每用户 booth_slots 3is_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 上真实存在的占位图 URLseed 执行前先上传一张 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/precreatePOST /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+2baseline + step225 min目标是找到拐点阶段 3 稳定性 + 阶段 4 破坏性 + §5.5 混合场景全部推到第二轮(验证)。第二轮窗口独立计算(详见 §6.3)。

阶段 第一轮 第二轮
1. Baseline (已有数据,跳过)
2. Step Ramp-up (已有拐点,跳过;如修复后想对比,可重跑)
3. Soak30min × N 不跑 跑(在已知拐点的 60% 安全水位)
4. Stress5min × 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 各跑一次 30minS4 见 §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 SQLP0-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 主键是 UUIDassets 新建的会被上面 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 上限。
  • WebSocketAI Chat:独立的长连接压力模型,本轮不压。
  • 活动榜单 / 星册接口:聚焦核心 7 场景。

5.6 场景间隔离缓冲与总时长B8 自审修复)

每个场景跑完不能立刻切下一个。必须等:

缓冲项 最少耗时 原因
TIME_WAIT 释放 120-240s Linux tcp_fin_timeout=60200 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.ymlPOSTGRES_MAX_CONNECTIONS 从 100 改到 50docker-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 baseline10 RPS × 1min × 7 场景),验证 preflight/seed/cleanup 链路通畅

Day 1 当晚 / 02:00 - 06:00即 Day 2 凌晨):第一次正式压测窗口

P0-3 对账:本表逐分钟与 §5.6 总时长表对账。02:00 开场到 05:45 收尾结束 = 225 min06: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 × 3min7 场景,场景间 1min 短 buffer
02:29 84 min 压力机跑 step 阶梯(每场景 6 阶 × 2min7 场景)
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 17484+90 交错) + 收尾 25 = 228 min ≈ §5.6 表 225 min3 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 时约 3hN=7全部场景时约 6.5h(要拆 2 个凌晨窗口)

仍在凌晨 02:00-06:00 窗口(必要时第二天 02:00-06:00 续)。

⚠️ JWT 重签(终审修复 C1pkg/jwtTokenExpiration = 7 * 24 * time.Hour7 天)。第一轮 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 自动 SIGINTssh 跑 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.shlite 模式,默认)
  ├ docker stats每 5s所有容器 CPU/MEM/NET/IO
  ├ pg_stat_activity每 5s
  ├ pg_stat_statements top 10每 30s
  ├ redis INFO每 5s
  └ uptime + df -h每 5s

被压机侧 · Prometheusfull 模式,可选)
  ├ cadvisor容器维度scrape 5s
  ├ node-exporterOS 层)
  ├ 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 > 85max 100 的 85% monitor.sh + pg_stat_activity 5s
R5 磁盘空间 disk_free < 5GBB9 修复:原 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 + SIGKILLdocker stop 超时也会触发,无法区分 OOM/手动 kill
  • restart: always 把容器自动拉起后,docker inspect 看到的是新实例的 ExitCode=0OOM 痕迹消失
  • 正确做法:docker events --filter event=oom 提供实时事件流;State.OOMKilled 字段是 docker daemon 直接记录的 OOM 标志

lib/circuit.go 主循环每 5s 检查;触发后向主进程发 SIGINTloadgen 优雅退出。

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 / P99Prometheus 从 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.9preflight 阶段将 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 整机 OOMPG 进程被 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:41container_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.prodDB_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=400Mpreflight 报错并退出,提示运维手动跑 §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 加载 OK1000 行
✓ ⑦ JWT_SECRET 与 prod 一致(用 1 个 token 调 /me/profile 验证返回 200
─────────────────────────────────────────
ALL CHECKS PASSED — 可以开压

任一项 failloadgen 拒绝启动主压测。

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 → 8S1 拐点提高 ~3x
- [ ] 给 statistic_mv 加 refresh 调度S5 拐点提高 ~2x
- [ ] 调 PG max_connections 100 → 150S5 紧迫)

10. 不在范围YAGNI

不做的原因
分布式追踪OpenTelemetry / Jaeger 早期摸底用不上
业务级埋点(每接口单独 metrics 加业务代码代价大
全链路日志聚合ELK Loki/Promtail 都嫌重
APMNew Relic / DataDog 商业产品,超出范围
TUI 实时仪表盘 stderr 行模式更可靠
第一轮混合场景 业务比例还没成型,凭直觉给比例没意义
WebSocket 压测 独立的长连接模型,本轮不做
IP 白名单 middleware 凌晨窗口 DAU 接近 0引入新风险得不偿失
端到端业务正确性测试 E2E 的工作
前端性能测试 范围外
安全/渗透测试 范围外
活动榜单/星册接口 聚焦核心 7 场景

11. 后续步骤

  1. 本设计文档复核 ← 当前节点
  2. 调用 writing-plans skill 生成实施计划
  3. Day 1: schema diff + seed 工具开发 + 压力机准备
  4. Day 2: 凌晨 02:00-06:00 第一次正式压测
  5. Day 3: review 报告与决策
  6. 修复期(可选)
  7. 第二轮压测(验证)

附录 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

— 设计文档结束 —