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:
@@ -97,7 +97,11 @@ python scripts/e2e-m3-file.py # 单跑一个;前提是后端 8080 在跑
|
||||
- 改了后端**忘了重新打包/重启**,测试会对着旧 jar 通过,这是最容易踩的空转
|
||||
- 跑完 `e2e-m3-*` 会**清掉名册绑定**(`collect_roster.bind_user_id`);
|
||||
想继续用浏览器验演示态,要先重新绑定(见交接摘要的"演示态"一节)
|
||||
- 清理残留:`python scripts/dev-clean-e2e.py`、`python scripts/dev-clean-test-users.py`
|
||||
- 清理残留:`python scripts/dev-clean-e2e.py`(**默认 dry-run**;
|
||||
`--only-orphan-files --apply` 只清磁盘孤儿文件 —— 压测跑几轮就能攒下几百 MB)、
|
||||
`python scripts/dev-clean-test-users.py`
|
||||
⚠️ `--apply` 会连 e2e 租户与"E2E 24数控1班…"这类演示组织一起删,**当前演示态正挂在其中一个班下面**,
|
||||
所以要清先确认演示态已迁到正式组织名下
|
||||
- 文档一致性:`python scripts/verify-docs.py`
|
||||
|
||||
## 四、接口与数据契约(不显然、容易做错)
|
||||
|
||||
+9
-2
@@ -217,7 +217,7 @@ B1 选 A,但希望增加审批环节
|
||||
|---|---|---|---|
|
||||
| D1 | OCR 引擎选型 | 待比价(腾讯云 / 百度 / 本地模型) | 需同时评估**数据出境合规** |
|
||||
| D2 | 视觉大模型(VLM)选型 | 抽成**可替换接口**,不绑定供应商 | 实测触发率仅 2%~10%,成本可控 |
|
||||
| ~~D3~~ | ~~门禁限流阈值~~ | ⚠️ **口径已更新,见 E2**:改为"**只对失败计数**"(按人 10 次/小时、按 IP 20 次/分钟、成功侧 200 次/分钟)—— 旧口径会把整间教室挡住 | 压测后已改(2026-09-30) |
|
||||
| ~~D3~~ | ~~门禁限流阈值~~ | ⚠️ **口径已更新,见 E2**:改为"**只对失败计数**"(按人 10 次/小时、按 IP 20 次/分钟;成功侧 600 次/分钟,2026-09-30 二次压测后由 200 上调)—— 旧口径会把整间教室挡住 | 压测后已改(2026-09-30,两次) |
|
||||
| D4 | 材料识别强特征词表 | 平台预置 + 租户可自维护 | **需在"社会评价"批次数据上补充标定** |
|
||||
| D5 | 一寸照片形态判定阈值 | 用真实样本标定(宽高比容差、背景纯净度) | — |
|
||||
| D6 | 微信三条来源通道的体积上限 | 需真机实测 | 决定超限提示与降级路径 |
|
||||
@@ -267,7 +267,7 @@ B1 选 A,但希望增加审批环节
|
||||
|---|---|---|---|
|
||||
| 失败(按人) | **只计失败** | 10 次/小时 | 防猜 |
|
||||
| 失败(按 IP) | **只计失败** | 20 次/分钟 | 防枚举 |
|
||||
| 成功(按 IP) | 只计成功 | 200 次/分钟 | 防"批量取数据" |
|
||||
| 成功(按 IP) | 只计成功 | **600 次/分钟**(2026-09-30 由 200 上调,见下) | 防"批量取数据" |
|
||||
|
||||
**为什么必须改**:一个班的学生坐在同一间教室、连同一个 WiFi,出口是同一个公网 IP。
|
||||
按旧口径,60 人同时扫码时**只有 20 个能进**,其余 40 个看到「操作过于频繁」——
|
||||
@@ -275,6 +275,13 @@ B1 选 A,但希望增加审批环节
|
||||
|
||||
关键区分:**枚举攻击产生的是失败,教室产生的是成功。**
|
||||
|
||||
> **2026-09-30 二次修正(备案,无需回复)**:成功侧上限原定 200 次/分钟,
|
||||
> 实测**恰好卡在"一个年级"的量级** —— 250 个不同学生同时核验(全部正确)时
|
||||
> **213 个成功、37 个被挡**。全校在同一个出口 IP 集中填报(开学第一课集体扫码)
|
||||
> 就会撞上,而**没有人做错了什么**。已上调为 **600 次/分钟**,
|
||||
> 复测 250 人 **250/250**、500 人 **500/500**;失败侧两个计数器原样未动
|
||||
> (防枚举的强度没有降低)。详见《09-部署与运维》§9.3。
|
||||
|
||||
### E3. 二期(社会收集 / 社会评价 / 专项职业能力)的启动时点
|
||||
|
||||
一期范围已按约定锁死为「学校收集 + 自主认定」(其他模式一律返回 `40044`)。
|
||||
|
||||
+22
-2
@@ -174,11 +174,27 @@
|
||||
所以它必须以命令的形式存在,不能以表格的形式存在 —— 见 §九。
|
||||
- **建议**:在 03 文档**新增一节「§17 实现补充与偏差记录」**,逐条列出这些接口(路径 / 权限 / 是否要补进 §4 清单),并在 §4 的合计口径里说明"121 是设计口径,另有 N 个实现补充"。改动后必须重跑 `python scripts/verify-docs.py`(它逐节核对接口数,加节时注意别改动 §4 的声称数)。
|
||||
|
||||
### F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析"
|
||||
### F-5【P2 · 数据卫生】E2E 租户残留到"每个请求多一次解析" —— **清理工具已补齐(2026-09-30)**
|
||||
|
||||
- **现象**:`sys_tenant` 17 行、`sys_user` 300 行,而脚本每次跑都会建测试租户,清理只删自己建的那一个。
|
||||
⚠️ 现在回头看,这个数字被低估了:跑了几十轮之后是 **48 个 `e2e*` 租户 / 709 个用户 /
|
||||
144 个挂在 demo 租户下的测试组织 / 82 个【演示·旧】任务**。
|
||||
- **影响**:本地演示/截图时列表里全是 `e2e-*` 名字;租户域名解析与登录"跨单位重名"分支被无意义数据搅扰。
|
||||
- **建议**:`scripts/dev-clean-e2e.py` 增加一节"按名字前缀删测试租户与账号"(默认 dry-run,`--yes` 才真删),并在 `AGENTS.md` 里把"跑完 E2E 顺手清一次"写成固定动作。
|
||||
- **真正的窟窿(这一轮才看清)**:**删库不删盘**。`backend/data/uploads` 里 **281 个文件 / 276 MB**,
|
||||
而库里只剩 16 行文件 —— 压测与 E2E 都只删自己的库行。
|
||||
文件的归属**只有库知道**,所以这件事必须由"拿库做对照"的工具来收,不能靠 `rm -rf`。
|
||||
- **已做**(`scripts/dev-clean-e2e.py`,**默认 dry-run**):
|
||||
① `--only-orphan-files --apply`:删**没有任何库行指向**的磁盘文件 —— **已执行,255 个 / 275.4 MB,
|
||||
uploads 从 276 MB 降到 0.3 MB**;
|
||||
② `--apply`:清 e2e 租户 + 测试组织 + **它们的业务行**(业务表按 `information_schema` 动态枚举,
|
||||
以后新增业务表不会漏);③ `--demo-tasks`:清【演示·旧】任务及其子表与文件。
|
||||
- ⚠️ **后两项我没有执行**,原因具体:**当前演示态正挂在其中一个"E2E 24数控1班 645451"组织上**
|
||||
(就是导出目录里那个班级名),删了组织等于把演示态弄坏。所以:
|
||||
要清就先确认演示态迁到正式组织名下,再跑 `--apply`。
|
||||
我也因此把 `--demo-tasks` 的匹配**收窄到只带「旧」的那批** ——
|
||||
`【演示】审核台` 是当前在用的,原来那句 `LIKE '【演示】%'` 会把它一起删掉,
|
||||
而这种错在预览里只表现为"83 个"(比 82 多一个),**极容易被放过**。
|
||||
- **建议**:把 `--only-orphan-files --apply` 写进"跑完压测/E2E 的收尾动作"(见 `AGENTS.md` 第三节)。
|
||||
|
||||
### F-6【P2 · 文档误导】`frontend-pc/AGENTS.md` 里的 Mock 说明已过时 —— **已修(2026-09-30)**
|
||||
|
||||
@@ -411,6 +427,8 @@
|
||||
| F-96 | ⚠️ **本轮我连续犯了三个"没断言就往下走"的错**,形态不同、根因相同:① **文本补丁静默没生效**:`str.replace` 没命中时不报错、不返回失败,脚本照样打印"成功",而文件一个字没改 —— 随后的编译错("方法不存在")看起来像另一个问题;② **夹具构造失败却继续往下走**:`with_checksum` 传错位数后,失败发生在 300 行之外的名册导入报告里("当前 19 位");③ **`R.call` 的返回值没查 `code`**:建草稿失败时 `data` 是 `null`,报的是 `TypeError: 'NoneType' object is not subscriptable`,真正原因一个字都没打 | ①改成"每处替换都断言命中"(`must_replace`),并**回读校验**;②把 helper 改成**参数不合法就抛**(治本,前两次我只改调用点);③每个 `R.call` 先查 `code` 并把 `msg` 带进断言信息。**根因是同一条:把"我以为发生了"当成"确实发生了"** —— 而这三处的共同代价是**把排查引向错误的方向**(比直接报错贵得多) |
|
||||
| F-97 | ⚠️ **我断言错了字段名,于是"缩略图功能"看起来没生效**:`FileUrl` 有 `url`(原图)与 `thumbUrl`(缩略图)两个字段,而我的验证脚本去检查 `d2["url"]` 里有没有 `imageView2` —— 按设计它**永远是原图链接**,所以那个断言必然为 false;更糟的是它连着把后面两条断言一起带错了(下载的是原图,于是"没变小") | 改成检查 `thumbUrl`,并**加一条反向断言**:`url` 不该带处理参数、`thumbUrl` 该带。⚠️ 这与 F-77(用错 CSS 选择器,差点修一个不存在的 bug)**是同一类**:**断言之前先确认"你在量的那个东西是对的"**。这次的隐蔽之处是字段名很像(`url` vs `thumbUrl`),而且接口确实返回 200、数据确实存在 —— 一切看起来都正常,只有断言是错的 |
|
||||
| F-98 | ⚠️ **"随机端口"这个防撞设计带来一个反直觉的副作用**:`check-test-profile.py` 每次取**随机空闲端口**,于是上一次失败留下的 JVM **永远撞不到**这个端口、也就永远不会被发现 —— 它会一直占着内存与 CPU,直到某一次"怎么都起不来"(实测:连跑几轮后本机攒了 5~7 个 java,启动从 10 秒变成 180 秒超时;而**下一次跑又好了**,这正是"时好时坏"的来源,很容易被归因成"配置有问题")。同时那次失败**没法排查**:子进程输出走 `PIPE` 而没人读,事后才读又已被 kill 截断,最后只剩一句"180 秒内没探活成功" | ①开跑前**按 jar + profile 清残留**(只杀本项目的 jar,且**保留 dev 后端** —— E2E 正打着它,顺手杀掉等于把后面的检查全废掉);②子进程输出**落盘**(`backend/_test-profile.txt`,已被 `.gitignore` 的 `backend/_*.txt` 覆盖),失败时打印"停在哪一行 + 关键字命中 + 尾部 20 行",成功时打印**启动用时**(基线 ~10 秒 / 72 行 / 9.5KB,一旦变成几十秒先怀疑本机负载);③等待期间每 15 秒报一次进度,**卡住时不留沉默**。**教训:给"防撞"加一层机制时,要问一句"那它把什么东西藏起来了"** |
|
||||
| F-99 | ⚠️ **补测"没测过的场景"时,压测工具自己连续撒了 8 次谎** —— 它们的共同点是"**度量/夹具出问题时,现象长得像产品缺陷**"。① **夹具前置没断言**:单独跑 `--only upload` 时 40 张图**全部**报「没有草稿」—— 因为上传需要先经门禁绑定,而以前它总是跟在门禁场景后面跑、"恰好"能跑;② **假 JPEG**:夹具图是"JPEG 头 + 随机字节",能过扩展名与大小校验,但服务端**解码即失败**,走的根本不是压缩路径 —— 换上真图后立刻量到 2850KB → 1329KB(**47%**);③ **猜列名**:`collect_export_file` 的列是 `part_no / file_name / file_path`,我按直觉写成 `part / name / path`,SQL 直接报错;④ **判据取错返回值**:`upload_file` 返回的是 `(HTTP 码, body)`,我拿 HTTP 码当业务判据,把 `code: 0` 的成功报成"串行基线失败"(与 F-84 / F-96 同一类,**第三次**);⑤ **跨租户同名账号**:`select id from sys_user where username='admin'` 命中的是**别人家**的 admin(库里有 50 多个同名),于是"初审人"被设成跨租户用户 id —— 正确做法是**从 token 的 `sub` 取 uid**;⑥ **不查接口返回码**:`PUT /tasks/{id}/orgs` 被服务端拒(`headUserId` 必须是**真持班主任角色**的账号)而我没查,"发布"失败也没查,最后学生端整齐地报 `40008 任务已截止、已归档或已暂停`,排查方向整个错了;⑦ **PowerShell `Select-Object -First N` 会提前停止读取上游**:被截断的进程**仍然活着**,看起来就是"卡住",我为此白等了十几分钟 —— 而外层工具又把输出缓冲到命令结束,于是"慢"与"被堵"根本分不开;⑧ **清理只删库行不删磁盘文件**:压测跑几轮就在 `backend/data/uploads` 攒下几百 MB | ①在上传/导出场景里**显式绑定 + 断言前置**(不成立就报"前置不成立",而不是"上传失败");②`make_img` 改用 Pillow 生成**真 JPEG**(噪声图,纯色量不出 CPU 成本);③按 DDL 写列名;④改判 `body["code"]`;⑤新增 `uid_of(token)`(解 JWT 的 `sub`);⑥新增 `call_ok()`:夹具里每个接口调用**都断言业务码为 0**;⑦长跑/压测/起服务**一律把日志落盘**,不用管道截断(与 F-98 同一个手段);⑧`cleanup()` 先取出文件路径再删行,**连磁盘文件一起删**(存储是七牛时那些路径本地不存在,是安全的 no-op)。**教训:补测的价值一半在数字,另一半在"原来夹具一直不成立"** —— 而后者只有在**断言前置**之后才会暴露 |
|
||||
| F-100 | ⚠️ **门禁"成功侧上限"卡在年级量级,会挡住全校集中填报**(F-92 的第二次复发,量级更大):契约 §1.6 只规定**失败侧**(同 openid 10 次/小时、同 IP 20 次/分钟),成功侧 200 次/分钟是本项目自己加的(防"拿匹配接口批量取数据"),而当初注释里写的本意是"一个 200 人的年级同时扫码也不该被挡"。实测 **250 个不同学生同时核验(全部正确)→ 213 成功、37 被挡**,提示「当前网络访问过于集中,请稍等 1 分钟再试」。"全校在同一个出口 IP 集中扫码"(开学第一课集体填报)正是会发生的场景,而**这些人没有一个做错了什么** —— 与 F-92「教室被挡住」是同一类,只是从"一个班"放大到"一所学校" | 上调 `IP_OK_LIMIT_PER_MINUTE` **200 → 600**(依据:一所学校一次集中填报的峰值;它仍然是限速,防枚举靠的是失败侧那两个计数器,**原样未动**)。**复测**:250 人 **250/250**、500 人 **500/500**(500 并发 p50 1.4s / p95 2.5s,是排队不是限流),`e2e-m3-gate.py` 仍 **29/29**。已在 `MpRosterService` 常量注释里写明依据与"改之前先量"的命令,并同步 `docs/01` E2、`docs/09` §9.3。**教训:自己加的"防御性限额"也要过一遍真实场景的量级 —— 否则它与它要防的东西一样,都是事故** |
|
||||
|
||||
---
|
||||
|
||||
@@ -514,6 +532,8 @@ M7 收尾 52 人日、M5 剩余前端约 31 人日。**关键路径仍是"审核
|
||||
| 2026-09-30 | **缩略图(P2-7)+ H5 独立 dev server(P2-9)**:`StorageService.thumbUrl` / `FileUrl.thumbUrl` / 审核端 `?thumb=true`;`frontend-mp` 加 `/api` 代理与 `VITE_API_BASE`;新增 `scripts/e2e-m9-thumb.py`(12 条,打到真实七牛);**同步 AGENTS.md 里"H5 没有独立 dev server"那句过期说明**;新增 F-96(**三个"没断言就往下走"**:补丁静默无效 / 夹具静默失败 /不查 code)/F-97(**断言错了字段名**,与 F-77 同类) |
|
||||
| 2026-09-30 | **对外待决事项归拢 + 一键交付自检**:`docs/01` 新增 **E 组·第二轮待确认**(E1 `union_id` 是"H5 与小程序不能同时上线"的阻断项,E2 D3 限流口径更新、E3 二期时点,D3 那行划掉);§四 的每条卡点补上"**找谁 / 编号**";交接摘要新增 §一·二十八(坐标刷新 + 这 20 多轮最该记住的 7 条);新增 `scripts/verify-all.py`(一条命令跑完交付自检,**跳过项如实列出、不算失败**)。全量回归 **36 脚本 / 1548 断言 / 0 失败** |
|
||||
| 2026-09-30 | **文档小账清理 + `check-test-profile` 加固**:① **附录 A 改为"可再生成"** —— `check-api-parity.py` 加 `--extra` 现算"代码有、契约 §4 没有"的接口(**41 个**;旧手抄表写 83/62/21,已差近一倍),并按"谁去改什么"分六类(只有 E 类要客户定口径);② **F-6 已修**:`frontend-pc/AGENTS.md` 的 Mock 说明改正,**并连带修 `.agents/skills/project-api/SKILL.md`**(整篇仍以 `vite-plugin-mock` 为主线,而 `mock/` 目录与插件都已删除 —— 只改指路牌不改路的尽头等于没改);③ `docs/03` 给 **§2 `notifyBizType` 的 5/6** 与 **§3.6/§9.5 的 `source`** 加 ⚠️ 注(**不改口径**,只把文档内部冲突摆明 + 指出实现以哪边为准,待客户确认);④ `check-test-profile.py` 改为**日志落盘 + 按 jar/profile 清残留 JVM** —— 随机端口让残留**永远撞不到端口**,于是静静攒着,直到某次"怎么都起不来"(新增 F-98) |
|
||||
| 2026-09-30 | **补测 `docs/09` §9.5 的"没测过"项(上传并发 / 名册导入规模 / 导出大包 / 长跑稳定性)**,`scripts/load-test.py` 加 `--base` / `--fixture-only` / `--files-per-user` / `--split-mb` / `--soak` / `--rps`,上传夹具换成**真 JPEG**,并新增 `export-big` 场景(造 N 份已通过的单 → 打包 → 逐卷验证 ZIP)。结果:**上传 20 并发 40/40**(串行 321ms / 并发 p50 620ms、p95 801ms;2850KB → 1329KB = **47%**)、**5000 行名册导入 20.2s**(RSS 782MB)、**导出 150 人 194.6MB 打包 13.2s**、**分卷 4 卷每卷可单独解开**、**长跑 60 分钟**(RSS / PG 连接数趋势见 `docs/09` §9.6)。**同一轮里压测工具自己撒了 8 次谎**(前置未断言 / 假 JPEG / 猜列名 / 判据取错返回值 / 跨租户同名账号 / 不查返回码 / 管道截断 / 清理不删文件)→ 新增 **F-99**,并把结论写进 `docs/09` §9.1 / §9.4 / §9.5 / §9.6 |
|
||||
| 2026-09-30 | **长跑稳定性测完 + 门禁成功上限 200→600(F-100)+ 磁盘清理(F-5 落地)**:① 长跑 51 个样本 / 15300 请求 / **0 失败**,RSS 前 1/3 均值 603.9MB → 后 1/3 582.9MB(**-3.5% 平稳**)、PG 连接 17.0 → 6.2(**定时任务高峰后正常回落**)、p95 62ms 纹丝不动 → 写进 `docs/09` §9.5.4;② **门禁成功侧上限实测卡在年级量级**(250 人 → 37 被挡)→ 上调 600,复测 250/250、500/500,`e2e-m3-gate` 29/29(**F-100**),同步 `docs/01` E2、`docs/09` §9.3;③ `dev-clean-e2e.py` 扩展(`--demo-tasks` / `--orphan-files` / `--only-orphan-files`,业务表按 `information_schema` 动态枚举),**已清磁盘孤儿文件 255 个 / 275.4MB**(uploads 276MB → 0.3MB);e2e 租户与"E2E 24数控1班"组织**刻意不删**(当前演示态挂在其中一个班下面,已记录原因)——问题清单累计 **F-1~F-100** |
|
||||
|
||||
|
||||
|
||||
|
||||
+143
-16
@@ -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 连接数是否仍然平稳 |
|
||||
|
||||
+42
-1
@@ -2286,7 +2286,7 @@ M1 规则模板与任务克隆 → M2 报考范围与名册改删 → M3 提交
|
||||
**§14 开放接口(认定机构对接,HMAC)** → 小程序登录与组织导入 → 工作台数据看板 →
|
||||
CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略图与 H5 dev server。
|
||||
|
||||
**每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 98 条编号问题(F-1~F-98)。
|
||||
**每一轮的"做了什么 / 发现了什么"都在 docs/08 §六**,包含 100 条编号问题(F-1~F-100)。
|
||||
|
||||
### 这 20 多轮里最该记住的几条
|
||||
|
||||
@@ -2379,3 +2379,44 @@ CI/CD 与 test/prod 环境 → 压测 → 用户手册与客服话术 → 缩略
|
||||
`docs/08` §四 剩下的**全部是外部依赖**(OCR 选型 / 小程序类目 / `union_id` / 真机 / 二期),
|
||||
另有 4 项"**没测过**":导出大包打包、上传并发、5000 行名册导入、长跑稳定性
|
||||
(`docs/09` §9.5 逐条写明"没测就是没测")。
|
||||
|
||||
---
|
||||
|
||||
## §一·三十 把"没测过的四项"测完 + 门禁上限二次修正(2026-09-30 上午)
|
||||
|
||||
> §一·二十九 里那 4 项"没测过",这一轮全部变成了"测过",数字都在 `docs/09` §9.5。
|
||||
> 顺带抓到一个会挡住**一所学校**的真缺陷(F-100),并把本地磁盘的糊涂账清了(F-5 落地)。
|
||||
|
||||
### 一、四项补测的结果(`docs/09` §9.5 有复现命令)
|
||||
|
||||
| 场景 | 结果 |
|
||||
|---|---|
|
||||
| 上传并发(真 JPEG 2850KB / 20 并发) | **40/40 成功**;**串行单请求 321ms**(压到 **47%**);并发 p50 620ms / p95 801ms —— 差值是排队,压缩是单线程 CPU 活 |
|
||||
| 名册导入 5000 行(PRD 上限) | **20.2s**("造 Excel 0.7s"是压测脚本自己的成本,已分开报);RSS 782MB |
|
||||
| 导出大包 150 人 / 194.6MB | **打包墙钟 13.2s**(≈15MB/s,JPEG 压不动 → 瓶颈是磁盘 I/O);强制分卷 → **4 卷且每卷都能单独解开**,汇总表在第 1 卷根 |
|
||||
| 长跑 60 分钟(5 请求/秒 / 名册 2000 行) | 51 样本 / **15300 请求 / 0 失败**;RSS **603.9 → 582.9MB(平稳)**;PG 连接 17.0 → 6.2(定时任务高峰后正常回落);p95 62ms 不变。**无泄漏迹象** |
|
||||
|
||||
### 二、门禁成功上限 200 → 600(F-100,F-92 的第二次复发)
|
||||
|
||||
- 契约 §1.6 只规定**失败侧**;成功侧 200/分钟是本项目自己加的,而它**恰好卡在"一个年级"**:
|
||||
实测 **250 个不同学生同时核验 → 213 成功、37 被挡**(「当前网络访问过于集中」)。
|
||||
全校在同一出口 IP 集中扫码(开学第一课集体填报)就会撞上,**而没有人做错了什么**。
|
||||
- 已上调 600,复测 **250/250、500/500**,`e2e-m3-gate` 仍 **29/29**;
|
||||
**失败侧两个计数器原样未动**(防枚举强度没有降低)。同步 `docs/01` E2 与 `docs/09` §9.3。
|
||||
|
||||
### 三、磁盘的糊涂账清了(F-5 落地)
|
||||
|
||||
`backend/data/uploads` 里 **281 个文件 / 276MB,而库里只剩 16 行** —— 压测与 E2E 都只删库行。
|
||||
`dev-clean-e2e.py` 新增 `--orphan-files` / `--only-orphan-files` / `--demo-tasks`
|
||||
(业务表按 `information_schema` **动态枚举**,以后加表不会漏),已清 **255 个 / 275.4MB**。
|
||||
⚠️ e2e 租户与"E2E 24数控1班"组织**刻意没删**:当前演示态正挂在其中一个班下面,
|
||||
删了会把演示态弄坏 —— 要清先确认演示态已迁到正式组织(预览:`python scripts/dev-clean-e2e.py`)。
|
||||
|
||||
### 四、这轮最该记住的两条
|
||||
|
||||
1. **补测的价值一半在数字,另一半在"原来夹具一直不成立"**(F-99,压测工具自己撒了 8 次谎:
|
||||
前置未断言 / 假 JPEG / 猜列名 / 判据取错返回值 / 跨租户同名账号 / 不查返回码 /
|
||||
PowerShell `-First` 截管道 / 清理不删文件)—— 其中"跨租户同名 admin"与"HTTP 码当业务码"
|
||||
都是 F-84/F-96 的同类,**第三次出现**。
|
||||
2. **自己加的防御性限额也要过一遍真实场景的量级**(F-100)——
|
||||
否则它与它要防的东西一样,都是事故。
|
||||
|
||||
Reference in New Issue
Block a user