From 3d33c783cd69580284d65801635c47cc2cf0ac61 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 03:31:17 +0800 Subject: [PATCH] feat: add docker capacity benchmark mode --- README.md | 9 ++ apps/api/Dockerfile | 2 +- docker-compose.api.benchmark.yml | 15 +++ docker-compose.api.yml | 1 + docs/refactor/README.md | 4 +- docs/refactor/architecture.md | 9 ++ .../refactor/performance-benchmark-runbook.md | 43 +++++++++ package.json | 1 + packages/db/src/index.ts | 8 +- scripts/api-performance-benchmark.js | 61 +++++++++++- scripts/run-docker-4c16g-benchmark.js | 95 +++++++++++++++++++ 11 files changed, 242 insertions(+), 6 deletions(-) create mode 100644 docker-compose.api.benchmark.yml create mode 100644 scripts/run-docker-4c16g-benchmark.js diff --git a/README.md b/README.md index 8f294b87..389d797a 100644 --- a/README.md +++ b/README.md @@ -557,6 +557,15 @@ docs/refactor/postgresql-4c16g-tuning.md docs/refactor/performance-benchmark-runbook.md ``` +本地 Docker Desktop 可用时,可以先用受限 API 容器做 4 核 16G shared-host 风格的预演。该入口默认把 API 容器限制为 2 CPU/4G、关闭 legacy `x-user-id`,并用 `Authorization: Bearer ` 跑 30 只读、50/100 混合读写矩阵: + +```powershell +$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" +npm run perf:api:docker-4c16g +``` + +注意:这个入口只限制 API 容器资源,本地 Supabase/PostgreSQL 仍受 Docker Desktop 全局资源影响。正式容量承诺仍要在目标 4 核 16G 云服务器复跑。 + PostgreSQL 调参与运行证据采集: ```powershell diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index b92d6fff..eb8d2874 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -12,7 +12,7 @@ RUN npm ci --workspaces --include-workspace-root FROM node:20-alpine AS build WORKDIR /app COPY --from=deps /app/node_modules ./node_modules -COPY package.json package-lock.json tsconfig.json ./ +COPY package.json package-lock.json ./ COPY apps/api ./apps/api COPY packages ./packages RUN npm run build:api diff --git a/docker-compose.api.benchmark.yml b/docker-compose.api.benchmark.yml new file mode 100644 index 00000000..ba4cbb7b --- /dev/null +++ b/docker-compose.api.benchmark.yml @@ -0,0 +1,15 @@ +name: tiku-saas-benchmark + +services: + api: + environment: + NODE_ENV: development + ALLOW_LEGACY_AUTH_HEADERS: "false" + ALLOW_PLATFORM_ADMIN_KEY: "false" + AUTH_SESSION_SECRET: ${AUTH_SESSION_SECRET:-development-session-secret-change-me} + DB_POOL_MAX: ${DB_POOL_MAX:-10} + MAX_JSON_BODY_BYTES: "1048576" + MAX_IMPORT_JSON_BODY_BYTES: "10485760" + cpus: "${BENCHMARK_API_CPUS:-2.0}" + mem_limit: "${BENCHMARK_API_MEMORY:-4g}" + memswap_limit: "${BENCHMARK_API_MEMORY:-4g}" diff --git a/docker-compose.api.yml b/docker-compose.api.yml index 316be187..7e5c746f 100644 --- a/docker-compose.api.yml +++ b/docker-compose.api.yml @@ -11,6 +11,7 @@ services: DATABASE_URL: postgresql://postgres:postgres@host.docker.internal:54322/postgres DEFAULT_TENANT_SLUG: master CORS_ORIGIN: http://127.0.0.1:5173,http://localhost:5173,http://127.0.0.1:5174,http://localhost:5174 + DB_POOL_MAX: 10 extra_hosts: - host.docker.internal:host-gateway ports: diff --git a/docs/refactor/README.md b/docs/refactor/README.md index 1eab5983..3ee56c06 100644 --- a/docs/refactor/README.md +++ b/docs/refactor/README.md @@ -16,7 +16,7 @@ - `apps/api`:新业务 API 服务,内部按 `src/core` 和 `src/features` 分层。 - `packages/config`、`packages/db`、`packages/domain`:新系统共享基础包。 - `scripts/import-pocketbase`:PocketBase schema/数据导入工具。 -- `docker-compose.api.yml`、`apps/api/Dockerfile`:本地 Docker API 运行入口。 +- `docker-compose.api.yml`、`docker-compose.api.benchmark.yml`、`apps/api/Dockerfile`:本地 Docker API 和受限资源压测入口。 - `docs/refactor/architecture.md`:新重构目录边界和工程规范。 - `docs/refactor/ai-development-guardrails.md`:后续 AI/开发者必须遵守的 Supabase-first 架构和安全守则。 - `docs/refactor/content-import-contract.md`:题目、单词、知识手册导入契约,明确后端校验、旧格式转换和前端职责。 @@ -29,7 +29,7 @@ - `docs/refactor/taro-frontend-integration.md`:Taro/H5/小程序启动、请求封装、页面/API 映射。 - `docs/refactor/taro-h5-deployment.md`:Taro H5 三域名部署、运行时配置、Nginx、CSP、缓存和 CORS 边界。 - `docs/refactor/postgresql-4c16g-tuning.md`:4 核 16G 自托管 PostgreSQL 起步调参、观察 SQL 和回滚方式。 -- `docs/refactor/performance-benchmark-runbook.md`:本地/云端 API 压测、4 核 16G 阶梯并发矩阵和报告归档方式。 +- `docs/refactor/performance-benchmark-runbook.md`:本地/云端 API 压测、4 核 16G 阶梯并发矩阵、Docker 受限资源预演和报告归档方式。 - `docs/refactor/performance-benchmark-summary-20260630.md`:真实迁移数据压测脱敏摘要。 - `docs/refactor/backend-open-items-and-capacity-20260701.md`:后端剩余功能、已定稿产品口径和最新在线容量估算。 - `docs/refactor/multitenant-auth-security-contract.md`:多租户隔离、鉴权、权限和资源安全红线。 diff --git a/docs/refactor/architecture.md b/docs/refactor/architecture.md index e686d491..f45bb19f 100644 --- a/docs/refactor/architecture.md +++ b/docs/refactor/architecture.md @@ -62,6 +62,14 @@ npm run docker:api:up 如果 Docker 拉取 `node:20-alpine` 超时,先配置 Docker Desktop 镜像源或代理,再重试 `npm run docker:api:build`。 +本地容量预演可以使用专门的 benchmark override: + +```bash +npm run perf:api:docker-4c16g +``` + +该命令会使用 `docker-compose.api.yml` + `docker-compose.api.benchmark.yml` 启动受限 API 容器,默认限制 API 为 2 CPU/4G、`DB_POOL_MAX=10`,关闭 `ALLOW_LEGACY_AUTH_HEADERS`,并用 Bearer `tk_` 会话跑压测。它只用于本地模拟和跑分,不是生产 compose 文件。 + 验证 API: ```bash @@ -81,6 +89,7 @@ npm run pb:import:validate - Docker Desktop 可用。 - Supabase 本地容器可启动。 +- API Dockerfile 已验证可构建;benchmark override 可启动受限 API 容器并通过短压测 smoke。 - `supabase db reset` 可完整执行三份 migration 和 seed。 - `supabase db reset` 可完整执行全部 migration 和 seed。 - `npm run db:smoke-seed` 可恢复最小业务烟测数据。 diff --git a/docs/refactor/performance-benchmark-runbook.md b/docs/refactor/performance-benchmark-runbook.md index 9ceb9e86..0b67cd4b 100644 --- a/docs/refactor/performance-benchmark-runbook.md +++ b/docs/refactor/performance-benchmark-runbook.md @@ -19,6 +19,16 @@ npm run perf:api:local - 默认不创建练习 session,不写业务数据。 - 输出 JSON 和 Markdown 报告到 `docs/refactor/performance-reports/`。该目录已被 `.gitignore` 忽略,不应提交。 +默认本地兼容模式使用 `x-user-id` 作为压测身份,只适合开发机。更接近 Taro/Supabase 接入方式的压测应启用迁移期 Bearer session: + +```powershell +$env:PERF_AUTH_MODE="app_session" +npm run perf:api:local +Remove-Item Env:\PERF_AUTH_MODE +``` + +`app_session` 模式会在测试库中为压测学生创建 2 小时 `tk_` 会话,然后用 `Authorization: Bearer ` 请求业务接口;API 可以关闭 `ALLOW_LEGACY_AUTH_HEADERS`。生产远程压测如果已经有真实 Supabase access token,也可以用 `PERF_AUTH_MODE=bearer` 和 `PERF_BEARER_TOKEN`。 + PostgreSQL 调参与运行证据: ```bash @@ -83,6 +93,8 @@ npm run perf:api:local | `PERF_INCLUDE_LEADERBOARD` | `false` | 是否加入排行榜接口;排行榜租户默认关闭,仅在租户明确开启并需要专项压测时打开 | | `PERF_OUTPUT_DIR` | `docs/refactor/performance-reports` | 报告输出目录 | | `PERF_REQUEST_TIMEOUT_MS` | `15000` | 单请求超时 | +| `PERF_AUTH_MODE` | `legacy` | `legacy` 使用本地 `x-user-id`;`app_session` 创建 `tk_` Bearer session;`bearer` 使用 `PERF_BEARER_TOKEN`;`none` 只适合公开接口 | +| `PERF_BEARER_TOKEN` | 空 | `PERF_AUTH_MODE=bearer` 时使用 | ## 默认工作负载 @@ -138,6 +150,37 @@ npm run perf:api:local 本地 Docker Desktop 可以先用同一矩阵做跑分,但只能证明代码、索引和本机 Docker 环境的趋势。正式容量承诺必须以目标云服务器、生产 PostgreSQL 参数、生产 API/worker 连接池、对象存储/CDN 和真实网络重新跑。 +## 本地 Docker 4c16g 模拟入口 + +仓库提供一个 Docker API 受限资源压测入口: + +```powershell +$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" +$env:BENCHMARK_API_CPUS="2.0" +$env:BENCHMARK_API_MEMORY="4g" +$env:DB_POOL_MAX="10" +npm run perf:api:docker-4c16g +Remove-Item Env:\BENCHMARK_API_CPUS +Remove-Item Env:\BENCHMARK_API_MEMORY +Remove-Item Env:\DB_POOL_MAX +``` + +它会使用 `docker-compose.api.yml` + `docker-compose.api.benchmark.yml` 构建并启动 API 容器,默认限制 API 容器为 2 CPU/4G,关闭 `ALLOW_LEGACY_AUTH_HEADERS`,然后用 `PERF_AUTH_MODE=app_session` 跑: + +- 30 worker / 120s / 只读上线门禁。 +- 50 worker / 60s / 10% 刷题闭环写入。 +- 100 worker / 60s / 8% 刷题闭环写入。 + +这个入口模拟的是“4 核 16G shared-host 中 API 容器的资源约束”,不是完整云服务器复刻。当前本地 Supabase/PostgreSQL 仍跑在 Docker Desktop 的 Supabase stack 中,除非额外手工限制 DB 容器资源,否则数据库容器仍可能使用 Docker Desktop 的全局资源。正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL shared-host 参数、真实对象存储/CDN 和真实网络下复跑。 + +如需保留 API 容器便于排查: + +```powershell +$env:PERF_KEEP_DOCKER_API="true" +npm run perf:api:docker-4c16g +Remove-Item Env:\PERF_KEEP_DOCKER_API +``` + PowerShell 示例: ```powershell diff --git a/package.json b/package.json index 9f0a7304..121c595d 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "smoke:auth:remote": "node scripts/remote-auth-jwt-smoke.js", "test:api": "npm run db:smoke-seed && npm run build:api && node scripts/api-integration-test.js --start-server", "perf:api:local": "npm run build:api && node scripts/api-performance-benchmark.js", + "perf:api:docker-4c16g": "node scripts/run-docker-4c16g-benchmark.js", "perf:postgres:evidence": "node scripts/postgres-tuning-evidence.js", "test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js", "test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js", diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 7b93a561..f32a54c9 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -9,10 +9,16 @@ export interface DbPoolOptions { idleTimeoutMillis?: number; } +function envPoolMax() { + const value = Number(process.env.DB_POOL_MAX || 10); + if (!Number.isFinite(value) || value <= 0) return 10; + return Math.trunc(value); +} + export function createPool(options: DbPoolOptions = {}) { return new Pool({ connectionString: options.connectionString || process.env.DATABASE_URL || DEFAULT_DATABASE_URL, - max: options.max || 10, + max: options.max || envPoolMax(), idleTimeoutMillis: options.idleTimeoutMillis || 30_000, }); } diff --git a/scripts/api-performance-benchmark.js b/scripts/api-performance-benchmark.js index 10d7e897..b1e48525 100644 --- a/scripts/api-performance-benchmark.js +++ b/scripts/api-performance-benchmark.js @@ -1,4 +1,5 @@ import { spawn } from 'node:child_process'; +import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import net from 'node:net'; import path from 'node:path'; @@ -23,6 +24,9 @@ const TENANT_CODE = process.env.PERF_TENANT_CODE || 'master'; const QUESTION_LIMIT = envNumber('PERF_QUESTION_LIMIT', 20); const MAX_ERRORS_TO_KEEP = envNumber('PERF_MAX_ERRORS', 20); const DEFAULT_TIMEOUT_MS = envNumber('PERF_REQUEST_TIMEOUT_MS', 15_000); +const AUTH_MODE = normalizeAuthMode(process.env.PERF_AUTH_MODE || 'legacy'); +const STATIC_BEARER_TOKEN = process.env.PERF_BEARER_TOKEN || ''; +const AUTH_SESSION_SECRET = process.env.AUTH_SESSION_SECRET || 'development-session-secret-change-me'; let apiBase = API_BASE_ENV || ''; let serverProcess = null; @@ -44,6 +48,14 @@ function envNumber(key, fallback, options = {}) { return fallback; } +function normalizeAuthMode(value) { + const normalized = String(value || '').toLowerCase().replace(/[-_]/g, ''); + if (normalized === 'appsession' || normalized === 'session' || normalized === 'tk') return 'app_session'; + if (normalized === 'bearer' || normalized === 'jwt' || normalized === 'token') return 'bearer'; + if (normalized === 'none' || normalized === 'anonymous') return 'none'; + return 'legacy'; +} + function getFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); @@ -91,6 +103,7 @@ async function requestJson(endpoint, context, timeoutMs = DEFAULT_TIMEOUT_MS) { const started = performance.now(); const tenantId = context.tenantId || context.tenant?.id; const userId = context.userId || context.user?.id; + const authToken = context.authToken || ''; try { const response = await fetch(buildUrl(apiBase, endpoint), { method: endpoint.method || 'GET', @@ -98,7 +111,8 @@ async function requestJson(endpoint, context, timeoutMs = DEFAULT_TIMEOUT_MS) { headers: { 'content-type': 'application/json', ...(endpoint.tenantHeader === false || !tenantId ? {} : { 'x-tenant-id': tenantId }), - ...(endpoint.userHeader === false || !userId ? {} : { 'x-user-id': userId }), + ...(AUTH_MODE === 'legacy' && endpoint.userHeader !== false && userId ? { 'x-user-id': userId } : {}), + ...(endpoint.authHeader === false || !authToken ? {} : { authorization: `Bearer ${authToken}` }), ...(endpoint.headers || {}), }, body: endpoint.body ? JSON.stringify(endpoint.body(context)) : undefined, @@ -311,6 +325,7 @@ async function startServerIfNeeded() { ...process.env, PORT: String(port), DATABASE_URL, + AUTH_SESSION_SECRET, MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '1048576', MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '10485760', }, @@ -342,6 +357,46 @@ async function many(pool, sql, params = []) { return result.rows; } +function createSessionToken() { + return `tk_${crypto.randomBytes(32).toString('base64url')}`; +} + +function hashSessionToken(token) { + return crypto.createHmac('sha256', AUTH_SESSION_SECRET).update(token).digest('hex'); +} + +async function createBenchmarkAuthToken(context) { + if (AUTH_MODE === 'none' || AUTH_MODE === 'legacy') return ''; + if (AUTH_MODE === 'bearer') { + if (!STATIC_BEARER_TOKEN) throw new Error('PERF_BEARER_TOKEN is required when PERF_AUTH_MODE=bearer.'); + return STATIC_BEARER_TOKEN; + } + if (AUTH_MODE !== 'app_session') return ''; + if (!context.tenant?.id || !context.user?.id) { + throw new Error('PERF_AUTH_MODE=app_session requires discovered tenant and user context.'); + } + const token = createSessionToken(); + const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 2 }); + try { + await pool.query( + ` + insert into app_private.auth_sessions ( + tenant_id, user_id, token_hash, provider, expires_at, metadata + ) + values ( + $1, $2, $3, 'performance_benchmark', + now() + interval '2 hours', + '{"createdBy":"api-performance-benchmark","authMode":"app_session"}'::jsonb + ) + `, + [context.tenant.id, context.user.id, hashSessionToken(token)], + ); + return token; + } finally { + await pool.end(); + } +} + async function discoverBenchmarkContext() { const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 4 }); try { @@ -790,6 +845,7 @@ async function runBenchmark(context) { concurrency: CONCURRENCY, rampSeconds: RAMP_SECONDS, includeWrites: INCLUDE_WRITES, + authMode: AUTH_MODE, practiceFlowRatio: INCLUDE_WRITES ? PRACTICE_FLOW_RATIO : 0, practiceFlowAnswers: INCLUDE_WRITES ? PRACTICE_FLOW_ANSWERS : 0, questionLimit: QUESTION_LIMIT, @@ -906,10 +962,11 @@ async function main() { throw new Error('PERF_API_BASE is required when PERF_START_SERVER=false.'); } const context = await discoverBenchmarkContext(); + context.authToken = await createBenchmarkAuthToken(context); await startServerIfNeeded(); if (!apiBase) throw new Error('API base URL was not resolved.'); console.log(`[perf] target api=${apiBase} tenant=${context.tenant.slug} user=${context.user.id}`); - console.log(`[perf] duration=${DURATION_SECONDS}s concurrency=${CONCURRENCY} includeWrites=${INCLUDE_WRITES}`); + console.log(`[perf] duration=${DURATION_SECONDS}s concurrency=${CONCURRENCY} includeWrites=${INCLUDE_WRITES} authMode=${AUTH_MODE}`); const report = await runBenchmark(context); const files = await writeReport(report); console.log(`[perf] requests=${report.summary.requests} ok=${report.summary.ok} errors=${report.summary.errors} rps=${report.summary.throughputRps} p95=${report.summary.latencyOk.p95Ms}ms`); diff --git a/scripts/run-docker-4c16g-benchmark.js b/scripts/run-docker-4c16g-benchmark.js new file mode 100644 index 00000000..6b1365ce --- /dev/null +++ b/scripts/run-docker-4c16g-benchmark.js @@ -0,0 +1,95 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +const repoRoot = process.cwd(); +const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; +const apiBase = process.env.PERF_API_BASE || 'http://127.0.0.1:8787'; +const matrix = [ + { name: 'read-30', duration: 120, concurrency: 30, ramp: 15, writes: false, ratio: 0 }, + { name: 'mixed-50', duration: 60, concurrency: 50, ramp: 10, writes: true, ratio: 0.1 }, + { name: 'mixed-100', duration: 60, concurrency: 100, ramp: 15, writes: true, ratio: 0.08 }, +]; + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { + cwd: repoRoot, + stdio: 'inherit', + shell: process.platform === 'win32', + env: { + ...process.env, + DATABASE_URL: databaseUrl, + AUTH_SESSION_SECRET: process.env.AUTH_SESSION_SECRET || 'development-session-secret-change-me', + DB_POOL_MAX: process.env.DB_POOL_MAX || '10', + BENCHMARK_API_CPUS: process.env.BENCHMARK_API_CPUS || '2.0', + BENCHMARK_API_MEMORY: process.env.BENCHMARK_API_MEMORY || '4g', + ...options.env, + }, + }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed with exit code ${result.status}`); + } +} + +function latestBenchmarkJson(before) { + const dir = path.join(repoRoot, 'docs', 'refactor', 'performance-reports'); + const files = fs.existsSync(dir) + ? fs.readdirSync(dir) + .filter(name => /^api-benchmark-\d{8}-\d{6}\.json$/.test(name)) + .map(name => path.join(dir, name)) + .filter(file => fs.statSync(file).mtimeMs >= before) + .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs) + : []; + if (!files[0]) throw new Error('Benchmark report JSON was not generated.'); + return path.relative(repoRoot, files[0]).replaceAll('\\', '/'); +} + +function waitForHealth(timeoutMs = 60_000) { + const started = Date.now(); + while (Date.now() - started < timeoutMs) { + const result = spawnSync(process.execPath, ['-e', `fetch('${apiBase}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))`], { + cwd: repoRoot, + stdio: 'ignore', + shell: process.platform === 'win32', + }); + if (result.status === 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000); + } + throw new Error(`API did not become healthy at ${apiBase}`); +} + +function main() { + const generated = []; + try { + run('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'up', '-d', '--build', 'api']); + waitForHealth(); + for (const item of matrix) { + const before = Date.now(); + run('node', ['scripts/api-performance-benchmark.js'], { + env: { + PERF_START_SERVER: 'false', + PERF_API_BASE: apiBase, + PERF_AUTH_MODE: 'app_session', + PERF_DURATION_SECONDS: String(item.duration), + PERF_CONCURRENCY: String(item.concurrency), + PERF_RAMP_SECONDS: String(item.ramp), + PERF_INCLUDE_WRITES: item.writes ? 'true' : 'false', + PERF_PRACTICE_FLOW_RATIO: String(item.ratio), + PERF_PRACTICE_FLOW_ANSWERS: '3', + }, + }); + generated.push({ scenario: item.name, report: latestBenchmarkJson(before) }); + } + console.log(JSON.stringify({ status: 'pass', apiBase, reports: generated }, null, 2)); + } finally { + if (process.env.PERF_KEEP_DOCKER_API !== 'true') { + spawnSync('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'down'], { + cwd: repoRoot, + stdio: 'inherit', + shell: process.platform === 'win32', + }); + } + } +} + +main();