Files
senmesh-zc/backend/docs/传输链路性能优化方案.md

657 lines
43 KiB
Markdown
Raw Normal View History

# 跨网传输链路性能优化方案
> 版本:v1.4(2026-11-03)
> 适用后端:s-file(alita-bjy-uygur)
> 状态:5.1/5.2/5.3/5.4/5.5 已实施;已完成真实端到端实测(§10.5)与基线 jar 对照实验(§10.6)→ 待现场验证;5.6 视现场文件大小分布决定
> **实测结论:端到端 123.1 s → 9.9 s(8–12×);组包 11.4×、解包 9.9×、包体 +3.63%。§2.1 的 45,934 ms 基线与 §3.4 原预测表已作废,一律以 §10.6 为准。**
> 实施与实测记录见第 10 节
> 关联代码:`TransferUserFileServiceImpl`(发送/组包)、`TransferFileMonitorServiceImpl`(接收/解包)、`TransferFileMonitorJob`(调度)、`FileServiceImpl` / `LocalFileStorage`(物理文件读写)
---
## 1. 背景与现象
现场反馈:**一页文件也要 2 分钟才能传到内网**,希望找到瓶颈。
自有服务器复现:**单个文件从点击发送到组包完成需要 20–30 秒**。
本文只解决「**慢**」,不涉及「传失败/传丢」类问题(那是另一条线,不在此文档范围内)。
---
## 2. 实测数据(本地环境,非推算)
数据来源:`sfile-iw-app` 容器日志中 `ControllerLogAspect` 输出的 `useTime` 字段(单位毫秒),以及 `TransferUserFileServiceImpl` 的「传输文件打包完成」日志时间戳。
### 2.1 单文件组包耗时
| 请求样本 | 内容 | useTime |
|---|---|---|
| userFileId=23 | 单个 txt,2.8 KB | 159 ms |
| userFileId=18 | 单个小文件 | 127 ms |
| userFileId=17 | 单个小文件 | 126 ms |
| userFileId=10 | 单个小文件 | 167 ms |
| userFileId=12 | 单个小文件 | 332 ms |
| userFileId=13 | 单个小文件 | 543 ms |
| userFileId=31 | 文件夹,9 个文件,合计约 19.6 MB | ~~**45,934 ms**~~ 见下方注 |
> **注(2026-11-03 对照实验后作废)**:45,934 ms 这个数字**不可复现**。§10.6 用同一份基线代码(`4eb67e2`)重测两次,组包 `useTime` 分别是 10,055 ms 和 10,622 ms,互差仅 5.6%。45.9 秒那次应是在冷缓存或有并发负载的状态下采的。**本节及 §2.2、§3.4 中所有以 45,934 ms / 2.33 s/MB 为基数的推算一律以 §10.6 的实测为准。**
**结论 1:小文件的组包本身就是几百毫秒,不慢。**
### 2.2 46 秒那次的分段(关键证据)
```
12:22:02.305 复制用户文件时跳过排除文件: id=41 ...
12:22:02.362 复制用户文件时跳过排除文件: id=32 ... ← 到此所有 SQL 执行完毕
12:22:02.362 ───────────── 45.7 秒无任何日志 ─────────────
12:22:48.110 传输文件打包完成: /data/transfer/send/1_45.zip
```
| 分段 | 耗时 | 占比 |
|---|---|---|
| 安全检测判定 + 建传输文件夹 + 复制文件记录(全部 SQL) | **57 ms** | 0.1% |
| `packTransferFiles`:读源文件 + 压缩 + 写摆渡目录 | **45,873 ms** | 99.9% |
**结论 2:耗时几乎全部在文件 I/O,与 SQL、索引、数据量无关。**
### 2.3 折算成单位成本
19.6 MB / 45.87 s = **2.33 秒/MB** = 0.43 MB/s。
正常 deflate 压缩速率是 20–40 MB/s,实测慢了约 **50–100 倍**,因此瓶颈不是 CPU,是每次读写的开销。
---
## 3. 耗时归因模型(可用来预测任意大小文件)
### 3.1 代码里的两处小缓冲
| 位置 | 缓冲大小 | 说明 |
|---|---|---|
| `TransferUserFileServiceImpl:139` | 读源文件 `byte[8192]` | 每 8 KB 一次 read() |
| `TransferUserFileServiceImpl:125` | `new ZipOutputStream(...)` | JDK8 中 `ZipOutputStream` 的内部 deflate 输出缓冲**硬编码 512 字节**,无法通过构造参数调整 → 每 512 B 一次 write() |
### 3.2 为什么小缓冲在这里是致命的
`/data/upload`(源文件)和 `/data/transfer/send`(摆渡目录)**都是宿主机 bind mount**:
- `deploy/offline/docker-compose.yml:92,95,96`
- `deploy/docker-compose.yml:53,55,56`
bind mount 若落在 Docker Desktop 文件共享(FUSE/9p)、NFS、SMB,或**光闸厂商的监控目录驱动**上,每次 syscall 都是一次跨层往返,成本在毫秒级;落在本地 ext4/xfs 上则只要几微秒。**同一段代码在两种挂载上的耗时可以差 100 倍。**
### 3.3 模型与实测吻合验证
```
组包耗时 ≈ 文件大小 × (1/512 + 1/8192) 次 syscall/字节 × 单次 syscall 耗时 + 压缩 CPU 时间
```
以实测反推单次 syscall 耗时:
- 每 MB 的 syscall 次数 = 1,048,576/512 + 1,048,576/8192 = 2048 + 128 = **2176 次**
- 实测 2.33 秒/MB,扣除压缩 CPU(约 0.04 秒/MB)→ 2.29 s / 2176 ≈ **1.05 ms/次**
这个数值与 FUSE/网络挂载的典型往返延迟一致,模型成立。
> **注意:1.05 ms/次 是本地 Windows Docker Desktop 环境的系数。**
> 客户现场(Linux + 光闸目录)必须用第 4 节的方法重新实测该系数,收益预测按现场系数换算。
### 3.4 按模型预测的组包耗时
> **本节原表以 2.33 s/MB 为基数,已作废**(该基数来自不可复现的 45,934 ms,见 §2.1 注)。
> 下表按 §10.6 对照实验的实测系数重算:改前 **0.524 s/MB**(18.7 MiB / 10,055 ms),改后 **0.046 s/MB**(18.7 MiB / 881 ms)。
| 文件大小 | 改前组包 | 改后组包 | 提升 | 改前端到端(含平均 60 s 轮询) | 改后端到端(含平均 5 s 轮询) |
|---|---|---|---|---|---|
| 100 KB(一页文档) | 0.05 s | 0.005 s | 11× | ~60 s | ~5 s |
| 10 MB | 5.2 s | 0.46 s | 11× | ~66 s | ~6 s |
| **50 MB** | **26 s** | **2.3 s** | **11×** | **~88 s** | **~8 s** |
| 200 MB | 105 s | 9.2 s | 11× | ~167 s | ~15 s |
| 500 MB | 262 s | 23 s | 11× | ~324 s | ~29 s |
| 1 GB | 524 s | 46 s | 11× | ~586 s | ~52 s |
端到端还要叠加解包(改前 0.415 s/MB、改后 0.042 s/MB)与两程轮询等待(改前平均 30+30 s、最坏 60+60 s;改后平均 2.5+2.5 s、最坏 5+5 s)。上表的端到端列已含平均轮询与解包。
> **50 MB 的回答(修正版):改前组包约 26 秒、端到端约 1.5 分钟(最坏 2.4 分钟);改后组包约 2.3 秒、端到端约 8 秒。**
>
> **关键修正:客户说的「两分钟」主要不是组包慢,是轮询等待慢。** 50 MB 场景下改前 88 秒里有 60 秒是两程轮询,只有 26 秒在组包。这解释了为什么 5.1(60 秒 → 5 秒)单独就能把端到端从 123 秒压到 21 秒(§10.6 B 组 vs A 组)。
> **系数适用范围**:0.524 / 0.046 s/MB 采自 99.94% 为已压缩格式(pptx/xlsx/png)的样本。
> 若内容以 txt/doc/源码为主,改前 level 6 的每 MB 成本更高、改后拿不到 level 0 收益,倍数会回落到 5.3 单独的 **8×** 量级,但改前的绝对秒数也会更大。
> 现场必须用第 4 节的方法按真实文件分布重测。
>
> **重要:500 MB 以上的文件现状组包需要 19 分钟,而这段时间是占用在一个 HTTP 请求 + 一个数据库事务里的**,浏览器/nginx 早就超时了。这解释了大文件传输在界面上"点了没反应"的现象。
---
## 4. 现场分段测量方法(实施前必须先测,改后同样用它验收)
不用改代码,只用文件时间戳和现成日志,把端到端时间切成 6 段。
| # | 取数位置 | 含义 | 相邻差值的意义 |
|---|---|---|---|
| T1 | 外网 send 目录 `xxx.zip` 的 mtime | 组包完成 | — |
| T2 | 内网 receive 目录 `xxx.zip` 的 mtime | 光闸送达内网 | **T2−T1 = 去程光闸耗时** |
| T3 | 内网日志 `开始解包文件: xxx.zip` | 监听任务发现它 | **T3−T2 = 内网轮询等待(0–60 s)** |
| T4 | 内网日志 `文件解包成功: xxx.zip` | 解包入库完成 | **T4−T3 = 解包入库耗时** |
| T5 | 内网 send 目录 `xxx.zip.finish` 的 mtime | 回执生成 | — |
| T6 | 外网日志 `处理完成标记文件: xxx.zip.finish` | 外网翻转状态 | **T6−T5 = 回程光闸 + 外网轮询等待** |
取数命令:
```bash
# T1 / T5
ls -la --time-style=full-iso "$GATE_SEND_DIR" | grep <folderId>
# T2
ls -la --time-style=full-iso "$GATE_RECV_DIR" | grep <folderId>
# T3 / T4 / T6
docker logs --since 30m <app容器> 2>&1 | grep -E "开始解包文件|文件解包成功|处理完成标记文件|传输文件打包完成"
```
### 4.1 一页文件 2 分钟的预期构成(待现场确认)
| 分段 | 预计耗时 | 性质 |
|---|---|---|
| 外网组包 | < 0.5 s | 处理 |
| 去程光闸搬运 | **未知,需实测** | 外部设备 |
| 内网轮询等待 | 平均 30 s,最坏 60 s | **纯等待** |
| 内网解包入库 | < 1 s | 处理 |
| 回程光闸搬运 | **未知,需实测** | 外部设备 |
| 外网轮询等待 | 平均 30 s,最坏 60 s | **纯等待** |
| **合计** | **约 60–120 s + 光闸两次周期** | — |
**真正干活的时间不到 2 秒,其余全是等待。** 两个 60 秒轮询窗口平均就贡献 60 秒、最坏 120 秒——这已经足以解释"一页文件 2 分钟",光闸周期还没算进去。
### 4.2 测量单次 syscall 系数(用来校准第 3.4 节的预测)
传两个大小差异明显的文件(例如 10 MB 和 50 MB),各记一次组包耗时,则:
```
每 MB 成本 = (耗时2 − 耗时1) / (大小2 − 大小1)
单次 syscall 耗时 = 每 MB 成本 / 2176
```
拿到现场系数后,把第 3.4 节的表格按现场系数重算,即为改造后的预期收益。
---
## 5. 优化项
### 5.1 目录监听周期:60 秒 → 5 秒【收益最大,改动最小】
**现状**:`TransferFileMonitorJob:29` 的 cron 为 `${file.transfer.monitor.cron:0 */1 * * * ?}`,即只在每分钟第 0 秒触发。文件落地时刻随机 → 平均白等 30 秒,最坏 60 秒。发送端和接收端两侧都有这个等待,合计平均 60 秒、最坏 120 秒。
**改法**:
- 配置项 `FILE_TRANSFER_MONITOR_CRON` 改为 `0/5 * * * * ?`(5 秒)
- 空目录时一轮的开销极小(两次 `Files.list` + 过滤),5 秒一次不会造成压力
- 若现场希望更激进,可评估改为 `java.nio.file.WatchService` 事件驱动(文件落地即触发),但 bind mount 上的 inotify 事件可靠性依赖挂载类型(FUSE/NFS 上可能收不到事件),**不建议一期就上**,先用 5 秒轮询这个确定有效的方案
**预期收益**:两侧等待从平均 60 s 降到平均 5 s,**端到端直接省掉约 55 秒**。
**风险**:低。唯一要注意的是必须保证同一时刻只有一轮在跑(见 5.2 的互斥要求)。
---
### 5.2 单轮处理上限:从「个数」改为「时间预算」
> **实施情况(2026-11-03,提交 `06852c5`)**:下面 (1) 时间预算、(2) 失败让位、(4) 积压可观测、
> `.finish` 优先 **均已实施并实测通过**(见 §10.5)。
> **(3) 认领改名与并发解包未实施**——两侧都是单实例部署,Spring 的 cron 任务要等上一轮跑完才排下一轮
> (已实测触发间隔精确 5.00 秒),不存在同实例重叠;认领机制只会引入 `kill -9` 后遗留 `.processing`
> 这类新故障模式,换不到任何吞吐。**若将来改为多实例共享同一摆渡目录,必须补认领与 Redisson 互斥。**
> (2) 的实现方式也与原设计不同:不用改名认领,而是用 mtime 是否静止超过 `stale-after-seconds`
> 来区分「光闸还在搬」与「包本身坏了」,只对后者标 `.error`。
**现状**:`TransferFileMonitorServiceImpl:87-99` 与 `:109-120`
```java
.sorted(Comparator.comparing(p -> Files.getLastModifiedTime(p))) // 最老的优先
.limit(10) // 每轮最多 10 个
```
`.finish` 标记文件同样是 `limit(10)`。
**问题**:
1. **吞吐被硬顶死**:10 个/分钟 = 600 个/小时。300 人站点若同一分钟内有 25 个传输,第 11–20 个要等下一分钟、第 21–25 个等第三分钟,**高峰期排队直接叠加分钟级延迟**。
2. **按个数限流本身不合理**:包的大小跨度极大(几十 KB 到几百 MB)。10 个小文件可能 1 秒处理完(限制毫无必要),10 个 500 MB 的包可能跑十几分钟(远超调度周期,还会把下一轮挤掉)。**个数不能代表工作量,时间才能。**
3. **失败的处理不占用预算**:一个包处理失败时既不删除也不改名,它的 mtime 最老,因此每一轮都排在第一位、每一轮都消耗一次处理时间。轮次预算被无效工作占用的比例会随时间上升。
**设计方案(四条)**:
#### (1) 上限语义:个数 → 时间预算
| 配置项 | 默认值 | 含义 |
|---|---|---|
| `file.transfer.monitor.round-budget-seconds` | 4 | 单轮处理的时间预算,超时就结束本轮,剩余留给下一轮 |
| `file.transfer.monitor.round-max-files` | 500 | 兜底个数上限,防止极端场景单轮过长 |
处理循环改为:
```
deadline = now + round-budget-seconds
for (每个按 mtime 升序的候选包) {
if (now > deadline || 已处理数 >= round-max-files) break; // 预算用尽,本轮结束
processZipFile(包);
}
```
**为什么预算默认 4 秒**:与 5.1 的 5 秒周期配套,留 1 秒余量,保证上一轮结束后下一轮能准时启动,不会互相挤压。
#### (2) 认领机制:先原子改名,再处理
处理前把 `x.zip` 原子改名为 `x.zip.processing`,改名成功才算认领成功:
| 结果 | 动作 |
|---|---|
| 认领成功 + 处理成功 | 删除 `.processing`(现有行为) |
| 认领成功 + 处理失败 | 改名 `x.zip.error`,移出扫描范围并告警,不再消耗后续轮次预算 |
| 认领失败(改名报错,说明被别的线程/实例拿走) | 跳过 |
| 进程重启后发现遗留 `.processing` | 启动时清理:改回 `.zip` 重新排队,或直接标 `.error` |
作用:支持并发与多实例部署时不重复处理;失败包不再占用时间预算;`.error` 保留现场便于排查。
> 注:接收端已有的 `.tmp` 过滤(`:90`)说明原设计就有"临时名"的概念,这里与之保持一致的命名风格。
#### (3) 有限并发解包
| 配置项 | 默认值 | 含义 |
|---|---|---|
| `file.transfer.monitor.concurrency` | 2 | 并发解包线程数 |
- 仍按 mtime 升序认领,保证先到先处理(公平性不变)
- **并发度不建议超过 4**:解包同时要写数据库(Druid `max-active: 50`)、写文件存储、提交安全检测,并发过高会互相抢连接和磁盘带宽,收益递减且放大 I/O 抖动
- 若并发度 > 1,`scanAndProcess` 必须加互斥(Redisson `tryLock`,拿不到锁直接返回),避免调度周期短于单轮耗时时出现两轮重叠
#### (4) 积压可观测
每轮结束打印一行结构化日志:
```
传输监听本轮结束: 处理=8(成功=7,失败=1), 耗时=3120ms, 预算=4000ms, 目录剩余待处理=23
```
`目录剩余待处理` 持续大于阈值(建议 50)即为积压告警信号,是容量规划的直接依据。
#### `.finish` 标记文件的处理
`.finish` 只做一次状态更新,极轻。建议:
- 不受 `round-max-files` 限制(或单独放宽到 500)
- 与 zip 处理**共享同一个时间预算**,且**优先处理 `.finish`**(先让发送端尽早看到"已完成",用户体验上收益直接)
**预期收益**:高峰期吞吐从 600 个/小时提升到「预算内能处理多少就多少」;按 5.3 改造后单包耗时估算,4 秒预算 + 2 并发 ≈ 每轮可处理数十个小包,**吞吐提升一个数量级以上**;同时消除排队叠加的分钟级延迟。
---
### 5.3 读写缓冲区:512 B / 8 KB → 64 KB【大文件收益最大】
**改法**(4 处):
| 位置 | 现状 | 改为 |
|---|---|---|
| `TransferUserFileServiceImpl:125` | `new ZipOutputStream(new FileOutputStream(f))` | `new ZipOutputStream(new BufferedOutputStream(new FileOutputStream(f), 65536))` |
| `TransferUserFileServiceImpl:139` | `byte[] buffer = new byte[8192]` | `new byte[65536]`;源流再用 `BufferedInputStream` 包一层 |
| `TransferFileMonitorServiceImpl:412` | `new ZipInputStream(new FileInputStream(f))` | `new ZipInputStream(new BufferedInputStream(new FileInputStream(f), 65536))` |
| `TransferFileMonitorServiceImpl:447` | `byte[] buffer = new byte[8192]` | `new byte[65536]` |
| `LocalFileStorage:25-26` | `IOUtils.copy(is, os)`(commons-io 默认 8 KB 缓冲) | `IOUtils.copy(is, os, 65536)`,或 `os` 包 `BufferedOutputStream(65536)` |
**关键点**:`ZipOutputStream` 的 512 字节缓冲在 JDK8 里是硬编码的(`ZipOutputStream extends DeflaterOutputStream`,构造时传入固定 BUFSIZE),**不能通过构造参数改**,所以必须用 `BufferedOutputStream` 在外层兜住——512 字节的小写先进内存缓冲,凑满 64 KB 才落一次盘。
**预期收益**:syscall 次数降为原来的 **1/68**(2176 次/MB → 32 次/MB)。按第 3.4 节模型,50 MB 组包从约 117 秒降到约 3.8 秒。
**风险**:极低。仅增加约 128 KB/流的内存占用(64 KB 读 + 64 KB 写)。需实测确认 `BufferedOutputStream` 生效(对比改前改后同一样本耗时)。
**注意事项(务必同时处理)**:解包侧把整个文件读进堆内 `ByteArrayOutputStream`(`TransferFileMonitorServiceImpl:446-451`),随后 `FileServiceImpl:374` 的 `file.getBytes()` 再拷一份、MD5 计算再一份 → **峰值堆占用约 2–3 倍单文件大小**。`-Xmx3g` 下 500 MB 以上的单文件有 OOM 风险。本项优化让 I/O 变快后,大文件更容易走到这一步,因此建议同时加一道**单文件大小上限校验**(超过阈值直接标 `.error` 并告警,而不是尝试解包后 OOM),或后续改为流式落盘。
---
### 5.4 已压缩格式不再二次压缩
**现状**:`ZipOutputStream` 默认 `Deflater.DEFAULT_COMPRESSION`(level 6),对 pptx / docx / xlsx / zip / rar / 7z / jpg / png / mp4 这类**本身已压缩**的格式,压缩率接近 0,但 CPU 照样全量跑一遍。
**改法**:按文件后缀判断,命中已压缩格式清单时用 `ZipEntry.setMethod(ZipEntry.STORED)`(原样存储,不压缩)。
> `STORED` 要求预先提供 entry 的 size 和 CRC-32。组包时源文件大小已知(`xs_file_record.file_size`),CRC 需在写入前算一遍(一次顺序读,成本远低于 level 6 压缩)。若不想引入两遍读,退一步用 `zos.setLevel(Deflater.NO_COMPRESSION)`(level 0),仍是 DEFLATE 存储块、压缩率同样接近 0,但 CPU 开销近乎为零,且无需预先提供 size/CRC。**推荐用 level 0 这个简单方案。**
**预期收益**:对 pptx/zip/mp4 类文件,去掉约 0.04 s/MB 的压缩 CPU;50 MB 样本约省 2 秒,1 GB 约省 40 秒。对文本/日志类文件不适用(应保持 level 6,压缩率收益大)。
**风险**:低。level 0 产出的仍是合法 zip,接收端解包逻辑无需改动。**注意包体积会变大**(对已压缩格式几乎无差别,但对可压缩格式若误判会明显变大),因此后缀清单要保守,只收录确定已压缩的格式。
---
### 5.5 `xs_file_record.md5_code` 加索引
**现状**:`FileServiceImpl:374-375` 在每次文件入库时做 MD5 去重查询:
```java
md5Code = DigestUtils.md5Hex(file.getBytes()).toUpperCase();
fileRecord = fileRecordService.find(FileRecord.builder().md5Code(md5Code).build());
```
而 `xs_file_record` **只有 `idx_file_code`,`md5_code` 上没有索引**(`deploy/offline/sql/01-init-table.sql:1499`、`backend/db/init-table-postgres-full.sql:1499`)→ 每个文件入库都是一次全表扫描。
**改法**(按 AGENTS.md 要求,init 与 upgrade 双写,且三个库版本都要覆盖):
```sql
CREATE INDEX idx_file_record_md5_code ON xs_file_record(md5_code);
```
- `backend/db/init-table.sql`(MySQL 版)
- `backend/db/init-table-postgres-full.sql`(PG 版)
- 新建 `backend/db/upgrade-20261103-transfer-perf.sql`(增量,供已上线环境执行)
- 同步到 `deploy/offline/sql/01-init-table.sql`(交付包用的合并版)
- 生产是 **DM8**,该语法在 DM8 通用;若现场数据量已较大,安排在业务低峰执行
**预期收益**:解包 N 个文件时的 N 次全表扫变成 N 次索引查找。数据量小(本地 23 行)时收益不可见;300 人规模下 `xs_file_record` 达数万至数十万行时,**单文件入库可省几十到几百毫秒,100 个文件的包可省数秒到数十秒**。
**风险**:极低。索引列 `varchar(64)`,空间成本可忽略;写入时多维护一个索引,单次 INSERT 增加的成本在微秒级。
---
### 5.6 (可选,二期)超大文件组包异步化
**现状**:`TransferUserFileServiceImpl.create()` 带 `@Transactional`,`packTransferFiles` 在 HTTP 请求线程内同步执行(`:59,88-94`)。
**问题**:完成 5.3/5.4 后,1 GB 组包约需 40–76 秒,仍然超过浏览器/nginx 的默认读超时(60 秒);且整个过程占用一条 Druid 连接和一个数据库事务。
**改法(二期评估)**:`create()` 只落一条传输任务记录并立即返回任务 ID,组包交给异步线程池执行,前端轮询任务状态。需要新增任务表 + 状态机 + 前端进度展示,改动面较大。
**判断标准**:若现场单文件普遍 ≤ 200 MB(改造后组包 ≤ 15 秒),**本项可以不做**;若存在 500 MB 以上的常态传输,则必须做。
---
## 6. 本次明确不做的事(附理由)
| 项 | 为什么不做 |
|---|---|
| `x_user_file` 补索引 | 实测整条链路的全部 SQL 只占 **57 ms**(见 2.2),不是瓶颈。等 300 人规模真实数据量上来后,用第 4 节的方法重测再决定 |
| `path LIKE '%...%'` 改前缀匹配 | 同上,属于数据量增长后的预防性优化,当前无收益 |
| 调度线程池扩容 | 客户环境未开启文件安全检测,`pollPendingScans` 首行即返回,不存在轮询任务抢占调度线程的问题。**若后续现场开启 MeshSafe,此项需重新评估**(`MeshSafeScanJob` 每 10 秒一轮、`listScanning()` 无条数上限、单次 HTTP 超时 120 秒,而 Spring 默认调度线程池只有 1 条线程,两者会互相阻塞) |
| 传输副本(rootType 2/3)归档清理 | 属于数据治理,与本次"慢"无关,单独立项 |
---
## 7. 验证清单
### 7.1 改造前基线(必须先采集)
- [ ] 按第 4 节采集一次完整的 T1–T6,记录 6 个分段耗时
- [ ] 按 4.2 测出现场的「每 MB 组包成本」与「单次 syscall 耗时」系数
- [ ] 记录测试样本:文件个数、总大小、格式
- [ ] 记录 `x_user_file` / `xs_file_record` 当前行数
### 7.2 单项验证
| 优化项 | 验证方法 | 通过标准 |
|---|---|---|
| 5.1 监听周期 | 传一个文件,看 T3−T2 | 从平均 30 s 降到 ≤ 5 s |
| 5.2 时间预算 | 一次性提交 30 个传输,看日志中每轮"处理=N"与"目录剩余" | 30 个在 2 轮内处理完;剩余数单调下降;无重复处理(同一包不出现两次"开始解包") |
| 5.2 认领机制 | 处理中 `kill -9` 容器后重启 | 遗留 `.processing` 被正确清理;无重复入库 |
| 5.2 失败让位 | 人为放一个损坏 zip 进 receive 目录 | 一轮后被改名 `.error`,后续轮次不再处理它,其它包不受影响 |
| 5.3 缓冲区 | 用同一个 19.6 MB 样本对比改前改后的组包耗时 | 45.9 s → ≤ 3 s(本地系数下) |
| 5.4 level 0 | 传一个 50 MB 的 pptx,对比 5.3 单独改造后的耗时 | 再降约 2 s;产出的 zip 能被接收端正常解包 |
| 5.5 md5 索引 | `EXPLAIN` 去重查询;解包一个 100 文件的包对比耗时 | 执行计划走 Index Scan;解包耗时明显下降 |
### 7.3 端到端验收
- [ ] 一页文件(≤ 100 KB)端到端:从点击发送到内网可见,**目标 ≤ 15 秒**(不含光闸搬运时间则 ≤ 5 秒)
- [ ] 一页文件端到端:到外网状态翻转为"正常",**目标 ≤ 光闸两次周期 + 15 秒**
- [ ] 50 MB 单文件:组包 ≤ 5 秒,端到端 ≤ 光闸周期 + 20 秒
- [ ] 300 并发峰值模拟(可用 30 个并发代替按比例外推):无排队叠加,无积压持续增长
- [ ] 回归:内穿外、外传内两个方向;单文件、文件夹两种形态;开启/关闭安全检测两种配置
---
## 8. 配置项汇总
| 配置项 | 类型 | 默认值 | 现状值 | 说明 |
|---|---|---|---|---|
| `FILE_TRANSFER_MONITOR_CRON` | env | `0/5 * * * * ?` | `0 */1 * * * ?` | 监听周期,5.1 |
| `file.transfer.monitor.round-budget-seconds` | yml | 4 | — | 单轮时间预算,5.2【已实施】 |
| `file.transfer.monitor.round-max-files` | yml | 500 | 硬编码 10 | 单轮兜底个数上限,5.2【已实施】 |
| `file.transfer.monitor.max-unpack-size-mb` | yml | 512 | 无 | 包内单文件大小上限,超限标 `.error`,5.3 注意事项【已实施】 |
| `file.transfer.monitor.stale-after-seconds` | yml | 300 | 无 | 静止超过该秒数仍校验不过即判坏包,5.2【已实施】 |
| ~~`file.transfer.monitor.concurrency`~~ | — | — | — | 未实施,理由见 5.2 实施情况 |
涉及文件:`deploy/offline/config/application.yml`、`deploy/offline/.env.iw`、`deploy/offline/.env.ow`、`deploy/config/application-iw.yml`、`deploy/config/application-ow.yml`、`deploy/offline/docker-compose.yml`、`deploy/docker-compose.yml`。
---
## 9. 实施顺序与风险
| 顺序 | 项 | 改动量 | 风险 | 收益(应用侧实测口径见 §10.6) | 状态 |
|---|---|---|---|---|---|
| 1 | 5.3 缓冲区 64 KB | 5 处,各 1 行 | 极低 | 组包 8.2×(容器内纯 I/O 基准) | 已实施 `4114c1f` |
| 2 | 5.1 监听周期 5 秒 | 配置 | 低 | 端到端 123.1 s → 21.0 s,省 102 s | 已实施 `06fb35b` |
| 3 | 5.5 md5 索引 | 4 个 SQL 文件 | 极低 | 本次样本量太小未体现,需现场数据量验证 | 已实施 `c5d19f4` |
| 4 | 5.4 level 0 | 小 | 低 | 与 5.3 叠加后组包 11.4×、解包 9.9×,包体 +3.63% | 已实施 `456b1ea` |
| 5 | 5.2 时间预算 + 坏包隔离 | 中 | 中 | 高峰期吞吐一个数量级;本次单包场景未体现 | 已实施 `06852c5`(认领/并发未做,见 5.2) |
| 6 | 5.6 异步组包 | 大 | 中 | 仅超大文件场景需要 | 待现场文件大小分布决定 |
1–5 已全部实施。**每一项单独提交**,现场出问题时可按提交号逐项回退定位。
**5.1 + 5.3 + 5.4 合计:端到端 123.1 s → 9.9 s(12.4×,本次实测;按平均轮询等待算约 7.9×)。现场汇报用 8–12× 区间。**
**每一项都必须单独验证并记录数字**,不要合并提交,否则现场出问题时无法定位是哪一项引起的。
---
## 10. 实施与验证记录(2026-11-03,本地环境)
一期 4 项 + 二期 5.2 已实施,每项独立提交:
| 项 | 提交 | 内容 |
|---|---|---|
| 5.3 | `4114c1f` | 组包/解包/落盘缓冲区 512B、8KB → 64KB,共 5 处 |
| 5.1 | `06fb35b` | 监听周期 `0 */1 * * * ?` → `0/5 * * * * ?`,Java 默认值 + 4 个部署文件 |
| 5.5 | `c5d19f4` | `idx_file_record_md5_code`,init 双库 + upgrade 脚本 + 交付包副本 |
| 5.4 | `456b1ea` | 按后缀对已压缩格式用 `Deflater.NO_COMPRESSION` |
| 5.2 | `06852c5` | 单轮上限改 4 秒时间预算、坏包 `.error` 隔离、`.finish` 优先、每轮结构化日志、单文件大小上限 |
编译:JDK 8 + `mvn package -Dmaven.test.skip=true`,BUILD SUCCESS。
(`-DskipTests` 不行:surefire 2.22.2 的传递依赖 `plexus-utils:1.1` 本地仓库没有,离线模式解析失败,需联网补一次。)
### 10.1 5.1 监听周期 — 已实测通过
重启后从 `sfile-iw-app` 日志读 `AlitaStopWatchAspect` 输出:
```
22:14:05.128 TransferFileMonitorJob.execute(),use(ms): 127 ← 首轮预热
22:14:10.028 TransferFileMonitorJob.execute(),use(ms): 13
22:14:15.013 TransferFileMonitorJob.execute(),use(ms): 12
22:14:20.025 TransferFileMonitorJob.execute(),use(ms): 13
22:14:25.015 TransferFileMonitorJob.execute(),use(ms): 13
22:14:30.024 TransferFileMonitorJob.execute(),use(ms): 11
22:14:35.003 TransferFileMonitorJob.execute(),use(ms): 8
```
- 触发间隔精确 5.00 秒,cron 表达式合法,应用启动无异常
- **空目录单轮 8–13 ms**,即每分钟约 150 ms 的开销,证实 5.1 中「空目录扫描开销可忽略」的判断
- 两侧轮询空等从平均 60 秒降到平均 5 秒
### 10.2 5.5 md5 索引 — 已实测通过
两个本地库(`sfile-iw-pg`、`sfile-ow-pg`)执行 upgrade 脚本均成功,脚本可重复执行(第 2 节 `IF NOT EXISTS` 生效)。
`EXPLAIN SELECT * FROM xs_file_record WHERE md5_code='ABC' AND deleted=FALSE LIMIT 1`:
- 默认:**Seq Scan**(当前 23 行,规划器判断顺序扫描更便宜,这是正确行为,也印证 5.5 中「数据量小时收益不可见」)
- `SET enable_seqscan=off` 后:**Index Scan using idx_file_record_md5_code** → 索引有效可用,数据量上来后会自动切换
### 10.3 5.3 + 5.4 缓冲区与压缩级别 — 已实测通过(容器内 I/O 基准)
端到端发送需要登录,本地 admin 口令未知,因此改用**容器内 I/O 基准**直接测量组包环节:复刻 `packTransferFiles` 的读写形态,在同一个 `sfile-app:local` 镜像、同一份 bind mount 上跑真实的 23 个文件(19.04 MB,即线上样本目录 `iw/upload/1`)。
必须在容器内测——syscall 放大来自容器到宿主机的文件共享层,在宿主机上直接测不出这个成本。
3 轮结果(每轮依次跑三种方式,输出 zip 随即删除):
| 方式 | 第 1 轮 | 第 2 轮 | 第 3 轮 | 产出大小 |
|---|---|---|---|---|
| 旧:512B 出 / 8KB 读,level 6 | 9033 ms | 8279 ms | 9701 ms | 18.12 MB |
| 新:64KB 出 / 64KB 读,level 6 | 1022 ms | 1109 ms | 1115 ms | 18.12 MB |
| 新:64KB 出 / 64KB 读,level 0 | 516 ms | 491 ms | 509 ms | 19.04 MB |
- **5.3 单独收益:约 8.2 倍**(9033 → 1022 ms)
- **5.3 + 5.4 合计收益:约 17.6 倍**(9033 → 509 ms)
- level 0 让包体从 18.12 MB 变成 19.04 MB(**+5.1%**)。反过来说明这个样本里 level 6 只压掉了 5%,即内容基本已是压缩格式——**用 5% 的体积换一半的时间,划算**
#### 与第 3.4 节预测的偏差(需据实修正)
| 项 | 预测 | 实测 | 说明 |
|---|---|---|---|
| 每 MB 组包成本(旧) | 2.33 s/MB(应用日志基线) | **0.47 s/MB**(基准 old 模式)、**0.524 s/MB**(§10.6 应用侧 A 组实测) | 2.33 s/MB 来自不可复现的 45.9 秒那次,**已作废**。容器基准与应用侧对照实验两个独立测法给出 0.47 / 0.524,互差 11%,这才是可信的改前系数 |
| 提速倍数 | 31× | **8.2×**(level 6)/ **17.6×**(level 0)/ **11.4×**(§10.6 应用侧端到端组包) | 31× 是纯 I/O 的理论值。应用侧 11.4× 落在 8.2× 与 17.6× 之间——因为应用侧组包含了 SQL、读原文件、元数据序列化这些不受缓冲区影响的固定开销,把倍数往 8.2× 那头拉 |
**结论:预测方向正确、量级偏高。** 现场汇报口径建议用 **11 倍(应用侧组包实测)/ 8 倍(仅改缓冲的下限)**,不要用 31 倍,也不要用 §10.5 一度写过的 52 倍。第 3.4 节的表格已按 §10.6 实测系数重算。
### 10.4 5.2 时间预算与坏包隔离 — 已实测通过
在内网侧接收目录(`gate/ow2iw`)投放三个测试样本,从 `sfile-iw-app` 日志与目录状态验证:
| 样本 | 构造 | 预期 | 实测 |
|---|---|---|---|
| `1_900001.zip` | 5000 字节随机内容,mtime 为当前 | 校验不过但未静止够久 → 保留待下一轮 | 每轮计入「未就绪=1」,文件保留 ✅ |
| `1_900002.zip` | 同上,mtime 回拨 10 分钟 | 静止超过 300 秒 → 判坏包改名 `.error` | 首轮即改名,reason=「校验持续不通过且文件已静止超过 300 秒,判定为坏包」✅ |
| `bogus.zip.finish` | 文件名无法解析出 `userId_folderId` | 永久性失败 → 改名 `.error` 停止重试 | 改名 `bogus.zip.finish.error`,后续轮次「完成标记=0/0」不再出现 ✅ |
每轮结构化日志(真实输出):
```
22:37:25.046 传输监听本轮结束: 完成标记=0/0, 传输包处理=2(成功=0,坏包=1,未就绪=1), 耗时=41ms, 预算=4000ms, 目录剩余待处理=0, 已搁置坏包=0
22:37:30.032 传输监听本轮结束: 完成标记=0/1, 传输包处理=1(成功=0,坏包=0,未就绪=1), 耗时=27ms, 预算=4000ms, 目录剩余待处理=0, 已搁置坏包=1
22:37:35.029 传输监听本轮结束: 完成标记=0/0, 传输包处理=1(成功=0,坏包=0,未就绪=1), 耗时=23ms, 预算=4000ms, 目录剩余待处理=0, 已搁置坏包=2
```
- 「已搁置坏包」随 `.error` 文件累积单调上升(0 → 1 → 2),运维可直接从日志看出目录里堆了多少坏包
- 空轮 + 1 个待处理包的单轮耗时 **17–41 ms**,远低于 4000 ms 预算
- `.finish` 优先于 `.zip` 处理,顺序符合设计
#### 顺带修掉一个 5.1 引入的日志副作用
`verifyFileMD5` 打不开 zip 时原来记 **ERROR 带完整堆栈**,而「光闸还没搬完」本来就是这个状态。
周期改成 5 秒后,每个在途文件会被刷 **60 条 ERROR/5 分钟**(原来只有 5 条)。
已降为 debug;真正的坏包由改名 `.error` 时记一条带 reason 的 ERROR,信息量更高且不重复。
实测确认:投放 1 个在途坏包后连续观察 70 秒(14 轮),`文件大小校验失败` 的 ERROR 条数 **0**,全容器 ERROR 条数 **0**。
#### 未验证的部分
时间预算的「超预算即结束本轮」分支未在本地触发——需要单轮塞进足够多的大包才能耗尽 4 秒,
本地没有这个数据量。现场验收时按 §7.2 的方法:一次性提交 30 个传输,观察日志中每轮「传输包处理=N」
与「目录剩余待处理」是否单调下降。
### 10.5 真实端到端传输实测(2026-11-03 23:13,本地内外网双实例)
用内网 `admin` 账号把文件夹 `test`(userFileId=31,13 个子文件)传外网,走完整链路:组包 → 摆渡目录 → 外网解包入库 → 回写 `.finish` → 内网状态翻正。
实际发出 9 个文件(4 个被安全检测拦下:id 32 `manual_review`、38 `reject`、39 `reject`、41 `pass_with_flag`),原始合计 19,647,211 B ≈ 18.7 MiB,产出包体 19,631,241 B。
#### 四段耗时
| 段 | 观测点 | 耗时 |
|---|---|---|
| ① 内网组包 | `ControllerLogAspect` 的 `useTime`(含 SQL、读原文件、deflate、落盘) | **881 ms** |
| ② 去程摆渡 | 包落盘 23:13:41.246 → 外网轮询发现 23:13:45.111 | 3.87 s(本机同一目录,真实搬运≈0,全是轮询等待) |
| ③ 外网解包入库 | `开始解包文件` 23:13:45.198 → `文件解包成功` 23:13:45.983 | **785 ms** |
| ③′ 外网整轮 | 含大小校验 87 ms + 解包入库 785 ms + 写 `.finish` + 删包 | **1001 ms**(`TransferFileMonitorJob.execute(),use(ms)`) |
| ④ 回程状态翻转 | `.finish` 落盘 23:13:45.992 → 内网处理完 23:13:50.027 | 4.04 s(同样是轮询等待),处理本身 **21 ms** |
**端到端 9.9 s,其中真正干活 1.90 s(881 + 1001 + 21),其余 ~8.0 s 是两次轮询等待。**
两侧日志 0 ERROR;外网侧收到文件夹 id=40 + 9 个文件,状态全为正常,字节数与内网逐一对应;内网侧传输副本文件夹 id=55 状态已翻回正常。
#### 与基线对比
见 §10.6 的对照实验。**本节原先写的「45,934 ms → 881 ms,52×」已作废**:那个 45,934 ms 是改造前在一次未知负载状态下测的,用同一份基线代码重测两次只有 10,055 ms 和 10,622 ms,**不可复现,弃用**。同条件下的真实倍数是 **11.4×**。
包体:基线 level 6 产出 18,943,562 B,改后 level 0 产出 19,631,241 B,**+3.63%**(§10.3 容器基准估的是 +5.1%,实测更小)。样本里 pptx/xlsx/png 占 19,635,943 / 19,647,211 = 99.94%,全部命中 level 0,所以包体几乎不缩——这正是「存原样」的预期表现。
**汇报口径**:现场若以 pptx/图片/压缩包为主,组包按 **11× 量级**说;若以 txt/doc/源码为主,只有 5.3 的缓冲区收益,按 **8× 量级**说。不要引用 52×。
解包侧 785 ms 是改后绝对值,改前对照见 §10.6(7,777 ms)。18.7 MiB / 9 文件 → 改后约 **42 ms/MB**,改前约 **415 ms/MB**。
#### 本次实测暴露的一个既有缺陷:包完整性校验空转
外网侧日志出现 9 条 `元数据中未找到文件大小信息: userFileId=56..64`,随后仍是 `文件大小校验通过: 1_55.zip (共 0 个文件)`。
根因:`x_user_file` 表**没有 `file_size` 列**(见 `backend/db/init-table.sql` 的建表语句)。`UserFileDTO.fileSize` 只在**查询路径**上由 `UserFileMapper.xml:7` 的 `SELECT t.*, fr.file_size ... JOIN x_file_record fr` 带出来。
而 `packTransferFiles` 写元数据用的是 `userFileService.create(ExpandUserFileCreateDTO)` 的**返回值**:`UserFileServiceImpl:171` 拿到的是 `create(UserFileCreateDTO)` 里 `UserFileConvert.toDto(userFile)` 转出的 DTO,`userFile` 来自刚 insert 的实体,插入路径上从来没有 size。于是 `files[].fileSize` 全为 null → `transferMetadata.json` 里没有 size → 接收侧 `expectedSizeMap` 是空 map。
注意:在 `UserFileCreateDTO` 上补 `.fileSize(...)` **没用**——`toEntity` 会丢弃这个字段,表里也存不下。
而 `verifyFileMD5`(`TransferFileMonitorServiceImpl:450`)的放行判据是 `fileCount == 0 || successCount != expectedSizeMap.size()`,此时 `fileCount=9`、`successCount=0`、`expectedSizeMap.size()=0`,`0 != 0` 为假 → **直接放行**。
后果:这道校验从上线起就没真正比对过任何一个字节数。一个被截断的包(光闸搬到一半停了、mtime 已静止超过 `stale-after-seconds`)会被当成完整包解包,**静默丢文件**,且不会进 `.error`。5.2 引入的 mtime 静止判据只解决「坏包反复重试」,解决不了「不完整的包看起来是好的」。
修法两处:
1. **补齐 size**:`UserFileServiceImpl.create(ExpandUserFileCreateDTO)` 返回前用带 JOIN 的查询路径重查一次子文件(`this.list(UserFileQueryDTO.builder().userId(userId).rootType(rootType).parentId(folderId)...)`),把带 `fileSize` 的列表作为 `files` 返回;或在 `packTransferFiles` 里按 `fileCode` 批量查 `x_file_record` 补齐。另一条重载 `create(UserFileDTO folder, List<UserFileDTO> files)`(外网解包入库走它,`TransferFileMonitorServiceImpl:579`)返回值同样缺 `fileSize`,但目前没有下游读它,属潜在问题,可一并修也可先放着。
2. **改掉空转判据**:`verifyFileMD5` 的 `successCount != expectedSizeMap.size()` 改成 `successCount != fileCount`(拿实际遍历到的条目数比,而不是拿可能为空的期望表比),保留 `fileCount == 0` 的在途判断。
第 2 条即使第 1 条不修也必须改——它是「静默丢文件」的直接闸门;第 1 条不修的话,改了判据会让所有包都校验不过,所以**两条必须一起上**。
改完后 §10.5 表格里 ③′ 的 87 ms 校验段才会真正开始比对字节数,届时需重测该段(预期增量很小:size 来自 ZIP 中央目录,`ZipFile` 已经打开过一次)。
### 10.6 对照实验:基线 jar 实测「改前」(2026-11-03 23:35–23:42)
§10.5 只有改后的绝对值,「改前」一直是推算。为坐实,用 `git worktree` 在 `4eb67e2`(所有优化提交之前的最后一个提交)单独构建了一份基线 jar,打成 `sfile-app:baseline` 镜像,通过 compose override 只替换 `app-iw` / `app-ow` 两个服务,数据库、配置、摆渡目录、发送内容全部不动。跑完 `docker compose up -d --force-recreate` 还原为 `sfile-app:local`。
发送内容与 §10.5 完全一致:同一个文件夹 userFileId=31,同样 9 个文件通过检测,原始 19,647,211 B。
跑了两组,用来分离两个变量:
- **A 组**:基线 jar + `FILE_TRANSFER_MONITOR_CRON=0/5 * * * * ?`,把监听周期摁成和优化版一样的 5 秒 → 差异只来自 5.3/5.4/5.5/5.2(I/O 与逻辑)
- **B 组**:基线 jar + 基线自带的 60 秒周期(不设环境变量)→ 真实「改前」端到端
#### 实测数据
| 段 | B 组 改前(60s) | A 组 改前(5s) | 改后(5s) §10.5 |
|---|---|---|---|
| ① 内网组包 `useTime` | 10,622 ms | 10,055 ms | **881 ms** |
| 包体 | 18,943,562 B (level 6) | 18,943,562 B | 19,631,241 B (level 0) |
| ② 去程轮询等待 | 50.3 s | 1.09 s | 3.87 s |
| ③ 外网大小校验段 | 81 ms | 173 ms | 87 ms |
| ③ 外网解包入库 | 7,728 ms | 7,777 ms | **785 ms** |
| ③′ 外网整轮 job | 7,940 ms | 8,200 ms | **1001 ms** |
| ④ 回程轮询等待 | 52.1 s | 1.83 s | 4.04 s |
| ④ 回程处理 | 12 ms | 9 ms | 21 ms |
| **端到端** | **123.1 s** | **21.0 s** | **9.9 s** |
#### 结论
**I/O 改动(5.3 + 5.4)的净效果**,A 组 vs 改后,周期相同:
- 组包 10,055 → 881 ms = **11.4×**
- 解包入库 7,777 → 785 ms = **9.9×**
- 接收侧整轮 8,200 → 1,001 ms = **8.2×**
- 代价:包体 +3.63%
两次基线组包(10,055 / 10,622 ms)互相只差 5.6%,说明这个数字是稳的;**§10.3 记的 45,934 ms 应用侧基线不可复现,已作废**。11.4× 也落在 §10.3 容器内基准的 8.2×(纯缓冲区)与 17.6×(叠加 level 0)之间,三者互相印证。
**周期改动(5.1)的净效果**,B 组 vs A 组,代码相同:端到端 123.1 → 21.0 s,省下的 102 s 全是轮询等待。B 组这次两程都撞上刚错过的分钟边界(50.3 s + 52.1 s),接近最坏的 120 s;平均情况应为 30 + 30 = 60 s,即端到端约 78 s。
**端到端总提升**:
- 按本次实测:123.1 → 9.9 s = **12.4×**
- 按平均轮询等待:约 78 → 9.9 s = **7.9×**
- 现场汇报建议用 **8–12×** 这个区间,不要给单点值——轮询等待占比随文件大小变化,小文件时 5.1 几乎是全部收益,大文件时 5.3/5.4 占比上升。
**B 组的 123 秒正好落在客户说的「一页文件两分钟都传不到内网」上。** 且客户那边走光闸,摆渡本身还要额外加时间,现场只会比 123 秒更慢。这条可以作为「问题已定位并复现」的证据。
#### 顺带复现了 §10.4 修掉的日志副作用
A 组(基线 jar + 5 秒周期)在包还在写的时候就轮询到了它,外网侧连续打出 2 条
`ERROR ... 文件大小校验失败: 1_65.zip` 带完整堆栈。这正是 §10.4 降为 debug 的那个问题——
基线在 60 秒周期下每个在途文件刷 5 条/5 分钟,改 5 秒后会变成 60 条/5 分钟。对照实验直接把改前状态复现了出来。
#### 实验残留
- 镜像 `sfile-app:baseline`(834 MB)保留着,方便后续复测;不需要时 `docker rmi sfile-app:baseline` 清掉
- `sfile-app:optimized` 是 `sfile-app:local` 的同 ID 别名,纯保险,可随时删
- worktree `D:/dev/baseline-4eb67e2` 已 `git worktree remove` 清理
- override 文件在 `D:/dev/ab-test/baseline-{5s,60s}.yml`(仓库外,未提交)
### 10.7 尚未验证的部分
- [ ] **外传内方向**:本次只跑了内穿外,回程方向(外网组包 → 内网解包)未实测
- [ ] **现场系数**:客户环境是 Linux + 光闸监控目录,syscall 单价与本地 Docker Desktop 不同,必须按第 4 节重测
- [ ] **时间预算耗尽分支**:需单轮塞进足够多的大包才能耗尽 4 秒,本地没有这个数据量(见 §10.4)