Files
Genarrative/scripts/loadtest

Genarrative 作品列表 K6 压测

本目录用于对“作品列表/公开广场”读接口做本地压测。数据源来自私有 SpacetimeDB migration但提取脚本只输出作品 profile 白名单表并对用户、作者、作品号、asset id 等标识做稳定映射。

文件

  • extract-works-list-data.mjs:从 migration JSON 提取作品列表压测数据;本地输出也会脱敏路由 ID因此默认用于列表接口压测详情接口需先把同一份脱敏数据导入目标环境。
  • k6-works-list.jsK6 压测脚本。
  • data/spacetime-migration-7.local.json:本地私有原始数据副本,已被 .gitignore 忽略,不要提交。
  • data/works-list.local.json:本地脱敏压测数据,已被 .gitignore 忽略,不要提交。
  • data/works-list.sample.json:可提交的少量脱敏样例。

数据边界

允许导入的表:

  • puzzle_work_profile
  • custom_world_profile
  • match3d_work_profile
  • square_hole_work_profile
  • big_fish_work_profile
  • visual_novel_work_profile

明确不导入:

  • 账号/认证:user_accountauth_identityrefresh_sessionauth_store_snapshot
  • 钱包/邀请:profile_wallet_ledgerprofile_redeem_*profile_invite_*
  • 游玩历史/埋点/存档:public_work_play_daily_statprofile_played_worldpuzzle_runtime_runprofile_save_archiveruntime_snapshot
  • AI 任务过程:ai_taskai_task_stageai_text_chunk
  • asset 二进制:asset_objectasset_entity_binding

提取脚本会移除 source_session_id / source_agent_session_id 等会话派生字段;这些字段不属于作品列表卡片压测必要字段。

重新提取数据

从仓库根目录执行:

npm run loadtest:extract-works -- \
  --input scripts/loadtest/data/spacetime-migration-7.local.json \
  --output scripts/loadtest/data/works-list.local.json \
  --sample-output scripts/loadtest/data/works-list.sample.json

也可以直接执行:

node scripts/loadtest/extract-works-list-data.mjs \
  --input scripts/loadtest/data/spacetime-migration-7.local.json \
  --output scripts/loadtest/data/works-list.local.json \
  --sample-output scripts/loadtest/data/works-list.sample.json

当前 local 全量提取结果:

  • puzzle_work_profile: 80
  • custom_world_profile: 1
  • match3d_work_profile: 0
  • normalizedWorks: 81

当前可提交 sample 结果:

  • puzzle_work_profile: 3
  • custom_world_profile: 1
  • match3d_work_profile: 0
  • normalizedWorks: 4

真实接口

已从 server-rs/crates/api-server/src/app.rs 确认的读接口:

公开接口,无需 Bearer token

  • GET /api/runtime/puzzle/gallery
  • GET /api/runtime/puzzle/gallery/{profile_id}
  • GET /api/runtime/custom-world-gallery
  • GET /api/runtime/custom-world-gallery/{owner_user_id}/{profile_id}
  • GET /api/runtime/custom-world-gallery/by-code/{code}

需要 Bearer token 的个人作品列表接口:

  • GET /api/runtime/puzzle/works
  • GET /api/runtime/puzzle/works/{profile_id}
  • GET /api/runtime/custom-world/works

K6 脚本默认只跑公开列表接口;传入 AUTH_TOKEN 后会额外跑需要登录态的个人作品列表接口。当前真实列表 handler 未暴露分页/排序 query 参数,因此脚本不追加 limit/offset;若后续接口增加分页参数,再在 K6 中补随机分页。

详情接口默认不压测,因为本地数据中的 profile_id / owner_user_id 已脱敏,直接请求未导入脱敏数据的目标服务会 404。只有在目标环境已导入同一份脱敏数据或改用真实 ID 本地文件时,才设置 DETAIL_RATIO 大于 0详情请求不把 404 视为成功。

启动服务

按项目约定启动本地 dev 栈:

npm run dev

注意端口可能漂移。以启动日志中的实际 api-server 端口为准,然后传给 K6。

注意K6 的 open() 会按 k6-works-list.js 所在目录解析相对路径,因此 WORKS_DATA 应写成 data/works-list.local.json,不要写成 scripts/loadtest/data/works-list.local.json

Bash / Git Bash

BASE_URL=http://127.0.0.1:<actual-api-port> WORKS_DATA=data/works-list.local.json npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"

PowerShell

$env:BASE_URL="http://127.0.0.1:<actual-api-port>"
$env:WORKS_DATA="data/works-list.local.json"
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"

50 HTTP req/s 口径

k6-works-list.js 默认一次 iteration 会依次请求两个公开列表接口:/api/runtime/puzzle/gallery/api/runtime/custom-world-gallery。因此目标约 50 HTTP req/s 时,ramping-arrival-ratePEAK_RPS 应设置为 25。如果传入 AUTH_TOKEN 或把 DETAIL_RATIO 设为大于 0每次 iteration 的请求数会增加,需要重新折算。

验收目标:

  • http_req_failed < 1%
  • http_req_duration p95 < 2000ms
  • dropped_iterations = 0
  • 压测窗口内 Nginx 无新增 502

Smoke

BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=smoke \
DETAIL_RATIO=0 \
npm run loadtest:k6:works

默认1 VU / 30s。

Baseline

BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=baseline \
VUS=10 \
DURATION=3m \
DETAIL_RATIO=0 \
npm run loadtest:k6:works

默认阈值:

  • http_req_failed < 1%
  • http_req_duration p95 < 800ms
  • http_req_duration p99 < 1500ms
  • works_list_shape_error_rate < 1%

Spike

BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=spike \
START_RPS=5 \
PEAK_RPS=25 \
HOLD=60s \
DETAIL_RATIO=0 \
npm run loadtest:k6:works

默认阈值:

  • http_req_failed < 1%
  • http_req_duration p95 < 2000ms
  • dropped_iterations = 0
  • works_list_shape_error_rate < 1%

PowerShell

$env:BASE_URL="https://genarrative.world"
$env:WORKS_DATA="data/works-list.local.json"
$env:SCENARIO="spike"
$env:START_RPS="5"
$env:PEAK_RPS="25"
$env:HOLD="60s"
$env:END_RPS="5"
$env:DETAIL_RATIO="0"
npm run loadtest:k6:works -- --summary-trend-stats="avg,min,med,p(90),p(95),p(99),max"

线上 release 回归可使用同一组环境变量:

SCENARIO=spike START_RPS=5 PEAK_RPS=25 HOLD=60s END_RPS=5 DETAIL_RATIO=0 npm run loadtest:k6:works

带登录态压测个人作品列表

先通过本地登录或接口获取 access token然后传入

BASE_URL=http://127.0.0.1:8787 \
AUTH_TOKEN='<access-token>' \
SCENARIO=smoke \
DETAIL_RATIO=0 \
npm run loadtest:k6:works

不要把 token 写入仓库文件、README 或 shell history 中可共享的位置。

详情接口压测

仅当目标环境存在 WORKS_DATA 中的同一批 profileId/ownerUserId 时启用:

BASE_URL=http://127.0.0.1:8787 \
WORKS_DATA=data/works-list.local.json \
SCENARIO=smoke \
DETAIL_RATIO=0.35 \
npm run loadtest:k6:works

如果详情请求返回 404说明压测数据 ID 未导入目标环境或目标服务数据不一致,应先修正数据源,不要把 404 当成功。

排障

  • 如果公开 gallery 返回 creation_entry_disabled 或 503检查本地 creation entry 配置是否禁用了对应入口。
  • 如果高压下返回 429优先确认目标环境是否设置了 GENARRATIVE_API_MAX_CONCURRENT_REQUESTS。429 表示 api-server 应用层背压已生效不等同于业务错误继续看内存、p95、http_req_failed 和 OTLP / Nginx timing 判断阈值是否偏低。
  • 如果直连 api-server 压测出现 connection refused 或 status 0说明压力已经打到 TCP 监听 / accept 层;此时同时检查 GENARRATIVE_API_LISTEN_BACKLOG、Nginx upstream keepalive 和是否需要在 Nginx 前置限流,不能只靠应用层背压解释。
  • 如果个人作品列表返回 401确认 AUTH_TOKEN 是当前 api-server 可识别的 access token。
  • 如果详情全部 404确认是否已向目标环境导入与 WORKS_DATA 一致的数据。

压测窗口采集

Nginx upstream timing

sudo tail -f /var/log/nginx/genarrative.access.log
sudo tail -f /var/log/nginx/genarrative.error.log

api-server 与 SpacetimeDB 日志:

sudo journalctl -u genarrative-api.service -f
sudo journalctl -u spacetimedb.service -f

api-server 的 OpenTelemetry 默认关闭。需要验证 OTLP traces / metrics / logs 时,先在服务器本机启动只监听 127.0.0.1otelcol-contrib debug exporter

npm run otel:debug

如果要把本机数据转发给 Rider OpenTelemetry 面板,先在 Rider 的 OpenTelemetry 设置中启用固定 OTLP server port例如 17011,再运行:

RIDER_OTLP_GRPC_ENDPOINT=127.0.0.1:17011 npm run otel:rider

脚本会在 .codex-temp/otelcol/ 生成临时 collector 配置,默认接收 api-server 发到 http://127.0.0.1:4318 的 OTLP HTTP 数据。需要改端口时可设置:

  • OTELCOL_OTLP_HTTP_ENDPOINT,默认 127.0.0.1:4318
  • OTELCOL_OTLP_GRPC_ENDPOINT,默认 127.0.0.1:4317
  • RIDER_OTLP_GRPC_ENDPOINT,默认 127.0.0.1:17011
  • OTELCOL_BIN,默认 otelcol-contrib

等价的 debug collector 配置如下:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 127.0.0.1:4317
      http:
        endpoint: 127.0.0.1:4318

exporters:
  debug:
    verbosity: detailed

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [debug]
    metrics:
      receivers: [otlp]
      exporters: [debug]
    logs:
      receivers: [otlp]
      exporters: [debug]
otelcol-contrib --config /etc/otelcol-contrib/genarrative-debug.yaml

然后在 /etc/genarrative/api-server.env 中打开:

GENARRATIVE_OTEL_ENABLED=true
OTEL_SERVICE_NAME=genarrative-api
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318

注意 api-server 当前使用 OTLP HTTP exporterOTEL_EXPORTER_OTLP_ENDPOINT 必须指向 Collector 的 HTTP base endpoint http://127.0.0.1:4318。不要把它改成 Collector gRPC 端口 4317,也不要直接指向 Rider 的 gRPC 端口Rider 只由 npm run otel:rider 启动的 Collector 通过 RIDER_OTLP_GRPC_ENDPOINT 转发。

OTLP logs 是远端观测增量不替代本地日志api-server 日志仍看 journalctl / logs/api-server/Nginx 日志仍看文件。日志等级继续用 GENARRATIVE_API_LOG / RUST_LOG 控制,例如 info,tower_http=info,spacetime_client=info

Rider 的 Logs 面板展示的是 OTLP log event 自身字段,不会自动把父 span 的全部 attributes 摊平到每一条日志。请求完成日志会直接携带 request_idhttp.request.methodhttp.routeurl.schemeurl.pathhttp.response.status_codestatus_classlatency_msslow_request;更完整的请求链路仍在 Traces 面板中按同一个 trace/span 关联查看。

压测期间可在 Metrics 面板或 debug exporter 中观察进程内存指标:

  • process.memory.usage:进程常驻内存 / RSS。
  • process.memory.virtual进程虚拟内存Windows 当前按 PrivateUsage 上报Linux 取 VmSize
  • genarrative.process.memory.private进程私有内存Windows 来自 PrivateUsageLinux 近似取 /proc/self/statusVmData
  • process.thread.count:线程数。
  • process.windows.handle.countWindows 句柄数。
  • process.unix.file_descriptor.countLinux 文件描述符数。
  • genarrative.http.server.response_bodies.in_flightAxum / Hyper 仍持有的响应 body 数;如果内存高但该值很低,说明热点不在业务 handler 生命周期内。
  • genarrative.http.server.request_permits.available:应用层 HTTP 背压剩余 permit 数;如果该值未接近 0说明没有打满 GENARRATIVE_API_MAX_CONCURRENT_REQUESTS
  • genarrative.puzzle_gallery.cache.hits / genarrative.puzzle_gallery.cache.misses / genarrative.puzzle_gallery.cache.rebuilds:拼图广场响应缓存命中、未命中和重建次数。
  • genarrative.puzzle_gallery.cache.rebuild.duration:拼图广场缓存重建耗时。
  • genarrative.puzzle_gallery.cache.data_json_bytes:拼图广场缓存内预序列化 data JSON 大小。
  • genarrative.spacetime.read.calls / genarrative.spacetime.read.duration_msSpacetimeDB 订阅本地 cache 读次数和耗时;read=list_puzzle_gallery 表示当前路径走 view / local cache不是 procedure。

/api/runtime/puzzle/gallery 单接口压测出现 GB 级瞬时内存峰值,先区分“持续泄漏”和“请求期分配峰值”:关闭 OTEL 后若峰值仍复现且压测结束后回落,主因通常不是 Collector / exporter。当前拼图广场列表命中缓存时应复用 PuzzleGalleryCache 中的预序列化 data JSON只按请求拼接 envelope meta不应每个请求重新深拷贝 PuzzleGalleryResponse 或构造完整 serde_json::Value

本地 Windows 直连 api-server 压测还要单独看 K6 的 VU / 连接模型。已验证在 250 RPS、PREALLOCATED_VUS=300 时,哪怕打 /healthz 这种小响应,也可能因为本地 300 个 Established 连接触发 api-server private memory 瞬时升到约 7GB压测结束后回落到 100MB 级;同样 250 RPS 改成 PREALLOCATED_VUS=20 MAX_VUS=40 后,拼图广场 p95 约 9ms峰值降到约 600MB。这个现象说明高水位主要来自本机直连连接 / 发送链路,不等价于 SpacetimeDB 或拼图 JSON 缓存泄漏。做本地容量判断时优先让 VU 接近真实并发,避免用过高预分配 VU 把测试变成 Windows 本机连接缓冲压力测试;生产仍以 Nginx upstream keepalive、系统内存和 OTLP 指标一起判断。

线上回归辅助命令:

systemctl show genarrative-api.service -p LimitNOFILE -p TasksMax
cat /proc/$(pidof api-server)/limits
tr '\0' '\n' < /proc/$(pidof api-server)/environ | grep GENARRATIVE_API_MAX_CONCURRENT_REQUESTS
ss -ltnp | grep 8082
curl -sS http://127.0.0.1:8082/healthz

验证命令

npx vitest run scripts/loadtest/extract-works-list-data.test.ts
npx eslint scripts/loadtest/extract-works-list-data.mjs scripts/loadtest/extract-works-list-data.test.ts scripts/loadtest/k6-works-list.js