Files
senmesh-zc/backend/docs/传输链路性能优化方案.md
KodouWZF 75ab51e13c docs(传输): 补一期实施与本地实测记录
监听周期实测精确 5.00 秒触发、空目录单轮 8-13ms;md5 索引在 23 行下
规划器仍选顺序扫描(正确行为),关掉后走 Index Scan 证明索引可用;
容器内 I/O 基准(同镜像同 bind mount,真实 23 个文件 19.04MB)测得
缓冲区改造 8.2 倍、叠加 level 0 共 17.6 倍,包体 +5.1%。

据实修正第 3.4 节的预测偏差:31 倍是纯 I/O 理论值,缓冲改完后 deflate
CPU 占到剩余耗时的一半,对外口径改用实测的 8 倍/17 倍。
另记录尚未验证的部分(应用内真实组包、解包侧、level 0 往返、现场系数)。
2026-11-03 22:22:27 +08:00

489 lines
28 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.1(2026-11-03)
> 适用后端:s-file(alita-bjy-uygur)
> 状态:一期(5.1/5.3/5.4/5.5)已实施并本地验证 → 待现场验证;二期(5.2)待实施
> 实施与实测记录见第 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** |
**结论 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) | 改 64 KB 缓冲后(约 0.075 s/MB) | 提升 |
|---|---|---|---|
| 100 KB(一页文档) | 0.23 s | 0.008 s | 29× |
| 10 MB | 23 s | 0.75 s | 31× |
| **50 MB** | **117 s(约 2 分钟)** | **3.8 s** | **31×** |
| 200 MB | 466 s(7.8 分钟) | 15 s | 31× |
| 500 MB | 1165 s(19 分钟) | 38 s | 31× |
| 1 GB | 2330 s(39 分钟) | 76 s | 31× |
改后系数 0.075 s/MB 的构成:I/O 约 0.035 s/MB(每 MB 只剩 32 次 syscall)+ 压缩 CPU 约 0.04 s/MB。
> **50 MB 的回答:现状约 2 分钟,改完约 4 秒。**
> 若再叠加 5.4 节(已压缩格式不二次压缩),50 MB 可降到约 2 秒。
>
> **重要: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 单轮处理上限:从「个数」改为「时间预算」
**现状**:`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/env | 4 | — | 单轮时间预算,5.2 |
| `file.transfer.monitor.round-max-files` | yml/env | 500 | 硬编码 10 | 单轮兜底个数上限,5.2 |
| `file.transfer.monitor.concurrency` | yml/env | 2 | 1(串行) | 并发解包线程数,5.2 |
| `file.transfer.monitor.max-unpack-size-mb` | yml/env | 建议 1024 | 无 | 单文件大小上限,超限标 `.error`,5.3 注意事项 |
涉及文件:`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. 实施顺序与风险
| 顺序 | 项 | 改动量 | 风险 | 收益 |
|---|---|---|---|---|
| 1 | 5.3 缓冲区 64 KB | 5 处,各 1 行 | 极低 | 大文件组包 31× |
| 2 | 5.1 监听周期 5 秒 | 配置 | 低 | 端到端省约 55 秒 |
| 3 | 5.5 md5 索引 | 4 个 SQL 文件 | 极低 | 多文件解包明显加速 |
| 4 | 5.4 level 0 | 小 | 低 | 已压缩格式再省 CPU |
| 5 | 5.2 时间预算 + 认领 + 并发 | 中 | 中(涉及并发与文件改名状态机,需完整测试) | 高峰期吞吐一个数量级 |
| 6 | 5.6 异步组包 | 大 | 中 | 仅超大文件场景需要,二期评估 |
建议 1–4 作为一期(都是低风险、可独立验证的改动),5 作为二期,6 视现场文件大小分布决定。
**每一项都必须单独验证并记录数字**,不要合并提交,否则现场出问题时无法定位是哪一项引起的。
---
## 10. 一期实施与验证记录(2026-11-03,本地环境)
一期 4 项已实施,每项独立提交:
| 项 | 提交 | 内容 |
|---|---|---|
| 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` |
编译: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 模式) | 基线 45.9 秒那次是在不同负载/冷缓存下采的;基准跑前已有预热。两者都是真实的,差异来自环境状态 |
| 提速倍数 | 31× | **8.2×**(level 6)/ **17.6×**(level 0) | 31× 是纯 I/O 的理论值。实测 new-l6 的 1022 ms 里约 500 ms 是 deflate CPU(因为 new-l0 只要 509 ms),缓冲改完后 CPU 占比升到一半,总倍数自然低于纯 I/O 倍数 |
**结论:预测方向正确、量级偏高。** 现场汇报口径建议用实测的 **8 倍(仅改缓冲)/ 17 倍(含 level 0)**,不要用 31 倍。第 3.4 节的表格在现场按 4.2 的系数重算后再对外给数。
### 10.4 尚未验证的部分
- [ ] **应用内真实组包**:需要登录后发送一次同一个文件夹,从 `ControllerLogAspect` 的 `useTime` 与「传输文件打包完成」日志确认应用侧数字(基准只覆盖 I/O 环节,未含 SQL 与元数据序列化)
- [ ] **解包侧**:64KB `ZipInputStream` 的改动只做了编译验证,未跑通一次真实解包(同样受登录限制)
- [ ] **level 0 产出的 zip 能否被接收端正常解包**:需一次内穿外 + 一次外传内的完整往返
- [ ] **现场系数**:客户环境是 Linux + 光闸监控目录,syscall 单价与本地 Docker Desktop 不同,必须按第 4 节重测