“Tus proxies van lentos” es uno de los mensajes más habituales que recibimos de los clientes. A veces es cierto. Más a menudo, cuando revisamos el scraper juntos, la mayor parte del tiempo se va en cosas que el propio scraper se hace a sí mismo: abrir una conexión nueva para cada página, analizar con una librería lenta, esperar una petición cada vez.
Para mostrar cuánto importa esto, cogimos un scraper sencillo, lo ejecutamos a través de nuestros proxies residenciales y cambiamos una cosa cada vez. Las mismas 120 páginas pasaron de 298 segundos a 40, una mejora de 7,5 veces, sin cambiar nada del proxy. Esta guía repasa esa prueba, la herramienta que usamos para encontrar las partes lentas y una lista de comprobación para diagnosticar tu propio scraper.
Conclusiones clave
- Mide antes de cambiar nada. Un temporizador por etapas que registre cuánto tiempo llevan la obtención, el análisis y la escritura te dirá en minutos dónde se va el tiempo.
- Reutiliza las conexiones. Abrir una conexión nueva a través de un proxy para cada petición más que triplicó el tiempo por obtención en nuestra prueba. Una
Sessionlo solucionó. - El análisis puede costar tanto como la obtención. Cambiar el analizador HTML redujo el tiempo de análisis a más de la mitad.
- La concurrencia da la mayor mejora y después expone el siguiente cuello de botella. Ocho workers redujeron el tiempo de ejecución a la mitad y mostraron que el análisis limitado por CPU se había convertido en el límite.
- No todo lo lento merece arreglarse. Las escrituras en la base de datos se llevaron 0,3 segundos de 298, así que agruparlas en lotes no cambió nada.
La prueba
Recogimos 120 artículos de la Wikipedia en inglés a través de los proxies residenciales de Shifter con salidas en EE. UU., desde una máquina en Europa. Los artículos de Wikipedia son páginas grandes, a menudo con varios cientos de kilobytes de HTML. Para cada página, el scraper obtiene el HTML, lo analiza con Beautiful Soup para leer el título y contar los enlaces, y escribe una fila en SQLite.
Ejecutamos el mismo trabajo cinco veces, cambiando una cosa cada vez. El scraper se identificaba con un User-Agent descriptivo, que Wikipedia exige (sus servidores rechazan el User-Agent por defecto de Python), y reintentaba una petición fallida hasta dos veces.
| Versión | Cambio | Tiempo total | Obtención por página | Análisis por página |
|---|---|---|---|---|
| 1. Base | Conexión nueva por petición, html.parser, una cada vez | 298 s | 1.906 ms | 577 ms |
| 2. Reutilización de conexiones | Un requests.Session | 125 s | 568 ms | 473 ms |
| 3. Analizador más rápido | lxml en lugar de html.parser | 76 s | 426 ms | 205 ms |
| 4. Concurrencia | 8 hilos de trabajo | 40 s | 551 ms | 2.024 ms |
| 5. Escrituras en lote | Una transacción en lugar de una por fila | 42 s | 579 ms | 2.141 ms |
Los tiempos de obtención a través de salidas residenciales varían de una ejecución a otra en unos cien milisegundos; las diferencias anteriores son mucho mayores que eso.
Paso uno: medir dónde se va el tiempo
La herramienta es un pequeño temporizador que suma el tiempo de reloj por etapa, de forma segura entre hilos:
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")
Envuelve cada etapa de tu scraper con with timer.stage("fetch"):, with timer.stage("parse"):, etc., e imprime el informe al final. Cuesta casi nada ejecutarlo y convierte “el scraper va lento” en una respuesta concreta.
Aquí está el scraper de prueba completo con el temporizador ya incorporado. La función run() recibe los cuatro ajustes que cambiamos:
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 contiene la lista de 120 URL de artículos. Al ejecutarlo se imprimió:
== 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
Qué hizo cada cambio
Reutilizar las conexiones redujo el tiempo de obtención en un 70%. En la versión base, cada llamada a requests.get() abre una conexión completamente nueva. A través de un proxy, eso implica una conexión TCP al proxy, una petición de túnel y un handshake TLS completo con el destino a través de la salida, antes de que llegue un solo byte de la página. Un requests.Session mantiene las conexiones abiertas y las reutiliza, de modo que la mayoría de las peticiones se saltan toda esa configuración. El tiempo de obtención por página bajó de 1,9 segundos a 0,57. Casi cualquier librería HTTP tiene un equivalente: un objeto cliente que creas una vez y compartes.
Un analizador más rápido redujo el tiempo de análisis a la mitad. Beautiful Soup puede usar varios analizadores. El html.parser integrado es Python puro; lxml está escrito en C. El cambio redujo el tiempo de análisis de 473 a 205 milisegundos por página en estas páginas grandes, con un solo argumento modificado. Si solo necesitas unos pocos campos, seleccionarlos directamente con lxml o extraer el JSON incrustado es todavía más rápido.
La concurrencia redujo el tiempo total a la mitad y desplazó el cuello de botella. La mayor parte de una obtención consiste en esperar a la red, y los hilos pueden esperar en paralelo. Con ocho workers, la ejecución bajó de 76 a 40 segundos. Pero fíjate en la columna de análisis: 205 milisegundos por página se convirtieron en más de 2 segundos. En CPython, los hilos comparten un único lock del intérprete, así que el trabajo intensivo en CPU, como el análisis, no se ejecuta en paralelo; ocho hilos hacen cola para el mismo núcleo. El temporizador por etapas lo hace visible: el análisis pasó de un tercio del tiempo de etapa a casi un 80%. Llegados a ese punto, el scraper está limitado por la CPU, y el siguiente paso es analizar en procesos separados o analizar menos.
Agrupar las escrituras no cambió nada, porque las escrituras nunca fueron el problema: 0,3 segundos de 298 en la versión base. La pequeña diferencia entre las versiones 4 y 5 es ruido de una ejecución a otra. Este es precisamente el sentido de medir primero. Agrupar las escrituras en la base de datos es un buen consejo en general, y aquí habría sido un esfuerzo desperdiciado.
Una lista de comprobación para un scraper lento
| Lo que observas | Causa probable | Qué comprobar |
|---|---|---|
| Cada petición es lenta, la CPU está inactiva | Una petición cada vez | Añade concurrencia; la mayoría de los scrapers pasan su tiempo esperando |
| La primera petición a cada host es mucho más lenta que el resto | Configuración de la conexión | Reutiliza una sesión o un cliente; comprueba que el keep-alive no esté desactivado |
| La CPU está al 100% en un núcleo | Análisis u otro trabajo limitado por CPU | Usa un analizador más rápido, extrae menos, o traslada el análisis a procesos |
| El rendimiento cae al aumentar la concurrencia | El destino te está limitando, o tu propia máquina está saturada | Vigila los códigos de estado y los tiempos de respuesta por destino; consulta nuestra guía sobre planificar el volumen de peticiones y la concurrencia |
| Algunas peticiones ocasionales tardan entre 20 y 30 segundos | Timeouts y reintentos | Establece timeouts explícitos, limita los reintentos y regístralos; consulta reintentos y backoff para scrapers web |
| El scraper se ralentiza con el paso de las horas | Colas que crecen, presión de memoria, búferes sin límite | Añade backpressure; consulta backpressure y control de flujo |
| Las páginas son lentas y pesadas | Renderizar en un navegador cuando bastaría con el HTML | Obtén el HTML directamente, o bloquea imágenes, medios y fuentes |
Cuando de verdad es el proxy
A veces el proxy sí es el cuello de botella, y vale la pena saber cómo distinguirlo.
- Compara con una referencia directa. Mide las mismas peticiones sin el proxy desde la misma máquina. Si ambas son lentas, el problema está en el destino o en tu código.
- Observa el tiempo de obtención tras reutilizar la conexión. Si las obtenciones siguen siendo lentas con una sesión reutilizada y el destino es rápido en directo, la ruta a través del proxy está añadiendo ese tiempo.
- Revisa tu segmentación. Los filtros estrechos, como una ciudad concreta, dejan menos salidas entre las que elegir. Las salidas lejos del destino añaden tiempo de ida y vuelta a cada petición.
- Vigila los percentiles, no las medias. Unas pocas salidas muy lentas pueden arrastrar una media hacia arriba mientras la mayoría de las peticiones van bien.
Nuestra guía sobre reducir la latencia con proxies residenciales trata en detalle los ajustes del lado del proxy. Si has medido y los números apuntan al proxy, envíalos a nuestro equipo de soporte: tener tiempos por etapa como los de arriba agiliza mucho la investigación de un informe de proxy lento.
Preguntas frecuentes
¿Por qué mi scraper va lento con proxies?
Las causas más habituales están en el propio scraper: abrir una conexión nueva para cada petición, obtener una página cada vez y un análisis lento. En nuestra prueba, corregir eso hizo que el mismo trabajo fuera 7,5 veces más rápido sin cambiar el proxy.
¿Funciona la reutilización de conexiones a través de un proxy?
Sí. Un requests.Session con los proxies configurados mantiene las conexiones abiertas, lo que ahorra la configuración de la conexión y del TLS en cada petición. En nuestra prueba redujo el tiempo de obtención por página de 1,9 segundos a 0,57.
¿Cuántos hilos debería usar un scraper?
Los suficientes para mantener la red ocupada, dentro de lo que permitan el destino y tu plan. Mide a medida que lo aumentas: cuando el tiempo de análisis por página o las tasas de error empiecen a subir, habrás encontrado el límite. El trabajo limitado por CPU, como el análisis, no se acelera con más hilos en CPython.
¿Es lento BeautifulSoup?
Beautiful Soup en sí es una capa de conveniencia; la mayor parte de su velocidad depende del analizador que hay detrás. Con lxml en lugar del html.parser integrado, el análisis fue más del doble de rápido en nuestra prueba.
En resumen
Antes de culpar al proxy, mide el scraper. Un temporizador por etapas muestra en minutos si el tiempo se va en obtener, analizar o escribir. En nuestra prueba, reutilizar conexiones, cambiar de analizador y añadir concurrencia hicieron que el mismo trabajo fuera 7,5 veces más rápido con el mismo proxy. Arregla primero la etapa más costosa, mide de nuevo, y detente cuando el tiempo restante sea de verdad la red.