El ticket de soporte suele decir siempre lo mismo: “el cliente dice que le cobraron dos veces por un pedido.” Alguien revisa los logs, encuentra una sola llamada a la API, un único charge_customer.delay(order_id) — y luego descubre que la tarea se ejecutó de todas formas dos veces, con doce segundos de diferencia, en dos workers distintos. Nadie escribió un bug que duplicara la llamada. El sistema funcionó exactamente como fue diseñado. Ese es el detalle que confunde a la gente: que una tarea de Celery se ejecute dos veces no es un malfuncionamiento, es el comportamiento documentado e intencional de la entrega at-least-once, y le va a pasar a cualquier despliegue de Celery que funcione el tiempo suficiente.
La solución no es encontrar y parchear la “race condition” — normalmente no hay ninguna que encontrar, en el sentido de un bug que se pueda señalar. La solución es aceptar que cualquier tarea puede ejecutarse más de una vez para la misma unidad lógica de trabajo, y diseñar la propia tarea para que ejecutarla dos veces produzca el mismo estado final que ejecutarla una sola vez. Eso es lo que realmente significa idempotencia aquí: no “esto nunca pasa”, sino “es seguro cuando pasa”.
Aprenderás:
- Por qué que una tarea de Celery se ejecute dos veces es un comportamiento esperado, no un bug, una vez que se entiende
acks_latey la redelivery del broker - Las causas reales de la ejecución duplicada en Celery — caídas de workers, timeouts de visibilidad, particiones de red y reintentos
- Cómo diseñar claves de idempotencia que conviertan la ejecución duplicada de una tarea en un no-op
- Cómo implementar un lock distribuido con
SET NX EXde Redis para evitar ejecuciones solapadas - Idempotencia a nivel de base de datos usando restricciones únicas como segunda línea de defensa
- Cómo manejar efectos secundarios que son inherentemente no idempotentes, como enviar un email o llamar a una API de pagos
- Cómo probar realmente que una tarea es idempotente, en lugar de simplemente asumirlo
Tabla de contenido
- Por qué la entrega at-least-once implica ejecución duplicada
- Las causas reales de la ejecución duplicada
- Claves de idempotencia: el patrón central
- Locks distribuidos con SET NX EX
- Idempotencia a nivel de base de datos como segunda línea de defensa
- Cómo manejar efectos secundarios no idempotentes
- Cómo probar la idempotencia
- Errores comunes
Por qué la entrega at-least-once implica ejecución duplicada
Celery, como prácticamente toda cola de tareas de Python, usa por defecto entrega at-least-once cuando se configura para durabilidad. Esa frase esconde mucho significado y la mayoría la pasa por alto: at-least-once garantiza que una tarea se intentará una o más veces — nunca cero — pero explícitamente no garantiza una única ejecución exacta. “Al menos” incluye “más de una vez”.
El mecanismo es task_acks_late. Con esta opción desactivada (el valor histórico por defecto de Celery), un worker confirma un mensaje en el momento en que empieza la tarea, antes de que el cuerpo de la función siquiera se haya ejecutado. Si el proceso worker muere a mitad de la ejecución — un OOM kill, un despliegue, una caída dura —, el mensaje ya está confirmado y desaparecido. La tarea nunca termina en silencio y nunca se reintenta. Eso es un riesgo de pérdida de datos, no un riesgo de duplicación.
Al activar task_acks_late = True, el equilibrio se invierte: el broker solo elimina el mensaje cuando la tarea termina con éxito. Si el worker muere después de haber terminado el trabajo real pero antes de que esa confirmación llegue al broker — un corte de red, un proceso matado en la mitad de segundo posterior al return —, el broker no tiene forma de saber que el trabajo ya ocurrió. Reenvía el mensaje a otro worker. El efecto secundario (el cobro, el email, la inserción en la fila) vuelve a ocurrir.
# celeryconfig.py
task_acks_late = True
task_reject_on_worker_lost = True
worker_prefetch_multiplier = 1
Este es el valor por defecto correcto para cualquier caso en el que perder una tarea en silencio sea peor que ejecutarla dos veces — que es la mayoría del trabajo en producción. Pero significa que la ejecución duplicada no es un caso extremo que quizás te ocurra alguna vez; es un escenario al que te apuntas explícitamente cada vez que eliges “no perder tareas” en lugar de “no repetir nunca las tareas”. No se pueden tener ambas cosas sin trabajo adicional a nivel de aplicación, porque ningún message broker puede acoplar de forma atómica “la tarea terminó” con “tu efecto secundario se confirmó” a través de un límite de red.
Las causas reales de la ejecución duplicada
Conocer el mecanismo ayuda, pero ayuda más saber qué lo dispara realmente en un sistema en vivo, porque “esto básicamente nunca pasa” es una suposición común y equivocada:
- Caída del worker a mitad de la tarea. OOM kills, un
SIGKILLdel orquestador durante un despliegue, o una caída dura del proceso después del efecto secundario pero antes del ack. - Expiración del timeout de visibilidad del broker. Con Redis como broker, un mensaje en vuelo (sin confirmar) se vuelve elegible para reenvío tras
visibility_timeoutsegundos, sin importar si el worker original sigue trabajando en él. Una tarea que legítimamente tarda más que el timeout configurado se reenvía a un segundo worker mientras el primero todavía la está ejecutando. - Reintentos manuales combinados con un éxito parcial. Una tarea lanza una excepción después de que su efecto secundario ya se confirmó (por ejemplo, la escritura en la base de datos tuvo éxito pero una llamada de notificación posterior expiró y lanzó un error), y
autoretry_forreintenta toda la función — repitiendo la parte que ya había tenido éxito. Precisamente por eso el diseño de backoff de reintentos y dead-letter queue merece el mismo tratamiento deliberado que la propia idempotencia. - Particiones de red entre el worker y el broker. El ack se envía pero nunca se recibe por un problema de red transitorio; desde la perspectiva del broker, eso es indistinguible de un worker que murió.
- Error operativo. Alguien reproduce manualmente un mensaje desde una dead-letter queue sin comprobar si ya había tenido éxito parcialmente antes de fallar.
Ninguno de estos casos requiere un bug en tu código. Son propiedades inherentes de ejecutar workers distribuidos sobre una red que no controlas por completo. Los despliegues de Celery respaldados por Redis tropiezan con el escenario del timeout de visibilidad lo suficientemente a menudo como para merecer su propio tratamiento detallado — consulta la sección sobre redelivery por timeout de visibilidad para entender la mecánica de cómo una tarea puede ser recogida dos veces solo porque el timeout se configuró más corto de lo que la tarea realmente tarda.
Claves de idempotencia: el patrón central
La solución estándar es la misma que popularizaron las APIs de pagos como Stripe: adjuntar una clave única de idempotencia a cada unidad lógica de trabajo y, antes de hacer nada con efectos secundarios, comprobar si esa clave ya fue procesada.
import redis
from celery import shared_task
redis_client = redis.Redis(host="localhost", port=6379, db=2)
@shared_task(bind=True)
def charge_customer(self, idempotency_key: str, order_id: int, amount_cents: int):
already_done = redis_client.get(f"idempotency:{idempotency_key}")
if already_done:
# Task already completed successfully — this is a redelivery, not new work.
return already_done.decode()
charge_id = payment_gateway.charge(order_id=order_id, amount_cents=amount_cents)
# Only mark as done AFTER the side effect succeeds.
redis_client.set(f"idempotency:{idempotency_key}", charge_id, ex=86400)
return charge_id
Aquí hay dos detalles más importantes que el camino feliz. Primero, la clave de idempotencia tiene que generarla el llamador, en el punto donde se toma la decisión lógica de hacer el trabajo — no dentro de la propia tarea. Si generas la clave dentro de charge_customer, una tarea reenviada genera una clave nueva cada vez y toda la comprobación pierde sentido. Genérala una vez, en un nivel superior, normalmente como un UUID ligado al evento de negocio (f"order-charge-{order_id}" funciona si un pedido solo puede cobrarse legítimamente una vez; un UUID nuevo por cada intento de checkout funciona si los checkouts reintentados son legítimamente cobros separados).
Segundo, la escritura que marca la finalización tiene que ocurrir después de que el efecto secundario tenga éxito, no antes — marcarlo como hecho primero y que luego el cobro real falle ocultaría en silencio trabajo real que nunca ocurrió. Este orden parece obvio una vez escrito, pero es exactamente el bug que aparece en implementaciones de idempotencia añadidas bajo presión de plazos.
Locks distribuidos con SET NX EX
La comprobación de la clave de idempotencia de arriba maneja la redelivery secuencial — la tarea se ejecutó, y luego se volvió a ejecutar más tarde. No maneja la redelivery concurrente, donde dos workers recogen el mismo mensaje casi al mismo instante y ambos pasan la comprobación de “¿ya está hecho?” antes de que ninguno haya escrito el marcador de finalización. Esa es una condición de carrera que la clave por sí sola no puede cerrar; para eso hace falta un lock.
El SET clave valor NX EX segundos de Redis es el bloque de construcción estándar para esto: establece una clave solo si todavía no existe (NX), con expiración automática (EX) para que un worker caído no retenga el lock para siempre.
import uuid
import redis
from celery import shared_task
redis_client = redis.Redis(host="localhost", port=6379, db=2)
def acquire_lock(lock_key: str, ttl_seconds: int = 30) -> str | None:
token = str(uuid.uuid4())
# NX: only set if not already held. EX: auto-expire so a dead worker
# can't hold the lock forever.
acquired = redis_client.set(lock_key, token, nx=True, ex=ttl_seconds)
return token if acquired else None
def release_lock(lock_key: str, token: str) -> None:
# Only release if we're still the holder — avoids releasing a lock
# that expired and was re-acquired by someone else in the meantime.
lua_script = """
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
"""
redis_client.eval(lua_script, 1, lock_key, token)
@shared_task(bind=True)
def charge_customer(self, idempotency_key: str, order_id: int, amount_cents: int):
lock_key = f"lock:charge:{idempotency_key}"
token = acquire_lock(lock_key, ttl_seconds=30)
if token is None:
# Someone else is already processing this exact unit of work.
# Retry later rather than proceeding concurrently.
raise self.retry(countdown=5, max_retries=10)
try:
already_done = redis_client.get(f"idempotency:{idempotency_key}")
if already_done:
return already_done.decode()
charge_id = payment_gateway.charge(order_id=order_id, amount_cents=amount_cents)
redis_client.set(f"idempotency:{idempotency_key}", charge_id, ex=86400)
return charge_id
finally:
release_lock(lock_key, token)
El TTL del lock necesita superar con margen cómodo el tiempo de ejecución en el peor caso de la tarea — si expira mientras la tarea todavía se está ejecutando legítimamente, un segundo worker puede adquirirlo y vuelves a tener una condición de carrera, solo que más rara. La eliminación mediante comparación en release_lock usando un script de Lua importa por la misma razón: sin ella, un worker cuyo lock ya expiró (y fue adquirido por otro mientras tanto) podría borrar el lock del nuevo propietario al hacer la limpieza, anulando todo el propósito.
Este patrón de lock más clave se generaliza bien más allá de los pagos: cualquier tarea con un efecto secundario externo y no transaccional (enviar una notificación, llamar a una API de terceros, escribir en un sistema que no controlas) se beneficia de la misma defensa en dos capas.
Una nota sobre los locks de Redis de instancia única
El patrón SET NX EX de arriba asume una única instancia de Redis (o un primario contra el que se lee y escribe de forma consistente), y esa suposición merece decirse explícitamente en lugar de pasarse por alto. Si Redis conmuta a un réplica antes de que la clave del lock se haya replicado — la replicación de Redis es asíncrona por defecto —, un segundo worker puede adquirir el “mismo” lock contra el nuevo primario mientras el primer worker todavía cree que lo tiene. Para la mayoría de los casos de uso de colas de tareas, este es un riesgo aceptable y bien entendido: la ventana de fallo es pequeña, y la comprobación de la clave de idempotencia más la restricción única de la base de datos más adelante cubren lo que el lock deja pasar. Si tu carga de trabajo realmente no puede tolerar ese hueco — operaciones financieras de muy alto valor, por ejemplo — conviene mirar el algoritmo Redlock, que adquiere el lock contra un quórum de instancias de Redis independientes en lugar de una sola, a costa de una complejidad operativa notablemente mayor. Para la inmensa mayoría de las cargas de trabajo de Celery, una única instancia de Redis bien configurada con un TTL razonable es la elección pragmática, y recurrir a Redlock por defecto suele ser resolver un problema que todavía no tienes.
Idempotencia a nivel de base de datos como segunda línea de defensa
Los locks y claves basados en Redis son rápidos pero no infalibles — Redis puede reiniciarse, las claves pueden expirar un poco antes de lo previsto bajo presión de memoria, y el TTL de un lock siempre es una suposición sobre el peor caso de tiempo. La garantía de idempotencia más robusta, cuando el efecto secundario es una escritura en base de datos, es una restricción única que la base de datos impone sin importar qué haya fallado en la capa de aplicación.
CREATE TABLE charges (
id SERIAL PRIMARY KEY,
idempotency_key VARCHAR(255) UNIQUE NOT NULL,
order_id INTEGER NOT NULL,
amount_cents INTEGER NOT NULL,
gateway_charge_id VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ DEFAULT now()
);
from sqlalchemy.exc import IntegrityError
@shared_task(bind=True)
def charge_customer(self, idempotency_key: str, order_id: int, amount_cents: int):
charge_id = payment_gateway.charge(order_id=order_id, amount_cents=amount_cents)
try:
db.session.add(Charge(
idempotency_key=idempotency_key,
order_id=order_id,
amount_cents=amount_cents,
gateway_charge_id=charge_id,
))
db.session.commit()
except IntegrityError:
# Row already exists for this key — a duplicate delivery raced us
# here. The charge already succeeded and was recorded; treat this
# as success, not failure.
db.session.rollback()
return charge_id
Fíjate en que esta versión sigue llamando a la pasarela de pagos antes de comprobar si hay un duplicado — eso es un hueco real, porque un duplicado genuinamente concurrente seguiría cobrando dos veces externamente aunque tu propia base de datos se mantenga consistente. Por eso exactamente es una segunda línea de defensa por debajo del lock de Redis, no un sustituto: el lock evita que la ejecución concurrente llegue dos veces a la llamada externa en primer lugar, y la restricción única atrapa los casos en los que la suposición del TTL del lock resulta ser incorrecta. Cinturón y tirantes, no lo uno o lo otro.
Cómo manejar efectos secundarios no idempotentes
Algunos efectos secundarios realmente no se pueden hacer idempotentes en el punto de ejecución — enviar un email es el caso clásico, porque no existe un paso de “comprobar primero” que la mayoría de los proveedores de email transaccional expongan que sea más barato que simplemente enviarlo. El patrón de clave de idempotencia sigue funcionando aquí, pero toda la carga recae por completo en la comprobación previa al envío, ya que no hay un sistema externo al que recurrir como segunda comprobación de la forma en que una restricción única respalda una escritura en base de datos.
@shared_task(bind=True)
def send_order_confirmation(self, idempotency_key: str, order_id: int):
lock_key = f"lock:email:{idempotency_key}"
token = acquire_lock(lock_key, ttl_seconds=15)
if token is None:
raise self.retry(countdown=3, max_retries=5)
try:
sent_key = f"idempotency:email:{idempotency_key}"
if redis_client.get(sent_key):
return "already-sent"
email_provider.send(to=get_order_email(order_id), template="order_confirmation")
redis_client.set(sent_key, "1", ex=604800)
return "sent"
finally:
release_lock(lock_key, token)
Para acciones externas genuinamente irreversibles y sin soporte de idempotencia por parte del proveedor, la respuesta honesta es que estás gestionando riesgo, no eliminándolo — el objetivo pasa a ser que los duplicados sean poco frecuentes y de bajo costo (un email de confirmación duplicado es una molestia) en lugar de asumir que puedes reducir la probabilidad a cero para cualquier efecto secundario posible. Reserva el locking más estricto y los TTL más largos para los efectos secundarios donde un duplicado realmente sale caro — pagos, decrementos de inventario, transiciones de estado irreversibles — y acepta garantías más laxas para aquellos donde solo resulta molesto.
Cómo probar la idempotencia
Los bugs de idempotencia son fáciles de introducir y fáciles de pasar por alto en un code review, porque el test del camino feliz — llamar a la tarea una vez, comprobar el resultado esperado — pasa de forma idéntica tanto si la tarea es realmente idempotente como si no. El test que importa la llama dos veces:
def test_charge_customer_is_idempotent():
key = str(uuid.uuid4())
result_1 = charge_customer.apply(args=[key, order.id, 1000]).get()
result_2 = charge_customer.apply(args=[key, order.id, 1000]).get()
assert result_1 == result_2
assert payment_gateway_mock.charge.call_count == 1 # not called twice
assert Charge.query.filter_by(idempotency_key=key).count() == 1
Una versión más sólida simula el escenario real de redelivery concurrente con hilos o multiprocessing, invocando la tarea con la misma clave desde dos workers a la vez y comprobando que la llamada externa se dispara exactamente una vez de todas formas. Ese test es más caro de escribir y más lento de ejecutar, pero es el único que realmente ejercita el camino del lock en lugar del camino secuencial de comprobación de clave — la mayoría de los bugs de idempotencia que llegan a producción son bugs de concurrencia, no errores de lógica secuencial, y un test puramente secuencial pasa justo por encima de ellos.
Errores comunes
Error: generar la clave de idempotencia dentro de la tarea. Esto anula por completo el patrón — una tarea reenviada con una clave recién generada parece trabajo nuevo cada vez. Solución: generar la clave una sola vez, en el punto donde se toma la decisión de negocio de hacer el trabajo, y pasarla a la tarea como argumento.
Error: marcar el trabajo como completo antes de que el efecto secundario realmente tenga éxito. Una caída entre la escritura del marcador y el trabajo real significa que el marcador miente. Solución: escribir siempre el marcador de finalización después de que el efecto secundario tenga éxito, nunca antes.
Error: confiar en un lock sin expiración, o con una más corta que la duración en el peor caso de la tarea. Un lock sin TTL convierte a un worker caído en un deadlock permanente; un TTL demasiado corto deja entrar a un segundo worker mientras el primero todavía se está ejecutando legítimamente. Solución: fijar el TTL generosamente por encima del percentil 99 de duración de la tarea, y monitorizar las tareas que se acercan regularmente a él.
Error: asumir que la restricción única de la base de datos por sí sola es suficiente. Atrapa las escrituras duplicadas, pero para cuando actúa, una llamada externa no transaccional (un cobro, un email) puede haber ocurrido ya dos veces. Solución: colocar un lock de Redis delante de la llamada externa y usar la restricción como segunda línea de defensa, no como la única.
Error: probar solo el caso secuencial. La condición de carrera de redelivery concurrente es la que realmente provoca incidentes, y un test secuencial de “llamarla dos veces, una tras otra” no detecta un lock mal implementado. Solución: escribir al menos un test que dispare la tarea de forma concurrente con la misma clave.
La idempotencia no es una funcionalidad que se le añade a una tarea después de que ha causado un incidente — es una restricción de diseño que se lleva a cada tarea desde el principio, igual que se asume que una llamada de red puede expirar. La entrega at-least-once no va a desaparecer, y no es una limitación específica de Celery — es lo que promete toda cola de tareas duradera dentro del panorama de colas de tareas en Python, porque la alternativa (exactly-once garantizado) simplemente no es alcanzable a través de un límite de red sin costes de coordinación que nadie quiere pagar. Diseña para los duplicados desde el primer día, y el ticket del doble cobro a las 2 de la madrugada se convierte en algo intrascendente en lugar de un incidente.

