“你的代理很慢”是我们从客户那里收到的最常见的留言之一。有时确实如此。但更多时候,当我们一起查看抓取器时,大部分时间都花在了抓取器自身的行为上:每一页都打开一个新连接、用速度慢的库解析、一次只等待一个请求。
为了展示这些因素的影响程度,我们拿了一个简单的抓取器,通过我们的住宅代理运行它,每次只改动一处。同样的120个页面,耗时从298秒降到40秒,速度提升了7.5倍,而代理本身没有任何改变。本指南将带你了解这次测试、我们用来找出慢点的工具,以及一份用于诊断你自己的抓取器的清单。
关键要点
- 在做任何改动之前先测量。 一个记录抓取、解析和写入各自耗时的阶段计时器,能在几分钟内告诉你时间都花在哪里。
- 复用连接。 在我们的测试中,每个请求都通过代理新开一个连接,使每次抓取的耗时增加了两倍多。一个
Session就解决了这个问题。 - 解析的开销可能和抓取一样大。 更换HTML解析器把解析时间削减了一半以上。
- 并发带来最大的提升,随后会暴露出下一个瓶颈。 8个工作线程使运行时间减半,并显示出受CPU限制的解析已经成为新的限制因素。
- 并非所有慢的地方都值得修复。 数据库写入在298秒中只占0.3秒,所以批量写入并没有带来任何变化。
测试
我们从欧洲的一台机器上,通过Shifter的住宅代理,使用美国出口节点,抓取了120篇英文维基百科文章。维基百科的文章是较大的页面,HTML常常有几百KB。对于每个页面,抓取器会获取HTML,用Beautiful Soup解析以读取标题并统计链接数,然后将一行数据写入SQLite。
我们运行了同一任务五次,每次只改动一处。抓取器使用一个描述性的User-Agent来标识自己,这是维基百科所要求的(其服务器会拒绝Python默认的User-Agent),并在请求失败时最多重试两次。
| 版本 | 改动 | 总耗时 | 每页抓取耗时 | 每页解析耗时 |
|---|---|---|---|---|
| 1. 基准版本 | 每个请求新建连接,使用html.parser,一次一个 | 298秒 | 1,906毫秒 | 577毫秒 |
| 2. 复用连接 | 使用一个requests.Session | 125秒 | 568毫秒 | 473毫秒 |
| 3. 更快的解析器 | 用lxml代替html.parser | 76秒 | 426毫秒 | 205毫秒 |
| 4. 并发 | 8个工作线程 | 40秒 | 551毫秒 | 2,024毫秒 |
| 5. 批量写入 | 用一次事务代替每行一次 | 42秒 | 579毫秒 | 2,141毫秒 |
通过住宅出口节点的抓取耗时在不同运行之间会有一百毫秒左右的波动;上面这些差异远大于这个波动范围。
第一步:测量时间都花在哪里
这个工具是一个小型计时器,它能跨线程安全地累计每个阶段的实际耗时:
import time
from collections import defaultdict
from contextlib import contextmanager
from threading import Lock
class StageTimer:
"""Adds up wall-clock time per pipeline stage, safely across threads."""
def __init__(self):
self.totals = defaultdict(float)
self.counts = defaultdict(int)
self._lock = Lock()
@contextmanager
def stage(self, name):
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
with self._lock:
self.totals[name] += elapsed
self.counts[name] += 1
def report(self, wall_seconds):
busy = sum(self.totals.values())
print(f"wall {wall_seconds:.1f}s, stage time {busy:.1f}s")
for name, total in sorted(self.totals.items(), key=lambda kv: -kv[1]):
print(f"{name:>8}: {total:7.1f}s {100 * total / busy:5.1f}% "
f"{1000 * total / self.counts[name]:7.1f} ms per call")
把抓取器的每个阶段分别包在with timer.stage("fetch"):、with timer.stage("parse"):等语句中,最后打印报告。它的运行开销几乎可以忽略不计,却能把”抓取器很慢”变成一个具体的答案。
下面是配有计时器的完整测试抓取器。run()函数接受我们改动的四个设置:
import os
import sqlite3
import time
from concurrent.futures import ThreadPoolExecutor
import requests
from bs4 import BeautifulSoup
from stagetimer import StageTimer
from urls import URLS
user, password = os.environ["PROXY_USER"], os.environ["PROXY_PASS"]
PROXY = f"http://{user}-country-us:{password}@p.shifter.io:443"
PROXIES = {"http": PROXY, "https": PROXY}
HEADERS = {"User-Agent": "ExampleScraper/1.0 (+https://example.com/bot)"} # name your scraper
def scrape(url, session, parser, timer):
with timer.stage("fetch"):
for attempt in range(3):
try:
if session is None: # a new connection for every request
response = requests.get(url, headers=HEADERS, proxies=PROXIES, timeout=20)
else: # reuse connections through one Session
response = session.get(url, timeout=20)
response.raise_for_status()
break
except requests.RequestException:
if attempt == 2:
raise
html = response.text
with timer.stage("parse"):
soup = BeautifulSoup(html, parser)
title = soup.find("h1").get_text(strip=True)
links = len(soup.select("a[href]"))
return url, title, links
def run(reuse=False, parser="html.parser", workers=1, batch_writes=False):
timer = StageTimer()
session = None
if reuse:
session = requests.Session()
session.proxies = PROXIES
session.headers.update(HEADERS)
adapter = requests.adapters.HTTPAdapter(pool_connections=workers, pool_maxsize=workers)
session.mount("https://", adapter)
db = sqlite3.connect("pages.db", check_same_thread=False)
db.execute("CREATE TABLE IF NOT EXISTS pages (url TEXT, title TEXT, links INTEGER)")
db.execute("DELETE FROM pages")
db.commit()
start = time.perf_counter()
with ThreadPoolExecutor(workers) as pool:
rows = pool.map(lambda u: scrape(u, session, parser, timer), URLS)
if batch_writes:
rows = list(rows)
with timer.stage("write"):
db.executemany("INSERT INTO pages VALUES (?, ?, ?)", rows)
db.commit()
else:
for row in rows:
with timer.stage("write"):
db.execute("INSERT INTO pages VALUES (?, ?, ?)", row)
db.commit() # one transaction per row
wall = time.perf_counter() - start
timer.report(wall)
return wall
if __name__ == "__main__":
steps = [
("baseline", {}),
("reuse connections", {"reuse": True}),
("faster parser", {"reuse": True, "parser": "lxml"}),
("8 workers", {"reuse": True, "parser": "lxml", "workers": 8}),
("batch writes", {"reuse": True, "parser": "lxml", "workers": 8, "batch_writes": True}),
]
for name, config in steps:
print(f"\n== {name}")
run(**config)
urls.py中保存着120篇文章URL的列表。运行该脚本打印出以下内容:
== baseline
wall 298.2s, stage time 298.1s
fetch: 228.7s 76.7% 1905.7 ms per call
parse: 69.2s 23.2% 576.6 ms per call
write: 0.3s 0.1% 2.2 ms per call
== reuse connections
wall 125.0s, stage time 125.2s
fetch: 68.2s 54.5% 568.3 ms per call
parse: 56.7s 45.3% 472.7 ms per call
write: 0.3s 0.2% 2.1 ms per call
== faster parser
wall 75.7s, stage time 75.9s
fetch: 51.1s 67.4% 425.8 ms per call
parse: 24.6s 32.4% 204.8 ms per call
write: 0.2s 0.3% 1.6 ms per call
== 8 workers
wall 39.7s, stage time 309.8s
parse: 242.8s 78.4% 2023.6 ms per call
fetch: 66.1s 21.3% 551.0 ms per call
write: 0.8s 0.3% 6.9 ms per call
== batch writes
wall 42.1s, stage time 326.4s
parse: 256.9s 78.7% 2141.0 ms per call
fetch: 69.5s 21.3% 579.0 ms per call
write: 0.0s 0.0% 1.8 ms per call
每一次改动做了什么
复用连接把抓取时间削减了70%。 在基准版本中,每次调用requests.get()都会打开一个全新的连接。通过代理时,这意味着在页面的第一个字节到达之前,要先与代理建立TCP连接、发送隧道请求,并通过出口节点与目标完成一次完整的TLS握手。requests.Session会保持连接处于打开状态并复用它们,因此大多数请求可以跳过所有这些建立连接的开销。每页的抓取时间从1.9秒降到了0.57秒。几乎每个HTTP库都有类似的机制:一个只创建一次并共享使用的客户端对象。
更快的解析器把解析时间削减了一半。 Beautiful Soup可以使用多种解析器。内置的html.parser是纯Python实现的;lxml则是用C编写的。只改动一个参数,就把每页的解析时间从473毫秒降到了205毫秒,而这些页面都比较大。如果你只需要少数几个字段,直接用lxml来选取它们,或者提取内嵌的JSON,速度会更快。
并发使总耗时减半,并转移了瓶颈所在。 抓取过程的大部分时间都花在等待网络上,而线程可以并行等待。使用8个工作线程后,运行时间从76秒降到了40秒。但请看解析那一列:每页205毫秒变成了超过2秒。在CPython中,线程共享同一个解释器锁,因此像解析这样的CPU密集型工作不会并行执行;8个线程会排队争抢同一个核心。阶段计时器让这一点变得清晰可见:解析所占的阶段时间比例从三分之一升到了将近80%。此时抓取器已经受限于CPU,下一步应该是把解析放到独立的进程中进行,或者减少需要解析的内容。
批量写入没有带来任何变化,因为写入从来都不是问题所在:在基准版本的298秒中,写入只占0.3秒。版本4和版本5之间的微小差异只是运行间的噪声。这正是先测量的意义所在。批量写入数据库通常是好建议,但在这里它只是白费力气。
抓取器变慢的排查清单
| 你观察到的现象 | 可能的原因 | 需要检查什么 |
|---|---|---|
| 每个请求都很慢,CPU却很空闲 | 一次只处理一个请求 | 增加并发;大多数抓取器的时间都花在等待上 |
| 对每个主机的第一个请求比之后的请求慢得多 | 连接建立开销 | 复用一个session或client;检查keep-alive是否被禁用 |
| 某个核心的CPU占用达到100% | 解析或其他CPU密集型工作 | 使用更快的解析器,减少提取的内容,或把解析移到多个进程中 |
| 吞吐量随并发数提高而下降 | 目标在限流你,或者你自己的机器已经饱和 | 按目标观察状态码和响应时间;参见我们关于规划请求量与并发的指南 |
| 偶尔有请求耗时20到30秒 | 超时和重试 | 设置明确的超时时间,限制重试次数,并记录日志;参见网络抓取器的重试与退避 |
| 抓取器运行数小时后逐渐变慢 | 队列不断增长、内存压力、无限制的缓冲区 | 增加背压机制;参见分布式爬虫中的背压与流量控制 |
| 页面加载慢且开销大 | 在HTML就能满足需求的情况下却在渲染浏览器 | 直接抓取HTML,或屏蔽图片、媒体和字体 |
什么时候问题确实出在代理上
有时候瓶颈确实在代理上,值得了解如何判断。
- 与直连基准做对比。 在同一台机器上,不使用代理计时同样的请求。如果两者都很慢,问题出在目标网站或你的代码上。
- 关注复用连接后的抓取时间。 如果在使用复用的session之后抓取仍然很慢,而目标网站直连时速度很快,那么通过代理的路径就是增加耗时的原因。
- 检查你的定位设置。 过于狭窄的筛选条件,比如指定某个具体城市,会让可选的出口节点变少。距离目标较远的出口节点会给每个请求增加往返时间。
- 关注百分位数,而不是平均值。 少数几个非常慢的出口节点可能会拉高平均值,而大多数请求其实都正常。
我们关于使用住宅代理降低延迟的指南详细介绍了代理端的相关设置。如果你已经完成测量,而数据指向代理,请把这些数据发给我们的支持团队:像上面那样的阶段耗时数据,能让”代理慢”的问题调查快得多。
常见问题
为什么我的抓取器用代理时很慢?
最常见的原因出在抓取器本身:每个请求都新建连接、一次只抓一个页面,以及解析速度慢。在我们的测试中,修复这些问题使同一任务在代理不变的情况下速度提升了7.5倍。
通过代理连接复用有效吗?
有效。设置了代理的requests.Session会保持连接打开,从而在每次请求中节省建立连接和TLS握手的开销。在我们的测试中,这把每页的抓取时间从1.9秒降到了0.57秒。
抓取器应该使用多少个线程?
在目标网站和你的套餐允许的范围内,用足够多的线程让网络保持忙碌状态。随着线程数增加要持续测量:当每页解析时间或错误率开始上升时,你就找到了上限。像解析这样的CPU密集型工作,在CPython中不会因线程增多而加速。
BeautifulSoup速度慢吗?
Beautiful Soup本身只是一个便利层;它的速度大多取决于底层使用的解析器。在我们的测试中,用lxml代替内置的html.parser,解析速度快了一倍多。
结论
在怪罪代理之前,先测量你的抓取器。阶段计时器能在几分钟内显示出时间究竟花在抓取、解析还是写入上。在我们的测试中,复用连接、更换解析器并增加并发,使同一任务在代理不变的情况下速度提升了7.5倍。先修复耗时最多的阶段,再次测量,当剩下的耗时确实是网络本身造成的时候,再停下来。