« Vos proxies sont lents » est l’un des messages les plus courants que nous recevons de la part des clients. Parfois, c’est vrai. Plus souvent, lorsque nous examinons le scraper ensemble, la majeure partie du temps est consacrée à des choses que le scraper s’inflige lui-même : ouvrir une nouvelle connexion pour chaque page, analyser avec une bibliothèque lente, attendre une requête à la fois.
Pour montrer à quel point cela compte, nous avons pris un scraper simple, l’avons fait passer par nos proxies résidentiels, et avons changé une chose à la fois. Les mêmes 120 pages sont passées de 298 secondes à 40, soit une accélération de 7,5 fois, sans rien changer au proxy. Ce guide parcourt ce test, l’outil que nous avons utilisé pour trouver les parties lentes, et une liste de vérification pour diagnostiquer votre propre scraper.
Points clés à retenir
- Mesurez avant de changer quoi que ce soit. Un chronomètre d’étape qui enregistre le temps que prennent la récupération, l’analyse et l’écriture vous indiquera en quelques minutes où le temps passe.
- Réutilisez les connexions. Ouvrir une nouvelle connexion via un proxy pour chaque requête a plus que triplé le temps par récupération dans notre test. Une seule
Sessiona résolu le problème. - L’analyse peut coûter autant que la récupération. Changer l’analyseur HTML a réduit le temps d’analyse de plus de moitié.
- La concurrence apporte le gain le plus important, puis révèle le goulot d’étranglement suivant. Huit workers ont divisé par deux le temps d’exécution, et ont montré que l’analyse, limitée par le CPU, était devenue la contrainte.
- Tout ce qui est lent ne vaut pas la peine d’être corrigé. Les écritures en base de données ont pris 0,3 seconde sur 298, donc les regrouper n’a rien changé.
Le test
Nous avons collecté 120 articles de Wikipedia en anglais via les proxies résidentiels de Shifter avec des sorties aux US, depuis une machine en Europe. Les articles de Wikipedia sont de grandes pages, souvent plusieurs centaines de kilooctets de HTML. Pour chaque page, le scraper récupère le HTML, l’analyse avec Beautiful Soup pour lire le titre et compter les liens, et écrit une ligne dans SQLite.
Nous avons exécuté la même tâche cinq fois, en changeant une chose à chaque fois. Le scraper s’identifiait avec un User-Agent descriptif, ce que Wikipedia exige (ses serveurs rejettent le User-Agent par défaut de Python), et relançait une requête échouée jusqu’à deux fois.
| Version | Changement | Temps total | Récupération par page | Analyse par page |
|---|---|---|---|---|
| 1. Référence | Nouvelle connexion par requête, html.parser, une à la fois | 298 s | 1 906 ms | 577 ms |
| 2. Réutilisation des connexions | Une seule requests.Session | 125 s | 568 ms | 473 ms |
| 3. Analyseur plus rapide | lxml au lieu de html.parser | 76 s | 426 ms | 205 ms |
| 4. Concurrence | 8 threads worker | 40 s | 551 ms | 2 024 ms |
| 5. Écritures groupées | Une transaction au lieu d’une par ligne | 42 s | 579 ms | 2 141 ms |
Les temps de récupération via les sorties résidentielles varient d’une exécution à l’autre d’une centaine de millisecondes environ ; les différences ci-dessus sont bien plus importantes que cela.
Étape un : mesurer où le temps passe
L’outil est un petit chronomètre qui additionne le temps réel par étape, de manière sûre entre threads :
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")
Enveloppez chaque étape de votre scraper avec with timer.stage("fetch"):, with timer.stage("parse"): et ainsi de suite, puis affichez le rapport à la fin. Cela ne coûte presque rien à l’exécution et transforme « le scraper est lent » en une réponse précise.
Voici le scraper de test complet avec le chronomètre en place. La fonction run() prend les quatre paramètres que nous avons changés :
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 contient la liste des 120 URL d’articles. Son exécution a affiché :
== 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
Ce que chaque changement a fait
La réutilisation des connexions a réduit le temps de récupération de 70%. Dans la version de référence, chaque appel à requests.get() ouvre une toute nouvelle connexion. Via un proxy, cela signifie une connexion TCP vers le proxy, une requête de tunnel, et une poignée de main TLS complète avec la cible à travers la sortie, avant qu’un seul octet de la page n’arrive. Une requests.Session garde les connexions ouvertes et les réutilise, de sorte que la plupart des requêtes évitent toute cette configuration. Le temps de récupération par page est passé de 1,9 seconde à 0,57. Presque toutes les bibliothèques HTTP ont un équivalent : un objet client que vous créez une fois et partagez.
Un analyseur plus rapide a divisé par deux le temps d’analyse. Beautiful Soup peut utiliser plusieurs analyseurs. Le html.parser intégré est en Python pur ; lxml est écrit en C. Ce changement a réduit le temps d’analyse de 473 à 205 millisecondes par page sur ces grandes pages, avec un seul argument modifié. Si vous n’avez besoin que de quelques champs, les sélectionner directement avec lxml ou extraire du JSON intégré est encore plus rapide.
La concurrence a divisé par deux le temps total et déplacé le goulot d’étranglement. La majeure partie d’une récupération consiste à attendre le réseau, et les threads peuvent attendre en parallèle. Avec huit workers, l’exécution est passée de 76 à 40 secondes. Mais regardez la colonne d’analyse : 205 millisecondes par page sont devenues plus de 2 secondes. Dans CPython, les threads partagent un seul verrou d’interpréteur, donc le travail intensif en CPU comme l’analyse ne s’exécute pas en parallèle ; huit threads font la queue pour le même cœur. Le chronomètre d’étape rend cela visible : l’analyse est passée d’un tiers du temps d’étape à près de 80%. À ce stade, le scraper est limité par le CPU, et l’étape suivante consiste à analyser dans des processus séparés ou à analyser moins.
Le regroupement des écritures n’a rien changé, car les écritures n’ont jamais été le problème : 0,3 seconde sur 298 dans la référence. La petite différence entre les versions 4 et 5 est du bruit d’une exécution à l’autre. C’est tout l’intérêt de mesurer d’abord. Regrouper les écritures en base de données est un bon conseil en général, et cela aurait été un effort gaspillé ici.
Une liste de vérification pour un scraper lent
| Ce que vous observez | Cause probable | Ce qu’il faut vérifier |
|---|---|---|
| Chaque requête est lente, le CPU est inactif | Une requête à la fois | Ajoutez de la concurrence ; la plupart des scrapers passent leur temps à attendre |
| La première requête vers chaque hôte est beaucoup plus lente que les autres | Configuration de la connexion | Réutilisez une session ou un client ; vérifiez que le keep-alive n’est pas désactivé |
| Le CPU est à 100% sur un cœur | Analyse ou autre travail limité par le CPU | Utilisez un analyseur plus rapide, extrayez moins, ou déplacez l’analyse vers des processus |
| Le débit chute quand la concurrence augmente | La cible vous limite, ou votre propre machine est saturée | Surveillez les codes de statut et les temps de réponse par cible ; consultez notre guide sur la planification du volume de requêtes et de la concurrence |
| Certaines requêtes prennent occasionnellement 20 à 30 secondes | Délais d’attente et relances | Définissez des délais d’attente explicites, plafonnez les relances, et journalisez-les ; voir relance et backoff pour les scrapers web |
| Le scraper ralentit au fil des heures | Files d’attente qui grossissent, pression mémoire, tampons non bornés | Ajoutez une contre-pression ; voir contre-pression et contrôle de flux |
| Les pages sont lentes et lourdes | Faire tourner un navigateur alors que le HTML suffirait | Récupérez le HTML directement, ou bloquez les images, médias et polices |
Quand c’est vraiment le proxy
Parfois, le proxy est bien le goulot d’étranglement, et il vaut la peine de savoir comment le vérifier.
- Comparez avec une référence directe. Chronométrez les mêmes requêtes sans le proxy depuis la même machine. Si les deux sont lentes, le problème vient de la cible ou de votre code.
- Regardez le temps de récupération après réutilisation des connexions. Si les récupérations sont encore lentes avec une session réutilisée et que la cible est rapide en direct, c’est le passage par le proxy qui ajoute le temps.
- Vérifiez votre ciblage. Des filtres restrictifs, comme une ville précise, laissent moins de sorties disponibles. Des sorties éloignées de la cible ajoutent du temps d’aller-retour à chaque requête.
- Surveillez les percentiles, pas les moyennes. Quelques sorties très lentes peuvent tirer une moyenne vers le haut alors que la plupart des requêtes se passent bien.
Notre guide sur la réduction de la latence avec les proxies résidentiels couvre en détail les réglages côté proxy. Si vous avez mesuré et que les chiffres pointent vers le proxy, envoyez-les à notre équipe de support : des mesures par étape comme celles ci-dessus rendent un rapport de proxy lent bien plus rapide à investiguer.
FAQ
Pourquoi mon scraper est-il lent avec des proxies ?
Les causes les plus courantes se trouvent dans le scraper lui-même : ouvrir une nouvelle connexion pour chaque requête, récupérer une page à la fois, et une analyse lente. Dans notre test, corriger ces points a rendu la même tâche 7,5 fois plus rapide sans changer le proxy.
La réutilisation des connexions fonctionne-t-elle à travers un proxy ?
Oui. Une requests.Session avec des proxies configurés garde les connexions ouvertes, ce qui économise la configuration de la connexion et du TLS à chaque requête. Dans notre test, cela a réduit le temps de récupération par page de 1,9 seconde à 0,57.
Combien de threads un scraper devrait-il utiliser ?
Assez pour garder le réseau occupé, dans les limites autorisées par la cible et votre forfait. Mesurez au fur et à mesure que vous l’augmentez : quand le temps d’analyse par page ou les taux d’erreur commencent à grimper, vous avez trouvé la limite. Le travail limité par le CPU comme l’analyse ne s’accélère pas avec plus de threads dans CPython.
BeautifulSoup est-il lent ?
Beautiful Soup lui-même est une couche de commodité ; la majeure partie de sa vitesse dépend de l’analyseur sous-jacent. Avec lxml au lieu du html.parser intégré, l’analyse était plus de deux fois plus rapide dans notre test.
En résumé
Avant de blâmer le proxy, mesurez le scraper. Un chronomètre d’étape montre en quelques minutes si le temps passe dans la récupération, l’analyse ou l’écriture. Dans notre test, réutiliser les connexions, changer d’analyseur et ajouter de la concurrence ont rendu la même tâche 7,5 fois plus rapide avec le même proxy. Corrigez d’abord l’étape la plus coûteuse, mesurez à nouveau, et arrêtez-vous quand le temps restant est vraiment celui du réseau.