StackPractices
intermediate Por Mathias Paulenko

Tareas en Segundo Plano (Background Jobs)

Descarga trabajo lento de las peticiones HTTP: ejecuta tareas en segundo plano con colas de trabajo, programación cron y workers en Python, JavaScript y Java.

Visión General

Cuando un usuario sube un archivo, hace un pedido o se registra, la respuesta HTTP debería volver en milisegundos. El render del PDF, el email de bienvenida o la sincronización con un tercero pueden ocurrir unos segundos después en un proceso aparte — para eso sirve una tarea en segundo plano. A continuación tienes colas de tareas y programación cron funcionando en Python (Celery + Redis), JavaScript (BullMQ + Redis) y Java (Spring @Scheduled), junto con los patrones de reintentos, dead-letter y monitorización que necesitas para llevarlos a producción.

Cuándo Usar

Recurre a una tarea en segundo plano cuando:

  • Un email o SMS puede salir unos segundos después de que termine la petición. Consulta Email Templates MJML para la parte del contenido.
  • Una exportación, un informe o un PDF tarda más de un segundo en generarse. Consulta Generate PDFs para la parte del documento.
  • Una imagen, un video o un documento subido necesita redimensión, transcodificación o análisis. Consulta Image Optimization para procesamiento multimedia.
  • Hay que sincronizar datos con una API externa en un horario fijo. Consulta Call REST API para la parte del cliente.
  • La agregación nocturna o la limpieza corre según un reloj y no por eventos de usuario. Consulta Scheduled Jobs para tareas programadas en serverless.

No mandes trabajo a una cola cuando el usuario está esperando activamente el resultado — la validación de formularios, las comprobaciones de autenticación y las lecturas van en la ruta de la petición. Las tareas únicas muy pequeñas en serverless (menos de ~100 ms) también suelen salir más baratas como invocación directa que como un salto por la cola.

Solución

Python (Celery + Redis)

from celery import Celery
from celery.schedules import crontab

app = Celery("tasks", broker="redis://localhost:6379/0", backend="redis://localhost:6379/0")

@app.task(bind=True, max_retries=3)
def send_email(self, to, subject, body):
    try:
        # La implementación real va por SMTP o una API como SendGrid
        print(f"Sending email to {to}")
    except Exception as exc:
        raise self.retry(exc=exc, countdown=60)

@app.task
def generate_report(user_id):
    print(f"Generating report for user {user_id}")
    return f"/reports/{user_id}.pdf"

@app.task
def cleanup_old_logs():
    print("Deleting log files older than 30 days")

# Las tareas periódicas corren a través de celery beat
app.conf.beat_schedule = {
    "daily-cleanup": {
        "task": "tasks.cleanup_old_logs",
        "schedule": crontab(hour=2, minute=0),
    },
}

# Desde la app web:
send_email.delay("alice@example.com", "Welcome", "Hello!")

JavaScript (BullMQ + Redis)

const { Queue, Worker } = require("bullmq");
const IORedis = require("ioredis");

// Los workers de BullMQ requieren maxRetriesPerRequest: null
const connection = new IORedis({ host: "localhost", port: 6379, maxRetriesPerRequest: null });

const emailQueue = new Queue("emails", { connection });

const worker = new Worker("emails", async (job) => {
  const { to, subject, body } = job.data;
  console.log(`Sending email to ${to}`);
  return { sent: true };
}, { connection });

async function main() {
  // Desde una ruta de API:
  await emailQueue.add("send-email", {
    to: "alice@example.com", subject: "Welcome", body: "Hello!",
  }, {
    attempts: 3,
    backoff: { type: "exponential", delay: 1000 },
  });

  // Trabajo recurrente: limpieza cada día a las 02:00
  const cronQueue = new Queue("cron", { connection });
  await cronQueue.add("cleanup", {}, { repeat: { pattern: "0 2 * * *" } });
}

main().catch(console.error);

Java (Spring @Scheduled)

import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;

@Service
public class JobService {

    // Requiere @EnableScheduling en una clase @Configuration
    @Scheduled(cron = "0 0 2 * * ?")   // todos los días a las 02:00
    public void cleanupOldLogs() {
        System.out.println("Running nightly cleanup");
    }

    @Scheduled(fixedRate = 300_000)    // cada 5 minutos
    public void syncExternalData() {
        System.out.println("Syncing with external API");
    }

    // El trabajo fire-and-forget dentro de una petición va por un pool
    public void runAsync(Runnable task, ThreadPoolTaskExecutor executor) {
        CompletableFuture.runAsync(task, executor);
    }
}

Explicación

Tres piezas componen cualquier montaje de tareas en segundo plano:

  1. Un productor (tu API o app web) encola un trabajo cuando ocurre algo — un registro, una subida, un tick programado.
  2. Un broker (Redis, RabbitMQ, Amazon SQS) guarda el trabajo hasta que un worker lo toma. Redis con persistencia AOF alcanza para la mayoría de apps; RabbitMQ o SQS justifica su coste operativo cuando perder un trabajo significa perder dinero.
  3. Un consumidor (el proceso worker) sondea el broker y ejecuta el trabajo. Los workers escalan horizontalmente y pueden vivir en máquinas distintas a la API.

Los cron jobs son el mismo patrón con un reloj como productor: Celery Beat, los trabajos repetibles de BullMQ y el @Scheduled de Spring encolan trabajo según un horario en lugar de por un evento.

Algo que sorprende a mucha gente: la entrega es al menos una vez, no exactamente una vez. Un worker puede terminar el trabajo y caerse antes de confirmarlo, así que el broker se lo entrega a otro worker y el efecto colateral se ejecuta dos veces. Celery solo elimina el trabajo cuando la tarea termina con task_acks_late=True; BullMQ reencola un trabajo estancado cuando expira su bloqueo; SQS hace lo mismo con su visibility timeout. Diseña cada trabajo para que ejecutarlo dos veces sea inofensivo — comprueba una marca, lleva una clave de idempotencia o convierte la escritura en un upsert.

flowchart diagram: Cliente

Variantes

HerramientaLenguajePersistenciaProgramaciónIdeal Para
Celery + RedisPythonRedis (o RabbitMQ)Celery BeatColas de tareas completas
BullMQJavaScriptRedisCron integradoProyectos Node.js, TypeScript
SidekiqRubyRedissidekiq-cronRuby on Rails
HangfireC#SQL Server/RedisIntegradoEcosistema .NET
Spring @ScheduledJavaN/A (en proceso)Expresiones cronTareas programadas simples
AWS Lambda + EventBridgeCualquieraN/A (serverless)Reglas EventBridgeCloud-native, pago por uso

Lo que Funciona

  • Haz los trabajos idempotentes. La entrega es al menos una vez en todo sistema de colas que valga la pena, así que un trabajo puede correr dos veces. Un ID único de trabajo más un upsert — o una clave de deduplicación como email:42:welcome escrita con SET NX — hace que la segunda ejecución sea inofensiva.
  • Reintenta los fallos transitorios con backoff. Un timeout a tu proveedor SMTP o una API inestable merece 3–5 reintentos con backoff exponencial y jitter, no un viaje inmediato a la dead-letter queue.
  • Registra el contexto del trabajo. Job ID, nombre de cola, número de intento y user ID en cada línea convierte “algo falló” en un evento consultable.
  • Separa colas por prioridad. Procesamiento de pagos en high, email en default, limpieza nocturna en low — un retraso de trabajos baratos no debería retrasar los caros.
  • Vigila la dead-letter queue. Un trabajo que agota sus reintentos es un bug o un mensaje envenenado. Alerta cuando la DLQ crece en lugar de descubrirlo semanas después.

Errores Comunes

  • Ejecutar trabajo pesado en el proceso web. Un PDF de 100 páginas dentro de la ruta de la petición significa un timeout para ese usuario y contención de CPU para todos los demás.
  • Saltarse la configuración de reintentos y dead-letter. Un reinicio de Redis sin persistencia configurada puede tirar todos los trabajos pendientes; una excepción no controlada puede tirar el mismo trabajo una y otra vez.
  • Asumir timing exacto de cron. Cron significa “en o después” de la hora programada — un worker ocupado puede retrasar un trabajo de las 02:00 varios minutos. No encadenes trabajos que asumen que el anterior corrió al segundo exacto.
  • No planear para caídas a mitad de trabajo. Sin acknowledgments ni visibility timeouts, un worker muerto se lleva consigo el trabajo que tenía en vuelo.
  • Inundar la cola. Encolar 100.000 trabajos de golpe mata de hambre al trabajo en tiempo real. Encola por lotes o limita la tasa.

Patrones Avanzados

Versiones ejecutables de estos ejemplos están en el repositorio companion.

Docker Compose para Desarrollo Local

# docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"

  worker:
    build: .
    command: celery -A tasks worker --loglevel=info --concurrency=4
    depends_on: [redis]
    environment:
      - REDIS_URL=redis://redis:6379/0

  beat:
    build: .
    command: celery -A tasks beat --loglevel=info
    depends_on: [redis, worker]
    environment:
      - REDIS_URL=redis://redis:6379/0

  api:
    build: .
    ports: ["8000:8000"]
    depends_on: [redis]
    environment:
      - REDIS_URL=redis://redis:6379/0

Colas con Prioridades en Celery

from celery import Celery
from kombu import Queue

app = Celery("tasks", broker="redis://localhost:6379/0")

# Enruta el trabajo a colas separadas por prioridad
app.conf.task_queues = (
    Queue("high", routing_key="high.#"),
    Queue("default", routing_key="default.#"),
    Queue("low", routing_key="low.#"),
)

app.conf.task_routes = {
    "tasks.process_payment": {"queue": "high"},
    "tasks.send_email": {"queue": "default"},
    "tasks.cleanup_logs": {"queue": "low"},
}

# Un worker consume las colas en orden — high primero
# celery -A tasks worker -Q high,default,low

Con un broker Redis, la prioridad se aproxima consumiendo las colas en orden. RabbitMQ soporta prioridad real de mensajes a través del argumento de cola x-max-priority.

Dead-Letter Queues en BullMQ

const { Queue, Worker } = require("bullmq");

const emailQueue = new Queue("emails", { connection });
const dlqQueue = new Queue("dlq", { connection });

const worker = new Worker("emails", async (job) => {
  const { to, subject, body } = job.data;
  if (to.includes("fail")) throw new Error("Simulated failure");
  console.log(`Email sent to ${to}`);
}, {
  connection,
  attempts: 3,
  backoff: { type: "exponential", delay: 2000 },
});

// "failed" se dispara en cada intento — solo mueve a la DLQ en el último
worker.on("failed", async (job, err) => {
  if (job.attemptsMade >= job.opts.attempts) {
    console.error(`Job ${job.id} exhausted retries: ${err.message}`);
    await dlqQueue.add("failed-email", job.data, { removeOnComplete: true });
  }
});

Seguimiento del Progreso de Trabajos

@app.task(bind=True)
def long_running_task(self, items):
    total = len(items)
    for i, item in enumerate(items):
        process(item)
        self.update_state(
            state="PROGRESS",
            meta={"current": i + 1, "total": total, "percent": (i + 1) / total * 100}
        )
    return {"status": "complete", "processed": total}

# La API guarda el task_id devuelto por .delay() y consulta con él:
result = app.AsyncResult(task_id)
print(result.info)  # {'current': 50, 'total': 100, 'percent': 50.0}

Consejos de Rendimiento

  1. Agrupa trabajos pequeños. Procesar 1.000 items trabajo a trabajo cuesta 1.000 viajes al broker. Agrúpalos:
@app.task
def process_batch(item_ids):
    # Se muestra ORM de Django; cualquier almacén vale
    items = Item.objects.filter(id__in=item_ids)
    for item in items.iterator():
        process(item)

# ids viene del conjunto que necesites procesar
for i in range(0, len(ids), 100):
    process_batch.delay(ids[i:i + 100])
  1. Limita el prefetch. Sin límites, un worker acapara todos los trabajos y el resto se queda ocioso:
# Cada worker toma solo 1 trabajo a la vez
celery -A tasks worker --prefetch-multiplier=1 --concurrency=4
  1. Da a los trabajos sensibles a latencia su propia cola. Una codificación de video de 5 minutos no debería estar delante de un reset de contraseña:
celery -A tasks worker -Q realtime --concurrency=8
celery -A tasks worker -Q batch --concurrency=2

Véase También

Preguntas frecuentes

¿Debo usar Redis o RabbitMQ para mi cola de tareas?

Redis es más simple de operar y maneja los volúmenes típicos de una app web sin despeinarse. RabbitMQ te da mayor durabilidad, routing flexible y prioridades de mensaje nativas — vale la pena para cargas financieras o sanitarias donde un trabajo perdido tiene coste. Para una app típica que empuja unos miles de trabajos al día, Redis con persistencia AOF activada es suficiente.

¿Cómo paso payloads grandes a una tarea en segundo plano?

Guarda el dato y pasa una referencia, nunca el payload entero. Un trabajo que lleva un video de 50 MB en base64 hincha el broker y puede agotar la memoria de Redis; un trabajo que lleva {"video_id": 9182} es diminuto y deja que el worker descargue de S3 o de la base de datos cuando corre.

¿Qué pasa si un worker se cae mientras procesa un trabajo?

El trabajo vuelve — esa es la gracia de la entrega al menos una vez. Celery con task_acks_late=True solo elimina el trabajo cuando la tarea termina; BullMQ reencola un trabajo estancado cuando expira su bloqueo; SQS lo devuelve tras el visibility timeout. Spring @Scheduled es la excepción: corre dentro del proceso, así que una caída de la JVM pierde la ejecución en vuelo.

¿Deben ser idempotentes las tareas en segundo plano?

Sí, siempre. Como la reentrega tras una caída es comportamiento normal, asume que cada trabajo puede correr dos veces. Escribe claves de deduplicación, haz upsert en lugar de insert y envía emails con una clave de idempotencia del proveedor cuando la API lo soporte.

¿Cómo monitorizo tareas en segundo plano en producción?

Sigue la profundidad de la cola y la edad del trabajo pendiente más viejo — esas dos métricas cazan casi cualquier incidente antes de que se quejen los usuarios. Celery tiene Flower, BullMQ tiene Bull Board y SQS expone ApproximateAgeOfOldestMessage en CloudWatch. Alerta cuando crece la dead-letter queue, no solo cuando cae un worker.

¿Cuántos workers necesito?

Los suficientes para vaciar la cola más rápido de lo que se llena. Los trabajos ligados a I/O (email, llamadas a APIs) paralelizan bien — un puñado de procesos por máquina va sobrado. Los ligados a CPU (codificación de video, generación de informes) necesitan más o menos un proceso por núcleo. Empieza pequeño y escala según la profundidad de la cola, no solo por CPU.