# 迅雷式落盘改造完成（稀疏文件 + 偏移直写 + rename）

- 日期：2026-09-12 23:15 上线
- 对象：本机下载中心 8899 分块上传落盘路径（`~/Downloads/download-server.js`）
- 起因：用户「按照迅雷的改」——见同目录《2026-09-12-为什么迅雷收尾快-机制对比与改造方案.md》
- 目标：消除「收尾合并拷贝」，把全程磁盘 I/O 从 **3× 降到 1×**，收尾从「分钟级」变「毫秒级」

---

## 一、改造内容

| # | 改动 | 说明 |
|---|---|---|
| 1 | **分片直写最终偏移** | 分片不再落地为 `<i>.part`，而是写进会话目录里的稀疏文件 `<会话>/data`，偏移 = `序号 × 分片大小`（`fs.createWriteStream(data, {flags:'r+', start: index*cs})`） |
| 2 | **收尾只做 rename** | `complete` 校验通过后 `rename(data → 目标路径)`。数据文件放在 `.upload-parts/<hash>/` 下、与目标同在 `用户上传` 分区内，因此 rename 是纯元数据操作；半成品也天然不会出现在列表页 |
| 3 | **预分配** | 首次收到分片时 `ftruncate(fileSize)` 把稀疏文件撑到最终大小，不占实际空间 |
| 4 | **收尾闸门保留** | 原来的「合并并发闸门」（`DL_MERGE_CONCURRENCY=2`）改用于「遗留分片补齐」，避免多文件同时搬盘 |

### 关键设计决定（都踩过坑）

1. **分片大小必须确知**：偏移 = 序号 × 分片大小，而分片大小由客户端决定。取值优先级：
   **`x-chunk-size` 声明 > 会话记忆(`meta.chunkSize`) > 存量 `.part` 推断 > 本次 Content-Length 反推 > 默认 64MB**。
   前端已同步加上 `x-chunk-size` 头；老页面（不带该头）也能被正确推断，**因此不需要强制刷新页面**。
2. **长度校验前移**：直写下长度错位会直接损坏文件。现在在**写入之前**用 `Content-Length` 校验「期望长度」（除最后一块外等于分片大小），不符直接 400 且不登记。
3. **崩溃安全**：只在写流 `finish`（数据真正写完）之后才把分片登记进 `meta`，避免崩溃时把半个分片误标为已收到。
4. **半截 `.part` 不算数**：状态接口现在按「长度是否等于期望」判定已收到。否则客户端会跳过截断的块、收尾又报长度不符，形成「跳过→失败→再跳过」死循环。
5. **存量会话混合兼容**：老分片留在 `.part` 不动，新分片直写 `data`；收尾时只把**老 `.part` 按偏移补进** `data`，新数据不重复搬运。优先采用直写数据，半截 `.part` 视为不存在。
6. **换文件兜底**：会话的 `fileSize`/`totalChunks` 与本次请求不一致时，服务端主动重置会话（前端也会先 abort）。

## 二、验证结果（真实 ext4 /dev/sda5 上）

### 专项测试：**20 / 20 通过**（`/tmp/xunlei-test.mjs`）

| 用例 | 结果 |
|---|---|
| 直写模式：会话目录只有稀疏 `data`，没有 `.part`；`data` 表观大小 = 文件大小 | ✅ |
| 3 分片（64+64+32MB）上传 → 收尾 → **逐字节一致** | ✅ 收尾 **9ms** |
| 存量会话（分片全在 `.part`）→ 收尾按偏移补齐 → 逐字节一致（`legacyCopied=2`） | ✅ |
| 半截 `.part` 被判为「未收到」，`complete` 正确 409 | ✅ |
| 长度不符的分片被拒（400）且不登记 | ✅ |
| 自定义分片大小 16MB×3 直写正确 | ✅ |
| 稀疏空洞（meta 声称收到但无数据）被检出 409 | ✅ |

### 回归测试：**23 / 23 通过**（原有上传功能全套：跳过去重、改名保存、断点续传、子目录落盘、页面注入）

### I/O 放大证明（640MB 上传，`sync` 后读块设备计数）

| 指标 | 旧方式（分片 + 合并拷贝） | **现在（迅雷式）** |
|---|---|---|
| 块设备写 | ≈ 2.00×（分片 1× + 合并 1×） | **1.00×**（实测 640MB 数据 → 写 640MB） |
| 块设备读 | ≈ 1.00×（合并时把分片读一遍） | **0.00×**（实测读 0MB） |
| 收尾耗时（512MB 对照） | 48.3 s（拷贝） | **3 ms**（rename） |
| 峰值占盘 | 2×（`.part` 与最终文件并存） | **1×** |

## 三、对「正在进行的那一批」的影响（重要）

- 已落盘模型：**25 个**（本会话开始时 13 个）。
- 剩余 **35 个会话**中，磁盘上已有 **57.3GB 的存量 `.part`** —— 这部分是改造前写入的，**仍必须搬一次**（读+写），这正是当前 `mergeQueued=7` 在做的事，每个多 GB 文件在本机机械盘上要几分钟。
- **新上传的分片从此直接落位**（已有 4 个会话在使用稀疏 `data` 文件）→ 这部分省掉了 2× 的重复 I/O。
- 因此本批的净收益是「未上传部分 1× 而非 3×」，估算可省约 1.5 小时；**完全受益的是下一批**（全程 1× I/O，约 2~3 倍提速）。
- 为什么不在收尾期间让上传继续跑？因为磁盘是全局瓶颈，两者总 I/O 不变，交错执行不会缩短总时间（实测磁盘写入上限 23MB/s）。

## 四、运维与回滚

```bash
systemctl status dl-server            # 服务状态
curl -s --noproxy '*' http://127.0.0.1:8899/healthz   # 含 mergeActive / mergeQueued
tail -f ~/Downloads/dl-server.log     # 带时间戳
```
- 可调参数：`DL_MERGE_CONCURRENCY`（收尾并发，默认 2）、`DL_UP_PARALLEL`（前端并发，默认 5）
- 会话目录结构：`.upload-parts/<sha256(name)[:24]>/` 内含 `meta.json`（含 `received`/`sizes`/`chunkSize`/`direct`）、`data`（稀疏目标文件）、以及存量 `*.part`
- 回滚：备份 `download-server.js.bak-20260912-214503` 是**改造前**的版本（不含本日其余修复，仅作参考）；如需回滚请连同 io_uring/连接治理改动一并评估

## 五、相关文件
- 服务端：`~/Downloads/download-server.js`
  （`handleUploadChunk` 直写、`completeUpload` 校验+补齐+rename、`ensureSparseData`、`copyIntoAt`、`chunkSizeOf`/`chunkExpect`、`handleUploadStatus` 合并判定）
- 测试脚本：`/tmp/xunlei-test.mjs`（20 项专项）、`/tmp/up-test.mjs`（23 项回归）、`/tmp/io-proof2.mjs`（I/O 放大证明）
  - 副本已存 `dl-hub/05-下载服务维护/tests/`

## 六、上线后发现并修掉的「看起来卡住」问题（23:27）

**现象**：用户看到 5 行停在 `🔍 … — 检查中…`，以为卡死。

**实情**：这 5 个文件**当时已经全部落盘成功**（chilloutmix 3.97GB、Counterfeit-V2.5-pruned 1.99GB、Counterfeit-V2.5_pruned 3.97GB、CounterfeitV30_v30-pruned 1.99GB、CounterfeitV30_v30 3.95GB）。用户看到的是**文案缺口**：
当前端检查到「所有分片其实早已收齐」（重选续传的常见情形）时，会从 `检查中…` **直接进入收尾落盘**，中间不更新行内文案；而服务端此时正在做 GB 级的遗留分片补齐，于是长时间停在 `检查中…`。

**服务端当时确实在高速工作**（同期实测）：
- 进程 20 秒内 `rchar +3239MB`、`wchar +3238MB`
- 块设备读 1651MB / 写 2143MB
- `/proc/<pid>/fd` 中可见正在拷贝的 `<i>.part` → `data`
- `mergeQueued` 从 7 降到 2~3，已落盘模型从 25 增至 28

**修复**：`completeMerge()` 进入收尾时立即把行内文案改为
`📦 文件名 — 收尾落盘中…（等待落盘队列）`
（需刷新页面生效；服务端已于 23:27:56 重启加载）
