Blogs / Celery + Redis: Trampas de las que Nadie te Avisa

Celery + Redis: Trampas de las que Nadie te Avisa

Publicado
13 de septiembre de 2026
Autor
Faizan Nadeem
Etiquetas
Celery Redis Python Task Queues
Primer plano macro extremo del circuito y los chips de un módulo de memoria RAM de computadora
Foto de Liam Briese en Unsplash

Redis es el broker por defecto para la mayoría de los proyectos nuevos de Celery, y por buenas razones — ya está en el stack para caching, es rápido, y poner un worker a funcionar contra él toma unas cuatro líneas de configuración. Lo que esa configuración fácil esconde es que Redis nunca fue diseñado como message broker de la forma en que lo fue RabbitMQ. El transporte Redis de Celery es una capa de compatibilidad construida sobre las estructuras de datos de propósito general de Redis, y varios de sus comportamientos — en particular alrededor de las garantías de entrega — son aproximaciones emuladas de lo que un broker AMQP de verdad hace de forma nativa, no funcionalidades de primera clase del propio Redis.

La mayor parte del tiempo esa brecha no importa. Importa muchísimo la primera vez que una tarea se ejecuta dos veces sin motivo aparente, o un worker se queda a cero CPU mientras una cola se acumula con miles de mensajes pendientes, o un lote de tareas que debía dispararse la próxima semana simplemente nunca lo hace. Ninguno de estos casos es un bug de Celery. Son las consecuencias específicas y bien documentadas de un puñado de ajustes que por defecto traen valores calibrados para un escenario distinto del que en realidad estás ejecutando — y una vez que sabes dónde mirar, cada uno de ellos se puede arreglar en un archivo de configuración.

Aprenderás:

  • Por qué Redis como broker de Celery solo aproxima la semántica real de una cola de mensajes, y dónde se rompe esa aproximación
  • Cómo una mala configuración del visibility timeout de Redis en Celery hace que las tareas se ejecuten dos veces en trabajos de larga duración
  • Qué controla realmente el prefetch multiplier de Celery, y por qué su valor por defecto causa acaparamiento de workers con duraciones de tarea mixtas
  • Por qué las tareas programadas muy lejos en el futuro pueden desaparecer en silencio de la cola
  • Cómo el ajuste maxmemory-policy de Redis puede desalojar en silencio tareas encoladas bajo presión de memoria
  • Una checklist práctica para diagnosticar la desaparición de tareas de Celery cuando los logs no muestran una causa evidente

Tabla de contenido

  1. Por Qué Redis No Es un Message Broker de Verdad
  2. Visibility Timeout y la Redelivery de Tareas
  3. Prefetch Multiplier y el Acaparamiento de Workers
  4. ETAs muy Lejanos y el Problema de la Reserva
  5. maxmemory-policy: el Ajuste que se Devora tu Cola
  6. Cómo Diagnosticar la Desaparición de Tareas en Celery
  7. Errores Comunes

Por Qué Redis No Es un Message Broker de Verdad

RabbitMQ habla AMQP de forma nativa: los acknowledgments, la redelivery por mensaje y el prefetch de consumidores son primitivas a nivel de protocolo que el propio broker impone. Redis no tiene nada de eso incorporado — es un almacén clave-valor con estructuras de datos de tipo lista, set y sorted set. El transporte redis de Celery construye la semántica de colas sobre listas de Redis (BRPOP para consumir) y usa un sorted set separado de “no confirmados” (unacked), indexado por score, para rastrear los mensajes en vuelo que todavía no han sido reconocidos.

Ese set de “no confirmados” es el núcleo de casi todas las trampas de este artículo. Como Redis no tiene un concepto nativo de “este consumidor todavía está trabajando en este mensaje, no se lo des a nadie más”, Celery tiene que simularlo: cuando un worker recoge una tarea, Celery mueve una copia al set de no confirmados con una marca de tiempo. Un barrido periódico (visibility_timeout segundos después) revisa ese set y, si un mensaje no ha sido confirmado para entonces, asume que el worker murió y lo devuelve a la cola principal para su redelivery. Es un diseño razonable — pero es una suposición basada en el tiempo transcurrido, no una señal real del worker sobre si sigue vivo y trabajando. Cada una de las trampas siguientes proviene de que esa suposición resulta equivocada de una forma específica y predecible.

Si tu equipo todavía está decidiendo qué broker usar en lugar de estar ya atado a Redis, vale la pena sopesarlo directamente contra las alternativas — mira la comparación de brokers en la elección de una cola de tareas en Python para ver cómo difieren Redis, RabbitMQ y SQS en garantías de entrega antes de estar tan metido en producción que cambiar salga caro.

Visibility Timeout y la Redelivery de Tareas

visibility_timeout (configurado vía broker_transport_options) es cuánto espera Celery antes de asumir que el worker de un mensaje no confirmado murió y devolverlo a la cola:

app.conf.broker_transport_options = {"visibility_timeout": 3600}  # seconds

El valor por defecto es 3600 segundos (una hora) en las versiones recientes de Celery — está bien para la mayoría de las cargas de trabajo, y es activamente peligroso para dos casos específicos: tareas que legítimamente tardan más que el timeout configurado, y tareas configuradas con task_acks_late = True (que la mayoría de los despliegues en producción deberían estar usando, según la discusión previa sobre idempotencia).

Aquí está la secuencia de fallo: una tarea arranca, tarda 90 minutos en completar una exportación grande, y el visibility timeout está fijado en 3600 segundos. A la marca de una hora, el barrido de Celery decide que el worker debe haber muerto — no ha llegado ningún ack — y redirige el mensaje a un segundo worker. Ahora dos workers están ejecutando la misma exportación de 90 minutos de forma concurrente, y el que termine segundo sobreescribe la salida del primero o duplica cualquier efecto secundario que la tarea realice. Este es un caso de manual de una tarea de Celery ejecutándose dos veces que no tiene nada que ver con un crash real — el worker original estuvo bien todo el tiempo, solo era más lento de lo que el timeout asumía.

El arreglo es mecánico una vez que sabes dónde buscarlo: fija visibility_timeout cómodamente por encima de la duración peor-caso de tu tarea más lenta, no de su promedio.

# If your slowest task can take up to 2 hours, don't set this to 1 hour.
app.conf.broker_transport_options = {"visibility_timeout": 10800}  # 3 hours

El trade-off es real, no gratuito: un visibility timeout más largo también significa que la tarea de un worker que realmente se cayó espera más antes de ser redirigida — si un worker muere un minuto después de empezar una tarea con un timeout de 3 horas, esa tarea queda sin procesar hasta 3 horas antes de que alguien (incluido Celery) lo note y la reencole. No hay un único valor correcto; hay un valor que coincide con la distribución real de duración de tus tareas, y vale la pena configurarlo por cola si tu carga de trabajo mezcla tareas rápidas y lentas, en lugar de elegir un solo número global que estará mal para la mitad de ellas.

Prefetch Multiplier y el Acaparamiento de Workers

worker_prefetch_multiplier controla cuántas tareas reserva de la cola cada proceso worker a la vez, como múltiplo de su concurrencia. El valor por defecto es 4 — es decir, un worker con concurrencia 8 agarrará hasta 32 tareas por adelantado y las mantendrá en memoria, trabajando primero en su propio backlog local antes de pedirle más a Redis.

Este valor por defecto se calibró para un mundo de tareas numerosas, pequeñas, rápidas y de duración más o menos uniforme, donde el prefetch reduce la sobrecarga de ida y vuelta de pedirle al broker la siguiente tarea una a la vez. Te perjudica activamente en el momento en que la duración de tus tareas varía mucho. Imagina una cola con una mezcla de tareas que tardan 100ms y tareas que tardan 10 minutos: un worker prefetchea 32 tareas, varias de las cuales resultan ser del tipo de 10 minutos, y ahora esas tareas quedan reservadas en el buffer local de ese worker — invisibles para cualquier otro worker inactivo — mientras el worker asignado avanza por su cola de forma serial. Los demás workers quedan ociosos porque, en lo que a Redis respecta, ese trabajo ya está comprometido.

Este es el clásico problema de acaparamiento por prefetch multiplier de Celery, y visto desde fuera se parece exactamente al síntoma de “tareas que desaparecen”: la longitud de cola reportada por el monitoreo baja (las tareas fueron prefetcheadas, así que técnicamente están “fuera” de la cola), pero el throughput no sube correspondientemente, porque están atascadas esperando detrás de tareas lentas en un worker que no está libre para tomar trabajo nuevo.

# Global default — fine for short, uniform tasks
app.conf.worker_prefetch_multiplier = 4

# For queues with long-running or highly variable-duration tasks
app.conf.worker_prefetch_multiplier = 1

Fijar worker_prefetch_multiplier = 1 hace que un worker solo reserve una tarea por slot concurrente a la vez — más cerca de un comportamiento de despacho justo de verdad, a costa de algunos viajes de ida y vuelta más al broker por tarea, un costo que vale la pena pagar para casi cualquier carga de trabajo con tareas de larga duración. Si estás mezclando duraciones de tarea en un mismo despliegue, enruta las tareas de larga duración a una cola dedicada con su propio pool de workers configurado a prefetch_multiplier=1, y deja el valor por defecto más alto para un pool separado que maneje tareas cortas y uniformes — mezclar ambos patrones en un mismo pool de workers con un mismo ajuste de prefetch es justo lo que causa el acaparamiento en primer lugar.

ETAs muy Lejanos y el Problema de la Reserva

Programar una tarea para un momento futuro específico — my_task.apply_async(eta=some_datetime) — funciona de forma distinta en Redis a como funciona conceptualmente: Celery no deja el mensaje esperando intacto en la cola principal hasta que llegue el ETA. Dependiendo de la configuración y del comportamiento del worker, una tarea con ETA puede ser reservada por un worker mucho antes de su momento de ejecución y quedar retenida en la memoria de ese worker, contando contra su límite de prefetch, hasta que llegue el momento programado.

Para una tarea programada con unos minutos de anticipación, esto es invisible. Para una tarea programada con días o semanas de anticipación — un email recordatorio, una verificación de renovación de suscripción — se convierte en un problema real: esa reserva ocupa un slot en el buffer de prefetch del worker durante todo el período de espera, y si ese worker se reinicia (un deploy, un crash, un evento de autoscaling) antes de que llegue el ETA, la tarea reservada puede perderse del estado local de ese worker dependiendo exactamente de cuándo y cómo ocurra el reinicio en relación con el propio seguimiento que Redis lleva de ella. El efecto neto se ve idéntico a que una tarea simplemente desaparezca — nada en los logs apunta a “esto se programó correctamente y luego se perdió durante un deploy rutinario”, porque desde la perspectiva de la aplicación, apply_async tuvo éxito y devolvió un ID de tarea.

La mitigación práctica es no apoyarse en el propio mecanismo de ETA de Celery para nada programado con más de una hora o así de anticipación. Usa celery beat para trabajo genuinamente periódico, o — para programación puntual muy lejana en el futuro — persiste la intención en tu propia tabla de base de datos con una columna scheduled_for y una tarea periódica de beat que consulte las filas vencidas y las encole justo a tiempo, en lugar de entregarle directamente al broker un ETA de una semana en el futuro y confiar en que un worker lo siga reteniendo, sin cambios, siete días después.

# Fragile: a worker restart between now and next month can lose this
send_renewal_reminder.apply_async(args=[user_id], eta=next_month)

# Sturdier: durable intent in your own database, dispatched just-in-time
class ScheduledReminder(db.Model):
    user_id = db.Column(db.Integer, nullable=False)
    scheduled_for = db.Column(db.DateTime, nullable=False)
    dispatched = db.Column(db.Boolean, default=False)

@app.task
def dispatch_due_reminders():
    due = ScheduledReminder.query.filter(
        ScheduledReminder.scheduled_for <= datetime.utcnow(),
        ScheduledReminder.dispatched.is_(False),
    ).all()
    for reminder in due:
        send_renewal_reminder.delay(reminder.user_id)
        reminder.dispatched = True
    db.session.commit()

maxmemory-policy: el Ajuste que se Devora tu Cola

Esta es la trampa que más confusión causa, porque ni siquiera es un ajuste de Celery — es una configuración del servidor Redis que la mayoría de los equipos hereda de un caso de uso de caching y nunca revisa cuando esa misma instancia de Redis también pasa a ser el broker de Celery.

maxmemory-policy le dice a Redis qué hacer cuando alcanza su techo de memoria configurado. Para una caché pura, allkeys-lru (desalojar la clave usada menos recientemente, sin importar el tipo) es un valor por defecto completamente razonable y común — los datos cacheados son desechables por definición, y el desalojo es el comportamiento previsto. El problema es que los datos de cola de Celery — las listas y sorted sets que contienen mensajes de tareas pendientes y no confirmados — viven como claves de Redis ordinarias sin ninguna protección especial. Si esa misma instancia de Redis se comparte entre la caché de tu aplicación y tu broker de Celery, y se acumula presión de memoria, allkeys-lru desalojará mensajes de tareas encoladas pero aún no ejecutadas con la misma facilidad con que desaloja una entrada de caché obsoleta. Las tareas no fallan. No dan error. Simplemente desaparecen — desalojadas de la misma forma en que lo haría una clave de caché vieja, porque en lo que a Redis respecta, no hay diferencia entre las dos.

# redis.conf — check this if Redis is shared between cache and Celery broker
maxmemory-policy allkeys-lru      # dangerous if this Redis also holds queue data

El arreglo es uno de dos enfoques, ambos legítimos: correr una instancia de Redis separada y dedicada para Celery con maxmemory-policy noeviction (Redis devuelve un error en las escrituras cuando está lleno en lugar de borrar datos en silencio — lo que convierte el problema de capacidad en un fallo ruidoso en vez de una pérdida de datos silenciosa), o, si una instancia compartida es inevitable, usar un índice de base de datos lógica separado (redis://localhost:6379/1 para Celery vs /0 para la caché) y asegurarte de que maxmemory-policy esté fijado en algo seguro para el caso de uso de la cola, dado que la política de desalojo de Redis aplica a toda la instancia, no por base de datos lógica — la separación tipo SELECT solo aísla los keyspaces, no el comportamiento bajo presión de memoria.

# celeryconfig.py — dedicated logical DB, separate from application cache
app.conf.broker_url = "redis://localhost:6379/1"
app.conf.result_backend = "redis://localhost:6379/2"
# On the dedicated broker instance (or if sharing is unavoidable, understand
# this applies to the whole instance, not just the Celery-owned keys):
maxmemory-policy noeviction

Vale la pena revisar esto incluso si nunca has tocado la configuración de Redis tú mismo — una instancia de Redis compartida provisionada por herramientas de infraestructura o un servicio cloud administrado suele venir con un valor por defecto orientado a LRU calibrado para caching, bajo la suposición razonable de que lo que sea que la use es una caché. Si más adelante Celery termina apuntando a esa misma instancia, nadie necesariamente revisita esa suposición.

Cómo Diagnosticar la Desaparición de Tareas en Celery

Cuando las tareas genuinamente parecen desvanecerse — sin error, sin entrada de dead-letter, sin rastro en los logs — recorre esto en orden, ya que cubre la abrumadora mayoría de los casos reales:

  1. Revisa maxmemory-policy en la instancia real del broker. redis-cli config get maxmemory-policy — si es algo distinto de noeviction y ese Redis maneja algo además de Celery, este es el primer sospechoso.
  2. Revisa el uso de memoria de Redis contra maxmemory. redis-cli info memory — si used_memory está cerca de maxmemory, el desalojo está ocurriendo activamente aunque todavía no hayas confirmado la política.
  3. Revisa si las tareas “desaparecidas” eran tareas con ETA/countdown. Si es así, sospecha del problema de reserva a futuro lejano, especialmente si un reinicio de worker o un deploy ocurrió durante la ventana de espera.
  4. Revisa los ajustes de prefetch contra la varianza de duración de las tareas. Una cola que se ve estancada con workers ociosos y un backlog creciente es el patrón de acaparamiento, no una desaparición — celery inspect active y celery inspect reserved mostrarán tareas retenidas en workers específicos en lugar de genuinamente perdidas.
  5. Revisa visibility_timeout contra las duraciones reales de las tareas si estás viendo duplicados en lugar de pérdidas — una tarea que parece ejecutarse dos veces es la imagen especular de esta misma clase de problema, no uno separado.

La mayoría de los reportes de “desaparición” resultan ser uno de los tres primeros; la mayoría de los reportes de “duplicado” se rastrean al cuarto. Muy pocos son bugs de Celery en el sentido literal — casi todos son ajustes por defecto que nunca se adaptaron a la forma específica de la carga de trabajo que en realidad se ejecuta contra ellos.

También vale la pena descartar las explicaciones aburridas antes de zambullirse en las internas del broker: confirma que la tarea realmente se encoló en primer lugar (un error de serialización silencioso del lado del productor puede hacer que apply_async parezca exitoso mientras nada llega a Redis), y confirma que estás inspeccionando la misma instancia de Redis y la misma base de datos lógica contra la que el worker realmente está configurado — apuntar redis-cli al host o índice de DB equivocado durante una investigación es lo bastante común, especialmente en configuraciones donde la caché y el broker comparten host pero usan índices de base de datos distintos, como para que valga la pena comparar CELERY_BROKER_URL contra lo que sea que use tu sesión de depuración antes de concluir que algo más exótico es el culpable.

Errores Comunes

Error: dejar visibility_timeout en su valor por defecto con tareas de larga duración. Las tareas que tardan más que el timeout se redirigen y se ejecutan dos veces. Solución: fíjalo por encima de la duración peor-caso de tu tarea más lenta, no de su promedio, y enruta las tareas largas a una cola con un timeout calibrado específicamente para ellas.

Error: usar el prefetch multiplier por defecto con tareas de duración mixta. Las tareas largas quedan acaparadas en el buffer de un worker mientras otros quedan ociosos. Solución: fija worker_prefetch_multiplier = 1 para colas con duraciones de tarea variables o largas, y aíslalas de las colas de duración corta y uniforme.

Error: programar trabajo muy a futuro con eta=/countdown= directamente. Las reservas retenidas por mucho tiempo pueden perderse entre reinicios de worker. Solución: usa celery beat más tu propia tabla de programación durable para cualquier cosa con más de una hora aproximadamente de anticipación.

Error: compartir una instancia de Redis calibrada para caching con Celery sin revisar maxmemory-policy. allkeys-lru desaloja tareas encoladas exactamente igual que entradas de caché obsoletas. Solución: dedica una instancia de Redis al broker con noeviction, o como mínimo confirma que la política de desalojo de la instancia compartida sea segura para datos de cola.

Error: asumir que las tareas “desaparecidas” y las “duplicadas” son problemas no relacionados. A menudo son la misma causa raíz — una garantía de entrega emulada por Redis reaccionando al tiempo transcurrido en lugar de a una señal real del worker — que se manifiesta como síntomas opuestos según el timing. Solución: trabaja el visibility timeout, el prefetch, el manejo de ETAs y maxmemory-policy como una sola checklist conectada, no como cuatro pistas sin relación.

Ninguna de estas trampas es culpa de que Redis sea poco fiable, y ninguna es culpa de que Celery tenga bugs — son las costuras visibles de Redis desempeñando un papel para el que originalmente no fue construido, sostenidas por ajustes que vienen con valores por defecto calibrados para una carga de trabajo genérica en lugar de la tuya específicamente. Una vez que has recorrido el visibility timeout, el prefetch, los ETAs muy lejanos y maxmemory-policy una sola vez, “las tareas desaparecieron y nadie sabe por qué” deja de ser un misterio y se convierte en una checklist de cinco minutos.

Faizan Nadeem
Autor
Faizan Nadeem

AI/GenAI engineer specialising in agentic RAG, LangGraph, and production LLM systems with Python and FastAPI. 2 years building AI agents, RAG pipelines, and the backends behind them.

Más artículos