# 为什么迅雷「收尾」那么快？——机制对比与本机改造方案

- 日期：2026-09-12
- 起因：用户提问「迅雷也是分块下载，但最后下载完成的速度就很快，是什么原因」
- 背景：本站下载中心 8899 使用「分片存临时目录 → 收尾合并拷贝」；本机 `/home` 为机械盘（`/dev/sda5`，`rotational=1`）

---

## 一、一句话结论

**迅雷没有「收尾合并」这一步。** 它下载时就把数据**直接写到最终文件的偏移位置**，完成时**只做一次改名（rename）**——不搬运任何数据。而本站是「分片先存别处，收尾再把整份数据按顺序拷贝拼成最终文件」，收尾本身要读一遍、写一遍。

## 二、迅雷式（BT 类）机制的四个要点

1. **预分配**：开始下载时先把文件撑到最终大小（稀疏/预占空间）。位子先占好，写的时候直接落位。
2. **偏移直写（核心）**：每个分片到达后用 `pwrite(fd, buf, len, piece_index × piece_size)` 写到它在文件中的**最终偏移**。分片之间互不依赖，所以能乱序写、多线程写、多来源并发写同一文件的不同区域。
3. **完整性靠位图/分片校验，不靠拼接**：每个分片单独校验并标记，全部置位即完成——不需要「把分片按顺序拼起来」这个动作。
4. **rename 收尾**：完成时把临时名（`.td`/`.xltd` 等）改成正式名。**同一分区内 rename 只改目录项**，与文件大小无关。

附加的感知层原因：迅雷是**异步落盘**（缓冲区写完就报「完成」，后台继续 flush），而本站的合并**同步卡在上传请求里**——客户端必须等合并 HTTP 响应才释放任务名额，所以那段慢是「看得见」的。另外多来源并行补最后几个分片，尾部不会因单源慢而拖尾。

## 三、两种机制的代价对比

| | 迅雷式（偏移直写 + rename） | 本站当前（分片 + 合并拷贝） |
|---|---|---|
| 全程磁盘 I/O | **1× 文件大小** | **3×**（上传写 1× + 合并读 1× + 合并写 1×） |
| 收尾耗时 | **毫秒~亚秒级**（rename） | 与文件大小成正比（读+写各一遍） |
| 峰值占盘 | 1×（预分配） | 2×（`.part` 与最终文件并存） |
| 乱序/断点续传 | 天然支持（写偏移） | 支持（分片文件），但收尾仍要整份拷贝 |
| 校验 | 分片哈希/位图 | 位图（`meta.received`）+ 总长校验 |

## 四、本机实测（机械盘 `sda5`，直接 I/O 绕过页缓存）

| 实验项 | 实测结果 |
|---|---|
| 预分配 2GB 稀疏文件（`truncate`） | 1.64 s，实际占盘 **0** |
| 偏移直写 256MB（写到最终位置） | 7.03 s → **36.4 MB/s**（就是磁盘写速） |
| **收尾 rename（1.25GB 文件）** | **0.74 s** |
| 对照：合并拷贝 512MB（读 1× + 写 1×） | **48.3 s** → 折合 10.6 MB/s 产出 |
| 单路直接顺序读 / 写 | 54.6 / 23.0 MB/s |
| 2 路并发读+写 | 各 10 MB/s（合计约 20 MB/s） |

**换算到这批 137GB**：迅雷式 = 137GB I/O、收尾≈0；本站式 = 411GB I/O，其中**多搬 274GB ≈ 多花 187 分钟**（按 25MB/s）。

## 五、改造方案（让本站也变成迅雷式）

### 目标设计
1. 分片到达时不再写 `.upload-parts/<hash>/<i>.part`，而是：
   - 首次收到该文件的分片时，在**目标目录**创建隐藏的稀疏文件 `.uploading-<uploadId>`，`ftruncate` 到 `fileSize`；
   - 用 `fs.createWriteStream(directPath, { flags:'r+', start: index × CHUNK_SIZE })` 把该分片**直接写到最终偏移**。
2. `complete`：校验 `meta.received` 覆盖全部分片 + 文件大小正确 → `renameSync('.uploading-<id>', finalTarget)`（含 `rename` 改名保存逻辑）→ 删除会话目录。**无数据拷贝。**
3. `abort` / 僵死清理：同时删除 `.uploading-*`。
4. 启动孤儿清理：删除没有对应会话的 `.uploading-*`。
5. 列表页隐藏：`.uploading-*` 以点开头（现有逻辑已隐藏隐藏文件），确保半成品不会被下载。

### 存量会话兼容（重要）
当前批次已有 67.8GB 分片存在 `.part` 里。混合策略：
- 分片路径判断：新分片 → 直写目标偏移；老分片 → 保持 `.part` 不动。
- `complete` 时：**只把「老 `.part` 存在的那部分」按偏移拷进临时文件**（这部分不可避免要搬一次），新分片已经在位；然后 rename。
- 收益：对已收 50% 的文件，剩余 50% 从「写 1× + 读 1× + 写 1×」降为「写 1×」；本批剩余 63.9GB 预计**省约 1.5 小时**；完全收到齐的文件（等合并的 5 个）仍需一次拷贝，与现状相同。

### 风险与验证
- 风险点：分片并发写同一文件不同偏移（安全，各写各的）；重复分片重写同一偏移（数据相同，安全）；写一半中断（稀疏空洞由 `meta.received` 判定，重新上传该分片即可）。
- 验证：现有 **23 项上传功能测试**（含 67MB 分块上传、字节级比对、改名保存、跳过去重），将全部重跑；再补一项「稀疏空洞不得被误判为已完成」的用例。
- 需要一次服务重启（会中断在传分片；进度保留，重选续传）。**建议在下一批上传前落地**，对当前批次的收益是部分的。

## 六、相关文件
- 服务端：`~/Downloads/download-server.js`（`handleUploadChunk` / `completeUpload` / `streamMerge` / 孤儿清理）
- 测试：`/tmp/up-test.mjs`（23 项功能测试）
