前言
「提升爬虫效率」这个题目最容易滑向两个误区。第一个是上来就写并发、写代理池、写分布式,却不先量一下瓶颈在哪儿;第二个是把「效率」等同于「单位时间发更多请求」,于是把并发开到很大——这在技术上或许痛快,但对方是个小站的话,你等于在替它做压力测试。效率的目标应该是在你的合规约束下,把等待时间压到最小,而不是「把对方打满」。
本文按这条思路展开:先讲怎么定位瓶颈,再逐个维度讲可以优化的地方(I/O、解析、调度、存储),最后给一段可运行的代码,用time.perf_counter()让你自己测出提速效果。本文不会给出任何「提速 N 倍」的数字——那类百分比要么有特定测试条件,要么纯属拍脑袋,都不应该被当成结论。所有示例只用标准库,Python 3.8+ 可直接运行。
先交代一句版本常识:Python 2.7 已于 2020 年 1 月 1 日停止维护。老教程里用xrange省内存、用dict.iteritems()遍历的写法,在 Python 3 里分别对应range(本身就是惰性的)和dict.items()。
一、先测量,再优化
优化前先问:慢在哪里?最常见的四类瓶颈:
| 现象 | 大概率瓶颈 | 先做什么 |
|---|
| 每页耗时都在 1 秒上下、很稳定 | 对方响应慢或网络延迟 | 检查限速设置,别着急加并发 |
| 少数请求特别慢,多数很快 | 超时/重试没设好 | 设 timeout、分类重试 |
| 抓下来的解析阶段 CPU 吃满 | 解析或字符串处理 | 换解析策略、减少重复拼接 |
| 内存一路上涨 | 把所有数据堆在内存里 | 流式处理、边抓边写 |
测量方法很朴素:用time.perf_counter()给每个阶段打点。顺序抓 5 个页面,把「请求耗时」和「解析耗时」分开打印,你立刻就知道该优化哪一段。盲目加并发是治不了 CPU 瓶颈的。
二、I/O 维度:爬虫的时间都花在等网络
爬虫是典型的 I/O 密集任务。要压缩这部分时间,有三个杠杆:
第一,减少固定开销。每次新连接都要做 DNS 解析和 TLS 握手。如果同一个站点要抓很多页,复用连接能省掉这部分开销——标准库的urllib.request每次urlopen走的是默认 opener,连接复用需要显式构造 opener 或改用支持连接池的 HTTP 客户端。第三方客户端库在这点上通常更方便,具体用法以该库官方文档为准。
第二,让等待重叠起来。一个请求发出后,大量时间花在「等服务器回话」上。这段时间里程序什么也做不了,除非你用多线程或异步 I/O。CPython 有全局解释器锁(GIL,Global Interpreter Lock),同一进程内同一时刻只有一个线程执行字节码;但线程在等待网络 I/O 时会释放 GIL,所以少量多线程对爬虫确实有效。反之,CPU 密集的任务用多线程没有意义,要用多进程(multiprocessing)或把重活交给 C 扩展。
第三,把超时和重试设对。urllib.request.urlopen的timeout不写就不超时;重试要区分错误类型——404 立即放弃,403 停下来反思,429 退避,网络层错误才值得指数退避重试。一个卡住 60 秒的请求,比六个 10 秒的请求还浪费。
一个必须强调的边界:这些手段都服务于你自己的合规额度。对同一域名,并发上限要设得很小,并且要有全局最小请求间隔。下面的示例就是这么做的。
三、解析维度:少做无用功
解析阶段是纯 CPU 工作,几个常见浪费:
- 重复解析同一段 HTML。同一页里抽多个字段,就一次遍历抽完,而不是对每个字段各扫一遍。
- 在循环里用
+拼长字符串。s = s + piece每轮都新建一个字符串对象,整体接近平方级开销;正确做法是累积到列表里,最后"".join(parts)。 - 用正则去解析 HTML。正则对结构化的 HTML 很脆弱,遇到嵌套结构还可能出现灾难性回溯,把 CPU 拖死。整页解析应该交给解析器,正则只用来处理解析之后的、规则明确的小片段(比如从一段文本里抽日期)。
- 把整页内容都留在内存。只需要几个字段,就不要把整个站点缓存起来。
四、调度维度:去重、上限与自适应
调度做不好,前面的优化都会被浪费:
- 去重:用
set记已访问的 URL,是成本最低的收益。少了它,环形链接会让你把同一页抓无数遍。 - 硬上限:页数或条数一定要设上限,防止失控。
- 自适应限速:与其固定
sleep,不如根据对方响应变慢就自动放大间隔、变快就略微缩小。完整框架里通常内置了这类节流器,自己写的话用「最近 N 次请求的平均耗时」做简单反馈即可。 - 尊重 robots:
urllib.robotparser.RobotFileParser.can_fetch(useragent, url)要在入队前调用,而不是抓完才发现不该抓。
五、存储维度:别让写文件成为瓶颈
- 用
open()打开一次、写一批、再关闭,而不是每条记录开关一次文件。 - CSV 用
csv.writer一次writerow一行,它自己处理转义,别手工拼逗号。 - 写文本文件显式指定
encoding="utf-8";写 CSV 还要加newline="",否则 Windows 上会多出空行。 - 数据量大时,边抓边追加写,别全堆在内存里等最后一起写。
实战:小并发 + 全局限速 + 去重
下面这段代码只用标准库。它做了三件事:把 5 个页面的请求提交给一个只有 3 个线程的线程池;用一个带锁的全局节流器保证任意两次请求的发起间隔不小于 0.5 秒;用time.perf_counter()量总耗时。
# 适用于 Python 3.8+
import threading
import time
import urllib.error
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
UA = "throughput-bot/0.2 (+mailto: ops@example.org)"
WORKERS = 3 # 并发上限,刻意保持很小
DELAY = 0.5 # 任意两次请求发起之间的最小间隔(秒)
_rate_lock = threading.Lock()
_last_request = 0.0
def polite_sleep():
"""全局节流:任意两次请求的发起时间间隔不小于 DELAY。"""
global _last_request
with _rate_lock:
now = time.perf_counter()
wait = _last_request + DELAY - now
if wait > 0:
time.sleep(wait)
now = time.perf_counter()
_last_request = now
def fetch(url, retries=2):
last_error = None
for attempt in range(1, retries + 1):
polite_sleep()
try:
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=15) as resp:
return url, len(resp.read())
except urllib.error.HTTPError as exc:
if exc.code in (403, 404): # 明确答复,不重试
raise
last_error = exc
except urllib.error.URLError as exc:
last_error = exc
time.sleep(2 ** attempt) # 指数退避
raise last_error if last_error else RuntimeError("请求失败")
def crawl(urls, workers=WORKERS):
results = {}
with ThreadPoolExecutor(max_workers=workers) as pool:
future_to_url = {pool.submit(fetch, u): u for u in urls}
for future in as_completed(future_to_url):
url = future_to_url[future]
try:
_url, size = future.result()
results[url] = size
except Exception as exc:
results[url] = "失败: %s" % exc
return results
def main():
urls = ["https://quotes.toscrape.com/page/%d/" % n for n in range(1, 6)]
t0 = time.perf_counter()
results = crawl(urls)
elapsed = time.perf_counter() - t0
for url, value in results.items():
print(url, "->", value)
print("总耗时 %.2f 秒" % elapsed)
if __name__ == "__main__":
main()这段代码里有一个值得想清楚的取舍:全局节流器把请求的发起强制串成了每 0.5 秒一次,那并发到底还有什么用?答案是——被压掉的是「发起」这一瞬间,而每个请求等待响应的那段时间是重叠的。所以并发优化的是「等服务器回话」的等待,而不是「发出去」的动作。这也解释了为什么在严格的礼貌限速下,并发带来的收益有上限:上限由你的限速决定,而不是由线程数决定。
想验证这一点,把DELAY改成 0,再跑一次,看看总耗时怎么变;再把WORKERS从 3 改成 1,看看又怎么变。用你自己的机器和网络测出来的数字,才是你可以引用的数字。
常见坑点
1. 不测量就加并发
❌ 上来就把线程数从 1 调到 50,结果耗时几乎没变——因为瓶颈其实在解析或对方响应。 ✅ 先用time.perf_counter()分段计时,找出真正的瓶颈。
2. 以为多线程能加速 CPU 密集环节
❌ 把解析、正则匹配这类 CPU 活丢进线程池,期待变快。 ✅ 记住 GIL:线程只在等待 I/O 时释放锁;CPU 密集要用多进程或 C 扩展。
3. 忘了并发要配限速
❌ 开了 20 个线程,还去掉了time.sleep,指向一个小站。 ✅ 并发上限保持很小,并加全局最小间隔;这不是「能不能」的问题,是「该不该」的问题。
4. 在循环里用+拼字符串
❌text = text + data逐段拼接长文本。 ✅parts.append(data),最后"".join(parts)。
5. 同一页反复解析
❌ 抽标题扫一遍 HTML,抽作者又扫一遍。 ✅ 一次遍历把需要的字段全抽出来。
6. 每次请求都重新建立连接
❌ 同一个站点抓几百页,每次都走一遍 DNS 与 TLS 握手。 ✅ 复用连接(构造带连接池的 opener,或用支持它的 HTTP 客户端库)。
7. 去掉重试,或对所有错误都无脑重试
❌ 403、404 也重试三遍 —— 既没意义,又加重对方负担。 ✅ 按状态码分类:404 放弃、403 停止、429 退避、网络错误才退避重试。
8. 把结果全堆内存,最后才写
❌ 抓几万条时才写文件,中途崩了一次全没了。 ✅ 边抓边追加写,或分批落盘。
总结
| 维度 | 优化点 | 主要代价 / 边界 |
|---|
| I/O | 连接复用、超时重试、少量并发 | 并发上限受你的礼貌限速约束 |
| 解析 | 一次遍历、join代替+、别用正则啃 HTML | 正则回溯可能拖死 CPU |
| 调度 | 去重、硬上限、自适应限速、查 robots | 宁可慢,不打满对方 |
| 存储 | 批量写、显式编码 | CSV 需newline="" |
提升爬虫效率的路线图其实很清晰:先测量找瓶颈,再在 I/O、解析、调度、存储四个维度里挑对症的手段,最后用一个自测脚本验证效果。唯一不能碰的捷径是「把对方压垮」——那不是效率,是把项目做死。所有百分比都请自己跑出来再说。