断点续传与分片下载是怎么实现的?Range 请求在视频下载中的工程实践

断点续传与分片下载是怎么实现的?Range 请求在视频下载中的工程实践

凌晨一点,你在下载一个 800MB 的课程视频。进度条爬到 92%,路由器闪了一下红灯------连接断了。下载器弹出一行冷冰冰的提示:「下载失败,请重新开始」。你盯着那个 92%,心里清楚接下来是又一个 40 分钟的等待。更糟的是,凌晨的网络和你一样不稳定。

如果这个下载器支持断点续传,它只需要从 92% 的位置继续拉剩下的 8%。这背后不是什么黑魔法,而是一个存在了二十多年的 HTTP 机制:Range 请求。这篇文章把它的协议原理和工程落地完整拆一遍,包括分片并发、限速、断点状态持久化、分片校验与合并------你在各类下载工具里看到的「多线程加速」「断点续传」「自动重试」,底层都是这一套。

先说边界:本文聚焦 HTTP Range 协议原理与通用工程实践,示例代码仅做原理演示;素材采集仅供个人学习使用,请遵守原平台的版权规则与使用条款,不要用于二次分发或商用。

📑 文章目录

  • [一、从 92% 的进度条说起:HTTP 下载的三种命运](#一、从 92% 的进度条说起:HTTP 下载的三种命运)
  • [二、协议层:Range、206、Accept-Ranges、If-Range 与 ETag](#二、协议层:Range、206、Accept-Ranges、If-Range 与 ETag)
  • 三、工程拆解:分片策略、并发与限速
  • [四、用 Python 实现一个分片下载器(原理演示)](#四、用 Python 实现一个分片下载器(原理演示))
  • [五、断点状态持久化:.part 文件与进度 JSON](#五、断点状态持久化:.part 文件与进度 JSON)
  • 六、分片校验、失败重试与合并
  • 七、合规红线与工程边界
  • [❓ 常见问题 FAQ](#❓ 常见问题 FAQ)
  • [📝 总结](#📝 总结)

🌐 一、从 92% 的进度条说起:HTTP 下载的三种命运

一次普通的 GET 请求下载文件,本质上是服务器把整个响应体按字节流推给你。这个过程只有两种状态:完整拿到,或者一无所有(HTTP/1.1 时代流式写入其实可以拿到部分字节,但客户端无法告诉服务器「从第 N 个字节继续」)。

于是出现了三种命运:

场景 不支持续传的下载器 支持续传的下载器
网络中断 丢弃已下载字节,从 0 重来 记录已下载偏移,从断点继续
暂停后恢复 已下载部分作废 发 Range 请求补齐剩余
想加速 单连接,速度受限于单流 切多个分片并行拉取

第三行值得多说一句:所谓「多线程下载提速」,并不是让服务器跑得更快,而是用多个 TCP 连接摊平单连接的拥塞窗口爬坡与丢包恢复成本,同时绕开部分服务端的单连接限速。这在高延迟链路上收益尤其明显。

🤔 那服务器凭什么愿意只给你文件的一半?

因为 HTTP/1.1 起协议里就定义了 Range 机制(RFC 7233,后被 RFC 9110 收编合并)。服务器按你指定的字节区间返回资源的部分内容,状态码是 206 而不是 200。这个能力不是客户端 hack 出来的,是协议的一等公民。

📡 二、协议层:Range、206、Accept-Ranges、If-Range 与 ETag

先把核心报文过一遍。一次标准的续传请求与响应长这样:

http 复制代码
GET /media/course-lesson03.mp4 HTTP/1.1
Host: cdn.example.com
Range: bytes=838860800-
If-Range: "33a64df551425fcc55e4d0a6f9e7c3b8"

HTTP/1.1 206 Partial Content
Content-Type: video/mp4
Content-Range: bytes 838860800-999999999/1000000000
Content-Length: 161139200
Accept-Ranges: bytes

逐个字段拆解:

字段 谁发的 作用 工程要点
Range 客户端 声明要哪段字节,如 bytes=0-1023bytes=1048576- 支持多区间,但视频下载几乎只用单区间
Accept-Ranges: bytes 服务端 告知客户端「我支持字节级 Range」 预检必看;值若为 none 或缺失,只能老实整体下载
206 Partial Content 服务端 部分内容响应,配 Content-Range 说明区间与总长 收到 200 说明服务器忽略了 Range,需按全量处理
Content-Range 服务端 bytes start-end/total,end 含闭区间 分片器靠它拿总大小和校验区间
If-Range 客户端 携带 ETag(或 Last-Modified),资源没变才返回 206 防止文件在服务端已更新却续传出「拼接怪」
416 Range Not Satisfiable 服务端 请求区间越界 通常是 total size 记错了,应清空进度重来

思考:💡 很多人以为断点续传只需要 Range,其实 If-Range 才是安全阀。 如果 CDN 上的文件在你暂停的间隙被重新上传(ETag 变了),不带 If-Range 的续传会把两个版本的字节拼在一起------视频前半段是新编码、后半段是旧的,播放器直接花屏。ETag 本质是资源内容的指纹,If-Range 把「资源是否还是那个资源」这个前置条件交还给服务器判断,这是整个续传逻辑里最容易被忽略的一环。

另外一个工程细节:bytes=0-0 这个只取 1 个字节的探测请求,常被用来低成本确认「服务器是否支持 Range、当前 ETag 是什么、总长多少」,比直接 HEAD 更稳------有些 CDN 对 HEAD 的支持和对 GET 不一致。

🧩 三、工程拆解:分片策略、并发与限速

协议层解决了「能取一段」,工程层要解决「取多少段、同时取几段、怎么不打死服务器」。一个生产可用的分片下载器,通常是这样的流水线:

  1. 探测 :发 Range: bytes=0-0,确认支持范围请求,拿到 ETag 与总大小;
  2. 切分:按固定分片大小(常见 1--8MB)把文件切成 N 段;
  3. 调度:用线程池/协程池维持固定并发数,完成一片领下一片;
  4. 落盘 :每片写入 .part 临时文件的对应偏移位置;
  5. 持久化:进度实时写入 JSON,崩溃后可恢复;
  6. 校验与合并:全部分片完成后校验、合并、改名。

分片大小的选择是个典型权衡:

分片大小 优势 劣势 适合场景
小(≤1MB) 断点粒度细,重试代价低 请求开销大,元数据多 不稳定网络、移动端
中(2--8MB) 请求开销与恢复粒度均衡 无明显短板 大多数视频下载
大(≥16MB) 请求少、吞吐高 单片失败重传成本大,限速响应慢 稳定内网、超大文件

🤔 并发数是不是越多越快?

不是。并发收益在链路带宽打满后立即归零,代价却线性增长:连接数过多会触发服务端限流甚至封禁,客户端侧则表现为内存占用和调度抖动。实践上 4--8 个并发是甜点区,配合「令牌桶」限速(每秒最多放行 N 字节的写盘/请求额度)还能避免把家里的网络打到视频会议卡顿------很多下载工具的「下载完不吵到正在开会的你」,就是靠这个。

🐍 四、用 Python 实现一个分片下载器(原理演示)

下面这段代码演示核心机制:探测、分片、并发请求、按偏移写盘。为了可读性省略了限速与重试(下一节补),请把它当作原理教学代码,而不是拿来即用的生产工具:

python 复制代码
import os
import requests
from concurrent.futures import ThreadPoolExecutor

CHUNK = 4 * 1024 * 1024  # 4MB 分片

def probe(url: str) -> dict:
    """探测服务器是否支持 Range,并返回 ETag 与总大小"""
    r = requests.get(url, headers={"Range": "bytes=0-0"}, timeout=10)
    if r.status_code != 206:
        raise RuntimeError("服务器不支持 Range 请求")
    # Content-Range: bytes 0-0/1000000000
    total = int(r.headers["Content-Range"].split("/")[-1])
    return {"total": total, "etag": r.headers.get("ETag")}

def download_chunk(url: str, etag: str, start: int, end: int,
                   part_path: str):
    """下载单个分片,按偏移写入 .part 文件"""
    headers = {"Range": f"bytes={start}-{end}"}
    if etag:
        headers["If-Range"] = etag  # 资源变了就整段重下,避免拼接错版
    with requests.get(url, headers=headers, stream=True, timeout=30) as r:
        r.raise_for_status()
        with open(part_path, "r+b") as f:
            f.seek(start)
            for buf in r.iter_content(chunk_size=256 * 1024):
                f.write(buf)

def download(url: str, out_path: str, workers: int = 4):
    meta = probe(url)
    part_path = out_path + ".part"
    # 预分配与目标等大的文件,避免写入过程中磁盘碎片
    with open(part_path, "wb") as f:
        f.truncate(meta["total"])
    ranges = [(s, min(s + CHUNK - 1, meta["total"] - 1))
              for s in range(0, meta["total"], CHUNK)]
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = [pool.submit(download_chunk, url, meta["etag"],
                               s, e, part_path)
                   for s, e in ranges]
        for fu in futures:
            fu.result()  # 抛出分片异常,便于上层重试
    os.rename(part_path, out_path)  # 原子改名,完成前对外不可见

三个值得停下来看一眼的点:

  • f.truncate(total) 预分配文件后用 r+b 模式随机写,这是分片并发落盘的基础------分片下载本质是把顺序流变成了对同一文件的随机写
  • If-Range 带上 ETag,服务器资源变更时会返回 200 全量而非 206,客户端据此可以判断「进度已失效」;
  • 最后 os.rename 原子改名:下载完成的标志不是「写完最后一个字节」,而是「临时文件变成正式文件」。这个细节能挡掉大量「下到 99% 的半截文件被剪辑软件读到」的怪问题。

💾 五、断点状态持久化:.part 文件与进度 JSON

进程随时可能被杀死:用户关电脑、系统更新、网络掉线。断点续传的下半场是把「下载到了哪」变成磁盘上的持久状态。常见的双文件方案:

复制代码
course-lesson03.mp4.part       # 数据文件:预分配、随机写
course-lesson03.mp4.part.json  # 状态文件:每片的完成位图与元数据
json 复制代码
{
  "url": "https://cdn.example.com/media/course-lesson03.mp4",
  "etag": "\"33a64df5...\"",
  "total": 1000000000,
  "chunk_size": 4194304,
  "completed": [true, true, true, false, false],
  "updated_at": "2026-09-02T01:23:45+08:00"
}

恢复流程:程序启动 → 读 JSON → 校验 ETag 与 URL 是否仍匹配 → 匹配则只对 completed 为 false 的分片发请求,不匹配则清空重来。注意两个坑:一是 JSON 要原子写(先写临时文件再 rename),否则崩溃瞬间会留下半截 JSON,恢复逻辑直接崩;二是「数据文件里写了但 JSON 里没记」的窗口期是允许的,代价只是重传个别分片,宁可多下不可错拼。

思考:💡 持久化的本质是用少量重复传输换取一致性。 分片机制把「整个文件」的最小重传单位缩小到「一片」,进度位图把恢复成本压到常数级------两者叠加,才有了用户感知上的「永远从断点继续」。

工程化到批量场景,这套状态还要再升一级:当一个下载器同时跑几十个视频任务,每个任务各自带着 .part 与进度文件,完成后的归档、去重、命名规范就变成素材管理问题,而不是下载问题。

🔗 六、分片校验、失败重试与合并

校验 :可靠的下载器会对整份文件算哈希。若服务端提供 ETag 为内容哈希或提供 x-hash 类响应头,客户端全量下载完成后本地重算比对;不一致则定位坏分片重传,而不是整文件重来。分片级校验更精细,但需要服务端配合提供分片哈希清单,公网 CDN 通常没有,所以实践中多为「整文件哈希 + 单分片重试」的组合。

重试:成熟的策略是指数退避加抖动------第 1 次失败等 1s,第 2 次等 2s,第 3 次等 4s,每次叠加随机偏移,避免大量客户端在同一时刻集体重试把服务器打得更死。同时区分错误类型:429/503 退避重试,416 清进度重来,403 则大概率是鉴权过期,需要重新获取下载地址。

合并 :如果你按偏移写进同一个 .part 文件,其实不存在「合并」步骤------改名即完成。但如果走的是「一片一个独立文件」的路线(某些流式处理或对象存储下载会这么设计),合并有两种方式:

bash 复制代码
# 方式一:字节级拼接(适合分片本身就是原文件的字节区间)
cat part_000 part_001 part_002 ... > output.mp4

# 方式二:ffmpeg concat 解封装器(适合每个分片是独立可解码的容器)
ffmpeg -f concat -safe 0 -i filelist.txt -c copy output.mp4

filelist.txt 的内容是:

text 复制代码
file 'part_000.ts'
file 'part_001.ts'
file 'part_002.ts'

注意 -c copy 是流复制、不重编码,速度接近 cp;这条命令也是各类 HLS(m3u8)下载工具把一堆 .ts 分段组装成完整视频的标准做法。字节拼接适合「同一文件的区间分片」,concat 适合「天然分段的多媒体流」------两者用反了,要么视频打不开,要么封装损坏。

🤔 为什么有的下载器要保存成一片片小文件,而不是直接随机写一个大文件?

随机写大文件要求文件系统支持稀疏文件与稳定偏移,且在进程异常时容易留下「长度对但内容错」的隐蔽损坏;独立小文件天然幂等------一片下完就是完整的,下完即校验即落定,回滚单位清晰。两种方案在真实产品里都存在,选型取决于你对「吞吐」和「可恢复性」的优先级取舍。

⚖️ 七、合规红线与工程边界

技术是中性的,使用不是。写下载相关的东西,有几条线必须先划清楚:

  • 素材采集仅供个人学习与备份使用,需遵守原平台的用户协议与版权规则;不要把下载能力用于二次分发、搬运或商业用途;
  • 尊重 robots.txt 与服务端限流信号,限速与合理并发不只是性能优化,也是对等服务礼仪;
  • 不做任何规避平台技术措施的功能设计,工程能力应该花在传输可靠性上,而不是对抗上。

说到底,Range 请求教给我们的不只是下载技巧,还有一个通用设计范式:把大任务切成可独立验证、可独立恢复的小单元。数据库 WAL、消息队列分段提交、视频转码的任务分片,都是同一个思想在不同层的投影。

❓ 常见问题 FAQ

Q1:怎么快速判断一个 URL 支不支持断点续传?

看响应头。发 Range: bytes=0-0,若返回 206 且带 Accept-Ranges: bytes,支持;返回 200 则说明服务器忽略了 Range(常见于某些动态接口、启用压缩的静态服务或代理层),只能整体下载。注意有些服务器 Accept-Ranges 头会撒谎,以实际是否返回 206 为准。

Q2:暂停之后恢复,为什么偶尔会从 0 重下?

最常见原因是 If-Range 校验失败:服务器上的资源在暂停期间更新了(ETag 变化),续传旧字节会导致内容错乱,下载器主动放弃旧进度。另一种是进度文件损坏或分片大小配置变了,位图对不上只能重切。

Q3:多线程下载一定比单线程快吗?

不一定。当瓶颈在服务器出口带宽或你自己的下行带宽时,加连接数没有收益;在高延迟、有单连接限速的链路上收益才明显。另外有些 CDN 对同 IP 并发连接数有惩罚策略,开太满反而整体变慢。

Q4:分片合并出来的视频播放到一半花屏,怎么回事?

大概率是分片顺序错了,或者混用了两种合并方式:字节区间分片被当成独立容器去 concat,或反之。排查时先比对文件总大小与 Content-Range 里的 total 是否一致,再检查分片排序。另一个可能是续传时没带 If-Range,新旧版本文件被拼接。

Q5:HTTP/2 / HTTP/3 之后,多连接分片还有意义吗?

有变化但没消失。HTTP/2 单连接多路复用降低了「多连接绕协议限制」的动机,但 TCP 拥塞控制仍然是单连接的,跨丢包链路上多连接仍能提速;HTTP/3(QUIC)改进了丢包恢复,进一步压缩了多连接的收益。分片的另一半价值------细粒度断点恢复------与协议版本无关,永远成立。

📝 总结

把整条链路串起来:Range 请求定义了「取一段」的协议语义,206/Content-Range 让客户端拿到了精确的字节坐标系,If-Range/ETag 保证了续传的内容一致性,分片与并发解决了吞吐与恢复粒度,.part 加进度 JSON 把这一切变成崩溃安全的持久状态,最后靠校验与原子改名交付一个可信文件。

如果你只想从这篇文章带走三句话:

  1. 断点续传的安全性核心是 If-Range + ETag,不是 Range 本身;
  2. 分片下载 = 对同一文件的随机写 + 可恢复的进度位图;
  3. 合并方式(字节拼接 vs concat)必须与分片语义匹配。

参考文献

  1. MDN Web Docs --- HTTP Range 请求:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Range_requests
  2. RFC 9110 --- HTTP Semantics(Range / If-Range / ETag 相关章节):https://www.rfc-editor.org/rfc/rfc9110
  3. Python requests 官方文档 --- Streaming Downloads 与响应头访问:https://requests.readthedocs.io/en/latest/user/advanced/#streaming-requests
  4. FFmpeg 官方文档 --- concat demuxer:https://ffmpeg.org/ffmpeg-formats.html#concat
  5. MDN Web Docs --- 206 Partial Content:https://developer.mozilla.org/zh-CN/docs/Web/HTTP/Status/206

为什么要懂协议层

写到这儿,想聊一点「为什么」。下载器这个品类的工具,图形界面上卷得厉害,但拉开差距的从来不是皮肤,是它对协议的理解深度:懂 If-Range 的工具,暂停一周之后还能继续;懂退避重试的工具,凌晨的网络抖动里默默自愈;懂原子改名的工具,永远不会把半截文件递到你的剪辑软件手里。这些差异用户说不出来,但都能感觉到。

我自己的习惯是,每学一层协议,就把手头的工具重写一次。写 HTTP 的时候你是在跟三十年前设计这套协议的人对话------他们把「资源变了没有」抽象成 ETag,把「要哪一段」抽象成 Range,这种化繁为简的手艺,比任何框架源码都值得读。懂了协议层,你看很多工具的神秘功能,会像看过魔术揭秘一样------原来是这么回事。剩下要做的,只是比别人把细节做得再扎实一点。

文中这套断点续传思路,我在自己迭代的一款素材工具里做过完整落地。后续我会在 CSDN 持续更新这款工具的实战记录,感兴趣的可以关注我的博客主页。

相关推荐
Dshaw3 小时前
抖音去水印下载:支持视频与图集
音视频
2601_962100733 小时前
2026年财报数据动画制作教程:财报图解转视频的完整流程
音视频
天天进步20154 小时前
Pixelle-Video 源码解析 #15:AI 视频片段生成:从静态配图到动态短视频
人工智能·音视频
美狐美颜SDK开放平台4 小时前
直播APP开发核心功能详解:美颜SDK、音视频、礼物等功能成本怎么计算?
android·人工智能·计算机视觉·音视频·直播美颜sdk
2601_962100735 小时前
手绘医学科普视频制作教程(2026 年)
音视频
汝裕光电5 小时前
上海酒店宴会厅舞台 LED 大屏音视频集成改造实操要点,婚宴 / 年会 / 论坛不停业改造落地指南
音视频
白搞电子5 小时前
ESP-Mosaico 掌上游戏开发:LVGL 抛硬币动画、音频混音与 BMI270 摇晃触发
单片机·嵌入式硬件·物联网·游戏·音视频
安河桥。5 小时前
V4L2架构在工程实践中的应用
嵌入式硬件·音视频
Likeadust6 小时前
一键创建,说开就开:私有化音视频系统EasyDSS视频会议的入会、协作与AI纪要全流程
人工智能·音视频·easydss