Scraping

Backpressure y control de flujo en crawlers distribuidos

Un crawler es una tubería, y su etapa más rápida inundará a la más lenta. Cómo las colas acotadas, el trabajo basado en extracción y los límites adaptativos por host mantienen estable a un crawler.

Chris Collins

Chris Collins

22 de septiembre de 2026 · 12 min de lectura

Cada rastreador distribuido acaba teniendo la misma mala tarde. Un parser se ralentiza porque un sitio ha empezado a servir páginas enormes. Los fetchers siguen capturando a máxima velocidad, porque nada les ha dicho que paren. La cola entre ambos crece por millones de elementos, la memoria sube, un nodo se cae, su trabajo se reencola en los demás, y los reintentos los tumban también a ellos. Mientras tanto, el único sitio que causó todo esto está recibiendo más tráfico que nunca.

Nada de eso es un fallo en ningún componente concreto. Es la ausencia de control de flujo entre ellos. Un rastreador es una tubería de etapas con velocidades muy distintas, y sin una forma de que una etapa lenta le diga a una rápida que espere, la rápida gana hasta que todo pierde.

Este artículo trata sobre cómo construir esa realimentación: de dónde vienen las señales, adónde tienen que llegar, y el puñado de reglas que hacen que un rastreador se degrade con suavidad en lugar de colapsar.

Un rastreador es una tubería, y las etapas no se ponen de acuerdo sobre la velocidad

Si se eliminan los detalles, la mayoría de los rastreadores tienen cuatro etapas:

EtapaQué la limitaCómo falla al saturarse
Frontier y schedulerCasi nada, solo elige URLsEmite trabajo más rápido de lo que nada aguas abajo puede absorber
FetchersSitios objetivo, capacidad de proxy, límites del planTimeouts, throttling, bloqueos
Parseo y renderizadoCPU, memoria, slots de navegador headlessLatencia creciente, luego agotamiento de memoria
Deduplicación y almacenamientoCapacidad de escritura de la base de datosEscrituras lentas, contención de bloqueos, acumulación

El frontier es casi gratuito de ejecutar, así que siempre será la etapa más rápida. Las otras tres están cada una limitada por algo distinto, y el límite se mueve: un sitio se vuelve más lento, un tipo de página se vuelve más pesado, una base de datos empieza a compactar. Control de flujo significa que la etapa que en cada momento sea más lenta marca el ritmo para todas las demás.

La alternativa es que el ritmo lo marque la etapa que primero se quede sin memoria.

Regla 1: toda cola tiene un límite

Una cola sin límite entre dos etapas no es un búfer. Es una promesa de absorber cualquier cantidad de exceso de trabajo, algo que ninguna máquina puede cumplir. Además, oculta el problema. La etapa anterior ve éxito en cada inserción mientras la cola crece calladamente, y para cuando alguien se fija, el elemento más antiguo lleva horas esperando.

Una cola acotada convierte la misma situación en una señal. Cuando se llena, el productor se bloquea, o recibe un rechazo explícito. La ralentización se propaga hacia atrás, una etapa cada vez, hasta llegar al frontier, que simplemente deja de repartir URLs durante un tiempo. No se pierde nada y nada explota.

En un único proceso esto es un solo argumento al constructor de la cola:

import asyncio


async def fetcher(frontier: asyncio.Queue, parsed: asyncio.Queue, fetch):
    while True:
        url = await frontier.get()
        try:
            page = await fetch(url)
            # Blocks when the parse queue is full: a slow parser slows fetching.
            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)  # blocks when the frontier is full
    await frontier.join()
    await parsed.join()
    for w in workers:
        w.cancel()

Entre máquinas el principio es el mismo, solo cambia el mecanismo: una cola de broker con un límite de longitud y una política de rechazo, un stream con lag de consumidor sobre el que realmente se actúa, o un frontier respaldado por base de datos que solo libera URLs a los workers con capacidad libre. Dimensiona los límites a partir de la latencia que puedes tolerar, no de la memoria que tienes. Si la etapa de parseo procesa 200 páginas por segundo y aceptas 10 segundos de encolado, la cola debe contener unos 2.000 elementos. Cualquier cosa más grande solo pospone el momento en que te enteras.

Regla 2: extraer, no empujar

El control de contrapresión más limpio es el que se obtiene gratis. Si los workers piden trabajo cuando tienen capacidad, en lugar de que un coordinador les asigne trabajo, un worker lento simplemente pide con menos frecuencia. El sistema no puede enviarle más de lo que puede manejar, porque nunca lo pidió.

Empujar trabajo obliga al coordinador a adivinar. Tiene que llevar la cuenta de la carga de cada worker, y adivinará mal justo en el momento en que la carga cambie. Los diseños basados en extracción, ya sea que los workers tomen elementos en préstamo de una cola o concedan créditos explícitos a su etapa anterior, trasladan esa decisión al único componente que conoce la respuesta.

Los préstamos necesitan caducidad. Un worker que muere reteniendo trabajo no debe retenerlo para siempre, así que los elementos en préstamo vuelven a la cola tras un tiempo límite. Fija ese tiempo límite a partir de la captura legítima más lenta, no de la media, o el trabajo sano pero lento acabará repartido dos veces.

Regla 3: una cola por host, no una cola global

Una única cola de captura global tiene un modo de fallo que los rastreadores sufren constantemente: el bloqueo de cabeza de línea (head-of-line blocking). Si las próximas mil URLs pertenecen todas a un sitio lento o que aplica throttling, todos los fetchers acaban esperando a ese sitio mientras el trabajo de sitios rápidos y sanos queda detrás.

Particiona la etapa de captura por host, o por la unidad que sea que un objetivo use para limitar la tasa. Cada host obtiene su propia cola y su propio límite de concurrencia, y los fetchers eligen entre los hosts que actualmente tienen hueco. Así, un sitio con problemas solo se ralentiza a sí mismo.

Aquí es también donde vive la cortesía. Un límite por host es simultáneamente control de flujo para ti y contención hacia el sitio. Cómo fijar ese límite en primer lugar, y qué te dicen las propias señales del sitio al respecto, se trata en rate limiting and request throttling.

Regla 4: haz que los límites por host sean adaptativos

Una concurrencia por host fija está mal en ambas direcciones. Demasiado baja desperdicia capacidad en un sitio que podría aceptar más. Demasiado alta sigue presionando a un sitio que ya ha empezado a resistirse, que es como el throttling temporal se convierte en un bloqueo.

La respuesta bien probada es la que usa TCP: incremento aditivo, decremento multiplicativo. Cada éxito sube un poco el límite. Cada throttle o timeout lo reduce a la mitad. El límite se asienta justo por debajo de lo que el sitio va a tolerar, y se mueve cuando el sitio cambia.

import asyncio


class HostLimiter:
    """Adaptive concurrency for one host: additive increase, multiplicative decrease."""

    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()

El incremento es deliberadamente lento, aproximadamente un slot extra por cada ronda completa de éxitos, y el decremento es deliberadamente brusco. La asimetría es intencionada: sobrepasar la tolerancia de un sitio cuesta mucho más que quedarse corto. Mantén un techo fijo por host que decidas tú mismo, para que un límite adaptativo nunca pueda salirse por encima de lo que consideras razonable.

Regla 5: saber de dónde viene cada señal

No todo “reduce la velocidad” significa lo mismo, y tratarlas por igual envía la presión al lugar equivocado.

SeñalDe dónde vieneQué debería ralentizar
429, latencia creciente, páginas de desafío de un sitioEl objetivoSolo el limitador de ese host
Cola de parseo llena, almacenamiento con retrasoTu propia tuberíaLa captura globalmente, y luego el frontier
Tope de concurrencia en una API de scrapingTu planEl total de solicitudes en curso a esa API
Cuota o créditos agotadosTu planTodo, hasta que el ciclo se reinicie o recargues

La Web Scraping API de Shifter, por ejemplo, devuelve 429 Too Many Requests cuando superas el tope de concurrencia de tu plan, y 509 cuando los créditos se agotan. La primera es una señal de control de flujo sobre ti, no sobre ningún objetivo, así que pertenece a un limitador global sobre las llamadas a la API, no a ningún limitador por host. La segunda es una condición de parada. Confundir cualquiera de las dos con el propio 429 de un sitio hace que un rastreador aplique throttling a sitios sanos por un problema que no tiene nada que ver con ellos.

Los reintentos son carga

La forma más común en que un rastreador anula su propia contrapresión es a través de los reintentos. Una captura falla, el worker reintenta de inmediato, el reintento también falla porque la causa no ha desaparecido, y todos los workers haciendo lo mismo multiplican el tráfico justo en el momento en que un objetivo, o tu propia etapa, menos capacidad tiene de absorberlo.

  • Los reintentos pasan por el mismo control de admisión que los intentos iniciales. Un reintento que se salta el limitador por host es una forma de saltarse tu propio control de flujo.
  • Aplica retroceso con jitter, para que un lote de workers que falló a la vez no reintente a la vez.
  • Da a cada tarea un presupuesto de reintentos, y controla los reintentos como proporción del total de solicitudes. Cuando esa proporción sube, el sistema está gastando su capacidad en fallos.
  • No apiles capas de reintentos. La Web Scraping API ya reintenta las capturas fallidas, los CAPTCHAs y los errores transitorios del objetivo hasta tres veces con proxies distintos antes de devolver un error, sin coste alguno. Reintentos agresivos del lado cliente encima de eso multiplican los intentos en lugar de añadir resiliencia.
  • Nunca reintentes lo que no puede tener éxito. Los errores de autenticación y de configuración fallan igual todas las veces.

Cuando no puedes seguir el ritmo, descarta a propósito

A veces la respuesta honesta es que el rastreador tiene más trabajo del que puede hacer. Entonces la contrapresión ralentiza todo por igual, lo cual suele ser el peor resultado: todas las tareas terminan tarde, incluidas las que importan.

El descarte de carga (load shedding) hace la elección explícita. Da a cada trabajo una prioridad, y cuando las colas se mantienen llenas más allá de un umbral, descarta o pospón los elementos de menor prioridad en el frontier, antes de que cuesten una captura. Actualizar una página de precios volátil vale más que volver a comprobar una página de archivo que no ha cambiado en un año. Qué páginas merecen el presupuesto es un problema en sí mismo, y es el tema de cost-aware crawl scheduling.

Mide la presión, no solo el rendimiento

Las gráficas de rendimiento se ven bien hasta justo antes del colapso. Las métricas que muestran la presión acumulándose son otras:

  • Profundidad de cola y edad del elemento más antiguo, por etapa. La edad importa más que la profundidad: una cola profunda que se vacía rápido está sana, una poco profunda llena de elementos rancios no lo está.
  • Solicitudes en curso y el límite adaptativo actual, por host. Un límite que sigue reduciéndose a la mitad es un sitio que está resistiendo.
  • Rechazos de admisión e inserciones bloqueadas, por etapa. Esto es la contrapresión haciendo su trabajo. Un aumento repentino indica cuál es ahora el cuello de botella.
  • Proporción de reintentos, por host y en global.

Un límite por host a la baja combinado con tasas crecientes de bloqueos y desafíos suele significar que un sitio se está poniendo en tu contra en lugar de simplemente estar lento. Convertir esas señales en un único juicio por sitio se trata en building a target health score. El conjunto más amplio de métricas de la tubería está en monitoring a web scraping pipeline.

Escalar hacia fuera no elimina la necesidad de control de flujo

Añadir workers eleva el techo de la etapa de captura. No eleva la tolerancia de ningún objetivo, tu capacidad de parseo ni los límites de tu plan. Un rastreador que escala fetchers automáticamente según la profundidad de cola, sin límites por host ni colas acotadas aguas abajo, escalará directo hacia todas las restricciones a la vez. Si funcionas sobre Kubernetes, escala según las señales anteriores en lugar de solo la CPU; la mecánica se trata en scaling residential proxy scraping with Kubernetes.

Lo mismo se aplica entre regiones. Las colas duraderas y la contrapresión entre regiones son lo que evita que un fallo regional se convierta en uno global, tal como se describe en residential proxy failover for multi-region pipelines.

La conclusión

El control de flujo no es una optimización que se añade una vez que el rastreador ya es grande. Es lo que decide si un rastreador bajo estrés se ralentiza o se derrumba. Las reglas son pocas: acota toda cola, deja que los workers extraigan, particiona por host, adapta los límites por host con fuerza hacia abajo y despacio hacia arriba, dirige cada señal de ralentización a la etapa que describe, trata los reintentos como carga, y descarta deliberadamente el trabajo de menor valor.

Un rastreador construido así tiene una propiedad más que merece la pena tener. Cuando un sitio empieza a tener dificultades, el rastreador se da cuenta y afloja por sí solo, lo cual es bueno para el sitio y, no por casualidad, bueno para las probabilidades del rastreador de seguir siendo bienvenido la semana que viene.

¿Listo para empezar?

Prueba los proxies residenciales de Shifter, más de 205M IPs, más de 195 países, desde 0,75 $/GB.

Comenzar