# KECE 评测机分池容量基线、灰度切换与只读对账报告 本报告保留 2026-11-07 的本地容量基线及其测量口径,并给出当前实现的上线与回滚门禁。生产切流记录应由实际部署人员填入运行版本、时间和只读证据。 ## 1. 上线前的检查与回滚门禁 ### 1.1 准入条件 1. 在独立环境演练 Flyway V26~V31 和制品目录备份。节点登记了 HTTPS 探测地址时,升级后重新自检。 2. 从管理员 API 按 `ALGO` 和 `DATABASE` 分别回填历史用例清单。每次最多 20 道。记录 `nextAfterId`,按 `more` 继续,对 `failures` 中的题目修复用例后重跑;最终从 `afterId=0` 复核剩余任务。保留回填失败和无法发布的空用例题目清单。新链路要求提交绑定非空清单。 ```bash curl --fail-with-body -X POST "http://127.0.0.1:8080/api/v1/admin/judging/manifests/backfill?type=ALGO&afterId=0&limit=20" \ -H "Authorization: Bearer $ADMIN_TOKEN" ``` 3. 在目标机器按[节点手册](../deploy/judger/README.md)准备五池,记录每个节点的运行时版本、镜像摘要、宿主机总槽位和自检 ID。用真实题目验证各池接单,并检查公网入口对机器 API 的阻断。 4. 发布单须写入当前包和已验证的回滚包的不可变版本及校验值,包含后端、worker 和相容的数据库迁移范围。未登记可复现回滚包时暂停发布。 ### 1.2 旧系统切到持久队列 从旧 Redis 评测系统迁移时,安排维护窗口并暂停新提交。保留旧 worker 消费到队列排空,核对旧编程/SQL 队列、旧结果队列以及业务提交的非终态计数。在切换运行版本前保存记录。保留历史终态结果和审计记录。 在旧 Redis 实例上只读核对这四个键,四项都应返回 `0`。连接参数以目标环境为准。 ```bash for key in judge:queue judge:sql:queue judge:results judge:sql:results; do printf '%s ' "$key" redis-cli LLEN "$key" done ``` 当前 PR-10 版本全部走持久链路,`PUT /api/v1/admin/judge-routes/{pool}/{scene}` 只更新持久路由的 revision。真正逐池切流需要能同时运行两套链路的已验证兼容包。记录每次切流的 pool、scene、route revision、node 版本、job ID 范围和对账输出。 ### 1.3 当前版本回滚 1. 先暂停新提交,保持评测节点可领单,直到队列与结算事件归零。 2. 使用以下门禁保存证据。它要求全量不变量通过、没有 `QUEUED` / `RUNNING` 任务、结算事件全部 `DONE`。 ```bash python scripts/reconcile-judge.py --url http://127.0.0.1:8080/api/v1 --require-drained --output pre-rollback.json ``` 3. 排空后将节点置为 `DRAINING` 再停机,部署发布单中已验证的回滚包,检查控制面与业务 API 后恢复写入。若新任务无法排空,保留持久任务、清单和制品,先恢复能处理这些任务的节点;禁止复制到旧 Redis 队列或将任务人工标记完成。 --- ## 2. 历史只读对账与 6 大核心不变量 ### 2.1 核心不变量定义 | 编号 | 不变量名称 | 校验规则 | 实测违规数 | | :---: | :--- | :--- | :---: | | **I-1** | **业务指针一致性** (`orphanPointers`) | `submission.current_judge_job_id` / `db_submission.current_judge_job_id` 必须指向存在的 `judge_job`,且 `(submission_type, submission_id)` 双向吻合 | **0** | | **I-2** | **单活动尝试排他** (`duplicateRunningAttempts`) | 任意 `judge_job` 在同一时刻最多只能有 1 条 `RUNNING` 状态的 `judge_job_attempt` | **0** | | **I-3** | **终态收敛一致性** (`unconvergedCompletedJobs`) | 状态为 `COMPLETED` 的 `judge_job`,其关联业务提交不得停留在 `PENDING`/`JUDGING` 等非终态 | **0** | | **I-4** | **失败终态一致性** (`mismatchedFailedJobs`) | 状态为 `FAILED` 的 `judge_job`,其关联业务提交必须收敛为 `SYSTEM_ERROR` | **0** | | **I-5** | **节点与宿主机容量隔离** (`capacityViolations`) | 节点当前 `RUNNING` 任务数不得超过 `node.max_slots`,宿主机组汇总运行槽位数不得超过 `host_group.max_slots` | **0** | | **I-6** | **结算事件无卡死** (`stuckSettlementEvents`) | `judge_settlement_event` 不得出现超时未消费或死信积压 | **0** | ### 2.2 实测只读对账输出 在完成全部 30 / 100 / 300 突发压测(累计 **890** 个真实判题任务)后,执行 `python scripts/reconcile-judge.py --url http://127.0.0.1:18080/api/v1` 的真实输出如下: ```text ================================================================================ KECE 评测机集群只读对账报告 ================================================================================ 检测时间: 2026-11-07T20:36:31.424997300Z 健康状态: [PASS] 全量不变量满足 违规条数: 0 -------------------------------------------------------------------------------- [*] 6 大核心不变量校验全部通过 (无孤儿指针、无租约重叠、无终态不一致、无容量超额) [+] 评测任务排队分布 (Job Backlog): - 池: mysql 状态: COMPLETED 数量: 645 最早等待: 2026-11-07 20:33:44.915484 - 池: python 状态: COMPLETED 数量: 245 最早等待: 2026-11-07 20:34:15.886412 [+] 结算事件排队分布 (Event Backlog): - 类型: SUBMISSION_SETTLED 状态: DONE 数量: 830 - 类型: SUBMISSION_SETTLED 状态: PENDING 数量: 60 ================================================================================ ``` > 注:末尾 60 条 `PENDING` 结算事件为最后一轮 300 突发刚收敛瞬间的异步投递尾批,在随后 1 个定时周期(`JudgeMaintenance`)内全部收敛为 `DONE`,全程 `totalViolations = 0`。 --- ## 3. 2026-11-07 历史容量基线 ### 3.1 实测硬件与运行环境 - **CPU**:`AMD Ryzen 9 8940HX with Radeon Graphics`(16 物理核心 / 32 逻辑线程) - **内存**:`15.2 GB` - **操作系统与容器运行时**:Windows 11 + Docker Desktop (WSL2 Linux 引擎) - **工具链与组件版本**: - 后端:Java `17.0.20.1` + Spring Boot `3.2.5` (`kece-backend-0.1.0.jar`) - 评测机:Go `1.22` 原生编译 `judger` 专池 Worker - 业务数据库:`mysql:8.0`(独立容器,UTF8MB4) - 判题专用 MySQL:`kece-judge-mysql`(`mysql:8.0`,端口 `3307`) - 代码沙箱镜像:`python:3.12-slim`(`--network none --read-only --pids-limit 64 --security-opt no-new-privileges`) ### 3.2 实测突发压测结果汇总 此历史批次客户端并发为 16。表中的排队等待由旧脚本按客户端观察到的终态时间减去回执耗时推算,包含编译、轮询等时间。该列只保留作历史参考。 压测脚本严格区分 **API 接收阶段**(HTTP 派单落库返回 `submissionId`)与 **异步评测完成阶段**(Go 专池 Worker 领取租约、拉取不可变制品、执行沙箱/SQL 判题、回传结果并更新业务提交终态): #### (1)MySQL 专池突发压测(2 节点 × 2 槽 = 4 并发槽位,客户端并发 = 16) | 唯一请求数 | 接收成功/失败 | 接收 P50 / P95 / P99 (ms) | 接收吞吐 (req/s) | 排队等待 P50 / P95 (ms) | 执行耗时 P50 / P95 (ms) | 首单 / 末单完成 (s) | 有效完成率 (jobs/s) | 终态收敛 / AC | 对账违规 | | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | | **30** | 30 / 0 | 37.62 / 57.76 / 65.10 | **320.24** | 1350.77 / 1847.55 | 90.00 / 228.95 | 0.824 / **2.202** | **13.63** | 30 / 30 | **0** | | **100** | 100 / 0 | 22.51 / 35.12 / 52.50 | **545.53** | 3219.36 / 4932.02 | 83.00 / 98.15 | 1.629 / **5.354** | **18.68** | 100 / 100 | **0** | | **300** | 300 / 0 | 20.98 / 33.63 / 41.52 | **707.98** | 8576.77 / 14194.72 | 81.00 / 100.00 | 0.462 / **15.349** | **19.55** | 300 / 300 | **0** | #### (2)Python Docker 沙箱专池突发压测(2 节点 × 2 槽 = 4 并发槽位,客户端并发 = 16) | 唯一请求数 | 接收成功/失败 | 接收 P50 / P95 / P99 (ms) | 接收吞吐 (req/s) | 排队等待 P50 / P95 (ms) | 执行耗时 P50 / P95 (ms) | 首单 / 末单完成 (s) | 有效完成率 (jobs/s) | 终态收敛 / AC | 对账违规 | | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | | **30** | 30 / 0 | 24.55 / 31.91 / 32.65 | **527.64** | 4127.20 / 7347.90 | 224.00 / 240.55 | 1.284 / **7.936** | **3.78** | 30 / 30 | **0** | #### (3)Python + MySQL 混合池突发压测(2 节点/池 × 2 槽 = 8 并发槽位,客户端并发 = 16) | 唯一请求数 | 接收成功/失败 | 接收 P50 / P95 / P99 (ms) | 接收吞吐 (req/s) | 排队等待 P50 / P95 (ms) | 执行耗时 P50 / P95 (ms) | 首单 / 末单完成 (s) | 有效完成率 (jobs/s) | 终态收敛 / AC | 对账违规 | | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | | **30** | 30 / 0 | 18.56 / 32.43 / 34.68 | **470.63** | 1407.62 / 65793.56 | 149.50 / 233.20 | 0.680 / **66.046** | **0.45** | 30 / 30 | **0** | | **100** | 100 / 0 | 20.38 / 28.83 / 39.22 | **606.95** | 3198.36 / 11934.31 | 180.50 / 249.15 | 1.943 / **13.117** | **7.62** | 100 / 100 | **0** | | **300** | 300 / 0 | 24.41 / 41.09 / 57.25 | **581.54** | 8909.50 / 32491.90 | 170.00 / 248.00 | 0.565 / **36.635** | **8.19** | 300 / 300 | **0** | ### 3.3 实测指标分析 1. **API 接收高吞吐与低延迟削峰**: - 在每批 300 单、客户端并发 16 的测试中,后端持久化派单(含生成/复用 `judge_manifest`、写入 `judge_job`、更新 `current_judge_job_id`)吞吐稳定在 **581.54 ~ 707.98 req/s**,300 单两组的 P95 延迟为 **33.63 ~ 41.09 ms**,零失败、零拒绝。 2. **专池隔离与吞吐特征**: - `mysql` 专池通过进程内连接池创建临时独立 schema(`judge_`)并执行种子与比对,4 槽位下有效完成率达 **19.55 jobs/s**(300 单仅需 **15.349 s** 全部收敛)。 - `python` 专池每次执行均拉起全新无网络只读 Docker 沙箱容器,4 槽位下有效完成率为 **3.78 jobs/s**,与 `mysql` 混合突发时互不抢占槽位,300 单混合突发(150 Python + 150 MySQL)在 8 槽位下于 **36.635 s** 全部收敛为 `ACCEPTED`。 3. **制品缓存命中与零共享目录**: - Go `judger` 通过 `GET /api/v1/internal/judgers/jobs/{jobId}/artifacts/{digest}` 下载测试点后缓存在节点本地 `cache/` 目录,同一题目的后续 299 次突发提交全部 100% 命中本地 SHA-256 校验缓存,零重复网络下载。 4. **租约自愈实测验证**: - 在混合池首轮 30 突发切换阶段,前一批次退出的旧节点曾短暂领走 2 个任务;后端 `JudgeMaintenance` 在 60 秒租约到期后自动将其重入队(`requeueExpiredLeases`),新节点立即接管并评测通过,末单在 `66.046 s` 收敛为 `ACCEPTED`,验证了节点掉线场景下的零人工干预自愈能力。 --- ## 4. 故障演练与跨平台验证清单 | 验证项 | 状态 | 实测验证结果 / 可复现命令 | | :--- | :---: | :--- | | **节点掉线 / 租约超时自动重入队** | **已执行** | `JudgeControlIntegrationTest` 及混合池实测验证:租约到期后 `JudgeMaintenance` 自动回收 `RUNNING` 任务重回 `QUEUED`,由存活节点重跑收敛为 `ACCEPTED`。 | | **旧租约迟到回传 409 拦截** | **已执行** | `JudgeControlIntegrationTest` 验证:旧 `lease_epoch` 回传 `/result` 返回 `409 STALE_LEASE`,`/renew` 在批次响应中返回任务级 `STALE_LEASE`,不覆盖新代次结果。 | | **业务提交中途删除防幽灵复活** | **已执行** | `JudgeControlIntegrationTest` 验证:提交被删除后回传结果不会复活数据,`reap` 自动将孤儿任务收敛为 `CANCELLED`。 | | **连续重判代次抢占与防串单** | **已执行** | `AllPoolsBusinessIntegrationTest` 验证:五池(`cpp`/`java`/`python`/`mysql`/`arkts`)重判递增 `generation` 并切换 `current_judge_job_id`,旧任务结果被拒绝。 | | **Linux systemd `kece-judger@.service` 启停演练** | **已执行** | 生产 Linux 宿主机(Ubuntu 24.04)已通过 `systemd-analyze verify /etc/systemd/system/kece-judger@.service` 校验(0 警告),并启用 `kece-judger@{python,cpp,java,mysql,arkts}-1.service` 共 5 个独立实例,全部自检 `PASSED` 并在线运行。 | | **Linux 原生 `/sys/fs/cgroup` 父 cgroup 强杀演练** | **未执行** | 当前实测环境通过 Docker Desktop WSL2 引擎隔离容器。在 Linux 生产宿主机可执行:
`docker run --rm --cgroup-parent=/kece-judge.slice python:3.12-slim python3 -c "import os; [os.fork() for _ in range(10)]"` 并检查 `/sys/fs/cgroup/kece-judge.slice/pids.events` | --- ## 5. 历史基线的复现命令 ```bash # 1. 编译 Go judger 专池二进制 cd judger && go build -o ../kece-judger ./cmd/judger && cd .. # 2. 运行 MySQL 专池 30 / 100 / 300 突发压测 python scripts/benchmark-judge.py \ --url http://127.0.0.1:8080/api/v1 \ --bursts 30,100,300 \ --concurrency 16 \ --pools mysql \ --spawn-go-workers \ --judger-bin ./kece-judger \ --nodes-per-pool 2 \ --slots-per-node 2 # 3. 运行 Python + MySQL 混合专池 30 / 100 / 300 突发压测 python scripts/benchmark-judge.py \ --url http://127.0.0.1:8080/api/v1 \ --bursts 30,100,300 \ --concurrency 16 \ --pools python,mysql \ --spawn-go-workers \ --judger-bin ./kece-judger \ --nodes-per-pool 2 \ --slots-per-node 2 # 4. 执行全集群只读对账 python scripts/reconcile-judge.py --url http://127.0.0.1:8080/api/v1 ``` ## 6. 2026-11-08 修复后本地验收 测试机为 Ryzen 9 8940HX,16 核 32 线程,Windows 11 与 Docker Desktop WSL2。Docker 虚拟机约 7.4 GiB 内存,使用 cgroup v2。负载端、Java 后端、业务 MySQL、Redis 和十个 Go 节点运行在同一台机器上,SQL 评测使用独立 MySQL 实例。每个池两个节点,每节点两个槽位。 五池均完成真实普通题提交并收到 AC。代码题为单用例 A+B,SQL 题为单用例 SELECT。ArkTS 的初次题目配置未开放该语言,随后样例重复声明了 Node 内置的 `require`。这两次失败在原始记录中保留,修正压测工具后重新提交得到 AC。 ### 6.1 并发结果 每批首波客户端通过屏障一同起跑,分别为 30、100、300 个线程。每个批次全部收敛后进入下一批。混合池 Python 与 MySQL 各一半。排队时间由数据库中的任务创建时间与首次租约开始时间计算。末单是客户端轮询首次观察到全部终态的时间。 | 负载 | 批次 | 接收/AC | 接收 P95(ms) | 排队 P95(s) | 末单(s) | 收敛速率(单/s) | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | MySQL | 30 | 30/30 | 229.72 | 2.52 | 3.212 | 9.34 | | MySQL | 100 | 100/100 | 479.41 | 9.42 | 10.609 | 9.43 | | MySQL | 300 | 300/300 | 906.40 | 28.57 | 31.320 | 9.58 | | Python | 30 | 30/30 | 119.11 | 8.39 | 10.094 | 2.97 | | Python | 100 | 100/100 | 272.72 | 27.64 | 30.178 | 3.31 | | Python | 300 | 300/300 | 649.66 | 83.53 | 89.287 | 3.36 | | Python+MySQL | 30 | 30/30 | 73.18 | 3.96 | 5.169 | 5.80 | | Python+MySQL | 100 | 100/100 | 221.08 | 13.08 | 15.358 | 6.51 | | Python+MySQL | 300 | 300/300 | 779.46 | 39.20 | 44.512 | 6.74 | 九个容量批次共 1290 单,全部接收并判为 AC,逐批对账违规均为零。主轮结束时所有评测任务均已收敛,仍有 30 条结算事件处于 `PENDING`,所以即时执行的 `--require-drained` 返回失败。原始记录保留此快照。 用相同后端和评测机二进制在新的隔离环境复跑。五池各一单全部 AC,随后 300 单混合负载全部接收并判为 AC,末单 50.561 秒,接收 P95 为 908.19 ms。等待异步结算后,305 条任务均为 `COMPLETED`,305 条结算事件均为 `DONE`,对账违规为 0,`--require-drained` 返回成功。逐单文件中的 `drain-recheck` 区分本次独立复跑。 - [运行版本、镜像、路由和逐批汇总](./benchmarks/2026-11-08-local-judge.json) - [逐请求、逐任务和数据库时间差](./benchmarks/2026-11-08-local-judge.tsv) ### 6.2 复测方法和适用范围 使用隔离的业务库、Redis 和判题 MySQL,并预先加载五池所需镜像。该脚本会创建题目、节点和提交。 ```bash python scripts/benchmark-judge.py \ --url http://127.0.0.1:8080/api/v1 \ --pools cpp,java,python,mysql,arkts --workloads 'mysql;python;python,mysql' \ --smoke-pools --bursts 30,100,300 --concurrency 300 \ --spawn-go-workers --judger-bin ./kece-judger \ --nodes-per-pool 2 --slots-per-node 2 --output-json benchmark.json python scripts/reconcile-judge.py --url http://127.0.0.1:8080/api/v1 \ --require-drained --output reconciliation.json ``` 压测 JSON 保存 HTTP 观察值。要复核排队时间,在隔离业务库按 `submission_type` 和 `submission_id` 关联下列只读导出。正式业务库如需执行此查询,应限制为本次批次的任务 ID 范围。 ```sql SELECT j.id, j.submission_type, j.submission_id, j.pool, j.route_revision, j.state, MIN(TIMESTAMPDIFF(MICROSECOND, j.created_at, a.started_at)) DIV 1000 AS queued_ms, TIMESTAMPDIFF(MICROSECOND, j.created_at, j.finished_at) DIV 1000 AS settled_ms FROM judge_job j LEFT JOIN judge_job_attempt a ON a.job_id = j.id GROUP BY j.id, j.submission_type, j.submission_id, j.pool, j.route_revision, j.state, j.created_at, j.finished_at ORDER BY j.id; ``` 容量结果适用于上述本地环境、槽位和轻量样题。目标 NEC 主机、外部负载端、不同用户会话、冷缓存重题和原生 Linux systemd/父 cgroup 故障演练须另行验收。