目录导读
- 断点续传的核心原理 – 为什么需要它?与普通下载有何不同?
- 脚本设计四步法 – 需求分析→文件分段→状态记录→异常恢复
- 实战代码详解 – Python+requests库实现断点续传下载与上传
- 企业级优化技巧 – 并发加速、校验机制、日志与监控
- 常见问题与解疑 – Q&A环节:网络闪断、服务端支持、大文件分片策略
- 总结与最佳实践 – 集成到更新系统的注意事项
断点续传的核心原理:从HTTP协议到文件指针
断点续传(Resumable Download/Upload)是指当文件传输中断后,能够从中断位置继续传输,而非从头开始,其底层依赖于HTTP/1.1的Range头字段与服务器返回的206 Partial Content状态码。

普通下载与断点续传对比:
- 普通下载:无论文件是否已下载部分,每次请求都从0字节开始
- 断点续传:客户端记录已下载字节数,在
Range头中指定起始位置(如Range: bytes=100-),服务器仅返回剩余部分
核心三要素:
- 文件分段:将大文件切分为多个块(如1MB/块)
- 状态持久化:将已完成的块ID、校验值写入本地文件或数据库
- 异常恢复:启动时读取状态,跳过已完成块,仅请求未完成部分
典型应用场景:
- 游戏客户端更新(动辄几十GB)
- 云盘同步客户端(需抵抗网络抖动)
- 离线下载工具(如aria2、迅雷)
脚本设计四步法
步骤1:需求定义
- 传输方向:仅下载?双向同步?
- 文件大小上限?单文件是否切片?
- 是否需要多线程/多通道?是否需要断点续传的上传?
步骤2:选择技术栈
- Python:
requests(支持Range)+os.stat获取文件大小 - Shell:
curl -C -(自动续传)或wget -c - Node.js:
axios+fs+stream - 企业级:golang +
net/http+sync.WaitGroup
步骤3:设计状态记录机制
# 推荐使用JSON文件记录状态
{
"file_name": "update_v2.0.zip",
"total_size": 104857600,
"chunk_size": 1048576, # 1MB
"completed_chunks": [0, 1, 2, ..., 50], # 已完成的块索引
"checksum_algo": "sha256",
"file_hash": "abc123..."
}
步骤4:实现重试与异常处理
- 网络超时:触发指数退避重试(1s, 2s, 4s...)
- 文件损坏:对比块哈希,重新下载对应块
- 服务端不支持Range:降级为全量下载(需特殊标记)
实战代码详解:Python断点续传下载脚本
import os
import hashlib
import json
import requests
from concurrent.futures import ThreadPoolExecutor
class ResumableDownloader:
def __init__(self, url, output_path, chunk_size=1*1024*1024, max_workers=4):
self.url = url
self.output = output_path
self.chunk_size = chunk_size
self.workers = max_workers
self.state_file = output_path + ".resume_state"
self.state = self._load_state()
self.session = requests.Session()
self.session.headers.update({"User-Agent": "ResumableUpdater/1.0"})
def _load_state(self):
if os.path.exists(self.state_file):
with open(self.state_file, "r") as f:
return json.load(f)
return {"completed": set(), "total_size": 0, "file_hash": None}
def _save_state(self):
self.state["completed"] = list(self.state["completed"])
with open(self.state_file, "w") as f:
json.dump(self.state, f, indent=2)
def get_file_info(self):
"""获取服务器文件大小及支持Range头"""
resp = self.session.head(self.url)
resp.raise_for_status()
accept_range = resp.headers.get("Accept-Ranges", "none") == "bytes"
total = int(resp.headers.get("Content-Length", 0))
return total, accept_range
def download_chunk(self, start, end, chunk_id):
"""下载指定字节范围,如果已下载则跳过"""
if chunk_id in self.state["completed"]:
return True
headers = {"Range": f"bytes={start}-{end}"}
try:
resp = self.session.get(self.url, headers=headers, stream=True, timeout=(10, 30))
if resp.status_code in [200, 206]:
with open(self.output, "r+b") as f:
f.seek(start)
for chunk_data in resp.iter_content(chunk_size=4096):
f.write(chunk_data)
self.state["completed"].add(chunk_id)
self._save_state()
return True
else:
print(f"Chunk {chunk_id} failed: HTTP {resp.status_code}")
return False
except Exception as e:
print(f"Chunk {chunk_id} error: {e}")
return False
def validate_file(self, expected_hash):
"""校验完整文件哈希"""
sha256 = hashlib.sha256()
with open(self.output, "rb") as f:
while chunk := f.read(8192):
sha256.update(chunk)
return sha256.hexdigest() == expected_hash
def download(self):
total_size, accept_range = self.get_file_info()
if not accept_range:
print("服务器不支持断点续传,执行全量下载")
self.full_download()
return
# 创建文件(如果不存在)
if not os.path.exists(self.output):
with open(self.output, "wb") as f:
pass
# 计算分片
chunk_count = (total_size + self.chunk_size - 1) // self.chunk_size
self.state["total_size"] = total_size
# 使用线程池并发下载未完成分片
with ThreadPoolExecutor(max_workers=self.workers) as executor:
futures = []
for i in range(chunk_count):
start = i * self.chunk_size
end = min(start + self.chunk_size - 1, total_size - 1)
futures.append(executor.submit(self.download_chunk, start, end, i))
results = [f.result() for f in futures]
if all(results):
print("所有分片下载成功")
# 可在此添加哈希校验
# if self.state["file_hash"] and self.validate_file(self.state["file_hash"]):
# print("文件校验通过")
else:
print("部分分片失败,重新运行脚本继续下载")
def full_download(self):
"""降级到全量下载(不推荐)"""
resp = self.session.get(self.url, stream=True)
with open(self.output, "wb") as f:
for chunk in resp.iter_content(chunk_size=8192):
f.write(chunk)
print("全量下载完成")
# 使用示例
if __name__ == "__main__":
downloader = ResumableDownloader(
url="https://example.com/update.zip",
output_path="./update.zip",
chunk_size=5*1024*1024, # 5MB分片
max_workers=8
)
downloader.download()
关键点注释:
- 状态文件(
.resume_state)存储已完成块ID,支持脚本任意中断重启 - 使用
Range头精确控制字节范围,确保多线程下不冲突 - 异常重试机制:捕获
requests.exceptions.ConnectionError后打印日志并返回False
企业级优化技巧
-
并发分片加速
使用ThreadPoolExecutor或asyncio实现多线程下载,注意CPU密集型场景下,IO线程数建议为(CPU核心数 * 2+4)。 -
校验机制
- 分片级校验:每块下载完成后计算其
sha256,与记录的哈希对比 - 文件级校验:所有块下载完成后,在合并前计算整体哈希
- 分片级校验:每块下载完成后计算其
-
日志与进度监控
# 使用logging记录每个块的状态 import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.info(f"开始下载块 {chunk_id}") -
服务端适配
- 需要服务端支持
Range头(几乎所有Web服务器如Nginx、Apache均支持) - 对于不支持的服务(如简易静态服务器),可降级全量下载
- 需要服务端支持
-
文件锁与多进程安全
若脚本可能同时运行多个实例,需使用文件锁(fcntl.flock或portalocker)防止状态文件并发写入导致损坏。
常见问题与解疑(Q&A)
Q1:网络闪断后,脚本如何确定从哪开始续传?
A:启动时读取状态文件,获取已完成的块ID集合,遍历所有分片,跳过已完成块,仅请求缺失的块,代码中download_chunk函数第一步检查chunk_id in self.state["completed"]。
Q2:如果服务端不支持Range头怎么办?
A:先执行HEAD请求检查响应头中的Accept-Ranges: bytes,若不存在,则执行全量下载(但此时无法断点续传),更好的做法是:在用户界面警告“当前服务器不支持续传功能”。
Q3:大文件(如50GB)如何平衡分片大小?
A:分片大小建议在1MB~10MB之间,过大则断点粒度粗糙,过小则状态文件膨胀且IO开销大,典型做法:文件1GB以下用1MB分片,1GB-10GB用5MB分片,10GB以上用10MB分片。
Q4:如何保证上传端的断点续传?
A:上传断点续传需要服务端支持(如HTTP/1.1的Content-Range处理或TUS协议),客户端需记录已上传字节数,在Content-Range头指定位置,代码类似下载逻辑,但需要额外处理服务端合并写入的逻辑。
Q5:脚本中校验哈希太慢怎么办?
A:可改为仅校验关键块(如首尾块+随机抽检块),或使用增量哈希算法(如xxhash),但安全性要求高的场景(如官方更新包)必须全量校验。
总结与最佳实践
编写断点续传脚本的核心要点:
- 状态持久化:使用文件或轻量级数据库记录已完成的块,脚本中断后可恢复
- 分片粒度选择:根据网络稳定性调整,避免过细导致状态文件过大
- 异常处理分层:网络错误重试、文件损坏重新下载、服务端不支持降级
- 兼容性设计:始终检测服务端是否支持
Range头,同时提供降级方案
集成到更新系统的建议:
- 将脚本封装为命令行工具(支持参数如
--url --output --chunk-size) - 配合定时任务(cron/计划任务)在后台自动检查并下载增量更新
- 使用
os.rename原子操作替换旧文件,避免更新中途被其他进程读取 - 提供进度回调接口,便于UI展示
延伸阅读:
- HTTP/1.1 Range Requests规范(RFC 7233)
- TUS协议(开源的可恢复上传协议)
- aria2配置文件中的
continue参数实现原理
断点续传脚本不是简单的“加个if”,而是对网络IO、文件系统、并发控制、状态管理的综合设计,本文提供的Python参考代码可直接运行,但生产环境中建议加入更多健壮性检查(如磁盘空间预检、防火墙超时处理等),按照上述原则编写的脚本,可在99%的异常场景下稳定恢复,极大提升大规模更新系统的可靠性。