大文件下载总中断?聊聊断点续传与并发分片下载的工程实现
前面聊 m3u8 合并、聊 CDN 就近接入,都是在"怎么拿到地址、怎么快"。本文往前一步:地址有了、网也不差,可一个几 GB 的文件下到 90% 断了怎么办?工程上靠两样东西——断点续传和并发分片。
目录
一、HTTP Range:下载的"书签"
关键在于请求头 Range:
Range: bytes=0-1023
服务端若支持,返回 206 Partial Content 并带上 Content-Range: bytes 0-1023/5000,只给这一段。判断是否支持,看响应里有没有 Accept-Ranges: bytes。
二、断点续传:从断的地方继续
思路极简:
- 下载前记录"已落盘字节数
offset"; - 重发请求时带
Range: bytes=offset-; - 以追加模式写入文件,而不是覆盖。
def resume_download(url, path):
offset = os.path.getsize(path) if os.path.exists(path) else 0
headers = {'Range': f'bytes={offset}-'}
with requests.get(url, headers=headers, stream=True) as r:
mode = 'ab' if offset else 'wb' # 有进度就追加
with open(path, mode) as f:
for chunk in r.iter_content(8192):
f.write(chunk)
断网、程序崩溃都不怕——重启后 offset 还是那个值,从头续上。
三、并发分片:把文件切成几段一起下
单连接受限于一条 TCP 的吞吐,把文件按大小切成 N 段,每段独立一个 Range 线程/协程并行下载,最后按顺序拼起来:
import asyncio, aiohttp
async def fetch_range(session, url, start, end, path):
headers = {'Range': f'bytes={start}-{end}'}
async with session.get(url, headers=headers) as r:
data = await r.read()
with open(path, 'r+b') as f:
f.seek(start)
f.write(data) # 各段按偏移写回原位
async def parallel(url, size, parts=8):
step = size // parts
async with aiohttp.ClientSession() as s:
tasks = []
for i in range(parts):
st = i * step
ed = size - 1 if i == parts-1 else (i+1)*step - 1
tasks.append(fetch_range(s, url, st, ed, 'out.bin'))
await asyncio.gather(*tasks)
注意:文件要先以 r+b 预分配大小(f.truncate(size)),各段才能 seek 到正确偏移写入。
四、合并与校验
- 分片落同一文件:如上,
seek回写,天然有序,无需再拼接。 - 分片落多个临时文件:则严格按段序号顺序
cat;顺序错了就是乱码。 - 校验:下完比对
Content-Length或算md5/sha256,防止某段静默损坏。
这种"先分片下载、再合并成完整文件"的思路,和 用 ffmpeg 把 m3u8 合并成 mp4 是同一类工程问题,只是对象从 TS 片段变成了字节区间。
五、常见坑
| 现象 | 原因 | 解决 |
|---|---|---|
| 返回 200 而非 206 | 服务端不支持 Range | 只能单连接,放弃分片 |
| 合并后画面错位 | 段顺序写反 / 偏移算错 | 严格按 start 偏移写回 |
| 续传从头开始 | 用了覆盖模式 wb |
改用追加 ab |
| 速度没提升 | 单 IP 被限速 / 线程过多 | 控制并发数,参考 CDN 就近接入 的思路选近节点 |
断点续传 + 并发分片,是把"大文件下载"从"听天由命"变成"可控工程"的两块基石。