Files
senmesh-zc/backend/docs/传输链路性能优化方案.md
KodouWZF fa77c561c0 docs(传输): 补基线 jar 对照实验,作废旧的 45.9 秒基线与 52×/31× 口径
用 4eb67e2 单独构建基线镜像跑 A/B 两组:A 组把监听周期摁成 5 秒隔离 I/O 改动,
B 组用基线自带 60 秒周期量真实改前端到端。

实测:组包 10,055/10,622 ms → 881 ms(11.4×),解包 7,777 → 785 ms(9.9×),
端到端 123.1 s → 9.9 s。原 45,934 ms 基线不可复现,§2.1/§3.4/§10.3/§10.5
中据此推算的数字全部改正。B 组 123 秒正好复现客户「两分钟传不到内网」的说法,
且其中 102 秒是轮询等待——瓶颈主要是 5.1 而非组包。
2026-11-03 23:47:46 +08:00

657 lines
43 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 跨网传输链路性能优化方案
> 版本: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)