perf(gate): 门禁成功上限 200→600 —— 250 人同时核验曾挡住 37 个(F-100)

契约 §1.6 只规定了失败侧(同 openid 10 次/小时、同 IP 20 次/分钟),
成功侧 200/分钟是本项目自己加的(防"拿匹配接口批量取数据"),
而当初注释里写的本意是"一个 200 人的年级同时扫码也不该被挡"。

实测(python scripts/load-test.py --only gate --rows 250):
250 个不同学生同时核验(全部正确)→ 213 成功、37 被挡,
提示「当前网络访问过于集中,请稍等 1 分钟再试」。
"全校在同一个出口 IP 集中扫码"(开学第一课集体填报)正是会发生的场景,
而这些人没有一个做错了什么 —— 与 F-92「教室被挡住」同一类,量级放大到一所学校。

上调为 600 的依据:一所学校一次集中填报的峰值。它仍然是限速(600 条/分钟),
而防枚举靠的是失败侧那两个计数器 —— 原样未动,防猜测强度没有降低。

验证:
  250 人 → 250/250(改前 213/37)
  500 人 → 500/500(500 并发 p50 1.4s / p95 2.5s,是排队不是限流)
  scripts/e2e-m3-gate.py → 29/29
文档同步:docs/01 E2(二次修正备案)、docs/09 §9.3 / §9.1。

教训:自己加的"防御性限额"也要过一遍真实场景的量级 ——
否则它与它要防的东西一样,都是事故。
This commit is contained in:
2026-09-30 11:10:05 +08:00
parent db6a51d617
commit f86a999c52
@@ -67,12 +67,25 @@ public class MpRosterService {
private static final int IP_FAIL_LIMIT_PER_MINUTE = 20; private static final int IP_FAIL_LIMIT_PER_MINUTE = 20;
/** /**
* 同一个 IP 每分钟最多**成功** 200 次。 * 同一个 IP 每分钟最多**成功** 600 次。
* *
* <p>成功会返回该学生的姓名与预填数据,所以要防"拿这个接口批量取数据"。 * <p>成功会返回该学生的姓名与预填数据,所以要防"拿这个接口批量取数据"。
* 但限额必须给教室规模留足量:**一个 200 人的年级同时扫码也不该被挡**。 * 但限额必须给**一所学校**留足量。
*
* <p>⚠️ <b>200 → 600 是压测改出来的(2026-09-30)</b>:契约 §1.6 只规定了失败侧
* (同 openid 10 次/小时、同 IP 20 次/分钟),这个成功侧上限是本项目自己加的,
* 而原来定的 200 恰好卡在"一个年级"的量级上 —— 实测 **250 个不同学生同时核验
* (全部正确)时 213 个成功、37 个被挡**,提示是「当前网络访问过于集中」。
* 全校在同一个出口 IP 集中填报(开学第一课集体扫码)就会撞上它,
* 而这些人**没有一个做错了什么**(与 §9.3 那次"教室被挡住"同一类)。
*
* <p>600 的依据:一所学校一次集中填报的峰值人数。它仍然是一个**限速**——
* 抓数据的人拿到手的是 600 条/分钟而不是不限速;而防猜测靠的是失败侧那两个计数器
* (枚举产生的是失败,不是成功)。
*
* <p>调这个数之前先量:`python scripts/load-test.py --only gate --rows 500`。
*/ */
private static final int IP_OK_LIMIT_PER_MINUTE = 200; private static final int IP_OK_LIMIT_PER_MINUTE = 600;
private static final String KEY_USER = "xt:mp:gate:user:"; private static final String KEY_USER = "xt:mp:gate:user:";
private static final String KEY_IP = "xt:mp:gate:ip:"; private static final String KEY_IP = "xt:mp:gate:ip:";