docs: 补测结论回填与限流口径同步(docs/01 E2 / docs/08 F-99·F-100 / docs/09 §9 / 交接摘要 §一·三十)

- docs/09 §9 重排为 9.1 结果 / 9.2 结论 / 9.3 门禁两次修正 / 9.4 怎么跑 /
  9.5 补测记录(上传并发、5000 行导入、导出大包、长跑)/ 9.6 仍然没测的;
  补记机器规格(12 物理核 / 59.6GB,全在同一台机器上)。
- docs/01 E2 二次修正备案:成功侧 200→600,附实测证据。
- docs/08:F-99(压测工具自己撒的 8 次谎)、F-100(门禁成功上限卡年级量级)、
  F-5/F-6 核销与更新,变更记录补 3 行;问题清单到 F-1~F-100。
- 交接摘要:§一·二十九(提交入库+文档小账)之后补 §一·三十(四项补测+门禁二次修正+磁盘清理)。
- AGENTS.md:清理残留一节写清 dry-run 默认与「演示态挂在 E2E 班下面」的风险。

验证:python scripts/verify-all.py --quick → 通过 5 / 失败 0
This commit is contained in:
2026-09-30 11:10:06 +08:00
parent 601714c476
commit 28dedb4c16
5 changed files with 221 additions and 22 deletions
+143 -16
View File
@@ -257,23 +257,31 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
## §9 压测记录(M7-09)
脚本:`python scripts/load-test.py [--rows N] [--only gate|read|upload|export] [--with-upload]`
脚本:`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 行上是两件事),
所以报告里每个数字都带规模。
### 9.1 结果(规模:名册 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** |
| **导出** | 前置检查正确拦住(`40035`,见 9.4);大包打包**未测**(需要真实审批过的数据) |
| **材料上传** | **默认不跑**(见 9.4) |
| **材料上传**(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 不是瓶颈
@@ -287,6 +295,10 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
所以并发下的 p50 156~375ms 是**排队效应**(20 个请求抢同一台机器的 CPU 与网络),
不是单请求成本。**要提吞吐得先加机器/拆进程,而不是改 SQL。**
同一条道理用在上传上:串行 321ms vs 20 并发 p50 620ms ——
差值就是排队。**压缩本身是 CPU 密集的单线程活**,所以"上传慢"的解法是加 CPU / 限并发,
不是改上传接口。
### 9.3 ⚠️ 门禁限流曾经会挡住整间教室(已修)
压测第一次跑出来的是:60 个**不同**学生同时核验(全部正确),
@@ -303,7 +315,7 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
|---|---|---|---|
| 失败(按人) | 只计失败 | 防**猜** | 10 次/小时 |
| 失败(按 IP) | 只计失败 | 防**枚举** | 20 次/分钟 |
| 成功(按 IP) | 只计成功 | 防"批量取数据" | 200 次/分钟 |
| 成功(按 IP) | 只计成功 | 防"批量取数据" | **600 次/分钟**(2026-09-30 由 200 上调,见下) |
关键洞见:**枚举攻击产生的是失败,而教室产生的是成功。**
把成功也算进防枚举的额度里,等于用防攻击的代价挡住正常用户 ——
@@ -313,23 +325,138 @@ artifacts 路径、以及**所有 shell 片段的语法**(开发机是 Windows
开学第一天几十人同时扫码必然出现几例,把它算进额度里会让整间教室在几秒内被限流,
**而没有一个人做错了什么**。
### 9.4 两条使用注意
⚠️ **成功上限经历过一次实测修正(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` 量一遍。
- **上传压测默认关闭**(`--with-upload` 才跑):本机 `application-local.yml` 里
`provider: qiniu`,所以上传落的是**线上那个空间**(bucket=tools-matrix)。
一次压测 40 张图打过去,七牛回了 `status:614`。两件事都不对:
### 9.4 压测怎么跑(两条使用注意)
- **上传压测默认关闭**(`--with-upload` 才跑),因为本机 `application-local.yml` 里
`provider: qiniu`,上传落的是**线上那个空间**(bucket=tools-matrix):
一次压测 40 张图打过去,七牛回了 `status:614`。两件事都不对 ——
压测不该依赖外部服务(网络抖动会让"性能结论"变成对七牛的评价),
也不该往真实桶里灌测试文件(删库不删桶)。
**要跑就先把存储切成本地**:`XT_STORAGE_PROVIDER=local` 重启后端。
也不该往真实桶里灌测试文件(**删库不删桶**)。
**正确跑法(推荐)**:另起一个用本地存储的实例,把压测打过去,不碰线上桶:
```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 还没测的
### 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 仍然没测的
| 场景 | 为什么没测 | 怎么测 |
|---|---|---|
| 导出**大包打包**(几百人 / 1GB+) | 需要真实审批过的数据,而造那种数据要跑完整审核链路 | 用 staging 的真实批次,量 `ExportService` 的打包耗时与内存峰值 |
| 上传并发(CPU 密集的压缩) | 见 9.4(会打到外部存储) | 切本地存储后 `--with-upload` |
| 5000 人名册(PRD 提到的导入上限) | 导入本身是同步的,压测里造 5000 行要 ~20s | `--rows 5000`,重点是**导入**耗时与内存 |
| 长时间稳定性(内存泄漏、连接泄漏) | 一次性压测测不出来 | 持续 1~2 小时的低强度压测,看 RSS 与 Hikari 活跃连接数是否单调上升 |
| **真机上的上传**(微信小程序 + 手机相册) | 需要真机与小程序资质 | 见 `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 连接数是否仍然平稳 |