2ff17cd0c9
用户决定:后端不再对外发静态文件,学生端 H5 交给网关。两者**仍然同源**
(微信只给一个授权回调域名;H5 的接口地址取自 location.origin),
所以前端业务代码、微信后台、application-dev.yml 的 h5-base-url 都不用改。
后端
- 删掉 dev/test/prod 三份 profile 里的 spring.web.resources.static-locations
- 主配置加 spring.web.resources.add-mappings=false —— 用开关而不是"指到空目录":
后者会连带启用 Spring Boot 的默认位置(classpath:/static 等),谁丢个文件进去就悄悄对外可见
- StaticCacheHeaderFilter → ApiNoStoreFilter,只剩 /api/** 的 no-store
(静态资源的缓存策略整体归 nginx,避免"改一处漏一处")
- 补 NoHandlerFoundException → 404:关掉静态映射后未匹配路径不再经过
ResourceHttpRequestHandler,抛的是 NoHandlerFoundException,只处理
NoResourceFoundException 时访问 / 会变成 500「系统繁忙」并刷 ERROR 日志
deploy
- 新增 xt-nginx(80):发 frontend-mp/dist/build/h5 + /api 反代 8080(host.docker.internal,
Linux 靠 extra_hosts host-gateway),端口与产物目录走 .env 的 H5_PORT/H5_DIR
- 三条"删了就会出事"的都已带上理由:client_max_body_size 50m(默认 1m,超了回 413
而后端日志一个字都没有)、/assets 的 immutable 与 *.html 的 no-cache(§一·十 那个
"发版后学生看到旧页面"的坑)、健康检查写 127.0.0.1(容器里 localhost 先解析到 IPv6,
nginx 只听 IPv4 → 永远 starting 而首页是好的)
CI 与文档
- CI:学生端 H5 构建从"e2e 的输入"改为编译检查,不再留 artifact(e2e 不消费它)
- 同步 AGENTS.md / README.md / docs/09(启动步骤、CI 表、发布注意、排障表)/ docs/11
- 交接摘要新增 §一·四十三(含理由、验证数字、要人工做的一步与环境限制)
验证
- mvn clean package 通过(必须 clean:删掉的旧类会留在 target/classes 被组件扫描)
- 后端直连:/ 与 /assets/* → 404({"code":40001,"msg":"接口不存在"})、
/api/.../captcha → 200 + no-store、/doc.html → 302→200、/v3/api-docs 与 /actuator/health → 200
- 经 nginx(80):首页 200 + no-cache、/assets/*.js 200 + immutable、/nope.js 404(无回退)、
/api 200 + no-store、gzip 生效;2MB multipart 上传 → 401(与直连一致 ⇒ 未踩 1m 的坑)、
55MB → 413
- e2e(XT_BASE=http://localhost,全程走 nginx):e2e-m0.py 37/37、e2e-m3-h5-auth.py 24/24、
e2e-m2-scope.py 38/38
- verify-all.py --quick 6/6;check-test-profile.py:test profile 仍能起来
496 lines
29 KiB
Markdown
496 lines
29 KiB
Markdown
# 09 · 部署与运维
|
||
|
||
> 对应里程碑 **M0-04(工程化)** 与 **M7-10(灰度发布 + 生产环境准备 + 数据迁移预案)**。
|
||
> 这份文档回答四个问题:**怎么发**、**密钥怎么办**、**升级与回滚怎么做**、**出事看哪里**。
|
||
|
||
---
|
||
|
||
## §1 四套环境
|
||
|
||
| 环境 | profile | 配置来源 | 数据库 | 存储 | 用途 |
|
||
|---|---|---|---|---|---|
|
||
| 本地 | `dev` | `application-dev.yml`(**含 dev 密钥,已提交**) | `xt-pg` 容器 | `local` | 开发与 E2E |
|
||
| 集成 | `test` | `application-test.yml`(**无密钥**,全走环境变量) | CI 临时容器 / 联调库 | `local` 或 `qiniu` | CI 与联调 |
|
||
| 预发 | `prod` + 灰度开关 | `application-prod.yml` | 独立库 | `qiniu` | 客户验收 |
|
||
| 生产 | `prod` | `application-prod.yml` | 独立库 | `qiniu` | 正式 |
|
||
|
||
**⚠️ dev 与 test/prod 的密钥不能混用。** `dev` 的 JWT / AES 密钥在仓库里能查到,
|
||
拿它能伪造任意用户的 token(含平台运营)。这件事由 `ProdEnvCheck` 在启动时拦下 ——
|
||
见 §3.4。
|
||
|
||
---
|
||
|
||
## §2 怎么发
|
||
|
||
### 2.1 本地
|
||
|
||
```bash
|
||
cd deploy && docker compose up -d # xt-pg(5432) + xt-redis(16379) + xt-nginx(80)
|
||
cd frontend-mp && pnpm build:h5 # nginx 挂的是产物目录 → 先出产物
|
||
cd backend
|
||
mvn -DskipTests package
|
||
java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar --spring.profiles.active=dev
|
||
```
|
||
|
||
- ⚠️ **工作目录必须是 `backend/`**:`application-local.yml`(本地私有配置,不提交)在那里,
|
||
是靠相对路径找到的
|
||
- 跑的是 **jar 不是热加载**:改后端要重新 package(先停进程,否则 jar 被占用)
|
||
- **H5 由 `xt-nginx` 托管(`http://localhost/`),后端只提供 `/api`**(2026-10 调整)。
|
||
两者**同源**是硬要求:微信网页授权的「授权回调页面域名」只能配一个,
|
||
而 H5 的接口地址取自 `location.origin` —— 跨源会让请求打到 nginx 自己身上(页面能开、接口全 404)。
|
||
端口与产物目录在 `deploy/.env`(`H5_PORT` / `H5_DIR`),站点配置是 `deploy/nginx/xt.conf`。
|
||
- ⚠️ **内网穿透要指到 nginx 的端口(80),不是后端的 8080** ——
|
||
指到 8080 的表现是"接口好用、页面 404"。
|
||
- nginx 那份配置里有两条**不能删**的:`client_max_body_size 50m`
|
||
(默认 1m,学生传一张照片就可能 413,而**后端日志里一个字都没有**)与
|
||
`/assets/`、`*.html` 的缓存头(没有它浏览器会启发式缓存,发版后学生看到旧页面,
|
||
见 `ApiNoStoreFilter` 的类注释)。
|
||
- 只想改学生端页面时不必走 nginx:`cd frontend-mp && pnpm dev:h5` → `http://localhost:5174`
|
||
(自带 `/api` 代理,**改一行不用重新 build**)
|
||
|
||
### 2.2 CI(GitLab)
|
||
|
||
`.gitlab-ci.yml` 三个阶段:
|
||
|
||
| 阶段 | 做什么 | 为什么这样切 |
|
||
|---|---|---|
|
||
| `check` | 契约对账、实体列对账、文档一致性、**e2e 脚本语法扫描** | 都不需要数据库,几十秒出结果,先在最快的阶段挡掉低级错误 |
|
||
| `build` | 后端打包、管理端 typecheck/build、**H5 build** | H5 build 现在是**编译检查**(2026-10 起不再由后端托管、e2e 也不消费它),学生端没有单独的 typecheck |
|
||
| `e2e` | 起 PG+Redis 容器 → 起后端(`test` profile)→ **依次跑全部 `scripts/e2e-*.py`** | 这个仓库**没有单元测试**,E2E 就是主验证手段;一个只跑编译的流水线在这里等于没跑 |
|
||
|
||
另外单独一个 job `生产环境自检` 跑 `scripts/e2e-prodguard.py` ——
|
||
它验的是"**配错时拒绝启动**",而这恰好是最容易被后来的改动悄悄破坏、
|
||
且破坏后**一切看起来都正常**的一件事。
|
||
|
||
**密钥策略:e2e 阶段每次随机生成密钥,不存任何 CI 变量。**
|
||
好处不只是省事 —— 它顺带证明**应用没有偷偷依赖某个 dev 默认值**(依赖了这里就红)。
|
||
|
||
### 2.3 生产发布
|
||
|
||
```bash
|
||
# ① 构建(CI 里做,产物是 xt-server.jar)
|
||
mvn -DskipTests package
|
||
|
||
# ② 先把迁移在一个只读副本上试跑(见 §4.2)
|
||
java -jar xt-server.jar --spring.profiles.active=prod \
|
||
--spring.flyway.enabled=true --spring.main.web-application-type=none
|
||
|
||
# ③ 滚动发布:先起新实例、探活通过、再摘旧实例
|
||
curl -sf http://127.0.0.1:8080/actuator/health # 探活用这个,不是 /
|
||
```
|
||
|
||
⚠️ **滚动发布期间会有两个版本同时在跑**,所以:
|
||
- **迁移必须是"向后兼容"的**(只加列/加表,不改列类型、不删列)——
|
||
否则旧实例会在新库上出错。改列要走"两次发布":这次加新列并双写,下次删旧列。
|
||
- **H5 静态产物要和新后端一起发**:H5 由 nginx/网关托管(不再由后端托管,2026-10 调整),
|
||
但"同源"没变,前后端不同步还是会出现"页面调用了还不存在的接口"。
|
||
|
||
---
|
||
|
||
## §3 密钥与凭据
|
||
|
||
### 3.1 清单
|
||
|
||
| 变量 | 用途 | 生成方式 | 轮换影响 |
|
||
|---|---|---|---|
|
||
| `XT_JWT_SECRET` | 登录 token 的 HS512 签名 | `openssl rand -base64 64` | **所有人被登出**(要做就安排在低峰) |
|
||
| `XT_AES_KEY` | **证件号加密** | `openssl rand -base64 32` | ⚠️ **不能直接换** —— 见 3.2 |
|
||
| `XT_HMAC_KEY` | 开放接口签名 / 去重 hash | `openssl rand -base64 48` | 接入方要改签名密钥;`id_card_hash` 类的去重值会变 |
|
||
| `XT_RSA_PRIVATE_KEY` | 登录密码解密(PKCS#8 单行 base64) | `openssl genpkey -algorithm RSA ...` | 公钥在前端,**要同时换**(接口 `/auth/public-key` 会下发新公钥) |
|
||
| `PG_PASSWORD` / `REDIS_PASSWORD` | 基础设施 | 运维 | 重启即生效 |
|
||
| `QINIU_*` | 对象存储 | 七牛控制台 | 换 key 不影响已存文件(URL 是签名的) |
|
||
| `WX_*` | 微信小程序 / 公众号 | 微信后台 | 换 secret 后旧 access_token 立刻失效(**服务自己会重新取**) |
|
||
|
||
### 3.2 ⚠️ AES 密钥不能"直接换"
|
||
|
||
`collect_roster.id_card_no` / `collect_file` 里的证件号是 **AES 密文**。
|
||
换密钥之后**旧密文解不开**,而系统不会报错 —— 它只会显示一串乱码,
|
||
或者门禁核验永远匹配不上(`id_card_hash` 也变了)。
|
||
|
||
正确做法(本项目**缓做**,等 OCR 供应商选型;**不再作为二期功能设计**):
|
||
1. 新密钥加进来(配置支持"当前密钥 + 历史密钥列表");
|
||
2. 写一个离线任务用旧密钥解密、新密钥重加密,并**同时重算 hash 与 mask**;
|
||
3. 全部迁完之后,旧密钥才下线。
|
||
|
||
**在此之前:`XT_AES_KEY` 一旦启用就不要换。** 这条比 token 那条更硬 ——
|
||
token 换了只是"重新登录一次",这个换了是"全校证件号读不出来"。
|
||
|
||
### 3.3 密钥轮换的记录
|
||
|
||
轮换是**运维动作**,要留痕(谁、什么时候、为什么)。项目里的操作日志表
|
||
`sys_operation_log` 可以承接这类记录,但**轮换本身在系统外发生**,
|
||
所以还应有独立的运维记录 —— 建议直接用 GitLab 的 issue 或运维台账。
|
||
|
||
### 3.4 启动自检(`ProdEnvCheck`)
|
||
|
||
`prod` profile 启动时会先自检,**任何一条不通过就拒绝启动**:
|
||
|
||
| 检查 | 不检查会怎样 |
|
||
|---|---|
|
||
| 六个必需环境变量齐全 | 直接起不来(但报错是一堆栈,不是清单 —— 所以这里提前拦) |
|
||
| `stub-enabled` 不为 `true` | **任何人可以登录任何学生** |
|
||
| 存储 provider 不是 `local` | 多副本下**必然丢文件**,且丢的时候没有任何报错 |
|
||
| JWT 密钥不是 dev 那把、且 ≥ 32 字符 | 拿仓库里那把就能**伪造任意用户的 token** |
|
||
| AES 密钥不是 dev 那把 | 证件号加密形同虚设 |
|
||
| `springdoc.api-docs.enabled` 不为 `true` | 全部接口与字段暴露给公网 |
|
||
|
||
实现是 `EnvironmentPostProcessor`(跑在任何 bean 之前)。
|
||
⚠️ **不能写成 `ApplicationRunner`**:那样缺密钥时 `JwtService` 构造先炸,
|
||
用户看到的是一段 56 行栈,而不是这份清单(这是实测踩过的)。
|
||
|
||
---
|
||
|
||
## §4 升级、迁移与回滚
|
||
|
||
### 4.1 迁移
|
||
|
||
Flyway 管理,迁移文件在 `backend/xt-server/src/main/resources/db/migration/`。
|
||
**已发布的迁移文件不许改**(`validate-on-migrate: true` 会校验)——
|
||
这是保护:它保证"生产跑的迁移"与"仓库里的"逐字节一致。
|
||
|
||
⚠️ **生产禁止 `flyway clean`**(`clean-disabled: true`):
|
||
它是"清空整个库再重建",在生产上执行等于把所有学校的填报数据删掉。
|
||
|
||
### 4.2 升级前必做
|
||
|
||
```bash
|
||
# ① 备份(**这是唯一的后悔药**)
|
||
pg_dump -Fc -h $PG_HOST -U $PG_USER $PG_DB > backup-$(date +%F-%H%M).dump
|
||
|
||
# ② 在副本上试跑迁移:先恢复备份到临时库,再对临时库跑一次
|
||
createdb xt_dryrun && pg_restore -d xt_dryrun backup-*.dump
|
||
java -jar xt-server.jar --spring.profiles.active=prod \
|
||
--spring.datasource.url=jdbc:postgresql://.../xt_dryrun \
|
||
--spring.main.web-application-type=none # 只跑迁移,不起 web
|
||
```
|
||
|
||
②的价值在于:**迁移失败的最好时机是"还没碰生产库"的时候**。
|
||
|
||
### 4.3 回滚
|
||
|
||
| 情形 | 怎么做 |
|
||
|---|---|
|
||
| 只换了应用版本、没跑新迁移 | 把上一个 jar 换回来,重启。**最快** |
|
||
| 跑了新迁移、但迁移是"只加列/加表" | 换回旧 jar 即可(旧版本不认识新列,不影响) |
|
||
| 跑了新迁移、且迁移**改了列类型或删了列** | ⚠️ 换回旧 jar 也救不了,必须**恢复备份** —— 所以 §2.3 要求迁移必须向后兼容 |
|
||
| 只是某个开关配错 | 改环境变量重启(`ProdEnvCheck` 会先校验一遍) |
|
||
|
||
**发布前把"回滚要多久"算清楚**,并写在发布单上。恢复一个几十 GB 的库
|
||
不是"几分钟"的事。
|
||
|
||
### 4.4 灰度
|
||
|
||
一期建议按**租户**灰度(不是按流量):客户是学校,一个学校要么全在新版本、
|
||
要么全在旧版本 —— 按流量切会让同一个学校里一半学生走新代码、一半走旧代码,
|
||
而"同一份填报单被两个版本各写一半"是最难排查的一类问题。
|
||
|
||
```bash
|
||
XT_TENANT_DOMAIN_ENABLED=true # 如果要按域名区分单位
|
||
```
|
||
|
||
---
|
||
|
||
## §5 出事看哪里
|
||
|
||
| 症状 | 先看 | 常见原因 |
|
||
|---|---|---|
|
||
| 服务起不来 | 启动日志里的 `生产环境启动自检未通过` | §3.4 的六条 |
|
||
| 学生上传失败 | `xt-server.log` 里 `MaterialFileService` | 存储密钥错 / 空间名错(开 `XT_STORAGE_SELF_CHECK=true` 能在启动时暴露) |
|
||
| 页面 404 但接口正常 | 穿透/网关指到了哪个端口 | H5 由 nginx 托管(80),**指到后端的 8080 就只剩接口**;另看 `deploy/.env` 的 `H5_DIR` 是否指向真实产物目录(目录不存在时 docker 会建一个**空目录**,nginx 一切正常而页面 404) |
|
||
| 上传报 413(后端日志里没有这条请求) | nginx 的 `client_max_body_size` | 默认 1m;`deploy/nginx/xt.conf` 已设 50m,**换网关时别漏这一条** |
|
||
| 本机 8080 被别的项目占用 | `netstat -ano \| findstr :8080` | 用 `--server.port=8081` 起后端,e2e 脚本加 `XT_BASE=http://localhost:8081`(脚本读这个环境变量);**不要去杀别人的进程** |
|
||
| 定时任务没生效 | 手工触发一次:`POST /api/v1/schedules/run {"job":"close"}` | 见下 |
|
||
| 「查不到数据」但库里有 | 租户上下文 | 定时任务/匿名接口的线程里没有登录用户,租户拦截器默认拒绝 |
|
||
|
||
**定时任务的排查办法**:`POST /api/v1/schedules/run` 可以手工触发一轮
|
||
(`close` / `urge` / `timeout` / `raw`),运维补跑也用它。
|
||
|
||
⚠️ 定时任务最安静的一类故障是"**跑了但什么都没做**":
|
||
候选查询的 SQL 别名没加双引号时会被 PostgreSQL 折成小写,
|
||
`row.get("tenantId")` 得到 `null`,异常被 catch 吞掉,日志只有一行 warn。
|
||
所以四个任务都加了候选行自检 —— 缺 `tenantId` 会把"实际的键"打进日志。
|
||
|
||
---
|
||
|
||
## §6 首次接入 CI 的检查单
|
||
|
||
`.gitlab-ci.yml` 里这几件事**在本地 Windows 上验不到**,第一次上 runner 时要重点看:
|
||
|
||
- [ ] `services: postgres / redis` 的容器别名能否解析(`PG_HOST=postgres`)
|
||
- [ ] runner 镜像里 `apt-get install python3 python3-pip curl postgresql-client` 可用
|
||
- [ ] `pip3 install openpyxl requests` 可用(E2E 脚本要用)
|
||
- [ ] `openssl rand -base64 64` 生成的随机串里含 `/` `+` `=` 时,
|
||
作为**环境变量**传给 Java 没问题(shell 里安全;哪天改用 `.env` 文件则要转义)
|
||
- [ ] `artifacts` 的路径与保留期
|
||
- [ ] `maven:3.9-eclipse-temurin-21` 与 `node:20` 的镜像拉取速度
|
||
- [ ] **`scripts/check-test-profile.py` 要先过**:它验的就是"CI 那个 profile 能不能起来"。
|
||
它红了就是配置问题,不要去查 E2E 脚本
|
||
- [ ] 密钥生成命令的长度要对:`openssl rand -base64 32` → 44 字符(AES-256 要 32 字节)。
|
||
长度不对时 `ProdEnvCheck` 会点名报出来("解出来 30 字节,需要 32 字节")
|
||
- [ ] **shell 片段的语法只能在 Linux 上验**:本次开发机的 `cmd.exe` 解析不了
|
||
单引号 pathspec(`'frontend-pc/**/*.vue'`),所以"lint 改动文件"那段
|
||
是照 Linux 语义写的,首次跑要留意
|
||
|
||
---
|
||
|
||
## §7 为什么没有 GitHub Actions
|
||
|
||
仓库里原本有一个模板自带的 `frontend-pc/.github/workflows/deploy.yml`
|
||
(部署到 GitHub Pages)。它在本项目**不可能工作**,已删除:
|
||
|
||
1. 远端是 **GitLab**(`gitlab.jxyunge.com`),不是 GitHub;
|
||
2. 它用 `npm ci` / `npm run build`,而本项目用 **pnpm**(仓库里是 `pnpm-lock.yaml`);
|
||
3. 它把管理端当**静态站**发到 Pages —— 而这个前端**必须连着后端**才有意义
|
||
(登录、接口、权限全部依赖 `/api`),发上去只会得到一个打不开的演示页。
|
||
|
||
留着它的害处不是"多一个文件",而是**它会让人以为"我们有部署流水线"**。
|
||
|
||
---
|
||
|
||
## §8 本地能验到哪一步
|
||
|
||
`.gitlab-ci.yml` 是配置文件,**"写了没验"的 CI 最糟**(第一次真跑才红,而那时所有人都在等)。
|
||
所以本轮把能本地跑的都跑了一遍:
|
||
|
||
| 阶段 | 本地怎么验 | 结果 |
|
||
|---|---|---|
|
||
| `check` | 直接跑那**五个**校验脚本(契约 / 实体列 / 文档 / 手册 / **接线**)+ 把 CI 里的 heredoc 片段原样执行 | ✅ |
|
||
| `build` | `mvn package`、`pnpm typecheck`、`pnpm build` | ✅ |
|
||
| `e2e` | 起 `test` profile 确认能起来(`check-test-profile.py`);全套 E2E 在 `dev` 上跑 | ✅ |
|
||
| `生产环境自检` | **真的起三次 jar**(三种误配形态) | ✅ 9/9 |
|
||
|
||
> **接线检查(`scripts/check-wiring.py`)为什么值得单独放一个阶段**:它查的是
|
||
> "**声明了但没接上**"(权限码没人校验、错误码没人引用、通知模板没有发送点、
|
||
> 菜单键前端没有对应项)—— 这一族错**编译 / lint / review 全都不红**,
|
||
> 只有"从入口走一遍"才暴露(F-1/F-31/F-50/F-52/F-101/F-106 都是它)。
|
||
> 它自己的假阳性也要当心:本轮修了三次(只数注解漏掉服务层、按标识符数引用被同名常量骗过、
|
||
> **注释里的提及被当成接线**)—— 所以它现在**先去 Java 注释再数**,
|
||
> 并且有意不接线的逐条写在脚本上方的例外表里(带理由),**不静默跳过**。
|
||
|
||
**本地验不到的**(要在真实 runner 上跑):
|
||
`services:` 的容器别名解析、`apt-get`/`pip3` 在 runner 镜像里可用性、
|
||
artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows,
|
||
`cmd.exe` 解析不了单引号 pathspec —— 所以"只 lint 改动文件"那段是照 Linux 语义写的)。
|
||
这几条都在 §6 的检查单里。
|
||
|
||
---
|
||
|
||
## §9 压测记录(M7-09)
|
||
|
||
脚本:`python scripts/load-test.py [--rows N] [--only gate|read|upload|export|export-big|soak] [--with-upload] [--base URL] [--fixture-only] [--files-per-user K] [--split-mb M] [--soak 分钟] [--rps N]`
|
||
|
||
⚠️ **本机是"负载生成器 + 后端 + PG + Redis 全在同一台机器(且 PG/Redis 在 Docker 里)"**,
|
||
所以绝对值偏悲观;**看比例与形状,不要看绝对值**。
|
||
另一条同样重要:**谈响应时间必须带规模**(同一个接口在 60 行与 2000 行上是两件事),
|
||
所以报告里每个数字都带规模。
|
||
|
||
**本机规格(2026-09-30 补记,读数字时一起看)**:AMD RYZEN AI MAX+ 392,
|
||
12 物理核 / 24 逻辑核,内存 59.6 GB;后端 JVM、PG、Redis、压测进程、浏览器全在同一台机器上。
|
||
|
||
### 9.1 结果(规模:名册 2000 人,除注明外)
|
||
|
||
| 场景 | 结果 |
|
||
|---|---|
|
||
| **门禁核验**(60 人同一出口 IP 同时扫码) | **60/60 成功**(修复前 **20/60**,见 9.3);并发下 p50 218ms / p95 327ms |
|
||
| **门禁核验·学校规模**(250 / 500 人同一出口 IP) | **250/250、500/500 成功**(成功上限 200→600 之后;此前 250 人会挡 37 个,见 9.3)。500 并发 p50 1.4s / p95 2.5s —— 是排队,**无人被限流** |
|
||
| **读接口**(20 并发 × 8 轮 = 640 请求,四类混合) | **0 失败**;单请求中位 **36~47ms**(与页大小 20/50/200 **无关**);20 并发下 p50 156~375ms |
|
||
| **名册列表的 SQL 条数** | **恒为 3 条**(`selectPage` + `count` + 组织名映射),**与 pageSize 无关 → 没有 N+1** |
|
||
| **DB 侧耗时** | 分页查一页 **1.2ms**、计数 **0.66ms**、组织映射 **0.06ms** |
|
||
| **材料上传**(20 并发,2850KB 真图) | **40/40 成功**;**串行单请求 321ms**(2850KB → 1329KB,**压到 47%**);20 并发 p50 **620ms** / p95 **801ms** / max 851ms;吞吐 **6/s**(见 9.6) |
|
||
| **名册导入规模**(5000 行,PRD 说的上限) | **导入接口 20.2s**(2000 行 7.7s);其中"造 Excel"是压测脚本自己的成本 **0.7s**,要分开看;导入后后端 RSS **782 MB** |
|
||
| **导出大包**(150 人 / 194.6 MB) | **打包墙钟 13.2s**,单卷 194.7 MB;150 个材料 + 汇总表共 **151 个条目**,ZIP 可解;30 人(38.9 MB)时 **2.8s** |
|
||
| **导出分卷**(同一批 150 人,`splitSizeMb=50`) | **4 卷**(49.3 / 49.3 / 49.3 / 46.7 MB),**每一卷都能单独解开**(39/38/38/36 个条目),汇总表在第 1 卷根目录 |
|
||
| **导出前置检查** | 不通过时正确拦成 `40035` 且**创建接口也拒绝**(见 9.4) |
|
||
| **长跑稳定性**(5 请求/秒 × 60 分钟,名册 2000 行) | 见 **9.5.4** |
|
||
|
||
### 9.2 结论:后端没有 N+1,DB 不是瓶颈
|
||
|
||
三条独立证据:
|
||
|
||
1. **数 SQL 条数**:一次名册列表请求恒 3 条 SQL,pageSize 从 5 到 200 都是 3 条;
|
||
2. **单请求延迟与页大小无关**(20/50/200 的中位分别是 47/36/44ms)——
|
||
如果是"按行做重活",这三个数会明显拉开;
|
||
3. **DB 侧 2ms 以内**(`EXPLAIN ANALYZE`)。
|
||
|
||
所以并发下的 p50 156~375ms 是**排队效应**(20 个请求抢同一台机器的 CPU 与网络),
|
||
不是单请求成本。**要提吞吐得先加机器/拆进程,而不是改 SQL。**
|
||
|
||
同一条道理用在上传上:串行 321ms vs 20 并发 p50 620ms ——
|
||
差值就是排队。**压缩本身是 CPU 密集的单线程活**,所以"上传慢"的解法是加 CPU / 限并发,
|
||
不是改上传接口。
|
||
|
||
### 9.3 ⚠️ 门禁限流曾经会挡住整间教室(已修)
|
||
|
||
压测第一次跑出来的是:60 个**不同**学生同时核验(全部正确),
|
||
**20 个成功、40 个被限流**(`40071`)。原因是 IP 限流 20 次/分钟,
|
||
而它计的是**所有请求**(成功也算)。
|
||
|
||
真实场景里这有多严重:**一个班的学生坐在同一间教室、连同一个 WiFi**,
|
||
出口就是同一个公网 IP。开学第一次收材料,后 40 个学生看到的是
|
||
「操作过于频繁,请稍后再试」—— 而他们不知道自己该等多久、也不知道为什么别人能进。
|
||
|
||
**修法:分开两个计数器,因为目的不同。**
|
||
|
||
| 计数器 | 计什么 | 目的 | 限额 |
|
||
|---|---|---|---|
|
||
| 失败(按人) | 只计失败 | 防**猜** | 10 次/小时 |
|
||
| 失败(按 IP) | 只计失败 | 防**枚举** | 20 次/分钟 |
|
||
| 成功(按 IP) | 只计成功 | 防"批量取数据" | **600 次/分钟**(2026-09-30 由 200 上调,见下) |
|
||
|
||
关键洞见:**枚举攻击产生的是失败,而教室产生的是成功。**
|
||
把成功也算进防枚举的额度里,等于用防攻击的代价挡住正常用户 ——
|
||
而挡住的量级恰好是"一个班"。
|
||
|
||
⚠️ 另有一条**刻意不计**的失败:`GATE_ALREADY_BOUND`(两个人抢同一个名册项)。
|
||
开学第一天几十人同时扫码必然出现几例,把它算进额度里会让整间教室在几秒内被限流,
|
||
**而没有一个人做错了什么**。
|
||
|
||
⚠️ **成功上限经历过一次实测修正(200 → 600,2026-09-30)**:
|
||
契约 §1.6 只规定了失败侧(同 openid 10 次/小时、同 IP 20 次/分钟),成功侧上限是本项目
|
||
自己加的,而最初定的 **200 恰好卡在"一个年级"的量级** —— 实测 **250 个不同学生
|
||
同时核验(全部正确)时 213 个成功、37 个被挡**,提示「当前网络访问过于集中」。
|
||
全校在同一个出口 IP 集中填报(开学第一课集体扫码)就会撞上,而**没有人做错了什么**。
|
||
上调到 600 后复测:**250 人 250/250、500 人 500/500**,且失败侧两个计数器原样未动。
|
||
要再调这个数,先跑 `python scripts/load-test.py --only gate --rows 500` 量一遍。
|
||
|
||
### 9.4 压测怎么跑(两条使用注意)
|
||
|
||
- **上传压测默认关闭**(`--with-upload` 才跑),因为本机 `application-local.yml` 里
|
||
`provider: qiniu`,上传落的是**线上那个空间**(bucket=tools-matrix):
|
||
一次压测 40 张图打过去,七牛回了 `status:614`。两件事都不对 ——
|
||
压测不该依赖外部服务(网络抖动会让"性能结论"变成对七牛的评价),
|
||
也不该往真实桶里灌测试文件(**删库不删桶**)。
|
||
|
||
**正确跑法(推荐)**:另起一个用本地存储的实例,把压测打过去,不碰线上桶:
|
||
|
||
```bash
|
||
cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar \
|
||
--spring.profiles.active=dev --server.port=18080 --fill.storage.provider=local
|
||
python scripts/load-test.py --base http://localhost:18080 --only upload --conc 20
|
||
python scripts/load-test.py --base http://localhost:18080 --only export-big --rows 150 --split-mb 50
|
||
```
|
||
|
||
⚠️ 这个实例**也会跑定时任务**(`@EnableScheduling` 没有开关),
|
||
所以量完就把它停掉,别让它长期和 dev 实例并行。
|
||
|
||
- **导出创建被 `40035` 拦住不是失败**:那是铁律(`canExport=false` 时禁止导出)
|
||
在服务端的正确生效。脚本会把这种情形报成「被正确拦住」,
|
||
并**断言创建接口也必须拒绝** —— 界面禁用按钮只是第一道,服务端是最后一道。
|
||
|
||
### 9.5 补测记录(2026-09-30):原来"没测过"的四项
|
||
|
||
这一节记的是**怎么量出来的**,因为数字会过期、方法不会。
|
||
|
||
#### 9.5.1 上传并发(CPU 密集的压缩)
|
||
|
||
```bash
|
||
cd backend && java -jar xt-server/target/xt-server-1.0.0-SNAPSHOT.jar \
|
||
--spring.profiles.active=dev --server.port=18080 --fill.storage.provider=local
|
||
python scripts/load-test.py --base http://localhost:18080 --only upload --conc 20
|
||
```
|
||
|
||
| 量 | 结果 |
|
||
|---|---|
|
||
| 夹具 | **真 JPEG 2000×1500 / 2850 KB**(噪声图;纯色量不出 CPU 成本) |
|
||
| **串行单请求** | **321 ms**,产物 **1329 KB → 压到 47%** |
|
||
| 20 并发(40 次上传) | **40/40 成功**;p50 **620 ms** / p95 **801 ms** / max 851 ms |
|
||
| 吞吐 | **6 次/秒**(40 次 / 6.6 秒墙钟) |
|
||
|
||
读法:**单请求 321ms vs 并发 p50 620ms,差值就是排队** —— 压缩是单线程 CPU 活,
|
||
所以"上传慢"的解法是加 CPU 或限并发,不是改接口。
|
||
⚠️ 上传场景有**前置条件**:学生必须先经过门禁绑定,否则 `POST /mp/submissions` 会被拒;
|
||
脚本现在会自己绑定并**断言草稿数**,不成立时明确报"前置不成立"而不是"上传失败"。
|
||
|
||
#### 9.5.2 名册导入规模(PRD 提到的 5000 行上限)
|
||
|
||
```bash
|
||
python scripts/load-test.py --rows 5000 --fixture-only
|
||
```
|
||
|
||
| 规模 | 造 Excel(脚本自己的成本) | **导入接口**(系统的成本) | 备注 |
|
||
|---|---|---|---|
|
||
| 150 行 | 0.3 s | **0.8 s** | |
|
||
| 2000 行 | 0.4 s | **7.7 s** | |
|
||
| **5000 行** | 0.7 s | **20.2 s** | 导入后后端 RSS **782 MB** |
|
||
|
||
⚠️ 两段必须分开报:**造 Excel 是压测脚本的成本**(openpyxl),混在一起会把脚本的耗时
|
||
算到"名册导入"头上 —— 那正是"度量工具的偏差伪装成产品缺陷"。
|
||
|
||
导入是**同步**的:5000 行会让这个请求占用 20 秒,前端要按长请求处理(别设 10 秒超时)。
|
||
|
||
#### 9.5.3 导出大包(客户最关心的那条链)
|
||
|
||
```bash
|
||
python scripts/load-test.py --base http://localhost:18080 --only export-big \
|
||
--rows 150 --files-per-user 1 # 不分卷
|
||
python scripts/load-test.py --base http://localhost:18080 --only export-big \
|
||
--rows 150 --files-per-user 1 --split-mb 50 # 强制分卷
|
||
```
|
||
|
||
夹具会自己走完整条业务链:门禁绑定 → 建草稿 → 上传 → 提交 → **初审(班主任)→ 终审(管理员)**
|
||
→ 前置检查 → 创建导出 → 轮询到完成 → **逐卷解开验证**。
|
||
|
||
| 规模 | 原件 | 打包墙钟 | 产物 | 条目数 |
|
||
|---|---|---|---|---|
|
||
| 30 人 | 38.9 MB | **2.8 s** | 单卷 38.9 MB | 31(30 材料 + 汇总表) |
|
||
| 150 人 | 194.6 MB | **13.2 s** | 单卷 194.7 MB | 151 |
|
||
| 150 人 + `splitSizeMb=50` | 194.6 MB | **11.4 s** | **4 卷**(49.3 / 49.3 / 49.3 / 46.7 MB) | 39 / 38 / 38 / 36 |
|
||
|
||
三条结论:
|
||
1. **吞吐约 15 MB/s**,且"原件 → 产物"压缩率 **0.0%** —— JPEG 本来就压不动,
|
||
打包的瓶颈是磁盘 I/O 而不是 CPU;所以大包的耗时基本可以按体积线性估。
|
||
2. **每一卷都能单独解开**(脚本对每卷跑 `zipfile.testzip()`)—— 这是分卷的承诺,
|
||
而它只能靠真解一遍来验证,"文件存在"断言不到。
|
||
3. 目录结构符合 PRD §5.9:`{批次号}-{任务名}/班级/材料类型/文件`,
|
||
汇总 Excel 在**第 1 卷的根目录**。
|
||
|
||
#### 9.5.4 长跑稳定性(60 分钟)
|
||
|
||
```bash
|
||
python scripts/load-test.py --only soak --rows 2000 --soak 60 --rps 5
|
||
```
|
||
|
||
判据是**趋势**,不是"跑完没崩":每分钟记一行 RSS(从系统取,不是应用自报的堆用量)
|
||
与 PG 连接数,最后比较**前 1/3 与后 1/3** 的均值。
|
||
|
||
**结果(2026-09-30 实测)**:
|
||
|
||
| 量 | 前 1/3 均值 | 后 1/3 均值 | 结论 |
|
||
|---|---|---|---|
|
||
| RSS | 603.9 MB | 582.9 MB | **-3.5%,平稳**(GC 还回收了一点) |
|
||
| PG 连接数 | 17.0 个 | 6.2 个 | **-63.7%,回落**(定时任务高峰把池撑到上限 20 后收回 minimumIdle≈6) |
|
||
| 延迟 p95 | 63.2 ms | 62.8 ms | **-0.7%,纹丝不动** |
|
||
|
||
样本 51 行(标称 60 分钟,实际每行约 70 秒:300 个请求 × 0.2s 均匀间隔)、
|
||
请求 **15300**、失败 **0**。RSS 峰值 622 MB 出现在**定时任务在跑的那几分钟**。
|
||
|
||
**结论:60 分钟内没有内存泄漏、没有连接泄漏、延迟没有退化。**
|
||
(更长的窗口见 9.6 —— 那是仍然没测的。)
|
||
|
||
⚠️ 读这条曲线时要先知道一件事:**每小时的第 5 分钟、以及每 10 分钟**有定时任务在跑
|
||
(`ScheduleJobs` 的 cron),那一刻 PG 连接数会**跳一下**再落回来。
|
||
实测见过 6 → 21 → 6 的形状 —— 那是任务在干活,**不是连接泄漏**。
|
||
真正的泄漏长这样:**回不去**,且后 1/3 的均值明显高于前 1/3。
|
||
|
||
### 9.6 仍然没测的
|
||
| 场景 | 为什么没测 | 怎么测 |
|
||
|---|---|---|
|
||
| **真机上的上传**(微信小程序 + 手机相册) | 需要真机与小程序资质 | 见 `docs/01` **D6**:量"选一张 5MB 照片到提交成功"的端到端时间 |
|
||
| **对象存储(七牛)的上传并发** | 本地跑会把测试文件灌进真实桶(见 9.4) | 用 staging 的独立 bucket,`provider=qiniu` + `--with-upload` |
|
||
| **多副本部署下的导出打包** | 本机只有一个实例 | 两实例同时导出同一任务,确认不会互相覆盖产物(导出线程池与落盘路径都要看) |
|
||
| **5000 人**(不是 5000 行)**的全链路** | 5000 行导入已测(9.5.2),但"5000 人都提交 + 审核 + 导出"是另一件事 | 按 9.5.3 的 `export-big` 放大到 5000,注意先看导出分卷与磁盘占用 |
|
||
| **生产规模下的长跑(数小时~数天)** | 60 分钟只够发现"快速泄漏" | 在 staging 按 9.5.4 的 soak 跑 12~24 小时,看 RSS 与 PG 连接数是否仍然平稳 |
|
||
|
||
---
|
||
|
||
## 附:PC 管理端构建(2026-10 重搭后)
|
||
|
||
| 项 | 说明 |
|
||
|---|---|
|
||
| 技术栈 | **纯 Vue 3 + Naive UI 2.42**(已无 `@arco-design/*` 与 Gi 模板依赖) |
|
||
| 依赖安装 | `pnpm install --frozen-lockfile`(`pnpm-lock.yaml` 随新 `package.json` 更新,**别用 npm**) |
|
||
| 构建 | `pnpm build` → `frontend-pc/dist/`(静态托管,`/api` 由网关/反代指向后端 8080) |
|
||
| 检查 | `pnpm typecheck`(**0 错**)、`pnpm lint`(ESLint;**Stylelint 已随重搭移除**) |
|
||
| 图标 | `scripts/extract-pc-icons.py` 生成 `src/icons/icon-data.ts`(37 个,内网离线可用);**图标集升级后必须重跑**,否则新图标是空占位且不报错 |
|
||
| 主题 | 主题配置在 `src/utils/naive.ts` 一侧;`src/styles/index.scss` 只放跨页面样式 |
|