资讯动态

requests文件下载进阶:流式传输、断点续传与踩坑指南

发布时间:2026/10/15 1:47:32 来源:尧图企业网站定制
简介requests是Python生态中最常用的HTTP客户端库相比标准库urllib2其API更简洁易用能让开发者用更少代码完成GET、POST、会话保持、文件上传等常见网络请求操作。这份围绕requests模块整理的学习资料共121个文件压缩包大小643KB其中包含76个Python示例脚本覆盖不同功能点的调用方式20个rst说明文档与5个HTML页面便于查阅接口细节和用法说明另有配置文件、证书样例等辅助内容适合初学Python网络编程或希望快速掌握requests库的开发者按需查阅。该资源目前已吸引7014人学习下载可见其针对性与实用性较强。通过对照源码、文档与示例读者可系统理解requests的请求参数、响应处理、异常捕获等核心用法并直接迁移到自己的爬虫或接口调试项目中。1. 用 requests 下载文件别只盯着 res.content用 Python 的 requests 模块下载文件看起来就是三行代码requests.get(url)然后拿res.content写进文件。但真去下载几个 GB 的安装包你会发现要么内存被打爆要么进度条卡到怀疑人生要么服务器超时直接翻车。requests 下载远不是get一个对象那么简单它牵扯到流式传输、连接复用、断点续传、超时控制这些每个下载器都必须面对的问题。如果你正在写爬虫抓文件、写自动更新工具、或者做数据集的批量下载脚本这篇笔记会把 requests 下载从入门到进阶的路径、参数和坑完整拆给你看。2. 下载前的原理课响应对象与流式读取的取舍2.1 选 requests 的底气它替你管好了连接和编码有人会问Python 标准库里有urllib.request为什么不直接用它因为 requests 把 HTTP 客户端的脏活都接过去了。urllib在遇到重定向、Cookies、代理、连接池这些真实网络环境里几乎绕不开的场景时代码会迅速膨胀而 requests 用优雅的 API 把这一切封装成了get、post、headers、params这几个直观概念。下载文件这件事requests 真正为你省下的是连接层面的心智负担。requests 底层依赖urllib3自带连接池管理。默认情况下它会复用同一主机名的 TCP 连接避免每次请求都重新走一次 DNS 解析、TCP 握手和 TLS 握手。这在批量下载同一站点的文件时收益非常明显实测下载 100 个小文件时用 Session 复用连接能比每次单独requests.get快 35 倍前提是服务器允许 keep-alive。另一个常被忽略的点是编码res.text会根据响应头里的charset自动解码而下载文件时我们用的是res.content它拿到的是未经解码的原始字节流完全避免了编码推测带来的乱码问题。选择 requests 做下载还有一个实际原因它给流式下载提供了标准 API。streamTrue参数配合iter_content可以让你边接收边写磁盘而不是等整个响应体完整到达内存再一次性落盘。这个能力是urllib也能做到但 requests 把细节揉得更好用比如超时区分连接超时和读超时比如用raise_for_status()在 4xx、5xx 响应时直接抛出异常。对下载脚本来说异常处理越早拦截踩坑成本就越低。2.2 res.content、res.text、iter_content 的应用边界这是下载文件最容易混淆的三兄弟很多新人直接用res.content一把梭遇到大文件就要命。我们可以用一个表说清各自适用场景。API返回数据类型内存行为适用下载场景res.contentbytes一次性将整个响应体读入内存几 MB 以内的小文件、接口 JSON、二进制配置res.textstr先读入 bytes 再做编码解码网页 HTML、纯文本响应不适合下载res.iter_content(chunk_size)生成器逐个产出 bytes每次最多 chunk_size 字节大文件、未知大小文件、需要进度条的场景用res.content拿文件时requests 会把整个响应体放到内存里假设一个 2 GB 的文件你的进程峰值内存轻松超过 2 GB多线程下载时直接 OOM 是家常便饭。iter_content则是边读取边产出每次产出一块指定大小的 bytes默认大小在 requests 内部是 1 个 chunk取决于流式读取的缓冲区。它适合的场景是大文件、视频、压缩包以及那些你根本不知道服务器会不会传输中断的资源。这里有个容易误用的点res.text底层其实也是基于res.content做的解码。如果某个接口返回的是 JSON但Content-Type写错了res.text可能会因为编码判断错误给你一串乱码而直接拿res.content再手动json.loads反而更稳。下载文件时永远不要用res.text因为字节流里的编码信息对文件内容毫无意义反而可能因为解码失败抛UnicodeDecodeError。2.3 一个 chunk 是怎么从网线到磁盘的理解iter_content的内部流能帮你排掉很多奇怪的下载故障。当streamTrue时requests.get发送请求后响应头已经返回但响应体还没有被完整读取它保存在底层 socket 的接收缓冲区里。iter_content内部每次调用urllib3的read(chunk_size)去底层连接读取指定的字节数读到一个 chunk 就yield一次直到读到空 bytes 表示流结束。因此 chunk_size 的选择直接决定 CPU 和内存的平衡。取得太小比如 128 字节会产生大量 Python 循环和文件写入调用下载速度被拖垮取得太大比如 16 MB虽然写入次数少了但单次内存占用高而且如果写入中途失败刚才这一大块就没有落盘。我一般取 81928 KB到 262144256 KB之间的值网络良好时 256 KB 读取效率更高网络不稳时 8 KB 更容易做粒度控制。还有一个细节iter_content返回的 chunk 长度可能比chunk_size小也可能某个 chunk 是空 bytes。空 bytes 代表流结束但正常情况下不会在循环中出现所以很多代码里会写if chunk:来判断这个判断在下载文件中可以省但在某些流式响应比如 SSE里必须加否则空 chunk 会被当作有效数据写进文件。3. 从零写一个下载器小文件、大文件与进度条3.1 小文件下载res.content 一把梭的下场对小于 10 MB 的文件用res.content是合理的省事且快。下面是标准的小文件下载函数import requests def download_small(url, save_path, timeout(5, 20)): res requests.get(url, timeouttimeout) res.raise_for_status() with open(save_path, wb) as f: f.write(res.content)这段代码的用意很直白请求发出后res.content把整个响应体拉进内存再一次性写进文件。raise_for_status()是关键如果服务器返回 404 或 5xx这里直接抛异常而不是继续把错误页面写进文件很多下载脚本翻车就是因为少这一行最终得到的是一个 HTML 后缀的假安装包。timeout参数我写成了元组(5, 20)第一个数字是连接超时表示建立 TCP 连接最多等 5 秒第二个是读超时表示服务器每次发数据之间最多等 20 秒。注意这不是整个请求的总时长而是两次 read 操作之间的间隔。对网络波动大的场景读超时给长一点避免下载中途因为服务器稍微停顿就被掐断。这个函数只适合小文件。如果文件超过几十 MBres.content就会导致内存峰值等于文件大小多个文件并发下载时内存用量的增长速度会让你怀疑人生。遇到这种情况必须换流式写盘。3.2 大文件下载iter_content 分块写盘的正确姿势大文件下载的标准姿势是用streamTrue配合iter_contentimport requests def download_large(url, save_path, chunk_size64 * 1024): res requests.get(url, streamTrue, timeout(10, 60)) res.raise_for_status() with open(save_path, wb) as f: for chunk in res.iter_content(chunk_sizechunk_size): if not chunk: break f.write(chunk)streamTrue必须在get时传入它告诉 requests 不要立刻读取响应体。然后每次从iter_content拿到一个chunk_size大小的字节块立即写入文件。对于网络正常的场景chunk_size64 * 102464 KB是性能和内存占用都均衡的选择。这里有两个容易忽略的参数含义。第一timeout(10, 60)中的读超时 60 秒指的是每读取一个 chunk 的最大间隔。如果服务器传输速度极慢一个 chunk 超过了 60 秒没发完请求会抛ReadTimeout。对不确定速度的资源可以把读超时加大到 300 秒但不要直接删掉 timeout否则一个假死的连接会永远挂住你的程序。第二iter_content的chunk_size并不是服务器每次发来的网络包大小而是我们向底层 socket 请求读取的目标字节数实际返回的 chunk 可能小于这个值所以不能假设每个 chunk 固定 64 KB。这段代码还有一个改进空间如果下载中断文件会残留一半再次运行会重新从头下载。我们可以稍后在断点续传里解决。现在先加进度条。3.3 给下载器加上进度条和超时保护手动写一个不依赖第三方库的进度条其实只需要几行import sys import requests def download_with_progress(url, save_path, chunk_size256 * 1024): res requests.get(url, streamTrue, timeout(10, 60)) res.raise_for_status() total int(res.headers.get(content-length, 0)) downloaded 0 with open(save_path, wb) as f: for chunk in res.iter_content(chunk_sizechunk_size): if not chunk: continue f.write(chunk) downloaded len(chunk) if total: percent downloaded / total * 100 sys.stdout.write(f\r{percent:.1f}% | {downloaded}/{total} bytes) sys.stdout.flush() if total: print(\n下载完成)关键是content-length头。服务器在响应头里给出了总字节数这个值决定了进度条分母。total可能为 0此时if total:会跳过进度显示这种情况一般是因为服务器返回的响应头里没有Content-Length或者启用了 chunked 传输编码。另外如果服务器对响应启用了 gzip 压缩Content-Length是压缩后大小而iter_content拿到的内容在 requests 里是解压后的原始数据downloaded最终会大于total进度条会算成超过 100%。这个问题我会在避坑章节展开。进度条用的是\r回车符让每次输出覆盖当前行而不是不断打印新行终端上看起来是一个动态刷新的百分比。如果你在多线程下载时也这样输出控制台会花所以进度条只适合单线程下载脚本。另外sys.stdout.write没有自动换行结束时需要手动打印换行。4. 断点续传与并发分块大文件下载的进阶打法4.1 用 Range 头实现断点续传中断后不必从头再来下载一个 5 GB 的资源网络断了一次就得从头再来这是最血亏的场景。HTTP 协议早就为你准备了Range头可以让服务器从指定字节开始发送。requests 通过headers参数就能轻松实现import os import requests def download_resume(url, save_path): headers {} if os.path.exists(save_path): existing os.path.getsize(save_path) headers[Range] fbytes{existing}- res requests.get(url, streamTrue, headersheaders, timeout(10, 60)) if res.status_code 206: mode ab elif res.status_code 200: mode wb else: res.raise_for_status() with open(save_path, mode) as f: for chunk in res.iter_content(chunk_size64 * 1024): if chunk: f.write(chunk)逻辑是先看本地有没有同名文件如果有就拿到它的大小existing在请求头里带上Range: bytesexisting-表示从 existing 字节之后开始下载。服务器如果支持会返回206 Partial Content这时用ab模式打开文件追加写入如果服务器忽略或拒绝 Range返回200 OK那就说明它不支持断点续传只能从头下载所以用wb覆盖写。这个方案有一个前提你下载的服务器返回的Content-Length是准确的而且文件内容在两次请求之间没有变化。如果文件在服务器端被更新过你拿本地旧大小去请求从中间续传出来的文件会拼接错误轻则文件损坏重则解压失败。所以对于重要资源续传后一定要做校验后面我会讲到 MD5 校验。另一个注意点os.path.getsize(save_path)得到的是本地文件当前大小如果你上一次下载时把多个临时分块写进了同一个文件这个大小可能不是真实内容的末尾续传就会写错位置。断点续传最稳妥的落地方式是下载时先写到一个临时文件下载完成后再重命名为目标文件这样即使下载中断临时文件的名字也明确告诉程序这是没完成的。4.2 多线程分块下载把文件切段并行拉取如果一个文件实在太大而服务器愿意支持 Range可以考虑多线程分块下载。思路是把文件按字节范围切分成几段每个线程负责一段最后拼成完整文件。下面是一个可用的实现骨架import os import requests from concurrent.futures import ThreadPoolExecutor def get_total_size(url): res requests.head(url, allow_redirectsTrue, timeout(5, 10)) res.raise_for_status() return int(res.headers.get(content-length, 0)) def download_chunk(url, start, end, save_path, chunk_size256 * 1024): headers {Range: fbytes{start}-{end}} res requests.get(url, headersheaders, streamTrue, timeout(10, 60)) res.raise_for_status() with open(save_path, rb) as f: f.seek(start) for chunk in res.iter_content(chunk_sizechunk_size): if chunk: f.write(chunk) def parallel_download(url, save_path, num_workers4): total get_total_size(url) if total 0: raise ValueError(无法获取文件大小无法分块下载) with open(save_path, wb) as f: f.truncate(total) part_size total // num_workers ranges [] for i in range(num_workers): start i * part_size end start part_size - 1 if i num_workers - 1 else total - 1 ranges.append((start, end)) with ThreadPoolExecutor(max_workersnum_workers) as executor: futures [ executor.submit(download_chunk, url, start, end, save_path) for start, end in ranges ] for future in futures: future.result()关键步骤先用HEAD请求拿到文件的总字节数total然后本地创建一个大小等于total的空文件使用truncate(total)预分配磁盘空间再把文件切分成num_workers段每段是一个(start, end)的字节区间最后通过线程池并发下载每一段每个线程在rb模式下打开同一个文件用seek移动指针到该段起点然后写入。Range: bytesstart-end中的end是包含的。比如bytes0-999表示下载字节索引 0 到 999共 1000 字节。所以分段时要小心边界我的写法里前num_workers - 1段用part_size - 1作为 end最后一段用total - 1兜底避免漏掉末尾字节。这个方案能跑通的前提是服务器支持 Range并且返回 206每段下载不会触发限流或封禁磁盘空间足够预分配并且对并发写同一个文件没有 I/O 瓶颈。实际上并发下载并不总比单线程快。如果你本地带宽本身就是瓶颈分 4 段并发拉取时每段速度都会减到平时的四分之一总吞吐量可能不变反而因为线程切换和磁盘写竞争更慢。一般只有在服务器对单连接有带宽限制时分块才值得做。4.3 服务端不支持 Range 时的降级方案并不是所有服务器都支持 Range。有些静态文件服务、CDN 或者旧代码可能直接忽略你的Range头返回完整的 200 响应。这时你不能硬着头皮把整个内容追加到已有文件也不该直接放弃断点续传。降级方案是如果请求后返回200说明不支持 Range把响应体完整写到一个临时文件下载完成后覆盖目标文件。如果返回416 Requested Range Not Satisfiable说明你请求的起始字节已经超出文件大小可能是文件完全下载完了也可能是本地文件比服务器文件还大版本变了。此时需要重新GET获取Content-Length做判断。如果返回206就是正常续传。最重要的一点是不要对不支持 Range 的服务器做多线程分块。强行分块的结果是你拿到多个 200 响应每个响应都是完整文件线程各自把完整文件写到不同位置最终文件变成多份完整内容拼接的巨型怪物。判断是否支持 Range 最直接的办法就是看响应状态码。写代码时永远先判断status_code再决定写入模式这个习惯能救回很多不明所以的下载失败。5. requests 下载高频踩坑5 个现象背后的真实原因5.1 内存被撑爆一切源于你没设置 streamTrue现象用 requests 下载一个 1 GB 的文件程序内存一路涨到 1.2 GB操作系统开始卡顿最后进程被 OOM Killer 干掉。 原因你直接用了res.contentrequests 默认会读取整个响应体到内存1 GB 文件就至少占 1 GB 内存。如果还开了多线程每个线程都拉一个完整文件内存直接爆炸。 解决在requests.get中传入streamTrue然后使用iter_content分块写盘。如果是从遗留代码改过来先检查有没有res.content或res.text被赋值给变量再决定是否重构。这个坑是下载大文件最容易踩的没有之一。5.2 进度条不走Content-Length 缺失或 gzip 搞鬼现象下载文件时进度条一直停在 0%或者文件下载完成后进度条显示超过 100%有时甚至直接变成 120% 才结束。 原因进度条依赖响应头里的Content-Length。有些服务器不返回这个头或者使用Transfer-Encoding: chunked这时total为 0进度条自然不动。另一种情况是服务器返回Content-Encoding: gziprequests 会自动解压Content-Length表示的是压缩后字节数而iter_content产出的 chunk 是解压后的原文最终downloaded会大于total。 解决用res.headers.get(content-length)拿不到就显示已下载字节数不要显示百分比。如果确认响应头里有Content-Encoding: gzip可以不用 requests 的自动解压而是设置Accept-Encoding: identity请求头强制服务器返回未压缩内容代价是可能带宽消耗更大但进度条会准确。很多下载器干脆放弃服务器给的 Content-Length改用已知文件大小的元数据比如从下载页面解析出来。5.3 文件变成 HTML重定向和 403 页面没被识别现象下载下来的 .zip 文件用压缩软件打不开用文本编辑器打开发现里面是html开头或者是一段 JSON 错误信息。 原因两种情况最常见。第一你请求的 URL 会 302 重定向到另一个页面而那个页面是 HTML 登录页或错误页requests 默认自动跟随重定向最终拿到的是那个页面。第二服务器对没有正确 User-Agent 或没有携带认证 Cookie 的请求返回 403403 页面本身也是 HTML。 解决下载前先打印res.status_code和res.headers.get(content-type)如果content-type是text/html而你应该下载的是二进制文件立即停止写入。另外给 requests 加上真实浏览器的User-Agent很多服务器只看 UA 就决定给不给你资源。检查重定向后的 URL 可以用res.url确认。还有一个好习惯是将下载内容先写入临时文件最后用os.rename改为目标扩展名避免错误内容直接污染正式文件。5.4 下载变慢或卡死连接池和超时参数没调好现象下载脚本跑着跑着越来越慢最后卡住不动或者报Connection pool is full。 原因如果你每次下载都直接requests.get而没有用 Sessionrequests 默认每个主机名只复用有限个连接。批量下载时频繁创建新连接握手导致速度变慢。另外 timeout 设置不当比如只给了一个很小的连接超时网络抖动一下就抛异常或者不给读超时一个坏连接会一直等下去。 解决用requests.Session()统一管理连接池设置session.headers指定 UA。对超大文件下载把读超时设置得足够大比如(10, 120)。如果发现连接池报错可以调整 Session 的HTTPAdapter参数但我更建议先检查你是否忘关了 Session。使用with requests.Session() as s:可以确保连接被回收。对于下载资源密集的场景Session 是必须的。5.5 僵尸临时文件中断后残留污染磁盘现象下载中断后磁盘上留下一个.part文件再次运行脚本从零开始下载旧临时文件不会清理日积月累占满 C 盘。 原因你的下载代码写错了逻辑比如直接以目标文件名写入中断后留下的是半个文件下次运行被覆盖还好如果你用临时文件模式但异常时没清理finally临时文件永远残留。 解决下载时先写文件名.part成功完成后os.replace改为目标文件名这个操作是原子的。用try/except/finally包裹下载逻辑在finally中判断文件是否下载完成如果没有就删除.part。如果想支持断点续传反而不要删除.part而是把它作为续传的依据。这个习惯能让你的下载脚本在恶劣网络环境下变得可靠。6. 下载体验再进一步Session 复用、校验与限速6.1 多文件下载优先 Session 复用批量下载几十个文件时每次都重新握手非常浪费。一个成熟的实现是这样的import requests def download_batch(urls, save_dir): with requests.Session() as session: session.headers.update({User-Agent: Mozilla/5.0}) for index, url in enumerate(urls): try: res session.get(url, streamTrue, timeout(10, 60)) res.raise_for_status() # 从响应或 URL 中取出文件名这里简化为序号 path f{save_dir}/file_{index}.bin with open(path, wb) as f: for chunk in res.iter_content(64 * 1024): if chunk: f.write(chunk) except requests.RequestException as e: print(f下载失败: {url} - {e})同一个 Session 会复用 keep-alive 连接省去 TCP 握手时间。注意session.headers.update设置一次即可之后所有请求都会带上。如果你要并行下载多个文件注意 Session 线程安全问题——大多数操作是线程安全的但共享 Session 同时写文件时要确保文件名不冲突。6.2 下载完成后用 MD5 确认文件完整性下载本身只是拿到字节文件是否完整必须由校验决定。简单做法是先下载再算本地 MD5和你从服务器拿到的参考值对比import hashlib def file_md5(path, chunk_size64 * 1024): h hashlib.md5() with open(path, rb) as f: while True: chunk f.read(chunk_size) if not chunk: break h.update(chunk) return h.hexdigest()如果你的下载页面上有>import time def download_throttled(url, save_path, bytes_per_second1024 * 1024): res requests.get(url, streamTrue, timeout(10, 60)) res.raise_for_status() start_time time.monotonic() downloaded 0 with open(save_path, wb) as f: for chunk in res.iter_content(32 * 1024): if not chunk: continue f.write(chunk) downloaded len(chunk) expected_time downloaded / bytes_per_second sleep_time expected_time - (time.monotonic() - start_time) if sleep_time 0: time.sleep(sleep_time)核心逻辑是根据已经下载的字节数计算期望耗时如果实际耗时小于期望就 sleep 补上差额。这个算法简单但有效bytes_per_second设为 0 时会除零所以调用前要做参数校验。限速后下载时间会变长但换来的服务器稳定性可能更重要。我习惯把所有下载脚本都拆成三个层次请求层、写盘层、校验层。请求层只负责拿流写盘层只负责按 chunk 落盘校验层负责验证最后结果。这样无论换什么下载源脚本主体逻辑都不变顶多改一改请求头。这套习惯帮我在多个下载项目里少走了很多弯路希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑