requests大文件下载实战:流式写入、断点续传与并发分段 简介Python开发者常用的requests模块完整资源包面向因urllib2的API过于繁琐而希望简化HTTP请求代码的Python学习者与工程师。该工具库以“让HTTP服务人类”为设计理念提供了更直观的请求发送、会话维持、重定向跟踪、超时与异常处理等封装适合日常接口调试及爬虫场景。压缩包共121个文件总体积仅643KB其中76个py文件构成核心源码与测试脚本20个rst和5个html组成可离线翻阅的文档其余为构建配置、许可证等支撑文件便于查看项目全貌。目前已有7017人学习/下载适合初学者对照源码理解请求库内部实现。读者可从中获得完整的库代码与官方文档既能通过阅读py模块掌握会话对象、请求与响应模型、连接适配器等设计思路也能借助rst或html快速查询常用接口用法项目中的构建脚本与配置文件还可作为学习如何组织开源库的参考。1. 为什么用 requests 下载文件总会在第二个文件上翻车“python requests模块下载”听起来是爬虫入门的第一道菜拿到 URLrequests.get()一把梭再把resp.write()写进文件。可这写法撑过第一个小文件靠运气撑过第二个就难了——网络抖动断在半路文件太大内存直接吃饱文件名取出来是一串乱码重新下载又从 0 开始。这些坑不全是 requests 的错而是“下载”和普通 GET 请求的诉求根本不同普通请求关心响应内容下载关心的是字节能不能完整、可续、可加速地落盘。这篇笔记按最小命令、参数细节、大文件流式、并发分段、高频踩坑的顺序讲透适合写爬虫、做数据采集和日常取件的开发者。2. 安装 requests 并跑通第一个下载环境与最小代码2.1 用 pip 装好 requests镜像源、虚拟环境和依赖链requests 是第三方库安装前先确认 Python 环境别乱。我一般会先建一个独立虚拟环境避免把系统 Python 搞脏在项目目录下执行python -m venv .venv然后激活它。Windows 下激活路径是.venv\Scripts\activateLinux/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, timeout10) resp.raise_for_status() with open(save_path, wb) as f: f.write(resp.content)timeout10把连接和读取的总超时设为 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; filenamea.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, headersheaders, timeout10)UA 字符串用自定义的简单标识只是保底。如果目标站点有反爬策略优先换成浏览器环境里复制出来的完整 UA这个动作往往比改什么参数都管用。Referer的作用是声明“你来自哪个页面”有些下载链接只在特定页面上下文里生效写成来源页面地址即可。Cookie 有两种传法放进 headers 里写Cookie: sessionidxxx或者用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, paramsparams, timeout(5, 60))注意这里timeout传的是元组(连接超时, 读取超时)。连接超时设短一点比如 5 秒网络不通时快速失败读取超时设长一点比如 60 秒给大文件传输留够时间。下载场景里读取超时比连接超时更容易被触发尤其服务器吞吐不稳定时。签名 URL 还有一个隐晦的坑如果服务端要求查询参数按特定顺序拼接params字典在某些 requests 版本下不保证顺序。遇到这种接口先把params手动拼成完整 URL 再请求或者在代码里构造PreparedRequest固定参数顺序避免上线后偶发出错。3.3 Session HTTPAdapter重试、连接池与连接释放下载场景里网络抖动太常见了requests 默认不重试一次断连就要整个任务从头来。我一般给每个下载任务单独建一个带重试策略的 Sessionimport requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session requests.Session() retry Retry( total3, connect2, read2, backoff_factor0.8, status_forcelist[500, 502, 503, 504], allowed_methods[GET], ) adapter HTTPAdapter(max_retriesretry, pool_connections10, pool_maxsize10) session.mount(https://, adapter)backoff_factor0.8控制重试间隔第一次等 0.8 秒第二次 1.6 秒按指数退避。status_forcelist列出的响应码会触发重试适合 5xx 这种暂时的服务器错误。allowed_methods[GET]限定只对 GET 重试因为 GET 是幂等的重复执行不会产生副作用POST 的重试要非常谨慎。pool_connections和pool_maxsize控制连接池大小如果后面要多线程并发下载这两个值必须大于等于并发数否则请求会排队等连接。配了重试的 Session配合streamTrue下载大文件如果连接断在传输中途urllib3 的重试逻辑会自动处理一部分场景。但如果你自己写循环处理分块异常发生时要记得resp.close()否则底层连接不会归还连接池跑上几十个任务后可能把本地端口耗尽。4. 大文件流式下载iter_content、分块写入与进度计算4.1 为什么大文件不能直接 resp.content上一章的最小代码对 2GB 的文件就是灾难resp.content要等整个响应体全部读完才会返回内存峰值约等于文件大小。加上进程里其他数据机器稍小一点就直接 OOM。正确做法是先拿响应头再按块取字节边读边写resp requests.get(url, streamTrue, timeout10) print(resp.headers.get(Content-Length))streamTrue告诉 requests 不要立刻把响应体读进内存而是保持底层连接打开按需读取。这一步的代价是你必须自己负责读完或者关闭连接。如果中途跳出循环不调用resp.close()这个连接不会自动释放。顺带说一个很多人不知道的点resp.headers在普通请求里也能用但 stream 模式下载时它更关键——你要靠Content-Length来预估大小和算进度这个头在流式场景下才真正发挥作用。4.2 带进度回调的流式下载工具函数把流式下载封装成一个函数后续项目里直接复用。这里给一个带进度输出的版本import requests def download_with_progress(url, save_path, timeout(5, 60), chunk_size65536): resp requests.get(url, streamTrue, timeouttimeout) 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_sizechunk_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, flushTrue) print() resp.close() return downloaded循环里的if not chunk: continue是过滤 keep-alive 连接产生的空块不写这行代码偶尔会在日志里出现写 0 字节文件的诡异现象。Content-Length缺失时total0这时跳过百分比计算避免除零。print用\r回车不换行加flushTrue强制刷新进度条才能实时更新。函数返回值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(fsize 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: fbytes{downloaded}-} resp requests.get(url, headersheaders, streamTrue, timeout(5, 60)) if resp.status_code 206: with open(tmp_path, ab) as f: for chunk in resp.iter_content(chunk_size65536): 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: gziprequests 解压后字节流本身没问题但最终落盘数据和你预期的原始文件不一致。解决先检查文件头。ZIP 文件的魔数是PK\x03\x04读前四个字节就能分辨with open(save_path, rb) as f: magic f.read(4) if magic ! bPK\x03\x04: print(not a zip, actually:, magic)如果文件开头是!DOCTYPE html或html基本可以断定被网关拦截了。排查完把请求头里的Accept-Encoding去掉让服务器按原始字节返回再重新下载一次。5.3 并发下载时内存持续上涨最后进程被杀现象用线程池同时下载十几个文件刚启动时一切正常几分钟后内存从几百 MB 涨到数 GB最后进程被系统杀掉。原因最常见的是某个子任务用了resp.content而不是流式写入或者虽然开了streamTrue却在循环里把分块 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, timeout10)这样既能保留 TLS 校验又不依赖系统证书库。如果只是为了临时调试可以在局部请求里加verifyFalse但这样的代码进了生产环境就是风险我一般在旁边注释写明“该连接未校验证书仅限内网调试”。内网下载如果长期存在更推荐在运维侧统一发放内部 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(rfilename\*UTF-8(.*), cd, re.IGNORECASE) if m: return unquote(m.group(1)) m re.search(rfilename?([^]*)?, 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: fbytes{start}-{end}} resp requests.get(url, headersheaders, streamTrue, 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_size65536): f.write(chunk) finally: resp.close()主函数负责分段、提交、合并from concurrent.futures import ThreadPoolExecutor def parallel_download(url, save_path, segments4): resp requests.head(url, allow_redirectsTrue, timeout10) total int(resp.headers[Content-Length]) resp.close() seg_paths [] with ThreadPoolExecutor(max_workerssegments) 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 的源站盲开并发只会让连接更快被掐断。这不是玄学是可复现的工程问题先探测再决定方案能让下载脚本少踩很多坑。希望帮到你。本文还有配套的精品资源点击获取