news 2026/10/19 8:50:20

requests大文件下载实战:流式写入、断点续传与并发分段

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
requests大文件下载实战:流式写入、断点续传与并发分段

简介:Python开发者常用的requests模块完整资源包,面向因urllib2的API过于繁琐而希望简化HTTP请求代码的Python学习者与工程师。该工具库以“让HTTP服务人类”为设计理念,提供了更直观的请求发送、会话维持、重定向跟踪、超时与异常处理等封装,适合日常接口调试及爬虫场景。压缩包共121个文件,总体积仅643KB,其中76个py文件构成核心源码与测试脚本,20个rst和5个html组成可离线翻阅的文档,其余为构建配置、许可证等支撑文件,便于查看项目全貌。目前已有7017人学习/下载,适合初学者对照源码理解请求库内部实现。读者可从中获得完整的库代码与官方文档,既能通过阅读py模块掌握会话对象、请求与响应模型、连接适配器等设计思路,也能借助rst或html快速查询常用接口用法;项目中的构建脚本与配置文件还可作为学习如何组织开源库的参考。

1. 为什么用 requests 下载文件,总会在第二个文件上翻车

“python requests模块下载”听起来是爬虫入门的第一道菜:拿到 URL,requests.get()一把梭,再把resp.write()写进文件。可这写法撑过第一个小文件靠运气,撑过第二个就难了——网络抖动断在半路,文件太大内存直接吃饱,文件名取出来是一串乱码,重新下载又从 0 开始。

这些坑不全是 requests 的错,而是“下载”和普通 GET 请求的诉求根本不同:普通请求关心响应内容,下载关心的是字节能不能完整、可续、可加速地落盘。这篇笔记按最小命令、参数细节、大文件流式、并发分段、高频踩坑的顺序讲透,适合写爬虫、做数据采集和日常取件的开发者。

2. 安装 requests 并跑通第一个下载:环境与最小代码

2.1 用 pip 装好 requests:镜像源、虚拟环境和依赖链

requests 是第三方库,安装前先确认 Python 环境别乱。我一般会先建一个独立虚拟环境,避免把系统 Python 搞脏:在项目目录下执行python -m venv .venv,然后激活它。Windows 下激活路径是.venv\Scripts\activate,Linux/macOS 下是source .venv/bin/activate。激活后终端提示符会变,说明当前走的是虚拟环境里的 Python。

装包命令只有一行:

pip install requests

如果你的网络访问 Python 官方包源很慢,就把-i参数指到内网或地理距离更近的镜像源,团队里更常见的做法是写进pip.conf或pip.ini,后续所有安装都自动走镜像。这里有个容易忽视的点:requests 本身依赖 urllib3、certifi、idna、charset_normalizer,直接pip install requests会把这些依赖一并装好。不建议手动下载 requests 的压缩包解压安装,依赖一多会滚雪球。

装完验证一下:

python -c "import requests; print(requests.__version__)"

能打印出版本号,说明安装链路是通的。如果提示找不到模块,先确认当前终端是否在虚拟环境里,再确认 pip 和 python 是不是同一个解释器。

2.2 最小可用的下载代码:get 请求、状态码与二进制写盘

先写一个最简单的下载,目标是“能跑、能存、不把错误页当文件”。以下是完整脚本:

import requests url = "https://example.com/files/demo.bin" save_path = "demo.bin" resp = requests.get(url, timeout=10) resp.raise_for_status() with open(save_path, "wb") as f: f.write(resp.content)

timeout=10把连接和读取的总超时设为 10 秒,超过就抛异常,避免脚本卡死。raise_for_status()会在响应码为 4xx/5xx 时直接抛异常,这行非常关键:如果服务器返回的是 404 错误页,没做这个检查,resp.content会被原样写进文件,你得到的是一个“HTML 内容的 .bin 文件”。打开文件用"wb"而不是"w",因为下载的是二进制字节,文本模式会做编码转换,可能把二进制数据写坏。

但要说清楚:resp.content是一次性读完整个响应体。文件小没问题,文件大时这段代码会在内存里先缓存完整内容,再一次性返回。2GB 的文件就直接把进程内存打爆。所以这只是一段“最小可用代码”,真正的大文件场景从第 4 章开始用流式。

注意:timeout这里传的是总超时。下载大文件时它实际表现为“最大允许空闲时间”,如果后端长时间不吐字节,连接会被掐断。第 3 章会讲拆成连接超时和读取超时的写法。

2.3 下载前先弄清三件事:Content-Type、Content-Length 与文件名

下载不是“打开连接就闷头写”,先看一眼响应头能省很多事。三个字段最常用:

响应头字段典型值作用
Content-Typeapplication/zip、application/octet-stream判断是不是二进制;HTML 说明可能被重定向/拦截
Content-Length字节数预估文件大小、算进度、校验完整性
Content-Dispositionattachment; filename="a.bin"服务端建议的文件名,常需要处理编码

从 URL 里提取文件名时,不要直接url.split("/")[-1],因为 URL 可能带查询参数,而且路径里可能有百分号编码。稳妥写法:

import posixpath from urllib.parse import unquote, urlparse path = urlparse(url).path file_name = posixpath.basename(path) file_name = unquote(file_name)

urlparse先拆出纯路径,把?后面的参数丢掉;posixpath.basename只取最后一段;unquote把%20这类编码还原成原始字符。如果服务端在Content-Disposition里给了明确的文件名,优先用那个,第 5.5 节会单独讲中文文件名的坑。

3. 下载请求的参数细节:请求头、超时、重定向与重试策略

3.1 带完整请求头下载:UA、Referer、Cookie 的落地姿势

不少下载服务器对没有 User-Agent 的请求直接拦截,或者返回一个降级页面。要稳定下载,请求头至少要把 UA、Referer、Accept 这三样补齐:

headers = { "User-Agent": "test-downloader", "Referer": "https://example.com/download-page", "Accept": "*/*", } resp = requests.get(url, headers=headers, timeout=10)

UA 字符串用自定义的简单标识只是保底。如果目标站点有反爬策略,优先换成浏览器环境里复制出来的完整 UA,这个动作往往比改什么参数都管用。Referer的作用是声明“你来自哪个页面”,有些下载链接只在特定页面上下文里生效,写成来源页面地址即可。

Cookie 有两种传法:放进 headers 里写Cookie: sessionid=xxx,或者用cookies={"sessionid": "xxx"}参数。后者更干净,requests 会自动拼接。登录后下载的场景建议直接用 Session 管理 Cookie,重定向到文件服务器时 Cookie 不会丢。

3.2 带签名 URL 下载:params、URL 编码与 timeout 元组

下载链接经常带着token、expires这类签名参数。手工拼字符串特别容易出错,因为 token 里的+、=、/属于 URL 保留字符,需要转义。requests 的params参数会帮你做正确编码:

params = { "token": "abc123/+==9098", "expires": "1735689600", "filename": "hello world.zip", } resp = requests.get("https://example.com/download", params=params, timeout=(5, 60))

注意这里timeout传的是元组:(连接超时, 读取超时)。连接超时设短一点,比如 5 秒,网络不通时快速失败;读取超时设长一点,比如 60 秒,给大文件传输留够时间。下载场景里读取超时比连接超时更容易被触发,尤其服务器吞吐不稳定时。

签名 URL 还有一个隐晦的坑:如果服务端要求查询参数按特定顺序拼接,params字典在某些 requests 版本下不保证顺序。遇到这种接口,先把params手动拼成完整 URL 再请求,或者在代码里构造PreparedRequest固定参数顺序,避免上线后偶发出错。

3.3 Session + HTTPAdapter:重试、连接池与连接释放

下载场景里网络抖动太常见了,requests 默认不重试,一次断连就要整个任务从头来。我一般给每个下载任务单独建一个带重试策略的 Session:

import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry( total=3, connect=2, read=2, backoff_factor=0.8, status_forcelist=[500, 502, 503, 504], allowed_methods=["GET"], ) adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=10) session.mount("https://", adapter)

backoff_factor=0.8控制重试间隔:第一次等 0.8 秒,第二次 1.6 秒,按指数退避。status_forcelist列出的响应码会触发重试,适合 5xx 这种暂时的服务器错误。allowed_methods=["GET"]限定只对 GET 重试,因为 GET 是幂等的,重复执行不会产生副作用;POST 的重试要非常谨慎。pool_connections和pool_maxsize控制连接池大小,如果后面要多线程并发下载,这两个值必须大于等于并发数,否则请求会排队等连接。

配了重试的 Session,配合stream=True下载大文件,如果连接断在传输中途,urllib3 的重试逻辑会自动处理一部分场景。但如果你自己写循环处理分块,异常发生时要记得resp.close(),否则底层连接不会归还连接池,跑上几十个任务后可能把本地端口耗尽。

4. 大文件流式下载:iter_content、分块写入与进度计算

4.1 为什么大文件不能直接 resp.content

上一章的最小代码对 2GB 的文件就是灾难:resp.content要等整个响应体全部读完才会返回,内存峰值约等于文件大小。加上进程里其他数据,机器稍小一点就直接 OOM。正确做法是先拿响应头,再按块取字节,边读边写:

resp = requests.get(url, stream=True, timeout=10) print(resp.headers.get("Content-Length"))

stream=True告诉 requests 不要立刻把响应体读进内存,而是保持底层连接打开,按需读取。这一步的代价是:你必须自己负责读完或者关闭连接。如果中途跳出循环,不调用resp.close(),这个连接不会自动释放。

顺带说一个很多人不知道的点:resp.headers在普通请求里也能用,但 stream 模式下载时它更关键——你要靠Content-Length来预估大小和算进度,这个头在流式场景下才真正发挥作用。

4.2 带进度回调的流式下载工具函数

把流式下载封装成一个函数,后续项目里直接复用。这里给一个带进度输出的版本:

import requests def download_with_progress(url, save_path, timeout=(5, 60), chunk_size=65536): resp = requests.get(url, stream=True, timeout=timeout) resp.raise_for_status() total = int(resp.headers.get("Content-Length", 0)) downloaded = 0 with open(save_path, "wb") as f: for chunk in resp.iter_content(chunk_size=chunk_size): if not chunk: continue f.write(chunk) downloaded += len(chunk) if total: percent = downloaded / total * 100 print(f"\r{percent:6.1f}% | {downloaded}/{total} bytes", end="", flush=True) print() resp.close() return downloaded

循环里的if not chunk: continue是过滤 keep-alive 连接产生的空块,不写这行代码,偶尔会在日志里出现写 0 字节文件的诡异现象。Content-Length缺失时total=0,这时跳过百分比计算,避免除零。print用\r回车不换行,加flush=True强制刷新,进度条才能实时更新。函数返回值downloaded是实际写入字节数,给调用方做校验。

chunk_size怎么选?小文件用 8192 最通用,内存占用极小;大带宽下载大文件用 1MB,更容易跑满带宽;块太小时 CPU 开销变大,块太大时进度条不够平滑。我的习惯是:文件超过 500MB 用1 * 1024 * 1024,小于 500MB 用65536。

4.3 落盘姿势:临时文件、part 后缀与完整性核对

直接把文件写到最终路径,下载到一半失败,磁盘上就会留一个“看起来像正常文件”的半截文件,别的任务拿去解析就会报错。我习惯先写<save_path>.part,全部成功后再原子改名:

import os tmp_path = save_path + ".part" downloaded = download_with_progress(url, tmp_path) expected = int(resp_headers.get("Content-Length", 0)) if expected and downloaded != expected: os.remove(tmp_path) raise RuntimeError(f"size mismatch: got {downloaded}, need {expected}") os.replace(tmp_path, save_path)

os.replace在 Windows 和 Linux 上都是原子操作,正式文件不会被半截写入。大小校验失败时把.part删掉,由调用方决定是否重试;如果后续要做断点续传,这个.part文件可以留着当断点记录,第 6 章会用到。磁盘空间不足时写入会抛OSError,所以校验前还要处理好异常上下文,避免临时文件堆积。

注意:如果请求头里带了Accept-Encoding: gzip且服务器返回了压缩内容,Content-Length对应的是压缩后的大小,requests 自动解压后,进度可能会超过 100%。下载二进制文件时不要手动要求压缩,让服务器按原始字节返回。

5. 下载场景五个高频翻车点:现象、原因与排查

5.1 下载一半报 ConnectionError,重试时又从 0 开始

现象:大文件传到一半,客户端抛出ConnectionError: ('Connection aborted.', RemoteDisconnected()),重新跑一遍又从 0 开始下载,已经写好的部分全部作废。

原因:公网链路抖动、对端服务器空闲超时、网关设备在没有数据传输时主动断开连接,都可能掐断长连接。urllib3 的重试逻辑对“建立连接阶段”可靠,但当数据已经流到一半时,连接被断开就难以透明恢复。

解决:先确认服务器是否支持 Range。对支持 Range 的源站,可以断点续传:

downloaded = os.path.getsize(tmp_path) headers = {"Range": f"bytes={downloaded}-"} resp = requests.get(url, headers=headers, stream=True, timeout=(5, 60)) if resp.status_code == 206: with open(tmp_path, "ab") as f: for chunk in resp.iter_content(chunk_size=65536): f.write(chunk)

状态码必须是206 Partial Content。如果服务器不支持 Range,它可能返回 200,然后把这个请求当成完整下载处理,你再以追加模式写入就会得到一个双倍大小的坏文件。所以判断完 206 再做追加写入。

5.2 文件大小对但 zip 打开报“文件损坏”

现象:下载完的压缩包,文件大小和服务端Content-Length完全一致,解压时却提示End-of-central-directory signature not found,或者解压到一半报 CRC 错。

原因:最常见的是没做raise_for_status(),把 403/404 的错误 HTML 页面当成文件存下来;另一个可能是响应经过 gzip 压缩,请求头手动声明了Accept-Encoding: gzip,requests 解压后字节流本身没问题,但最终落盘数据和你预期的原始文件不一致。

解决:先检查文件头。ZIP 文件的魔数是PK\x03\x04,读前四个字节就能分辨:

with open(save_path, "rb") as f: magic = f.read(4) if magic != b"PK\x03\x04": print("not a zip, actually:", magic)

如果文件开头是<!DOCTYPE html>或<html>,基本可以断定被网关拦截了。排查完把请求头里的Accept-Encoding去掉,让服务器按原始字节返回,再重新下载一次。

5.3 并发下载时内存持续上涨,最后进程被杀

现象:用线程池同时下载十几个文件,刚启动时一切正常,几分钟后内存从几百 MB 涨到数 GB,最后进程被系统杀掉。

原因:最常见的是某个子任务用了resp.content而不是流式写入,或者虽然开了stream=True,却在循环里把分块 append 到列表里再统一写盘。chunks.append(chunk)这种写法在下大文件时,内存会被分块数据无限累积填满。

解决:统一改成流式边读边写,所有分块直接写入文件,不做任何保留;线程池的max_workers不要超过 8,不是越多越快,源站带宽和连接池都会成为瓶颈;连接池的pool_maxsize必须大于等于并发数。写代码时给自己定一条铁律:任何下载函数里,都不允许出现把整个文件或全部分块攒在内存里的写法。

5.4 内网资源报 SSLError,业务被证书卡住

现象:下载公司内部系统的安装包或数据文件时,requests 抛出SSLError: [SSL: CERTIFICATE_VERIFY_FAILED],但同一个地址在浏览器里访问完全正常。

原因:很多内网系统的证书是自签名,或者由内部 CA 签发,Python 的 certifi 证书库里没有这张链,TLS 校验就失败了。这不是 requests 的问题,是信任链缺失。

解决:两害相权,优先把内部 CA 证书导出成.pem文件,传给verify参数:

resp = requests.get(url, verify="/path/to/internal-ca.pem", timeout=10)

这样既能保留 TLS 校验,又不依赖系统证书库。如果只是为了临时调试,可以在局部请求里加verify=False,但这样的代码进了生产环境就是风险,我一般在旁边注释写明“该连接未校验证书,仅限内网调试”。内网下载如果长期存在,更推荐在运维侧统一发放内部 CA 证书,而不是逐个改代码。

5.5 响应头里的中文文件名变成乱码

现象:Content-Disposition: attachment; filename*=UTF-8''%E7%94%A8%E6%88%B7%E6%89%8B%E5%86%8C.pdf,直接把响应头字符串取出来,是一串%开头的乱码。

原因:filename*=UTF-8''是 RFC 2231 标准格式,文件名用百分号编码;而旧的filename=格式不带编码声明。requests 的响应头解析不会自动帮你解 Unicode 编码,要自己处理。

解决:写一个解析函数,优先匹配filename*:

import re from urllib.parse import unquote def parse_filename(resp): cd = resp.headers.get("Content-Disposition", "") m = re.search(r"filename\*=UTF-8''(.*)", cd, re.IGNORECASE) if m: return unquote(m.group(1)) m = re.search(r'filename="?([^"]*)"?', cd) return m.group(1) if m else None

取到文件名后,还要做一次路径清洗:Windows 路径里不能出现/、:、*等字符,否则写盘时会报错。把这些字符替换成-,比直接抛异常让用户处理体验好得多。

6. 大文件还能更快:Range 并发分段下载与完整性校验

HTTP 的 Range 头不只用于断点续传,它还能把一个大文件拆成多个区间并行拉取。思路是:先 HEAD 请求拿到总大小,按并发数分段,让每个线程只下载自己的区间,最后按顺序合并。源站需要明确支持 Range,不支持时整个策略要退化为普通流式下载。

先写一个分段下载函数:

def fetch_segment(url, start, end, seg_path): headers = {"Range": f"bytes={start}-{end}"} resp = requests.get(url, headers=headers, stream=True, timeout=(5, 60)) try: if resp.status_code != 206: raise RuntimeError("server does not support range") with open(seg_path, "wb") as f: for chunk in resp.iter_content(chunk_size=65536): f.write(chunk) finally: resp.close()

主函数负责分段、提交、合并:

from concurrent.futures import ThreadPoolExecutor def parallel_download(url, save_path, segments=4): resp = requests.head(url, allow_redirects=True, timeout=10) total = int(resp.headers["Content-Length"]) resp.close() seg_paths = [] with ThreadPoolExecutor(max_workers=segments) as pool: futures = [] for i in range(segments): start = i * total // segments end = (i + 1) * total // segments - 1 if i == segments - 1: end = total - 1 seg_path = f"{save_path}.part{i}" seg_paths.append(seg_path) futures.append(pool.submit(fetch_segment, url, start, end, seg_path)) for future in futures: future.result() with open(save_path, "wb") as out: for seg_path in seg_paths: with open(seg_path, "rb") as f: while True: block = f.read(65536) if not block: break out.write(block) os.remove(seg_path)

校验这一步不能省。合并后先比大小,和 HEAD 拿到的Content-Length一致只是最基础的检查;更严格的做法是拿服务端给出的ETag或sha256校验值,下载完用hashlib对合并文件重新计算一遍。网络传输中字节翻转的概率不高,但被网关改写、被压缩、被截断的情况一直存在,校验是最后一道后悔药。

我现在的下载脚本一定会加一个--check参数:下载完先比大小,再算哈希。确认并发分段只适用于明确支持 Range 的源站,盲开并发只会让连接更快被掐断。这不是玄学,是可复现的工程问题,先探测再决定方案,能让下载脚本少踩很多坑。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/19 8:49:51

工业反应器设计案例:从搅拌选型到换热与安全控制

1. 项目概述搞工业反应器设计这事&#xff0c;乍一听像是化工专业的老法师才碰的活儿&#xff0c;但真上手了会发现&#xff0c;它其实是“工艺、机械、控制、安全”四门功课的交叉口。标题里的“工业反应器设计案例”&#xff0c;往大了说可以是一整套年产几十万吨的连续搅拌釜…

作者头像 李华
网站建设 2026/10/19 8:49:38

Diagram Design:系统性思维的视觉契约与可执行设计方法

1. 为什么“diagram-design”不是画图&#xff0c;而是一场系统性思维重构最近在几个跨团队协作项目里反复遇到一个现象&#xff1a;需求文档写得密密麻麻&#xff0c;流程图也画了七八张&#xff0c;可开发一动手就卡在“这一步到底该谁触发”“这个判断分支有没有遗漏边界条件…

作者头像 李华
网站建设 2026/10/19 8:49:37

从自研CRM到业务增长:销售管理系统的数据模型与性能优化实践

1. 从“随便用个CRM”到动手写DeskcommCRM&#xff1a;这中间发生了什么1.1 市面上一堆CRM&#xff0c;为什么还是想自己折腾先说说背景。我们是一个二十来人的销售型团队&#xff0c;业务是典型的B2B项目制销售&#xff0c;客户从线索到成交流转周期长&#xff0c;涉及售前、销…

作者头像 李华
网站建设 2026/10/19 8:48:51

开源CLI代码审查工具:本地LLM+Git深度集成

1. 项目概述&#xff1a;一个真正能落地的开源代码审查 CLI 工具“open-code-review”这个名字乍看平平无奇&#xff0c;但拆开来看——open不是指“开源”&#xff0c;而是指“开放上下文”&#xff1b;code-review也不是走形式的 PR 点头流程&#xff0c;而是让大模型真正理解…

作者头像 李华
网站建设 2026/10/19 8:48:07

CLI驱动的Git原生代码审查范式:LLM+规则+历史可审计

1. 项目概述&#xff1a;这不是一个“工具”&#xff0c;而是一套可落地的代码审查新范式open-code-review 这个名字乍看像某个开源项目仓库&#xff0c;但实际它代表的是一种正在快速成型的工程实践范式——用开源精神重构代码审查&#xff08;Code Review&#xff09;的整个生…

作者头像 李华
网站建设 2026/10/19 8:47:59

Flink本地开发环境搭建:IDEA中跑通WordCount的完整指南

1. 为什么本地跑通WordCount比想象中更重要很多人第一次接触Flink&#xff0c;脑子里想的都是"流批一体""Exactly-Once""状态后端"这些大词&#xff0c;结果打开IDEA新建工程&#xff0c;光是把依赖跑通就卡了大半天。我见过不少同行&#xff0c…

作者头像 李华