“Seus proxies estão lentos” é uma das mensagens mais comuns que recebemos de clientes. Às vezes é verdade. Mais frequentemente, quando analisamos o scraper junto com o cliente, a maior parte do tempo vai para coisas que o próprio scraper faz consigo mesmo: abrir uma nova conexão para cada página, fazer parsing com uma biblioteca lenta, esperar uma requisição de cada vez.
Para mostrar o quanto isso importa, pegamos um scraper simples, rodamos através dos nossos proxies residenciais e mudamos uma coisa de cada vez. As mesmas 120 páginas foram de 298 segundos para 40, uma aceleração de 7,5 vezes, sem mudar nada no proxy. Este guia percorre esse teste, a ferramenta que usamos para encontrar as partes lentas e um checklist para diagnosticar seu próprio scraper.
Principais conclusões
- Meça antes de mudar qualquer coisa. Um cronômetro de estágio que registra quanto tempo a busca, o parsing e a escrita levam vai te dizer para onde o tempo vai, em minutos.
- Reutilize conexões. Abrir uma nova conexão através de um proxy para cada requisição mais do que triplicou o tempo por busca no nosso teste. Uma
Sessionresolveu isso. - O parsing pode custar tanto quanto a busca. Trocar o parser de HTML cortou o tempo de parsing pela metade.
- A concorrência traz o maior ganho, e depois expõe o próximo gargalo. Oito workers reduziram o tempo de execução pela metade e mostraram que o parsing limitado por CPU tinha se tornado o limite.
- Nem tudo que é lento vale a pena consertar. As escritas no banco de dados levaram 0,3 segundos de 298, então agrupá-las em lote não mudou nada.
O teste
Coletamos 120 artigos da Wikipédia em inglês através dos proxies residenciais da Shifter com saídas nos EUA, a partir de uma máquina na Europa. Os artigos da Wikipédia são páginas grandes, frequentemente com várias centenas de kilobytes de HTML. Para cada página, o scraper busca o HTML, faz o parsing com o Beautiful Soup para ler o título e contar os links, e escreve uma linha no SQLite.
Rodamos o mesmo job cinco vezes, mudando uma coisa de cada vez. O scraper se identificava com um User-Agent descritivo, o que a Wikipédia exige (seus servidores rejeitam o User-Agent padrão do Python), e repetia uma requisição que falhasse até duas vezes.
| Versão | Mudança | Tempo total | Busca por página | Parsing por página |
|---|---|---|---|---|
| 1. Baseline | Nova conexão por requisição, html.parser, um de cada vez | 298 s | 1.906 ms | 577 ms |
| 2. Reutilização de conexões | Uma requests.Session | 125 s | 568 ms | 473 ms |
| 3. Parser mais rápido | lxml em vez de html.parser | 76 s | 426 ms | 205 ms |
| 4. Concorrência | 8 threads de worker | 40 s | 551 ms | 2.024 ms |
| 5. Escritas em lote | Uma transação em vez de uma por linha | 42 s | 579 ms | 2.141 ms |
Os tempos de busca através de saídas residenciais variam de execução para execução em cerca de cem milissegundos; as diferenças acima são muito maiores do que isso.
Primeiro passo: medir para onde o tempo vai
A ferramenta é um pequeno cronômetro que soma o tempo de relógio real por estágio, de forma segura 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")
Envolva cada estágio do seu scraper com with timer.stage("fetch"):, with timer.stage("parse"): e assim por diante, e imprima o relatório no final. Isso custa quase nada para rodar e transforma “o scraper está lento” em uma resposta específica.
Aqui está o scraper de teste completo com o cronômetro implementado. A função run() recebe as quatro configurações que mudamos:
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 contém a lista de 120 URLs de artigos. Rodar o código imprimiu:
== 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
O que cada mudança fez
Reutilizar conexões cortou o tempo de busca em 70%. No baseline, cada chamada a requests.get() abre uma conexão completamente nova. Através de um proxy, isso significa uma conexão TCP com o proxy, uma requisição de túnel e um handshake TLS completo com o alvo através da saída, antes que um único byte da página chegue. Uma requests.Session mantém as conexões abertas e as reutiliza, então a maioria das requisições pula toda essa configuração. O tempo de busca por página caiu de 1,9 segundos para 0,57. Quase toda biblioteca HTTP tem um equivalente: um objeto cliente que você cria uma vez e compartilha.
Um parser mais rápido cortou o tempo de parsing pela metade. O Beautiful Soup pode usar vários parsers. O html.parser embutido é Python puro; o lxml é escrito em C. A troca cortou o tempo de parsing de 473 para 205 milissegundos por página nessas páginas grandes, com um único argumento alterado. Se você só precisa de alguns campos, selecioná-los diretamente com lxml ou extrair o JSON embutido é ainda mais rápido.
A concorrência reduziu o tempo total pela metade e deslocou o gargalo. A maior parte de uma busca é espera pela rede, e threads podem esperar em paralelo. Com oito workers, a execução caiu de 76 para 40 segundos. Mas observe a coluna de parsing: 205 milissegundos por página se tornaram mais de 2 segundos. No CPython, as threads compartilham um único lock do interpretador, então trabalho intensivo em CPU, como o parsing, não roda em paralelo; oito threads entram na fila para o mesmo núcleo. O cronômetro de estágio torna isso visível: o parsing foi de um terço do tempo de estágio para quase 80%. Nesse ponto, o scraper está limitado pela CPU, e o próximo passo é fazer o parsing em processos separados ou fazer parsing de menos coisas.
Agrupar as escritas em lote não mudou nada, porque as escritas nunca foram o problema: 0,3 segundos de 298 no baseline. A pequena diferença entre as versões 4 e 5 é ruído de execução. Esse é o ponto de medir primeiro. Agrupar escritas de banco de dados em lote é um bom conselho em geral, e teria sido esforço desperdiçado aqui.
Um checklist para um scraper lento
| O que você observa | Causa provável | O que verificar |
|---|---|---|
| Toda requisição é lenta, a CPU está ociosa | Uma requisição de cada vez | Adicione concorrência; a maioria dos scrapers passa o tempo esperando |
| A primeira requisição para cada host é muito mais lenta que as outras | Configuração de conexão | Reutilize uma sessão ou cliente; verifique se o keep-alive não está desativado |
| A CPU está em 100% em um núcleo | Parsing ou outro trabalho limitado por CPU | Use um parser mais rápido, extraia menos, ou mova o parsing para processos |
| O throughput cai conforme a concorrência aumenta | O alvo está limitando você, ou sua própria máquina está saturada | Observe os códigos de status e tempos de resposta por alvo; veja nosso guia sobre planejamento de volume de requisições e concorrência |
| Requisições ocasionais levam de 20 a 30 segundos | Timeouts e retentativas | Defina timeouts explícitos, limite as retentativas e registre-as; veja retentativa e backoff para web scrapers |
| O scraper fica mais lento ao longo das horas | Filas crescendo, pressão de memória, buffers sem limite | Adicione contrapressão; veja contrapressão e controle de fluxo |
| As páginas são lentas e pesadas | Renderizar um navegador quando o HTML seria suficiente | Busque o HTML diretamente, ou bloqueie imagens, mídia e fontes |
Quando é realmente o proxy
Às vezes o proxy é o gargalo, e vale a pena saber como identificar isso.
- Compare com um baseline direto. Cronometre as mesmas requisições sem o proxy, a partir da mesma máquina. Se ambos estiverem lentos, o problema é o alvo ou seu código.
- Observe o tempo de busca depois da reutilização de conexões. Se as buscas ainda estiverem lentas com uma sessão reutilizada e o alvo for rápido diretamente, a rota através do proxy está adicionando o tempo.
- Verifique seu direcionamento. Filtros estreitos, como uma cidade específica, deixam menos saídas para escolher. Saídas distantes do alvo adicionam tempo de ida e volta a cada requisição.
- Observe os percentis, não as médias. Algumas saídas muito lentas podem puxar uma média para cima enquanto a maioria das requisições está bem.
Nosso guia sobre redução de latência com proxies residenciais cobre as configurações do lado do proxy em detalhe. Se você mediu e os números apontam para o proxy, envie-os para nossa equipe de suporte: cronometragens de estágio como as acima tornam um relatório de proxy lento muito mais rápido de investigar.
Perguntas frequentes
Por que meu scraper está lento com proxies?
As causas mais comuns estão no próprio scraper: abrir uma nova conexão para cada requisição, buscar uma página de cada vez e parsing lento. No nosso teste, corrigir isso tornou o mesmo job 7,5 vezes mais rápido sem mudar o proxy.
A reutilização de conexão funciona através de um proxy?
Sim. Uma requests.Session com proxies configurados mantém as conexões abertas, o que economiza a configuração de conexão e TLS a cada requisição. No nosso teste, isso cortou o tempo de busca por página de 1,9 segundos para 0,57.
Quantas threads um scraper deve usar?
O suficiente para manter a rede ocupada, dentro do que o alvo e seu plano permitem. Meça conforme você aumenta: quando o tempo de parsing por página ou as taxas de erro começam a subir, você encontrou o limite. Trabalho limitado por CPU, como o parsing, não acelera com mais threads no CPython.
O BeautifulSoup é lento?
O Beautiful Soup em si é uma camada de conveniência; a maior parte de sua velocidade depende do parser por baixo. Com lxml em vez do html.parser embutido, o parsing foi mais que duas vezes mais rápido no nosso teste.
Conclusão
Antes de culpar o proxy, meça o scraper. Um cronômetro de estágio mostra em minutos se o tempo vai para busca, parsing ou escrita. No nosso teste, reutilizar conexões, trocar parsers e adicionar concorrência tornaram o mesmo job 7,5 vezes mais rápido com o mesmo proxy. Corrija primeiro o maior estágio, meça de novo e pare quando o tempo restante realmente for a rede.