Consistencia de datos en microservicios

Gestionar la integridad de los datos en una arquitectura distribuida es uno de los mayores retos técnicos. Cuando una operación afecta a múltiples servicios, las transacciones ACID tradicionales no funcionan porque dependen de bloqueos que destruyen la escalabilidad y crean acoplamiento temporal.

En este artículo aprenderás a implementar una solución profesional combinando el patrón Saga para la lógica de negocio distribuida y el patrón Transactional Outbox para garantizar que los eventos nunca se pierdan.

En resumen — La combinación de Saga y Outbox permite ejecutar transacciones largas divididas en pasos locales, asegurando que cada cambio de estado en la base de datos se publique de forma atómica en el bus de mensajería mediante una tabla auxiliar.

1. Qué son Saga y Outbox

💡 Analogía: Imagina que pides una pizza por una app. Primero se valida el pago, luego la cocina acepta el pedido y finalmente el repartidor sale. Si la cocina no tiene ingredientes, la app debe devolverte el dinero automáticamente. Cada paso es una transacción local y la devolución es la "transacción compensatoria".

El Patrón Saga gestiona transacciones distribuidas como una secuencia de transacciones locales. Cada servicio actualiza su propia base de datos y publica un evento. Si un paso falla, la Saga ejecuta transacciones compensatorias para deshacer los cambios anteriores, manteniendo la consistencia eventual. La versión estable actual de los frameworks que implementan esto (como Eventuate o Temporal) se centra en la resiliencia ante fallos de red.

El Patrón Outbox resuelve la fiabilidad del envío de mensajes. En lugar de enviar un mensaje directamente al broker (como Kafka o RabbitMQ) después de guardar en la DB, guardas el mensaje en una tabla `outbox` dentro de la misma transacción de la DB. Un proceso separado (Relay) lee esa tabla y publica los mensajes. Esto garantiza la entrega "al menos una vez" (at-least-once delivery).

2. El problema del Dual Write

El escenario de fallo más común ocurre cuando intentas hacer dos cosas a la vez: actualizar tu base de datos y enviar un evento a un broker. Si la base de datos confirma el cambio pero el broker falla, el resto del sistema nunca se enterará de la actualización, creando una inconsistencia permanente.

Este problema se agrava en sistemas de alta carga. Si usas bloqueos distribuidos (2PC - Two Phase Commit), el rendimiento cae drásticamente porque todos los servicios deben esperar al más lento. La combinación Saga + Outbox elimina la necesidad de bloqueos globales, permitiendo que cada microservicio escale de forma independiente mientras se garantiza que los datos eventualmente coincidirán en todo el ecosistema.

3. Implementación paso a paso

Para implementar esta solución, necesitas separar la escritura de la publicación del evento. Aquí te muestro el flujo técnico estándar.

Paso 1. Configuración de la tabla Outbox

Crea una tabla en cada base de datos de microservicio para almacenar los eventos pendientes de envío. Debe incluir el tipo de evento, el payload y el estado.

CREATE TABLE outbox_events (
    id UUID PRIMARY KEY,
    aggregate_id VARCHAR(255) NOT NULL,
    event_type VARCHAR(100) NOT NULL,
    payload JSONB NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    processed BOOLEAN DEFAULT FALSE
);

Paso 2. Transacción atómica local

Cuando procesas un comando (por ejemplo, "Crear Pedido"), insertas el pedido y el evento en la misma transacción SQL. Si uno falla, el otro también, manteniendo la consistencia local.

// Pseudocódigo de aplicación
public void createOrder(Order order) {
    transactionManager.runInTransaction(() => {
        orderRepository.save(order);
        OutboxEvent event = new OutboxEvent(order.getId(), "ORDER_CREATED", order.toJson());
        outboxRepository.save(event);
    });
}

Paso 3. Publicación mediante CDC o Polling

El componente Message Relay lee los eventos no procesados. Usar CDC (Change Data Capture) con herramientas como Debezium es más eficiente que hacer consultas periódicas (polling) porque lee directamente el log de transacciones de la base de datos.

// Configuración simplificada de Debezium (Kafka Connect)
{
    "name": "order-outbox-connector",
    "config": {
        "connector.class": "io.debezium.connector.postgresql.PostgresConnector",
        "table.include.list": "public.outbox_events",
        "transforms": "outbox",
        "transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter"
    }
}

4. Coreografía vs. Orquestación

Existen dos formas de gestionar el flujo de la Saga. La elección depende de la complejidad de tu lógica de negocio.

CriterioCoreografía (Eventos)Orquestación (Comando)
AcoplamientoBajo (Decentralizado)Centralizado en un gestor
VisibilidadDifícil de rastrearFácil de monitorizar
MantenibilidadCompleja en flujos largosMás sencilla de modificar
Punto de falloDistribuidoEl orquestador es crítico

Si tu proceso tiene más de 5 pasos o decisiones lógicas complejas, usa Orquestación. Para flujos simples y lineales, la Coreografía ofrece mayor agilidad.

5. Errores críticos y soluciones

⚠️ Error frecuente: Olvidar la idempotencia en los consumidores de eventos causa duplicación de datos al reintentar fallos.

Como el patrón Outbox garantiza la entrega "al menos una vez", es inevitable que algunos mensajes lleguen duplicados debido a reintentos de red. Si no manejas esto, podrías cobrar dos veces a un cliente o descontar stock de más.

Solución por mensaje de error

// Error: Unique constraint violation (Duplicate Event)
// Causa: El consumidor procesó el mensaje pero falló al confirmar el ACK.
// Solución: Usar un Idempotency Key (como el Order ID) para verificar si ya existe.

if (processedEvents.exists(event.getId())) {
    logger.info("Evento ya procesado, omitiendo...");
    return;
}
process(event);
processedEvents.add(event.getId());

6. Consejos de producción

Para sistemas que procesan más de 1000 transacciones por segundo, el polling de la tabla outbox puede saturar el disco. Usa CDC para reducir la latencia a menos de 50ms entre la escritura en la DB y la disponibilidad en el bus.

Implementa siempre una "Saga de Tiempo de Espera" (Timeout). Si un servicio externo no responde, la Saga debe ser capaz de disparar las compensaciones automáticamente tras un periodo definido para no dejar el sistema en un estado indeterminado.

📌 Puntos clave

  • Saga divide transacciones largas en pasos locales con compensaciones.
  • Outbox evita la pérdida de eventos mediante persistencia atómica local.
  • CDC es la forma más eficiente de extraer eventos de la tabla Outbox.

Preguntas frecuentes

Q. ¿Saga reemplaza por completo a 2PC (Two-Phase Commit)?

A. Sí, en microservicios modernos Saga es preferible por su escalabilidad y resiliencia.

Q. ¿Qué pasa si el Transactional Outbox Relay falla?

A. Al reiniciarse, leerá desde el último punto guardado (offset) y enviará los mensajes pendientes.

Q. ¿Cómo manejar fallos en la transacción compensatoria?

A. Deben reintentarse infinitamente o alertar a intervención manual; no pueden fallar definitivamente.

Post a Comment