One self-hosted console to run your entire business — commerce, ERP, HRM, CRM & manufacturing

Colas y workers a gran escala: de un worker serial a un conjunto de procesos con autoescalado

Una ráfaga expone primero los jobs en segundo plano. Sigue una configuración desde un worker serial, donde solo entre 20% y 26% de los jobs terminó a tiempo, hasta colas separadas y un conjunto de procesos Horizon que escala en segundos.

Author

Anichur Rahaman

hace 2 semanas12 min read2 views
Colas y workers a gran escala: de un worker serial a un conjunto de procesos con autoescalado

Al ingeniero de guardia de una plataforma de exámenes le queda un minuto, a las 8:59 de la mañana de la prueba. A las 9:00 en punto, 1,000 estudiantes presionan Enviar dentro del mismo minuto. Los servidores web responden cada clic en milisegundos y los tableros siguen en verde. A las 9:03 el buzón de soporte empieza a llenarse: los estudiantes vieron "Enviado" en pantalla, pero no llegó ninguna confirmación, y el equipo de calificación ve apenas un puñado de respuestas. Es una escena ilustrativa, pero yo preparé una plataforma real para un pico exactamente así.

La causa no era la capa web. Un solo worker en segundo plano procesaba los envíos uno por uno. Las colas convierten una ráfaga en una fila, así que la calidad de tus workers decide si esa fila tarda tres segundos o 16 minutos.

Este artículo sigue el camino que recorrí desde un worker serial hasta un conjunto de procesos que crece y se reduce solo, junto con los hábitos de diseño de jobs que hacen seguro operar ese conjunto.

Esta es la parte 2 de la serie «Ingeniería para alto volumen». La parte 1 cubre el camino de la petición desde el borde hasta la aplicación: Autoescalado ante picos de tráfico: qué escala de verdad.

Por qué el trabajo en segundo plano falla primero

En una petición web hay una persona esperando, así que notamos cuando es lenta. Un job en cola no tiene a nadie esperando en ese momento, así que la lentitud se esconde hasta que el atraso es enorme. Además, una cola convierte una ráfaga en una fila: si llegan 1,000 jobs en un minuto y cada uno tarda un segundo, un solo worker necesita más de 16 minutos para terminar.

Ese era exactamente mi punto de partida. Un worker, iniciado con un comando como queue:work --queue=retakes,default, atendía 1,000 jobs de la ráfaga uno por uno. Peor aún, el trabajo de baja prioridad compartía la misma fila con los envíos en vivo, así que un lote de jobs sin importancia podía quedar delante de algo que un cliente estaba esperando. En la repetición de ese pico, solo entre el 20 y el 26 por ciento de los jobs terminó a tiempo. Son cifras de una configuración concreta, no un benchmark universal, pero la forma del fallo es muy común.

La base de datos estaba bien. La instancia administrada ni se acercaba a sus límites. El cuello de botella era una decisión de diseño: un proceso, una fila, ninguna prioridad.

Paso 1: separa los workers de la web

El primer cambio fue físico. La web y los workers compartían una máquina de 8 GB, y cuando los workers se ocupaban le quitaban CPU y memoria a PHP-FPM. El sitio se volvía lento por culpa de los mismos jobs que él mismo había puesto en cola.

Los workers pasaron a su propio nodo. Ahora una cola ocupada puede usar todos los núcleos que quiera sin tocar la carga de una sola página, y un problema en la web no impide que la cola se vacíe. También permite dimensionar cada máquina según su trabajo: nodos web para muchas peticiones cortas, el nodo de workers para jobs que consumen mucha memoria.

Diagrama de antes y después: un nodo compartido de web y workers con una sola cola, frente a nodos separados de web y workers con una cola por prioridad
Antes: una máquina y una fila. Después: web y workers por separado, una cola por tipo de trabajo.

Paso 2: una cola por prioridad y por tipo

El segundo cambio fue dejar de mezclar todo en una sola fila. Creé colas separadas según lo que el trabajo significa para el cliente:

  • Submissions: el trabajo en vivo de cara al cliente, que debe terminar en segundos.
  • Notifications: correos, SMS y mensajes push. Importantes, pero un minuto de retraso es tolerable.
  • Image processing: pesado, con mucha memoria y nunca urgente.
  • Reports: exportaciones y resúmenes lentos que pueden esperar a que pase el pico.

Colas separadas te permiten darle a cada una su propio número de workers, su propio timeout y sus propias reglas de reintento. Un job de reporte que corre cuatro minutos ya no puede bloquear la confirmación de un pago. Regla práctica: si dos tipos de trabajo tienen distinta urgencia, distintas necesidades de memoria o distinto comportamiento al fallar, van en colas distintas.

Listar las colas por orden de prioridad en un solo worker (la primera siempre gana) es mejor que una fila mezclada, pero sigue siendo un solo proceso. Los procesos dedicados por cola son lo que elimina de raíz el problema de que unos trabajos se cuelen delante de otros.

Paso 3: ¿cuántos workers necesitas?

Agrega workers y la fila se vacía más rápido, hasta cierto punto. En mis pruebas, unos 12 procesos worker despacharon 1,000 jobs en aproximadamente 3 segundos cuando eran ligeros, y en aproximadamente 10 segundos cuando eran pesados. Pasando de unos 20 workers, agregar más no ayudó: simplemente hacían fila entre ellos para escribir en la base de datos.

Procesos workerLo que observé (una configuración)
1Los jobs corren uno por uno; solo 20–26% a tiempo
Cerca de 121,000 jobs ligeros en ~3 s, pesados en ~10 s
Más de ~20Sin mejora; los workers esperan escrituras en la base de datos

Los números cuadran con aritmética simple. Los tiempos por job son los que implican los resultados, así que tómalos como ilustrativos: un job ligero de unos 0.03 segundos hace que 1,000 jobs sean 30 segundos de trabajo, y 12 workers lo reparten en cerca de 2.5 segundos. Un job pesado de unos 0.12 segundos hace 120 segundos de trabajo, y 12 workers lo terminan en unos 10 segundos. Un solo worker necesitaría los 120 segundos completos, y para entonces los envíos en vivo ya habrían perdido su ventana.

Esta es la lección detrás de la hoja de capacidad del final: el número correcto no es "tantos como se pueda". Es el punto en el que el siguiente worker deja de acortar la fila. Encuéntralo con una prueba de carga, no a ojo, y no amplíes la base de datos antes de que esa prueba demuestre que de verdad está saturada. La capa de datos la cubro en la parte 3.

Escala procesos, no máquinas

Un conjunto fijo de 12 es un desperdicio en un martes tranquilo y quizá se queda corto en un día grande. El autoescalado es la respuesta, pero importa qué capa escalas. Arrancar una máquina virtual nueva tarda de uno a tres minutos, y un pico programado llega a su máximo en segundos. Como expliqué en la parte 1, para eventos conocidos se escala por adelantado. Para las colas hay una opción mejor: escalar los procesos dentro de una máquina que ya está corriendo.

Laravel Horizon lo hace con su ajuste balance. Según la documentación oficial de Laravel, Horizon ofrece tres estrategias: simple reparte los jobs entrantes por igual entre los procesos worker, auto ajusta el número de procesos por cola según la carga actual de cada una, y false desactiva el balanceo. Con auto, unas cuantas opciones definen qué tan rápido reacciona.

AjusteQué controlaValor que usé en la cola de envíos
balanceEstrategia: auto, simple o falseauto
minProcessesProcesos que se mantienen por cola aunque estén ociosos1
maxProcessesLímite superior de procesos al que Horizon puede escalar10
balanceMaxShiftCuántos procesos se pueden agregar o quitar en un ajuste3
balanceCooldownSegundos de espera entre ajustes3

Los valores predeterminados documentados de Horizon son más suaves: un proceso por ajuste, cada tres segundos. Yo subí el shift a tres porque una ráfaga no debería tardar medio minuto en arrancar. El resultado: cuando la fila crecía, Horizon creaba más procesos worker en segundos; cuando se vaciaba, los retiraba. Sin máquina nueva, sin clúster y sin costo extra.

Gráfica ilustrativa: la profundidad de la cola sube durante una ráfaga mientras los procesos worker aumentan y luego bajan
Una ráfaga ilustrativa: los workers siguen la profundidad de la cola hacia arriba en segundos y bajan cuando la fila queda vacía.

Dimensiona la memoria para el máximo

El escalado por procesos tiene una trampa. El contenedor debe aguantar el máximo, no el promedio. Si cada job llega a unos 128 MB y permites 10 procesos, son cerca de 1.3 GB, así que configuré el contenedor con unos 2 GB. Si lo dimensionas para el estado tranquilo, la primera ráfaga real termina en cierres por falta de memoria, y el kernel elimina workers a mitad de un job.

Conviene medir la memoria por job y no por worker, porque un solo tipo de job pesado puede quedarse con todo el presupuesto.

Adelgaza el job pesado antes de comprar hardware

Un job de mi configuración llegaba a 288 MB. Descargaba sus propias imágenes por HTTP y leía archivos del disco local, y lo mantenía todo en memoria a la vez. Al leer los archivos en streaming desde el almacenamiento de objetos, el pico bajó a unos 128 MB. Solo ese cambio nos ahorró un nivel completo de tamaño de máquina.

Antes de agregar RAM o un nodo más grande, hazte tres preguntas sobre cada job pesado:

  1. ¿Carga un archivo completo en memoria cuando podría leerlo en streaming?
  2. ¿Descarga por la red datos que podría recibir en su payload o leer de un almacén cercano?
  3. ¿Se puede dividir en muchos jobs pequeños en lugar de uno grande?

Los jobs pequeños también encajan mejor con el balanceo de Horizon, porque puede mover capacidad en pasos pequeños.

Compara las opciones de autoescalado

Horizon no es la única forma de escalar workers. Así se comparan las cuatro opciones más comunes para una cola al estilo Laravel.

OpciónQué escalaTiempo de reacciónCosto y esfuerzo
Réplicas fijas (Compose o Swarm)Nada; tú defines un número fijoManualSencillo, pero pagas la capacidad del pico todo el tiempo
Kubernetes con HPAPods, según CPU o métricas personalizadasMinutosRequiere un clúster y un flujo de métricas
Kubernetes con KEDAPods, según la longitud de la colaMinutosRequiere un clúster; escala con la señal real
Horizon auto-balanceProcesos dentro de un contenedorSegundosGratis, sin clúster, limitado a una máquina

KEDA es un buen proyecto. Según su documentación, revisa cada trigger, como la longitud de una lista de Redis, en cada intervalo de sondeo (30 segundos por defecto), y el escalado de una réplica a muchas lo maneja el Horizontal Pod Autoscaler de Kubernetes. Agregar pods también implica programar e iniciar contenedores. Para un pico programado que llega a su máximo en segundos, sigue siendo demasiado lento, y un clúster es un costo real para un equipo pequeño.

Mi regla: usa Horizon auto-balance para la reacción rápida y escala por adelantado la máquina o el mínimo antes de un evento que ya sabes que viene. Considera KEDA cuando un solo nodo ya no alcance para el trabajo de todos los días.

Diseña los jobs para que reintentar sea seguro

Un conjunto de workers rápidos deja al descubierto cada debilidad en la forma en que escribes tus jobs. Estos hábitos mantuvieron seguros los míos:

  • Haz los jobs idempotentes. Un job puede correr dos veces: después de un timeout, un deploy o un reintento. Correr dos veces no debe enviar dos correos ni cobrar dos veces. Usa una llave única o revisa el estado actual al inicio del job.
  • Despacha después del commit. Si un job se pone en cola dentro de una transacción, un worker puede tomarlo antes de que existan los datos, o la transacción puede revertirse y dejar un job sin sentido. Despacha solo cuando el commit haya sido exitoso.
  • Cifra los payloads que llevan datos personales. Los payloads en cola quedan en Redis en texto legible si no los cifras. Pasa identificadores cuando puedas y cifra el resto.
  • Define timeouts y reintentos a propósito. El timeout de un job debe ser menor que la ventana de reintento de la cola, con espera creciente entre intentos, y una tabla de jobs fallidos que de verdad revises.
Diagrama de flujo de un job en cola: commit, despacho, cola por tipo, el worker toma el job, revisión de si ya se hizo, ejecución, revisión de éxito, reintento con retraso o tabla de jobs fallidos
Un job y todos sus desenlaces: se omite por duplicado, se completa, se reintenta tras un retraso o queda en la tabla de fallidos, donde una persona puede verlo.

El scheduler es un singleton

Las tareas programadas tipo cron deben correr en exactamente un nodo. Si escalas nodos web o de workers y cada uno ejecuta el scheduler, cada reporte programado se crea varias veces, cada recordatorio sale dos veces y cada limpieza choca consigo misma.

Elige un nodo, documéntalo y mantenlo fuera de cualquier grupo de autoescalado. Los comandos en segundo plano también necesitan el mismo cuidado que las peticiones web. En una instalación en servidores propios, un scheduler que arrancaba todo el framework para cada comando pequeño, sin OPcache en CLI y con límites de memoria ajustados, provocó cientos de cierres por falta de memoria. Ejecutar los relays dentro de un proceso de larga duración y activar OPcache en CLI lo resolvió.

Drena las colas en cada deploy

Un worker es un proceso de larga duración, así que conserva el código viejo en memoria hasta que lo reinicias, y un reinicio brusco mata el job que esté corriendo. El orden seguro es dejar de tomar jobs nuevos, dejar que terminen los que están en curso y luego iniciar los workers nuevos.

  1. Ejecuta el comando de terminación de Horizon (horizon:terminate) para que los workers terminen su job actual y luego salgan.
  2. Dale al contenedor un periodo de gracia de parada generoso, mayor que tu job más largo, para que el orquestador no lo mate antes de tiempo.
  3. Inicia los workers nuevos con la nueva versión.
  4. Inicia el scheduler al final, para que ninguna tarea programada se dispare contra un sistema a medio desplegar.

Reviso la secuencia completa de deploy, con blue/green y reversas, en la parte 5.

Una hoja de capacidad de workers

Usa esta lista corta antes de tu próximo pico:

  1. Mide cuántos jobs genera el evento en su minuto pico, por cola.
  2. Mide el tiempo de ejecución y la memoria pico de cada tipo de job en un worker real.
  3. Divide los jobs entre el tiempo de ejecución para estimar los procesos que necesitas para despachar el pico dentro de tu meta.
  4. Fija el máximo un poco por encima de eso, y configura la memoria del contenedor como procesos máximos por memoria pico del job.
  5. Haz una prueba de carga con la mezcla real y vigila el tiempo de escritura en la base de datos: deja de agregar workers cuando se convierta en el límite.
  6. Confirma que el scheduler corre una sola vez, que los jobs son idempotentes y que un deploy drena limpio, y decide qué cifras de profundidad de cola y tiempo de espera vas a vigilar (mira la parte 4).

Volvamos a las 9:00 del día del examen. Con los mismos 1,000 envíos, la cola de envíos tiene sus propios procesos y nada se le atraviesa. Horizon pasa de uno a diez procesos en unos diez segundos, la ráfaga se despeja en lo que un estudiante tarda en volver a mirar la pantalla, y las confirmaciones salen antes de que se escriba el primer correo a soporte. Los jobs de reportes e imágenes esperan, como deben.

La siguiente parte, La capa de datos bajo carga, explora qué pasa cuando los workers ya son lo bastante rápidos para presionar la base de datos: pool de conexiones, roles de Redis y un almacén aparte para los embeddings de IA.

Puntos clave

  • Los jobs en segundo plano suelen fallar primero en una ráfaga, y la lentitud se esconde hasta que el atraso es grande.
  • Saca los workers de los nodos web y dale a cada prioridad o tipo de trabajo su propia cola.
  • Escala los procesos worker dentro de una máquina con Horizon auto-balance para reaccionar en segundos, y dimensiona la memoria para el máximo.
  • Adelgaza los jobs pesados antes de comprar hardware y deja de agregar workers cuando las escrituras en la base de datos sean el límite.
  • Haz los jobs idempotentes, despacha después del commit, cifra los datos personales en los payloads y corre el scheduler en un solo nodo.
  • Drena las colas en cada deploy con un periodo de gracia generoso.

Anichur Rahaman es arquitecto de software y creador de StoreConsole. Diseña sistemas de comercio y ERP para negocios en crecimiento, con enfoque en arquitectura orientada a eventos, integridad de datos y operación en servidores propios.

About the Author

Anichur Rahaman

Continue Reading