“Ihre Proxies sind langsam” ist eine der häufigsten Nachrichten, die wir von Kunden erhalten. Manchmal stimmt das. Häufiger ist es so, dass die meiste Zeit, wenn wir uns den Scraper gemeinsam ansehen, für Dinge draufgeht, die der Scraper sich selbst antut: eine neue Verbindung für jede Seite öffnen, mit einer langsamen Bibliothek parsen, eine Anfrage nach der anderen abwarten.
Um zu zeigen, wie sehr das ins Gewicht fällt, haben wir einen einfachen Scraper genommen, ihn über unsere Residential Proxies laufen lassen und jeweils eine Sache nach der anderen geändert. Dieselben 120 Seiten gingen von 298 Sekunden auf 40 zurück, eine 7,5-fache Beschleunigung, ohne irgendetwas am Proxy zu ändern. Dieser Leitfaden geht diesen Test durch, das Werkzeug, das wir zum Auffinden der langsamen Stellen verwendet haben, und eine Checkliste zur Diagnose des eigenen Scrapers.
Die wichtigsten Erkenntnisse
- Messen Sie, bevor Sie etwas ändern. Ein Stage-Timer, der aufzeichnet, wie lange Abrufen, Parsen und Schreiben dauern, zeigt Ihnen in Minuten, wo die Zeit hingeht.
- Verbindungen wiederverwenden. Für jede Anfrage eine neue Verbindung über einen Proxy zu öffnen, hat in unserem Test die Zeit pro Abruf mehr als verdreifacht. Eine einzige
Sessionhat das behoben. - Parsen kann genauso viel kosten wie das Abrufen. Der Wechsel des HTML-Parsers hat die Parse-Zeit um mehr als die Hälfte reduziert.
- Nebenläufigkeit bringt den größten Gewinn und deckt dann den nächsten Engpass auf. Acht Worker haben die Laufzeit halbiert und gezeigt, dass CPU-gebundenes Parsen zum limitierenden Faktor geworden war.
- Nicht alles, was langsam ist, lohnt sich zu beheben. Datenbankschreibvorgänge haben 0,3 Sekunden von 298 beansprucht, sodass das Bündeln dieser Vorgänge nichts verändert hat.
Der Test
Wir haben 120 englische Wikipedia-Artikel über Shifters Residential Proxies mit US-Exits von einer Maschine in Europa aus abgerufen. Wikipedia-Artikel sind große Seiten, oft mehrere hundert Kilobyte HTML. Für jede Seite ruft der Scraper das HTML ab, parst es mit Beautiful Soup, um den Titel zu lesen und die Links zu zählen, und schreibt eine Zeile in SQLite.
Wir haben denselben Job fünfmal durchgeführt und jedes Mal eine Sache geändert. Der Scraper hat sich mit einem beschreibenden User-Agent identifiziert, was Wikipedia verlangt (ihre Server lehnen den Standard-Python-User-Agent ab), und einen fehlgeschlagenen Request bis zu zweimal wiederholt.
| Version | Änderung | Gesamtzeit | Fetch pro Seite | Parse pro Seite |
|---|---|---|---|---|
| 1. Baseline | Neue Verbindung pro Anfrage, html.parser, eine nach der anderen | 298 s | 1.906 ms | 577 ms |
| 2. Verbindungen wiederverwenden | Eine requests.Session | 125 s | 568 ms | 473 ms |
| 3. Schnellerer Parser | lxml statt html.parser | 76 s | 426 ms | 205 ms |
| 4. Nebenläufigkeit | 8 Worker-Threads | 40 s | 551 ms | 2.024 ms |
| 5. Gebündelte Schreibvorgänge | Eine Transaktion statt einer pro Zeile | 42 s | 579 ms | 2.141 ms |
Fetch-Zeiten über Residential Exits variieren von Lauf zu Lauf um etwa hundert Millisekunden; die obigen Unterschiede sind weit größer als das.
Schritt eins: Messen, wo die Zeit hingeht
Das Werkzeug ist ein kleiner Timer, der die Wall-Clock-Zeit pro Stage summiert, threadsicher:
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")
Umschließen Sie jede Stage Ihres Scrapers mit with timer.stage("fetch"):, with timer.stage("parse"): und so weiter, und geben Sie den Report am Ende aus. Es kostet fast nichts, dies auszuführen, und macht aus “der Scraper ist langsam” eine konkrete Antwort.
Hier ist der vollständige Test-Scraper mit eingebautem Timer. Die Funktion run() nimmt die vier Einstellungen entgegen, die wir geändert haben:
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 enthält die Liste der 120 Artikel-URLs. Der Lauf gab Folgendes aus:
== 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
Was jede Änderung bewirkt hat
Die Wiederverwendung von Verbindungen hat die Fetch-Zeit um 70% reduziert. In der Baseline öffnet jeder Aufruf von requests.get() eine brandneue Verbindung. Über einen Proxy bedeutet das eine TCP-Verbindung zum Proxy, eine Tunnel-Anfrage und einen vollständigen TLS-Handshake mit dem Ziel über den Exit, bevor überhaupt ein Byte der Seite ankommt. Eine requests.Session hält Verbindungen offen und verwendet sie wieder, sodass die meisten Anfragen den gesamten Aufbau überspringen. Die Fetch-Zeit pro Seite fiel von 1,9 Sekunden auf 0,57. Fast jede HTTP-Bibliothek hat ein Äquivalent: ein Client-Objekt, das man einmal erstellt und gemeinsam nutzt.
Ein schnellerer Parser hat die Parse-Zeit halbiert. Beautiful Soup kann mehrere Parser verwenden. Der eingebaute html.parser ist reines Python; lxml ist in C geschrieben. Der Wechsel hat die Parse-Zeit von 473 auf 205 Millisekunden pro Seite bei diesen großen Seiten reduziert, mit nur einem geänderten Argument. Wenn Sie nur wenige Felder benötigen, ist das direkte Auswählen mit lxml oder das Extrahieren von eingebettetem JSON noch schneller.
Nebenläufigkeit hat die Gesamtzeit halbiert und den Engpass verschoben. Der größte Teil eines Fetch besteht aus Warten auf das Netzwerk, und Threads können parallel warten. Mit acht Workern fiel die Laufzeit von 76 auf 40 Sekunden. Aber sehen Sie sich die Parse-Spalte an: 205 Millisekunden pro Seite wurden zu über 2 Sekunden. In CPython teilen sich Threads eine Interpreter-Lock, sodass CPU-intensive Arbeit wie das Parsen nicht parallel läuft; acht Threads reihen sich für denselben Kern ein. Der Stage-Timer macht das sichtbar: Das Parsen ging von einem Drittel der Stage-Zeit auf fast 80%. An diesem Punkt ist der Scraper CPU-limitiert, und der nächste Schritt ist, in separaten Prozessen zu parsen oder weniger zu parsen.
Das Bündeln von Schreibvorgängen hat nichts verändert, weil Schreibvorgänge nie das Problem waren: 0,3 Sekunden von 298 in der Baseline. Der kleine Unterschied zwischen Version 4 und 5 ist Rauschen zwischen den Läufen. Das ist der Sinn des vorherigen Messens. Das Bündeln von Datenbankschreibvorgängen ist im Allgemeinen ein guter Rat, und hier wäre es vergebliche Mühe gewesen.
Eine Checkliste für einen langsamen Scraper
| Was Sie sehen | Wahrscheinliche Ursache | Was zu prüfen ist |
|---|---|---|
| Jede Anfrage ist langsam, CPU ist untätig | Eine Anfrage nach der anderen | Nebenläufigkeit hinzufügen; die meisten Scraper verbringen ihre Zeit mit Warten |
| Die erste Anfrage an jeden Host ist viel langsamer als die übrigen | Verbindungsaufbau | Eine Session oder einen Client wiederverwenden; prüfen, ob Keep-Alive nicht deaktiviert ist |
| CPU ist bei 100% auf einem Kern | Parsen oder andere CPU-gebundene Arbeit | Einen schnelleren Parser verwenden, weniger extrahieren, oder das Parsen in Prozesse auslagern |
| Durchsatz sinkt, wenn die Nebenläufigkeit steigt | Das Ziel drosselt Sie, oder Ihre eigene Maschine ist ausgelastet | Statuscodes und Antwortzeiten pro Ziel beobachten; siehe unseren Leitfaden zur Planung von Request-Volumen und Nebenläufigkeit |
| Gelegentliche Anfragen brauchen 20 bis 30 Sekunden | Timeouts und Wiederholungen | Explizite Timeouts setzen, Wiederholungen begrenzen und protokollieren; siehe Retry und Backoff für Web-Scraper |
| Der Scraper wird über Stunden hinweg langsamer | Wachsende Warteschlangen, Speicherdruck, unbegrenzte Puffer | Backpressure hinzufügen; siehe Backpressure und Flusskontrolle |
| Seiten sind langsam und schwer | Ein Browser wird gerendert, wo das HTML genügen würde | HTML direkt abrufen, oder Bilder, Medien und Schriftarten blockieren |
Wenn es wirklich der Proxy ist
Manchmal ist der Proxy der Engpass, und es lohnt sich zu wissen, wie man das erkennt.
- Mit einer direkten Baseline vergleichen. Dieselben Anfragen ohne den Proxy von derselben Maschine aus messen. Wenn beide langsam sind, liegt das Problem beim Ziel oder bei Ihrem Code.
- Die Fetch-Zeit nach der Wiederverwendung von Verbindungen betrachten. Wenn Fetches mit einer wiederverwendeten Session immer noch langsam sind und das Ziel direkt schnell ist, fügt die Route über den Proxy die Zeit hinzu.
- Ihr Targeting prüfen. Enge Filter, etwa eine bestimmte Stadt, lassen weniger Exits zur Auswahl. Exits, die weit vom Ziel entfernt sind, fügen jeder Anfrage Round-Trip-Zeit hinzu.
- Perzentile beobachten, nicht Durchschnittswerte. Ein paar sehr langsame Exits können einen Durchschnitt nach oben ziehen, während die meisten Anfragen in Ordnung sind.
Unser Leitfaden zur Reduzierung der Latenz bei Residential Proxies behandelt die proxyseitigen Einstellungen im Detail. Wenn Sie gemessen haben und die Zahlen auf den Proxy hindeuten, senden Sie sie an unser Support-Team: Stage-Timings wie die obigen machen die Untersuchung eines Berichts über einen langsamen Proxy viel schneller.
FAQ
Warum ist mein Scraper mit Proxies langsam?
Die häufigsten Ursachen liegen im Scraper selbst: für jede Anfrage eine neue Verbindung öffnen, eine Seite nach der anderen abrufen, und langsames Parsen. In unserem Test machte die Behebung dieser Dinge denselben Job 7,5-mal schneller, ohne den Proxy zu ändern.
Funktioniert die Wiederverwendung von Verbindungen über einen Proxy?
Ja. Eine requests.Session mit gesetzten Proxies hält Verbindungen offen, was den Verbindungs- und TLS-Aufbau bei jeder Anfrage spart. In unserem Test reduzierte das die Fetch-Zeit pro Seite von 1,9 Sekunden auf 0,57.
Wie viele Threads sollte ein Scraper verwenden?
Genug, um das Netzwerk ausgelastet zu halten, innerhalb dessen, was das Ziel und Ihr Plan erlauben. Messen Sie, während Sie die Anzahl erhöhen: Wenn die Parse-Zeit pro Seite oder die Fehlerraten anfangen zu steigen, haben Sie die Grenze gefunden. CPU-gebundene Arbeit wie das Parsen wird in CPython nicht durch mehr Threads schneller.
Ist BeautifulSoup langsam?
Beautiful Soup selbst ist eine Komfortschicht; die meiste Geschwindigkeit hängt vom darunterliegenden Parser ab. Mit lxml statt dem eingebauten html.parser war das Parsen in unserem Test mehr als doppelt so schnell.
Fazit
Bevor Sie dem Proxy die Schuld geben, messen Sie den Scraper. Ein Stage-Timer zeigt innerhalb von Minuten, ob die Zeit beim Abrufen, Parsen oder Schreiben draufgeht. In unserem Test machten die Wiederverwendung von Verbindungen, der Wechsel des Parsers und das Hinzufügen von Nebenläufigkeit denselben Job 7,5-mal schneller, mit demselben Proxy. Beheben Sie zuerst die größte Stage, messen Sie erneut, und hören Sie auf, wenn die verbleibende Zeit wirklich auf das Netzwerk zurückzuführen ist.