Toda vez que um crawler distribuído passa por uma tarde ruim, é sempre a mesma história. Um parser fica mais lento porque um site começou a enviar páginas enormes. Os fetchers continuam buscando a todo vapor, porque nada os avisou para diminuir o ritmo. A fila entre eles cresce em milhões de itens, a memória sobe, um nó cai, seu trabalho é reenfileirado nos outros, e as tentativas repetidas derrubam esses também. Enquanto isso, o site que causou tudo isso está recebendo mais tráfego do que nunca.
Nada disso é um bug em nenhum componente isolado. É a ausência de controle de fluxo entre eles. Um crawler é um pipeline de estágios com velocidades muito diferentes, e sem uma forma de um estágio lento dizer a um estágio rápido para esperar, o rápido vence até que tudo perca.
Este texto trata de como construir essa realimentação: de onde vêm os sinais, para onde eles precisam ir, e o punhado de regras que fazem um crawler degradar de forma suave em vez de simplesmente colapsar.
Um crawler é um pipeline, e os estágios discordam sobre velocidade
Removendo os detalhes, a maioria dos crawlers tem quatro estágios:
| Estágio | O que o limita | Como falha quando sobrecarregado |
|---|---|---|
| Frontier e scheduler | Quase nada, é só escolher URLs | Emite trabalho mais rápido do que qualquer coisa a jusante consegue absorver |
| Fetchers | Sites de destino, capacidade de proxy, limites do plano | Timeouts, throttling, bloqueios |
| Parse e renderização | CPU, memória, slots de browser headless | Latência crescente, depois esgotamento de memória |
| Dedupe e armazenamento | Capacidade de escrita do banco de dados | Escritas lentas, contenção de locks, acúmulo de pendências |
O frontier é quase gratuito de executar, então será sempre o estágio mais rápido. Os outros três são cada um limitado por algo diferente, e o limite se move: um site fica mais lento, um tipo de página fica mais pesado, um banco de dados começa a compactar. Controle de fluxo significa que qualquer estágio que esteja mais lento agora dita o ritmo de todos os outros.
A alternativa é que o ritmo seja dado por qualquer estágio que ficar sem memória primeiro.
Regra 1: toda fila tem um limite
Uma fila sem limite entre dois estágios não é um buffer. É uma promessa de absorver qualquer quantidade de excesso de trabalho, o que nenhuma máquina consegue cumprir. Isso também esconde o problema. O estágio a montante vê sucesso em cada enfileiramento enquanto a fila cresce silenciosamente, e quando alguém finalmente olha, o item mais antigo já está esperando há horas.
Uma fila limitada transforma a mesma situação em um sinal. Quando ela enche, o produtor bloqueia, ou recebe uma rejeição explícita. A lentidão se propaga a montante, um estágio por vez, até chegar ao frontier, que simplesmente para de distribuir URLs por um tempo. Nada se perde e nada explode.
Em um único processo, isso é apenas um argumento no construtor da fila:
import asyncio
async def fetcher(frontier: asyncio.Queue, parsed: asyncio.Queue, fetch):
while True:
url = await frontier.get()
try:
page = await fetch(url)
# Bloqueia quando a fila de parse está cheia: um parser lento diminui o ritmo do fetch.
await parsed.put(page)
finally:
frontier.task_done()
async def parser(parsed: asyncio.Queue, store):
while True:
page = await parsed.get()
try:
await store(page)
finally:
parsed.task_done()
async def run(urls, fetch, store, fetchers=16, parsers=4):
frontier = asyncio.Queue(maxsize=1000)
parsed = asyncio.Queue(maxsize=200)
workers = [asyncio.create_task(fetcher(frontier, parsed, fetch)) for _ in range(fetchers)]
workers += [asyncio.create_task(parser(parsed, store)) for _ in range(parsers)]
for url in urls:
await frontier.put(url) # bloqueia quando o frontier está cheio
await frontier.join()
await parsed.join()
for w in workers:
w.cancel()
Entre máquinas o princípio é o mesmo, apenas o mecanismo muda: uma fila de broker com limite de tamanho e uma política de rejeição, um stream com lag de consumidor sobre o qual você realmente age, ou um frontier baseado em banco de dados que só libera URLs para workers com capacidade livre. Dimensione os limites a partir da latência que você consegue tolerar, não da memória que você tem. Se o estágio de parse processa 200 páginas por segundo e você aceita 10 segundos de enfileiramento, a fila comporta cerca de 2.000 itens. Qualquer coisa maior apenas posterga o momento em que você descobre o problema.
Regra 2: puxe, não empurre
O controle de contrapressão mais limpo é aquele que você ganha de graça. Se os workers pedem trabalho quando têm capacidade, em vez de um coordenador atribuir trabalho a eles, um worker lento simplesmente pede com menos frequência. O sistema não consegue enviar mais do que ele consegue processar, porque ele nunca pediu.
Empurrar trabalho faz o coordenador ter que adivinhar. Ele precisa rastrear a carga de cada worker, e vai adivinhar errado exatamente no momento em que a carga mudar. Designs baseados em puxar, seja workers arrendando itens de uma fila ou concedendo créditos explícitos ao seu estágio a montante, transferem essa decisão para o único componente que sabe a resposta.
Arrendamentos precisam de expiração. Um worker que morre segurando trabalho não pode segurá-lo para sempre, então os itens arrendados voltam para a fila após um timeout. Defina esse timeout a partir do fetch legítimo mais lento, não da média, ou trabalho saudável mas lento acaba sendo distribuído duas vezes.
Regra 3: fila por host, não uma fila global
Uma única fila de fetch global tem um modo de falha que os crawlers enfrentam constantemente: o head-of-line blocking. Se as próximas mil URLs pertencem todas a um site lento ou que está aplicando throttling, todos os fetchers acabam esperando por esse site enquanto o trabalho de sites rápidos e saudáveis fica na fila atrás dele.
Particione o estágio de fetch por host, ou por qualquer unidade sobre a qual um destino aplique rate limiting. Cada host recebe sua própria fila e seu próprio limite de concorrência, e os fetchers escolhem entre hosts que atualmente têm espaço. Um site com problemas então desacelera apenas a si mesmo.
É também aqui que vive a educação (politeness). Um limite por host é simultaneamente controle de fluxo para você e comedimento em relação ao site. Como definir esse limite em primeiro lugar, e o que os próprios sinais do site indicam sobre ele, é abordado em rate limiting e throttling de requisições.
Regra 4: torne os limites por host adaptativos
Uma concorrência fixa por host está errada nas duas direções. Muito baixa desperdiça capacidade em um site que poderia suportar mais. Muito alta continua pressionando um site que começou a reagir, o que é como um throttling temporário se transforma em um bloqueio.
A resposta bem testada é a que o TCP usa para controle de congestionamento: aumento aditivo, diminuição multiplicativa. Cada sucesso empurra o limite um pouco para cima. Cada throttle ou timeout o corta pela metade. O limite se estabiliza pouco abaixo do que o site vai tolerar, e se move quando o site muda.
import asyncio
class HostLimiter:
"""Concorrência adaptativa para um host: aumento aditivo, diminuição multiplicativa."""
def __init__(self, start=4, floor=1, ceiling=32):
self.limit = start
self.floor = floor
self.ceiling = ceiling
self.in_flight = 0
self._cond = asyncio.Condition()
async def acquire(self):
async with self._cond:
await self._cond.wait_for(lambda: self.in_flight < int(self.limit))
self.in_flight += 1
async def release(self, outcome):
async with self._cond:
self.in_flight -= 1
if outcome == "ok":
self.limit = min(self.ceiling, self.limit + 1 / max(1, int(self.limit)))
elif outcome in ("throttled", "timeout"):
self.limit = max(self.floor, self.limit / 2)
self._cond.notify_all()
O aumento é deliberadamente lento, aproximadamente um slot extra por rodada completa de sucessos, e a diminuição é deliberadamente brusca. A assimetria é o objetivo: ultrapassar a tolerância de um site custa muito mais do que ficar abaixo dela. Mantenha um teto rígido por host que você mesmo define, para que um limite adaptativo nunca consiga passar do que você considera razoável.
Regra 5: saiba de onde veio cada sinal
Nem todo “diminua o ritmo” significa a mesma coisa, e tratá-los de forma igual manda a pressão para o lugar errado.
| Sinal | De onde se origina | O que deve ser desacelerado |
|---|---|---|
429, latência crescente, páginas de desafio de um site | O destino | Apenas o limitador daquele host |
| Fila de parse cheia, armazenamento atrasado | Seu próprio pipeline | Fetch globalmente, depois o frontier |
| Teto de concorrência em uma API de scraping | Seu plano | Total de requisições em andamento para aquela API |
| Cota ou créditos esgotados | Seu plano | Tudo, até o ciclo reiniciar ou você recarregar |
A Web Scraping API da Shifter, por exemplo, retorna 429 Too Many Requests quando você excede o teto de concorrência do seu plano, e 509 quando os créditos se esgotam. O primeiro é um sinal de controle de fluxo sobre você, não sobre nenhum destino, então pertence a um limitador global nas chamadas à API, não a nenhum limitador por host. O segundo é uma condição de parada. Confundir qualquer um deles com o próprio 429 de um site faz com que um crawler desacelere sites saudáveis por um problema que não tem nada a ver com eles.
Retries são carga
A forma mais comum de um crawler derrotar sua própria contrapressão é através de retries. Um fetch falha, o worker tenta de novo imediatamente, o retry também falha porque a causa não desapareceu, e todos os workers fazendo a mesma coisa multiplicam o tráfego exatamente no momento em que um destino, ou o seu próprio estágio, está menos capaz de absorvê-lo.
- Os retries passam pelo mesmo controle de admissão que as primeiras tentativas. Um retry que ignora o limitador por host é uma forma de contornar seu próprio controle de fluxo.
- Recue com jitter, para que um lote de workers que falhou junto não tente de novo junto.
- Dê a cada tarefa um orçamento de retries, e acompanhe os retries como uma parcela do total de requisições. Quando essa parcela sobe, o sistema está gastando sua capacidade em falhas.
- Não empilhe camadas de retry. A Web Scraping API já tenta novamente fetches falhos, CAPTCHAs e erros transitórios do destino até três vezes com proxies diferentes antes de retornar um erro, sem custo. Retries agressivos do lado do cliente por cima disso multiplicam as tentativas em vez de adicionar resiliência.
- Nunca tente de novo o que não pode ter sucesso. Erros de autenticação e configuração falham da mesma forma todas as vezes.
Quando você não consegue acompanhar o ritmo, descarte de propósito
Às vezes a resposta honesta é que o crawler tem mais trabalho do que consegue fazer. A contrapressão então desacelera tudo de forma uniforme, o que muitas vezes é o pior resultado: toda tarefa termina atrasada, incluindo as que importam.
O descarte de carga (load shedding) torna a escolha explícita. Dê ao trabalho uma prioridade, e quando as filas permanecerem cheias além de um limite, descarte ou adie os itens de menor prioridade no frontier, antes que custem um fetch. Atualizar uma página de preço volátil vale mais do que reverificar uma página de arquivo que não muda há um ano. Quais páginas merecem o orçamento é um problema em si, e é o tema de agendamento de crawl consciente de custo.
Meça a pressão, não apenas o throughput
Gráficos de throughput parecem bons até o exato momento do colapso. As métricas que mostram a pressão se acumulando são diferentes:
- Profundidade da fila e a idade do item mais antigo, por estágio. A idade importa mais do que a profundidade: uma fila profunda que esvazia rapidamente é saudável, uma fila rasa cheia de itens obsoletos não é.
- Requisições em andamento e o limite adaptativo atual, por host. Um limite que continua se reduzindo pela metade é um site reagindo.
- Rejeições de admissão e puts bloqueados, por estágio. Isso é a contrapressão cumprindo sua função. Um aumento súbito diz qual estágio agora é o gargalo.
- Parcela de retries, por host e no geral.
Um limite por host em queda combinado com taxas crescentes de bloqueio e de desafios geralmente significa que um site está se voltando contra você, em vez de estar apenas lento. Transformar esses sinais em um único julgamento por site é abordado em construindo um score de saúde de destino. O conjunto mais amplo de métricas de pipeline está em monitorando um pipeline de web scraping.
Escalar horizontalmente não elimina a necessidade de controle de fluxo
Adicionar workers eleva o teto do estágio de fetch. Isso não eleva a tolerância de nenhum destino, sua capacidade de parse ou os limites do seu plano. Um crawler que escala fetchers automaticamente com base na profundidade da fila, sem limites por host e filas a jusante limitadas, vai escalar direto contra todas as restrições ao mesmo tempo. Se você roda em Kubernetes, escale com base nos sinais acima em vez de apenas em CPU; a mecânica é abordada em escalando scraping de proxy residencial com Kubernetes.
O mesmo se aplica entre regiões. Filas duráveis e contrapressão entre regiões são o que impede que uma falha regional se transforme em uma falha global, como descrito em failover de proxy residencial para pipelines multirregionais.
Conclusão
Controle de fluxo não é uma otimização a ser adicionada quando um crawler já é grande. É o que decide se um crawler sob estresse desacelera ou colapsa. As regras são poucas: limite toda fila, deixe os workers puxarem, particione por host, adapte os limites por host de forma brusca para baixo e lenta para cima, direcione cada sinal de desaceleração ao estágio que ele descreve, trate retries como carga, e descarte deliberadamente o trabalho de menor valor.
Um crawler construído dessa forma tem mais uma propriedade que vale a pena ter. Quando um site começa a sofrer, o crawler percebe e recua por conta própria, o que é bom para o site e, não por coincidência, bom para as chances do crawler continuar sendo bem-vindo na semana seguinte.