Upserts idempotentes y la ilusión de exactly-once
La entrega exactly-once de mensajes es una mentira en la que todos acuerdan fingir. Los sistemas honestos asumen entrega at-least-once y usan upserts idempotentes en Postgres, claves de dedup y restricciones unique para hacer los duplicados seguros.
El webhook se disparó dos veces. Stripe reintenta en timeout; el handler no tenía clave de idempotencia. Dos filas de suscripción, dos emails de bienvenida, reconciliación financiera desfasada en un MRR. El ingeniero dijo que la cola prometía exactly-once. La cola mintió — o más bien, hizo marketing. Los upserts idempotentes en Postgres y las tablas de dedup son cómo los sistemas en producción sobreviven la verdad: las redes reintentan, los consumidores crashean a mitad del handler, y at-least-once es la única garantía de entrega por la que vale la pena diseñar.
El procesamiento exactly-once requiere entrega exactly-once más handlers idempotentes más transactional outbox más infraestructura cooperativa — un stack que la mayoría de equipos implementa parcialmente y asume por completo. Los upserts idempotentes colapsan el problema a: si este mensaje llegó antes, el estado de la base no cambia; si no, se aplica una vez.
Garantías de entrega en la práctica
| Guarantee | Reality |
|---|---|
| At-most-once | Los mensajes pueden perderse — elección rara |
| At-least-once | Los mensajes pueden duplicarse — default para colas, webhooks, Kafka |
| Exactly-once | Requiere consumidores idempotentes + dedup + a menudo semántica transaccional — "efectivamente una vez" |
Diseñar para at-least-once. Hacer los efectos idempotentes. Medir duplicados en métricas — si son cero para siempre, la observabilidad puede estar mal.
Exactly-once es un resultado que se construye, no un checkbox en el broker.
Claves de idempotencia en la capa de aplicación
El cliente o mensaje lleva clave estable:
Idempotency-Key: stripe_evt_1A2b3C...
Lógica del handler:
- Begin transaction
INSERT INTO processed_events (idempotency_key) VALUES ($1) ON CONFLICT DO NOTHING RETURNING id- Si no devuelve fila → duplicado → commit vacío, return 200
- Else aplicar lógica de negocio + commit
Esquema de tabla:
CREATE TABLE processed_events (
idempotency_key TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
payload_hash TEXT
);Política de retención: mantener claves el tiempo suficiente para cubrir la ventana de reintento — Stripe reintenta por días; colas internas pueden necesitar 7–30 días. Particionar o TTL de claves viejas.
Patrones de upsert en Postgres para aplicar estado
INSERT ... ON CONFLICT DO UPDATE — aplicar último estado de forma idempotente:
INSERT INTO subscription_status (stripe_subscription_id, status, updated_at)
VALUES ($1, $2, now())
ON CONFLICT (stripe_subscription_id) DO UPDATE
SET status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at
WHERE subscription_status.updated_at < EXCLUDED.updated_at;La cláusula WHERE en update evita que reintentos obsoletos sobrescriban estado más nuevo — versionado monótono o comparación de updated_at es crítico para entrega fuera de orden.
ON CONFLICT DO NOTHING — gana la primera escritura; duplicados ignorados — bueno para semántica create-once.
Las restricciones unique definen el alcance de idempotencia
- Una fila por ID de evento externo
- Una fila por (user_id, operation_type, business_key)
- Índices unique parciales para patrones soft-delete
El diseño de índices intersecta con patrones de índices compuestos — las búsquedas de idempotencia son point queries en columnas clave; primary key o unique index requerido.
Webhooks, outbox y doble publicación
Webhooks entrantes: clave de idempotencia del ID de evento del proveedor.
Eventos salientes: patrón transactional outbox — escribir fila de negocio y fila outbox en la misma transacción; el publisher lee outbox. Duplicados del publisher posibles — los consumidores siguen siendo idempotentes.
BEGIN;
UPDATE orders SET status = 'paid' WHERE id = $1;
INSERT INTO outbox (event_id, type, payload) VALUES ($1, 'OrderPaid', ...);
COMMIT;
El consumidor que procesa OrderPaid usa el mismo event_id en la tabla dedup processed_events.
Ilusiones exactly-once que causan incidentes
"Nuestro SDK maneja idempotencia." Verificar qué deduplica — a menudo solo capa HTTP, no la fila de negocio.
Upsert sin guarda monótona. Segundo reintento con payload más viejo sobrescribe estado de pago más nuevo.
Colisión de clave de idempotencia. Reutilizar claves entre operaciones distintas — las claves deben acotar operación + entidad.
Transacciones largas reteniendo locks. Insert de idempotencia más lógica de negocio lenta en una transacción — mantener el check de dedup rápido, lógica de negocio optimizada.
Borrar filas dedup demasiado agresivo. Reintento tras ventana TTL crea efectos duplicados.
Probar idempotencia antes de que producción lo demuestre
Los bugs de idempotencia son invisibles hasta la entrega duplicada — probar deliberadamente:
Test de integración double-submit. Enviar el mismo payload de webhook dos veces; afirmar una fila, un efecto secundario, segunda respuesta 200 sin job de email duplicado encolado.
Simulación de crash. Matar consumidor tras escritura de negocio pero antes del ack — redeliver mensaje; estado sin cambios, sin duplicado.
Entrega fuera de orden. Aplicar evento más viejo tras más nuevo; guarda monótona de updated_at preserva último estado.
Auditoría de colisión de claves. Revisar construcción de clave de idempotencia — asegurar que operaciones distintas sobre la misma entidad usen namespaces de clave diferentes.
Load test de tasa de insert en tabla dedup — claves de idempotencia calientes bajo entrega concurrente no deberían convertirse en cuello de botella de contención de locks. Particionar processed_events por semana si la tasa de insert supera el throughput cómodo de tabla única.
Fallo parcial: cuando la idempotencia no basta
La idempotencia previene aplicación duplicada exitosa. Los fallos parciales necesitan patrones complementarios:
- Efectos en dos fases. Reservar con fila
pendingidempotente; confirmar en segundo paso — primer paso duplicado inofensivo. - Acciones compensatorias. Cobro idempotente; reembolso también idempotente con clave separada — el pensamiento de rollback aplica a flujos message-driven.
- Patrón inbox. Almacenar mensaje crudo de forma idempotente; procesar en transacción separada — replay de procesamiento sin re-fetch desde cola.
Crash del handler tras llamada a API externa pero antes del commit dedup es la ventana clásica — el insert dedup debe estar en la misma transacción que el cambio de estado de negocio, o las llamadas externas deben ser idempotentes en el proveedor (claves de idempotencia Stripe en API, no solo webhook).
Cómo deben implementar los equipos handlers idempotentes en Postgres
Checklist de implementación para consumidores de mensajes y webhooks.
¿De dónde deben venir las claves de idempotencia?
ID de evento del proveedor externo para webhooks; UUID generado por cliente para APIs; hash de (operación, entidad, versión) para jobs internos. Las claves deben ser estables entre reintentos de la misma operación lógica.
¿Basta una tabla dedup?
A menudo una processed_events más restricciones unique específicas de dominio. Sistemas de alto volumen particionan por fecha. Tablas separadas por consumer group si se necesita aislamiento.
¿Cómo se relaciona esto con saga y compensación?
La idempotencia previene efectos forward duplicados. Las sagas manejan fallo parcial entre servicios — complementarios. Las transacciones compensatorias también deben ser idempotentes — reembolsar dos veces es el mismo modo de fallo que cobrar dos veces.
A common argument runs the other way
Un argumento frecuente sostiene que el overhead de idempotencia es innecesario si los consumidores son cuidadosos y los reintentos son raros.
Los reintentos no son raros — están garantizados bajo at-least-once. Código "cuidadoso" sin dedup falla al crashear entre efecto secundario y ack — la ventana clásica de duplicado. El costo de idempotencia es un insert indexado por mensaje; el costo de incidente por duplicado es confianza del cliente y horas de finanzas.
Key takeaways
- Diseñar para entrega at-least-once; exactly-once se construye vía efectos idempotentes.
- Tabla dedup con primary key de clave de idempotencia — insert en la misma transacción que la escritura de negocio.
- Los upserts necesitan guardas monótonas — no dejar que reintentos obsoletos sobrescriban estado más nuevo.
- Probar con double-submit, simulación de crash y entrega fuera de orden antes de producción.
- Los fallos parciales necesitan patrón inbox o acciones compensatorias idempotentes — dedup solo no basta.
- Los handlers de webhook usan ID de evento del proveedor como clave; retener claves durante la ventana de reintento.
- Transactional outbox para eventos salientes; los consumidores siguen deduplicando.
Conclusion
La entrega exactly-once es una historia reconfortante que los brokers cuentan y los ingenieros repiten hasta que webhooks duplicados golpean producción. Los upserts idempotentes en Postgres hacen los duplicados visibles e inofensivos — una clave, un efecto, reintentos bienvenidos.
Auditar los handlers de webhook y cola de mayor volumen: ¿hay un insert de clave dedup en la misma transacción que la escritura de negocio? Insert faltante es idempotencia faltante — arreglar antes de la próxima tormenta de reintentos.