Cuando algo falla a mitad
Una deuda pendiente
En el episodio 1, Ana recibió un error, pulsó «Reintentar» y Luis cobró dos veces. Lo dejamos sin resolver. Hoy toca pagarlo, y no viene solo.
La transferencia a otro banco del episodio 3 tiene varios pasos, y entre paso y paso pueden pasar cosas. Una red que pierde una respuesta. Un servicio que se reinicia en mal momento. Un broker que, por asegurarse, entrega el mismo evento dos veces. Ninguna es rara: en un sistema con suficiente tráfico, pasan todos los días.
Lo peligroso no es que algo falle, sino que falle a mitad: cuando unos pasos ya se han hecho y otros no. Este episodio presenta tres piezas que, juntas, hacen que una operación termine bien o termine deshecha, pero nunca a medias ni repetida: la saga, el outbox y la idempotencia.
Cinco formas de fallar
A la izquierda, la transferencia sin protecciones: cargar, enviar, publicar. A la derecha, con las tres piezas. Marca uno o varios fallos y pulsa Enviar. Cada envío empieza desde cero, con 1.000 € en la cuenta de Ana, y al terminar el recuadro Resultado marca en verde lo que ha quedado como debía y en rojo lo que no.
Qué ha pasado
Saga: reservar primero, compensar si hace falta
Sin protecciones, Transferencias carga los 100 € a Ana y luego los envía. Si el banco de Luis rechaza, el dinero ya ha salido y el código no tiene ningún camino para devolverlo.probar en el laboratorio No se puede hacer un rollback: el cargo se hizo en otra transacción, en otro servicio, hace un rato.
Una saga es una operación larga partida en pasos locales, cada uno con su propia transacción, donde cada paso tiene una compensación: otro paso que deshace su efecto. Si algo falla más adelante, se ejecutan las compensaciones de lo que ya se hizo, en orden inverso.
| Paso | Compensación |
|---|---|
| Retener 100 € en la cuenta de Ana | Liberar la retención |
| Enviar al banco de Luis | — (si lo acepta, ya no hay vuelta atrás) |
| Convertir la retención en cargo | — (solo se hace si el banco aceptó) |
Fíjate en dos detalles. Primero, el orden: se retiene antes de enviar y se carga después de saber la respuesta. La retención es una reserva: el dinero sigue en la cuenta de Ana pero no puede gastarlo. Si hay que compensar, liberar una retención es limpio; devolver un cargo deja dos movimientos en el extracto y un rato en el que Ana ve menos dinero del que tiene.
Segundo, compensar no es borrar. Es una acción nueva, que deja rastro. Igual que en el libro de movimientos de Cuentas un error no se borra sino que se corrige con otro movimiento. Y hay pasos que no se pueden compensar: una vez que el banco de Luis ha abonado el dinero, ya no se puede «desenviar». Por eso los pasos irreversibles van lo más tarde posible.
Outbox: el evento no puede quedarse en memoria
Ahora tira Transferencias justo después de guardar el resultado.probar en el laboratorio Sin protecciones, la base de datos dice «COMPLETADA», pero TransferenciaCompletada nunca se publica: Ana no recibe el aviso y Reporting no anota nada. No hay ningún error en ningún sitio; simplemente falta algo.
Es el problema de la doble escritura: hay que escribir en dos sitios (la base de datos y el broker) y no hay una transacción que abarque los dos. Cambiar el orden no ayuda. Si publicas antes de guardar y te caes entre medias, has anunciado una transferencia que tu base de datos no conoce.
El outbox lo resuelve convirtiendo dos escrituras en una. Transferencias guarda el resultado y el evento en su propia base de datos, en una tabla outbox, en la misma transacción: o se guardan los dos o ninguno. Después, un proceso aparte (el relay) lee el outbox, publica en el broker y, cuando el broker confirma, marca la fila como enviada. Si Transferencias se cae, el evento sigue en la tabla y el relay lo publica al volver.
El relay también se puede caer: justo después de publicar y antes de marcar la fila. Al volver, la publicará otra vez. El outbox garantiza que el evento sale al menos una vez, no exactamente una. Por eso hace falta la tercera pieza.
Idempotencia: que repetir no cambie nada
Una operación es idempotente si hacerla dos veces tiene el mismo efecto que hacerla una. «Poner el saldo a 900 €» lo es; «restar 100 €» no. En un sistema distribuido todo se acaba repitiendo, así que la pregunta no es cómo evitar las repeticiones, sino cómo hacer que no importen. En la simulación hay tres sitios donde se repite algo:
- Ana reintenta.probar en el laboratorio La app genera una clave de idempotencia (K1) cuando Ana pulsa «Enviar» y la reutiliza si pulsa «Reintentar». Transferencias guarda cada clave con su respuesta; si le llega una que ya conoce, devuelve la misma respuesta sin hacer nada más. Este es el arreglo del duplicado del episodio 1.
- Transferencias reintenta el envío.probar en el laboratorio Tras un timeout no sabe si el banco recibió el envío (el apoyo sobre timeouts del episodio 1 ya avisaba). Si lo manda con un id estable (T-1), el banco de Luis puede reconocerlo y contestar «ya lo tengo». Esto depende del otro lado: el receptor tiene que guardar los ids que ha visto.
- El broker entrega dos veces.probar en el laboratorio Cada consumidor apunta el id de los eventos que ya ha procesado, en la misma transacción que su efecto (el aviso, la línea del extracto). Si le llega uno repetido, lo ignora.
Las tres son la misma idea: un identificador estable por intento y alguien que recuerda cuáles ha visto. Lo que cambia es quién pone el id y quién lo recuerda.
Las tres juntas
Marca «Todo a la vez».probar en el laboratorio Sin protecciones, Luis recibe 300 €, Ana pierde 200 y recibe un aviso de más. Con las tres piezas, el resultado es el mismo que si nada hubiera fallado. Se necesitan entre sí: la saga sin outbox pierde pasos, el outbox sin idempotencia duplica eventos, y la idempotencia sin saga no deshace nada.
Lo que cuestan
- Más estado. Una tabla de claves de idempotencia (¿cuánto tiempo guardarlas? Un día suele bastar para reintentos de un cliente), un outbox, una tabla de eventos procesados en cada consumidor, y estados intermedios en la transferencia.
- Más piezas que operar. El relay es un proceso más que vigilar. Si se atasca, los eventos se acumulan en el outbox sin que nadie los publique.
- Compensaciones de verdad. Hay que diseñarlas y probarlas como cualquier otro camino, y algunas no existen: un SMS enviado no se puede «desenviar»; como mucho, se manda otro que corrige.
- Estados intermedios visibles. Mientras dura la saga, Ana ve 100 € retenidos. Es honesto, pero hay que explicárselo en la app.
Criterio
No hace falta todo en todas partes. Busca en tu operación qué puede fallar y pon la pieza que toca:
| Si en tu operación… | Pon | En BCPS Bank |
|---|---|---|
| Hay varios pasos en servicios distintos y alguno puede fallar después de otro | Saga, con una compensación por paso y los pasos irreversibles al final | Retener → enviar → cargar o liberar |
| Guardas algo y tienes que publicar un evento sobre ello | Outbox | Transferencias guarda el resultado y TransferenciaCompletada juntos |
| Un cliente puede reintentar una petición que cambia algo | Clave de idempotencia en la petición | La app de Ana manda K1 en cada intento |
| Reintentas llamadas a otro sistema tras un timeout | Un id estable por operación, y que el otro lo respete | El envío T-1 al banco de Luis |
| Consumes eventos (de cualquier broker) | Consumidor idempotente: recordar lo procesado | Notificaciones y Reporting |
Y dos reglas que salen de todo lo anterior:
- Da por hecho que todo mensaje puede llegar dos veces. Ningún broker ni ninguna red te libra de eso. Lo que se vende como «exactamente una vez» es, casi siempre, «al menos una vez» más idempotencia.
- La transferencia interna del episodio 1 no necesita saga. Cuentas mueve el dinero en una sola transacción de base de datos, y eso sigue siendo lo mejor cuando se puede. La saga es para cuando no se puede, no un sustituto de las transacciones.
En el mundo real
La palabra saga viene de un artículo de Hector Garcia-Molina y Kenneth Salem de 1987, pensado para transacciones largas dentro de una misma base de datos. Chris Richardson la popularizó para microservicios; en su web (microservices.io) están descritos también el outbox y el consumidor idempotente.
Las claves de idempotencia se hicieron conocidas con la API de Stripe, que acepta una cabecera Idempotency-Key en cada petición que cambia algo y guarda la respuesta 24 horas. Hay un borrador del IETF para estandarizar esa cabecera, y muchas APIs de pagos la siguen.
Para el relay del outbox hay dos formas habituales: consultar la tabla cada poco tiempo, o leer el registro de cambios de la base de datos (change data capture). Debezium hace lo segundo y trae un componente específico para outbox. Kafka tiene productores idempotentes y transacciones que dan «exactamente una vez» dentro de Kafka; en cuanto el efecto sale de Kafka (un aviso, un abono en otro banco), vuelves a necesitar idempotencia en el consumidor.
Y los sistemas de pagos entre bancos llevan un identificador de extremo a extremo en cada operación precisamente para lo que hace T-1 en la simulación: que un reintento no se convierta en dos pagos.
Siguiente episodio
En toda la serie, el broker ha sido una caja genérica que guarda mensajes hasta que alguien los recoge. Pero no todos los brokers hacen lo mismo con un mensaje una vez entregado: unos lo borran y otros lo guardan. La diferencia parece un detalle de implementación hasta que Reporting descubre un error en el extracto de marzo.
Siguiente · Episodio 5 Log vs cola Reporting necesita reprocesar los movimientos de todo el mes. ¿Siguen en algún sitio? Y qué significa de verdad «al menos una vez».