Patrón Saga en Transacciones Distribuidas - Con Ejemplos en Go

Transacciones en microservicios con el patrón Saga

Índice

El patrón Saga proporciona una solución elegante al dividir las transacciones distribuidas en una serie de transacciones locales con acciones compensatorias.

En lugar de depender de bloqueos distribuidos que pueden bloquear operaciones entre servicios, Saga permite la consistencia eventual a través de una secuencia de pasos reversibles, lo que lo hace ideal para procesos comerciales de larga ejecución.

En las arquitecturas de microservicios, mantener la consistencia de los datos entre servicios es uno de los problemas más desafiantes. Las transacciones ACID tradicionales no funcionan cuando las operaciones abarcan múltiples servicios con bases de datos independientes, dejando a los desarrolladores buscando enfoques alternativos para garantizar la integridad de los datos.

Esta guía demuestra la implementación del patrón Saga en Go con ejemplos prácticos que cubren tanto los enfoques de orquestación como de coreografía. Si necesita una referencia rápida sobre los fundamentos de Go, la Hoja de trucos de Go proporciona un panorama útil.

trabajador de la construcción con transacciones distribuidas Esta bonita imagen es generada por el modelo de IA Flux 1 dev.

Comprensión del Patrón Saga

El patrón Saga fue descrito originalmente por Hector Garcia-Molina y Kenneth Salem en 1987. En el contexto de los microservicios, es una secuencia de transacciones locales donde cada transacción actualiza los datos dentro de un solo servicio. Si algún paso falla, se ejecutan transacciones compensatorias para deshacer los efectos de los pasos anteriores.

A diferencia de las transacciones distribuidas tradicionales que utilizan el compromiso en dos fases (2PC), Saga no mantiene bloqueos entre servicios, lo que lo hace adecuado para procesos comerciales de larga ejecución. La compensación es la consistencia eventual en lugar de la consistencia fuerte.

Características Clave

  • Sin Bloqueos Distribuidos: Cada servicio gestiona su propia transacción local
  • Acciones Compensatorias: Cada operación tiene un mecanismo de compensación correspondiente
  • Consistencia Eventual: El sistema eventualmente alcanza un estado consistente
  • Larga Ejecución: Adecuado para procesos que toman segundos, minutos o incluso horas

Enfoques de Implementación de Saga

Existen dos enfoques principales para implementar el patrón Saga: orquestación y coreografía.

Patrón de Orquestación

En la orquestación, un coordinador central (orquestador) gestiona todo el flujo de la transacción. El orquestador es responsable de:

  • Invocar servicios en el orden correcto
  • Manejar fallos y desencadenar compensaciones
  • Mantener el estado de la saga
  • Coordinar reintentos y tiempos de espera

Ventajas:

  • Control y visibilidad centralizados
  • Más fácil de entender y depurar
  • Mejor manejo de errores y recuperación
  • Pruebas más simples del flujo general

Desventajas:

  • Punto único de fallo (aunque esto puede mitigarse)
  • Servicio adicional para mantener
  • Puede convertirse en un cuello de botella para flujos complejos

Ejemplo en Go:

type OrderSagaOrchestrator struct {
    orderService    OrderService
    paymentService  PaymentService
    inventoryService InventoryService
    shippingService ShippingService
}

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    sagaID := generateSagaID()
    
    // Paso 1: Crear pedido
    orderID, err := o.orderService.Create(order)
    if err != nil {
        return err
    }
    
    // Paso 2: Reservar inventario
    if err := o.inventoryService.Reserve(order.Items); err != nil {
        o.orderService.Cancel(orderID) // Compensar
        return err
    }
    
    // Paso 3: Procesar pago
    paymentID, err := o.paymentService.Charge(order.CustomerID, order.Total)
    if err != nil {
        o.inventoryService.Release(order.Items) // Compensar
        o.orderService.Cancel(orderID)          // Compensar
        return err
    }
    
    // Paso 4: Crear envío
    if err := o.shippingService.CreateShipment(orderID); err != nil {
        o.paymentService.Refund(paymentID)      // Compensar
        o.inventoryService.Release(order.Items) // Compensar
        o.orderService.Cancel(orderID)          // Compensar
        return err
    }
    
    return nil
}

Patrón de Coreografía

En la coreografía, no hay un coordinador central. Cada servicio sabe qué hacer y se comunica a través de eventos. Los servicios escuchan los eventos y reaccionan en consecuencia. Este enfoque orientado a eventos es particularmente poderoso cuando se combina con plataformas de transmisión de mensajes como AWS Kinesis, que proporcionan infraestructura escalable para la distribución de eventos entre microservicios. Para una guía completa sobre la implementación de microservicios orientados a eventos con Kinesis, consulte Construcción de Microservicios Orientados a Eventos con AWS Kinesis.

Ventajas:

  • Descentralizado y escalable
  • Sin punto único de fallo
  • Los servicios permanecen débilmente acoplados
  • Ajuste natural para arquitecturas orientadas a eventos

Desventajas:

  • Más difícil de entender el flujo general
  • Difícil de depurar y rastrear
  • Manejo de errores complejo
  • Riesgo de dependencias cíclicas

Ejemplo con Arquitectura Orientada a Eventos:

// Servicio de Pedidos
type OrderService struct {
    eventBus EventBus
    repo     OrderRepository
}

func (s *OrderService) CreateOrder(order Order) (string, error) {
    orderID, err := s.repo.Save(order)
    if err != nil {
        return "", err
    }
    
    s.eventBus.Publish("OrderCreated", OrderCreatedEvent{
        OrderID:    orderID,
        CustomerID: order.CustomerID,
        Items:      order.Items,
        Total:      order.Total,
    })
    
    return orderID, nil
}

// Nota: s.repo.Save seguido de s.eventBus.Publish es una escritura dual.
// En producción, reemplace esto con el patrón de caja de salida transaccional para que
// el evento se escriba de forma atómica con la fila del pedido y sea publicado por un retransmisor.

func (s *OrderService) HandlePaymentFailed(event PaymentFailedEvent) error {
    return s.repo.Cancel(event.OrderID) // Compensación
}

// Servicio de Pagos
type PaymentService struct {
    eventBus EventBus
    client   PaymentClient
}

func (s *PaymentService) HandleOrderCreated(event OrderCreatedEvent) {
    paymentID, err := s.client.Charge(event.CustomerID, event.Total)
    if err != nil {
        s.eventBus.Publish("PaymentFailed", PaymentFailedEvent{
            OrderID: event.OrderID,
        })
        return
    }
    
    s.eventBus.Publish("PaymentSucceeded", PaymentSucceededEvent{
        OrderID:   event.OrderID,
        PaymentID: paymentID,
    })
}

func (s *PaymentService) HandleInventoryReservationFailed(event InventoryReservationFailedEvent) error {
    // Compensación: reembolsar pago
    return s.client.Refund(event.PaymentID)
}

Estrategias de Compensación

La compensación es el corazón del patrón Saga. Cada operación debe tener una compensación correspondiente que pueda revertir sus efectos.

Tipos de Compensación

  1. Operaciones Reversibles: Operaciones que pueden deshacerse directamente

    • Ejemplo: Liberar inventario reservado, reembolsar pagos
  2. Acciones Compensatorias: Operaciones diferentes que logran el efecto inverso

    • Ejemplo: Cancelar un pedido en lugar de eliminarlo
  3. Compensación Pesimista: Preasignar recursos que pueden liberarse

    • Ejemplo: Reservar inventario antes de cobrar el pago
  4. Compensación Optimista: Ejecutar operaciones y compensar si es necesario

    • Ejemplo: Cobrar el pago primero, reembolsar si el inventario no está disponible

Requisitos de Idempotencia

Todas las operaciones y compensaciones deben ser idempotentes. Esto garantiza que volver a intentar una operación fallida no cause efectos duplicados. Igualmente importante es asegurarse de que cada participante de la saga publique sus eventos de manera confiable después de un compromiso local; el patrón de caja de salida transaccional es la forma estándar de cerrar esa brecha entre una escritura en la base de datos y una publicación en el broker.

func (s *PaymentService) Refund(paymentID string) error {
    // Verificar si ya fue reembolsado
    payment, err := s.getPayment(paymentID)
    if err != nil {
        return err
    }
    
    if payment.Status == "refunded" {
        return nil // Ya reembolsado, idempotente
    }
    
    // Procesar reembolso
    return s.processRefund(paymentID)
}

Mejores Prácticas

1. Gestión del Estado de la Saga

Mantenga el estado de cada instancia de saga para rastrear el progreso y habilitar la recuperación. Al persistir el estado de la saga en una base de datos, elegir el ORM correcto es crucial para el rendimiento y la mantenibilidad. Para implementaciones basadas en PostgreSQL, considere la comparación en Comparando ORMs de Go para PostgreSQL: GORM vs Ent vs Bun vs sqlc para seleccionar la mejor opción para sus necesidades de almacenamiento de estado de saga:

type SagaState struct {
    ID           string
    Status       SagaStatus
    Steps        []SagaStep
    CurrentStep  int
    CreatedAt    time.Time
    UpdatedAt    time.Time
}

type SagaStep struct {
    Service     string
    Operation   string
    Status      StepStatus
    Compensated bool
    Data        map[string]interface{}
}

2. Manejo de Tiempos de Espera

Implemente tiempos de espera para cada paso para evitar que las sagas se queden colgadas indefinidamente:

type SagaOrchestrator struct {
    timeout time.Duration
}

func (o *SagaOrchestrator) ExecuteWithTimeout(step SagaStep) error {
    ctx, cancel := context.WithTimeout(context.Background(), o.timeout)
    defer cancel()
    
    done := make(chan error, 1)
    go func() {
        done <- step.Execute()
    }()
    
    select {
    case err := <-done:
        return err
    case <-ctx.Done():
        // Se produjo un tiempo de espera, compensar
        if err := step.Compensate(); err != nil {
            return fmt.Errorf("compensación fallida: %w", err)
        }
        return fmt.Errorf("paso %s agotado después de %v", step.Name(), o.timeout)
    }
}

3. Lógica de Reintento

Implemente retroceso exponencial para fallos transitorios:

func retryWithBackoff(operation func() error, maxRetries int) error {
    backoff := time.Second
    for i := 0; i < maxRetries; i++ {
        err := operation()
        if err == nil {
            return nil
        }
        
        if !isTransientError(err) {
            return err
        }
        
        time.Sleep(backoff)
        backoff *= 2
    }
    return fmt.Errorf("operación fallida después de %d reintentos", maxRetries)
}

4. Origen de Eventos para el Estado de la Saga

Utilice el origen de eventos para mantener un registro de auditoría completo. Al implementar tiendas de eventos y mecanismos de reproducción, los genéricos de Go pueden ayudar a crear código de manejo de eventos seguro en cuanto al tipo y reutilizable. Para patrones avanzados usando genéricos en Go, consulte Genéricos de Go: Casos de Uso y Patrones.

type SagaEvent struct {
    SagaID    string
    EventType string
    Payload   []byte
    Timestamp time.Time
    Version   int64
}

type SagaEventStore struct {
    store EventRepository
}

func (s *SagaEventStore) AppendEvent(sagaID string, eventType string, payload interface{}) error {
    data, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("error al serializar la carga útil: %w", err)
    }
    
    version, err := s.store.GetNextVersion(sagaID)
    if err != nil {
        return fmt.Errorf("error al obtener la versión: %w", err)
    }
    
    event := SagaEvent{
        SagaID:    sagaID,
        EventType: eventType,
        Payload:   data,
        Timestamp: time.Now(),
        Version:   version,
    }
    
    return s.store.Save(event)
}

func (s *SagaEventStore) ReplaySaga(sagaID string) (*Saga, error) {
    events, err := s.store.GetEvents(sagaID)
    if err != nil {
        return nil, fmt.Errorf("error al obtener eventos: %w", err)
    }
    
    saga := NewSaga()
    for _, event := range events {
        if err := saga.Apply(event); err != nil {
            return nil, fmt.Errorf("error al aplicar evento: %w", err)
        }
    }
    
    return saga, nil
}

5. Monitoreo y Observabilidad

Implemente registro y trazabilidad exhaustivos:

func (o *OrderSagaOrchestrator) CreateOrder(order Order) error {
    span := tracer.StartSpan("saga.create_order")
    defer span.Finish()
    
    span.SetTag("saga.id", sagaID)
    span.SetTag("order.id", order.ID)
    
    logger.WithFields(log.Fields{
        "saga_id": sagaID,
        "order_id": order.ID,
        "step": "create_order",
    }).Info("Saga iniciada")
    
    // ... ejecución de saga
    
    return nil
}

Patrones Comunes y Anti-Patrones

Patrones a Seguir

  • Patrón de Coordinador de Saga: Utilice un servicio dedicado para la orquestación
  • Patrón de Caja de Salida: Garantice la publicación confiable de eventos
  • Claves de Idempotencia: Utilice claves únicas para todas las operaciones
  • Máquina de Estados de Saga: Modele la saga como una máquina de estados

Anti-Patrones a Evitar

  • Compensación Sincrónica: No espere a que la compensación se complete
  • Sagas Anidadas: Evite que las sagas llamen a otras sagas (use sub-sagas en su lugar)
  • Estado Compartido: No comparta estado entre pasos de la saga
  • Pasos de Larga Ejecución: Divida los pasos que toman demasiado tiempo

Herramientas y Marcos de Trabajo

Varios marcos de trabajo pueden ayudar a implementar patrones Saga:

  • Temporal: Plataforma de orquestación de flujos de trabajo con soporte integrado para Saga
  • Zeebe: Motor de flujos de trabajo para orquestación de microservicios
  • Eventuate Tram: Marco de trabajo Saga para Spring Boot
  • AWS Step Functions: Orquestación de flujos de trabajo sin servidor
  • Apache Camel: Marco de trabajo de integración con soporte para Saga

Para servicios orquestadores que necesitan interfaces de línea de comandos para gestión y monitoreo, Construcción de Aplicaciones de CLI en Go con Cobra & Viper proporciona patrones excelentes para crear herramientas de línea de comandos para interactuar con orquestadores de saga.

Al desplegar microservicios basados en saga en Kubernetes, implementar una malla de servicios puede mejorar significativamente la observabilidad, la seguridad y la gestión del tráfico. Implementación de Malla de Servicios con Istio y Linkerd cubre cómo las mallas de servicios complementan los patrones de transacciones distribuidas al proporcionar preocupaciones transversales como trazabilidad distribuida y circuit breaking.

Cuándo Usar el Patrón Saga

Utilice el patrón Saga cuando:

  • ✅ Las operaciones abarcan múltiples microservicios
  • ✅ Procesos comerciales de larga ejecución
  • ✅ La consistencia eventual es aceptable
  • ✅ Necesita evitar bloqueos distribuidos
  • ✅ Los servicios tienen bases de datos independientes

Evitar cuando:

  • ❌ Se requiere consistencia fuerte
  • ❌ Las operaciones son simples y rápidas
  • ❌ Todos los servicios comparten la misma base de datos
  • ❌ La lógica de compensación es demasiado compleja

Conclusión

El patrón Saga es esencial para gestionar transacciones distribuidas en arquitecturas de microservicios. Aunque introduce complejidad, proporciona una solución práctica para mantener la consistencia de los datos a través de los límites de los servicios. Elija orquestación para un mejor control y visibilidad, o coreografía para escalabilidad y acoplamiento débil. Asegúrese siempre de que las operaciones sean idempotentes, implemente una lógica de compensación adecuada y mantenga una observabilidad exhaustiva.

La clave para una implementación exitosa de Saga es comprender sus requisitos de consistencia, diseñar cuidadosamente la lógica de compensación y elegir el enfoque correcto para su caso de uso. Con una implementación adecuada, Saga le permite construir microservicios resilientes y escalables que mantienen la integridad de los datos a través de sistemas distribuidos.

Enlaces Útiles

Suscribirse

Recibe nuevas publicaciones sobre sistemas, infraestructura e ingeniería de IA.