download_omarchy.py:大文件分段下载、断点续传与完整性校验

[!abstract] 核心思路 把约 5.76 GiB 的 ISO 按固定字节区间切成 738 段;每段独立请求、落盘和续传。全部分段完成后,按序拼接并同时计算 SHA-256。只有大小和官方哈希都正确,才把临时成品替换成最终 ISO。脚本是这次下载任务的专用实现,参数和路径均写死在文件中。

实际脚本:/Users/czn/agent-workingspace/download_omarchy.py
最终镜像:/Users/czn/agent-workingspace/omarchy-4.0.4.iso
原始来源:Omarchy 4.0.4 发布页及其指向的 https://iso.omarchy.org/omarchy-4.0.4.iso。

一、为什么要设计这套策略

目标是把一个 6,185,304,064 字节的 ISO 可靠地下载到本地。当时服务器响应 Accept-Ranges: bytes,意味着客户端可以只请求某段字节。磁盘可用空间充足,但当前网络路径的单连接吞吐较低,连接也可能中途超时。

最先试用 aria2:16 条连接时约 0.6 MiB/s;该工具的 --max-connection-per-server 上限就是 16。于是改用多个独立的 curl 进程请求互不重叠的字节区间。脚本先用了 96 个并发任务,后来调到 192 个。这个数字只是当时的调优选择;最终日志中的 0.68 MiB/s average 是已完成分段字节数除以本次进程运行时间,并不等于真实网络瞬时吞吐。期间还试过另一镜像和 HTTP/3,未得到更好的可用路径。

这里真正要解决的是两个问题:

  1. 传输效率:用并发掩盖高延迟或单连接限速。
  2. 工作保存:连接断掉时保留已收到的字节,避免从该段起点重下。

并发只能改善某些瓶颈。如果出口带宽或代理本身已饱和,继续增加连接可能更慢,也可能增加服务器压力。因此 WORKERS = 192 不应被理解成通用最佳值。

二、先建立字节模型

脚本将总长 SIZE = 6_185_304_064 按 CHUNK = 8 * 1024 * 1024,即每段 8 MiB,切为:

总长度 SIZE = 6,185,304,064 字节
标准段长 CHUNK = 8,388,608 字节
分段数 count = ceil(SIZE / CHUNK) = 738

段 0000: [0,             8,388,607]      8 MiB
段 0001: [8,388,608,    16,777,215]      8 MiB
   ...
段 0737: [6,182,404,096, 6,185,304,063] 2,899,968 字节

第 i 段的计算式是:

start = i * CHUNK
end = min(start + CHUNK, SIZE) - 1
expected_size = end - start + 1

HTTP Range: bytes=start-end 两端都包含在内,所以 end 必须减 1。最后一段由 min 截断,避免请求越过文件结尾。各段恰好覆盖 [0, SIZE - 1],相邻段不重叠、无空洞。正因为边界可计算,拼接时只需按编号顺序读取分段。

三、整体控制流

flowchart TD
    A[读取固定参数:URL、SIZE、CHUNK、哈希] --> B[创建分段目录]
    B --> C[提交 738 个分段任务]
    C --> D[最多 192 个任务并发执行]
    D --> E{段文件大小正确?}
    E -->|是| F[复用现有 .part]
    E -->|否| G[从 .tmp 长度处继续请求]
    G --> H{HTTP 206 且收到字节?}
    H -->|是| I[将 .tail 追加到 .tmp]
    H -->|否| J[清理 .tail 并按条件退避]
    I --> K{达到该段目标长度?}
    J --> G
    K -->|否| G
    K -->|是| L[重命名为 .part]
    F --> M{所有分段完成?}
    L --> M
    M -->|否| D
    M -->|是| N[按编号拼接至 .download,同时计算 SHA-256]
    N --> O{总大小和哈希均正确?}
    O -->|否| P[报错并保留分段供排查]
    O -->|是| Q[用 .download 替换最终 ISO,清理分段]

主程序 main() 负责分配任务、按序拼接、校验和发布最终文件;fetch(index) 只负责一个字节区间。这种职责划分使分段可以并发,而拼接仍保持确定的顺序。

四、四种文件状态分别做什么

路径或后缀含义中断后如何处理
0000.tail本轮 HTTP 请求收到的字节暂存启动该段任务时删除;没有记录本轮请求起点,不能安全直接复用。
0000.tmp该段从起点开始的连续前缀读取文件长度,下一次从 start + offset 请求。
0000.part达到该段预期长度的完整分段下次运行先检查长度,符合则跳过网络请求。
omarchy-4.0.4.iso.download按序拼接中的临时成品只有完整哈希验证通过,才替换最终 .iso。

分段放在 .omarchy-4.0.4.parts/ 中。0000 等四位编号保证文件名可读,但拼接并不依赖目录排序,而是显式按 range(count) 顺序读取。

初版脚本在超时后删除整个 .tmp,使慢连接已经下载的几 MiB 白费。后续改成上面的两级暂存:.tail 接收本次请求,确认服务器返回 206 Partial Content 后再追加到 .tmp。这个修改直接解决了超时重下的问题。

五、fetch(index) 如何断点续传

每段开始先检查 .part 的长度。长度恰好正确就返回;否则读取 .tmp 当前长度 offset,发出范围请求:

原始段范围:     [start, end]
已保存前缀:     [start, start + offset - 1]
本轮请求范围:   [start + offset, end]

例如第 0 段已有 3,145,728 字节,则下次请求 Range: bytes=3145728-8388607。服务器的 206 响应体写入 .tail。即使 curl 因 180 秒总时限而以非零状态退出,只要已收到的响应标记为 206,脚本仍会把 .tail 中的字节追加到 .tmp,下轮从新的偏移继续。

请求由 Python 线程启动 curl 子进程。几个参数各有作用:

参数作用
--range start-end只要指定的字节区间。
--location跟随服务器重定向。
--fail对 HTTP 错误返回失败状态。
--connect-timeout 20连接阶段最多等待 20 秒。
--max-time 180每次请求最长运行 180 秒;超时后可保留已收到的有效字节。
--write-out %{http_code}将 HTTP 状态码送回 Python,区分 206 与其他响应。

达到 expected_size 后,.tmp 被重命名为 .part。超过目标长度则报错,防止字节溢出污染相邻段。一个分段最多尝试 30 轮;非 206 的失败会短暂退避。某段始终失败时,程序退出并保留已完成分段,下次可以续跑。

[!warning] 续传的信任边界 当前代码只检查状态码 206 和长度,没有逐次验证 Content-Range、ETag 或每段哈希。因此长度正确的 .part 只能称作“传输完成”,还不能称作“内容正确”。最终整文件 SHA-256 才是内容完整性的判定依据。若中途换源或远端文件被替换,最后会因哈希不符而拒绝发布。

六、并发模型与进度数字

ThreadPoolExecutor(max_workers=192) 最多同时运行 192 个 fetch。每个运行中的 fetch 同步等待一个 curl 子进程,所以主要工作是网络 I/O,Python 线程不负责解析或复制大量响应体。as_completed(futures) 在每段完成时回收结果。

这个实现的资源成本是同时存在大量 TCP 连接、curl 进程、文件描述符和临时文件。它能在单连接很慢时提高总吞吐,但并发数太高会增加连接建立与重试成本。实际调优应以稳定的有效字节/秒衡量,而不是连接数。

日志中的进度只把已完成的 .part 计入 done。正在写入的 .tmp 和 .tail 没算进去,因此下载时出现过“完整分段约 77%,目录却已接收约 5.5 GiB”的现象。进程重启后,显示的平均速度也会重新计时,不能拿它直接比较两次运行的网络性能。

七、拼接、哈希和最终发布

所有 fetch 返回后,main() 按分段编号顺序把每段复制到 .iso.download。每读取 1 MiB,就同时执行 digest.update(block) 和 output.write(block);无需再为拼接阶段额外扫描一次所有分段。

然后执行两项门槛检查:

  1. 拼接文件的 SHA-256 必须等于官方发布页给出的 ddeded2758c48318d201dfdac905ecb28f570441883f0c052ea3cd5d05acf92d。
  2. 拼接文件大小必须为 6,185,304,064 字节。

通过后,OUTPUT.replace(TARGET) 在同一文件系统内把临时成品替换为 omarchy-4.0.4.iso,最后清理分段目录和之前 aria2 的控制文件。若校验失败,代码在替换目标前抛错,分段与 .download 留在本地供排查。replace 提供文件名层面的原子替换;这段代码没有调用 fsync,不承诺断电情况下的持久化语义。

此次完成后,还对最终落盘的 ISO 独立运行了 shasum -a 256。结果与拼接时的哈希和官方发布页一致,最终文件大小也重新由 stat 确认。

八、失败模式与工程取舍

场景当前处理边界或代价
单次请求超时保留 206 已接收的字节,下一轮按 .tmp 长度续传。本轮 .tail 在进程被强制结束时可能丢失。
HTTP 错误或不支持 Range不追加响应体,重试或最终报错。只看 206,没有检查完整的 Content-Range。
某段长度异常报错,停止最终发布。无自动定位和修复已写错内容的段。
进程重启复用长度正确的 .part 和 .tmp 连续前缀。仅按长度复用,段内正确性仍靠整文件哈希。
源内容发生变化整文件哈希不符,不发布。需要重新定位受影响分段;脚本没有 If-Range 或版本锁定。
磁盘写满系统写入报错,最终 ISO 不会发布。未做运行中剩余空间监控。
并发过高可修改 WORKERS 后重启,已完成分段继续复用。过高并发可能让代理、服务器或本机成为瓶颈。

值得继续改进的地方是:从官方 .sha256 文件动态读取预期哈希;对每个响应校验 Content-Range 与固定的 ETag;用分段哈希或清单快速定位错误段;对 .tail 记录请求起点以便异常退出后复用;把 URL、路径、并发数与超时改成命令行参数;按 .part + .tmp + .tail 的实际字节数报告进度;使用受控关闭和更明确的磁盘空间检查。这些都是未来改进方向,不属于当前脚本已实现的功能。

九、如何复现与确认结果

脚本是特定版本的一次性下载器,运行前应先看清文件顶部的常量,尤其是 ROOT、TARGET、SIZE 和 EXPECTED。已经得到通过校验的 ISO 后,无需再运行脚本;重新运行会重新构建分段。

python3 /Users/czn/agent-workingspace/download_omarchy.py
stat -f '%N %z bytes' /Users/czn/agent-workingspace/omarchy-4.0.4.iso
shasum -a 256 /Users/czn/agent-workingspace/omarchy-4.0.4.iso

本次实测最终大小为 6,185,304,064 字节,独立重算的 SHA-256 为 ddeded2758c48318d201dfdac905ecb28f570441883f0c052ea3cd5d05acf92d。

相关笔记

  • [[Windows 10 与 Omarchy 双系统:MSI MS-1759 安装 U 盘、分区与启动指南]]