Saga pattern – transakcje rozproszone

TL;DR: Saga pattern to wzorzec zarządzania transakcjami rozproszonymi w mikrousługach. Zamiast jednej wielkiej transakcji ACID, dzieli operację na mniejsze kroki z mechanizmami kompensacji. Istnieją dwa główne podejścia: Choreography (zdecentralizowane) i Orchestration (centralne zarządzanie).

Dlaczego Saga Pattern jest niezbędny w mikrousługach

W świecie mikrousług tradycyjne transakcje ACID stają się niemożliwe do zastosowania. Gdy operacja biznesowa wymaga zmian w wielu serwisach (każdy z własną bazą danych), nie możemy już polegać na dwufazowym commicie (2PC), który jest zbyt powolny i zawodny w środowisku rozproszonym.

Distributed transactions w mikrousługach to jeden z największych wyzwań architektury – Saga pattern oferuje eleganckie rozwiązanie tego problemu.

Problem ilustruje klasyczny przykład e-commerce: zamówienie wymaga weryfikacji płatności, rezerwacji towaru w magazynie i zaktualizowania konta klienta. W architekturze mikrousług to oznacza komunikację między trzema niezależnymi serwisami.

Co się nauczysz:

  • Jak implementować Saga pattern w Spring Boot aplikacjach
  • Różnice między Choreography i Orchestration approach
  • Praktyczne wzorce kompensacji i error handling
  • Performance i consistency trade-offs
  • Production-ready monitoring i debugging saga flows
  • Integration z Apache Kafka i Spring Cloud
Wymagania wstępne: Doświadczenie z mikrousługami, Spring Boot 2.x, messaging systems (RabbitMQ/Kafka), podstawy eventual consistency.

Dwa podejścia do implementacji Saga

Choreography-based Saga

W tym podejściu każdy serwis zna tylko swój następny krok. Komunikacja odbywa się przez events, a każdy serwis nasłuchuje określonych zdarzeń i odpowiada publikowaniem własnych.

@Service
@Component
public class OrderService {
    
    @Autowired
    private EventPublisher eventPublisher;
    
    @Autowired
    private OrderRepository orderRepository;
    
    public void createOrder(CreateOrderRequest request) {
        // 1. Tworzymy zamówienie w stanie PENDING
        Order order = new Order(request.getUserId(), 
                               request.getItems(), 
                               OrderStatus.PENDING);
        orderRepository.save(order);
        
        // 2. Publikujemy event do Payment Service
        PaymentRequested event = new PaymentRequested(
            order.getId(),
            order.getTotalAmount(),
            order.getUserId()
        );
        
        eventPublisher.publish("payment.requested", event);
    }
    
    @EventListener
    public void handlePaymentProcessed(PaymentProcessedEvent event) {
        Order order = orderRepository.findById(event.getOrderId());
        order.markPaymentCompleted();
        orderRepository.save(order);
        
        // Następny krok - rezerwacja w magazynie
        InventoryReservationRequested inventoryEvent = 
            new InventoryReservationRequested(
                order.getId(),
                order.getItems()
            );
        
        eventPublisher.publish("inventory.reservation.requested", inventoryEvent);
    }
    
    @EventListener 
    public void handleInventoryReserved(InventoryReservedEvent event) {
        Order order = orderRepository.findById(event.getOrderId());
        order.markInventoryReserved();
        order.setStatus(OrderStatus.CONFIRMED);
        orderRepository.save(order);
        
        // Saga completed successfully
        OrderConfirmedEvent confirmedEvent = new OrderConfirmedEvent(order.getId());
        eventPublisher.publish("order.confirmed", confirmedEvent);
    }
}

Orchestration-based Saga

Tutaj jeden centralny komponent (orchestrator) zarządza całym procesem, wywołując poszczególne serwisy i reagując na ich odpowiedzi.

@Service
public class OrderSagaOrchestrator {
    
    @Autowired
    private PaymentServiceClient paymentService;
    
    @Autowired
    private InventoryServiceClient inventoryService;
    
    @Autowired
    private OrderService orderService;
    
    public void processOrder(CreateOrderRequest request) {
        SagaTransaction saga = new SagaTransaction(request);
        
        try {
            // Step 1: Create order
            Order order = orderService.createOrder(request);
            saga.addCompensation(() -> orderService.cancelOrder(order.getId()));
            
            // Step 2: Process payment  
            PaymentResult payment = paymentService.processPayment(
                order.getTotalAmount(), 
                request.getPaymentDetails()
            );
            saga.addCompensation(() -> paymentService.refundPayment(payment.getId()));
            
            // Step 3: Reserve inventory
            InventoryReservation reservation = inventoryService.reserveItems(
                order.getItems()
            );
            saga.addCompensation(() -> inventoryService.releaseReservation(reservation.getId()));
            
            // Step 4: Confirm order
            orderService.confirmOrder(order.getId());
            saga.markCompleted();
            
        } catch (Exception e) {
            // Coś poszło nie tak - uruchamiamy kompensację
            saga.compensate();
            throw new OrderProcessingException("Failed to process order", e);
        }
    }
}
Pro tip: Orchestration jest łatwiejsze do debugowania i monitorowania, ale tworzy coupling między serwisami. Choreography zapewnia loose coupling, ale może być trudniejsze do śledzenia.

Implementacja mechanizmów kompensacji

Kluczowym elementem Saga pattern są compensating actions – operacje, które „cofają” skutki wcześniejszych kroków.

public class SagaTransaction {
    private List compensations = new ArrayList<>();
    private boolean completed = false;
    
    public void addCompensation(CompensationAction action) {
        compensations.add(action);
    }
    
    public void compensate() {
        // Wykonujemy kompensacje w odwrotnej kolejności
        Collections.reverse(compensations);
        
        for (CompensationAction action : compensations) {
            try {
                action.execute();
            } catch (Exception e) {
                // Logujemy błąd ale kontynuujemy - 
                // nie możemy zatrzymać procesu kompensacji
                log.error("Compensation failed", e);
            }
        }
    }
    
    @FunctionalInterface
    public interface CompensationAction {
        void execute() throws Exception;
    }
}

@Service
public class PaymentService {
    
    public PaymentResult processPayment(BigDecimal amount, PaymentDetails details) {
        // Przetwarzanie płatności
        PaymentResult result = chargeCard(details.getCardNumber(), amount);
        return result;
    }
    
    // Compensation action
    public void refundPayment(String paymentId) {
        Payment payment = paymentRepository.findById(paymentId);
        if (payment.getStatus() == PaymentStatus.COMPLETED) {
            // Wykonujemy refund
            refundProcessor.processRefund(payment);
            payment.setStatus(PaymentStatus.REFUNDED);
            paymentRepository.save(payment);
        }
    }
}
Uwaga: Kompensacje mogą się nie powieść! Zawsze implementuj retry logic i monitoring dla compensation actions. W niektórych przypadkach może być potrzebna manualna interwencja.

Event sourcing w Saga pattern

Połączenie Saga pattern z Event Sourcing daje potężne możliwości auditingu i recovery:

@Entity
public class SagaEvent {
    @Id
    private String id;
    private String sagaId;
    private String eventType;
    private String eventData;
    private LocalDateTime timestamp;
    private SagaEventStatus status;
    
    // getters, setters
}

@Service
public class SagaEventStore {
    
    @Autowired
    private SagaEventRepository repository;
    
    public void recordEvent(String sagaId, String eventType, Object eventData) {
        SagaEvent event = new SagaEvent();
        event.setId(UUID.randomUUID().toString());
        event.setSagaId(sagaId);
        event.setEventType(eventType);
        event.setEventData(objectMapper.writeValueAsString(eventData));
        event.setTimestamp(LocalDateTime.now());
        event.setStatus(SagaEventStatus.PENDING);
        
        repository.save(event);
    }
    
    public List getSagaHistory(String sagaId) {
        return repository.findBySagaIdOrderByTimestamp(sagaId);
    }
    
    public void markEventCompleted(String eventId) {
        SagaEvent event = repository.findById(eventId);
        event.setStatus(SagaEventStatus.COMPLETED);
        repository.save(event);
    }
}

Integration z Apache Kafka

W produkcji często używamy Apache Kafka jako event backbone dla Saga pattern:

@Configuration
@EnableKafka
public class KafkaConfig {
    
    @Bean
    public ProducerFactory producerFactory() {
        Map configProps = new HashMap<>();
        configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
        configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class);
        configProps.put(ProducerConfig.ACKS_CONFIG, "all"); // Ensure durability
        return new DefaultKafkaProducerFactory<>(configProps);
    }
    
    @Bean
    public KafkaTemplate kafkaTemplate() {
        return new KafkaTemplate<>(producerFactory());
    }
}

@Service
public class KafkaSagaEventPublisher {
    
    @Autowired
    private KafkaTemplate kafkaTemplate;
    
    public void publishSagaEvent(String sagaId, SagaEvent event) {
        // Używamy sagaId jako partition key dla ordered processing
        kafkaTemplate.send("saga-events", sagaId, event)
            .addCallback(
                result -> log.info("Event published successfully"),
                failure -> log.error("Failed to publish event", failure)
            );
    }
}

@Component
public class SagaEventListener {
    
    @KafkaListener(topics = "payment-events", groupId = "order-saga-group")
    public void handlePaymentEvent(PaymentEvent event) {
        if (event.getType() == PaymentEventType.PAYMENT_COMPLETED) {
            // Kontynuuj saga - następny krok
            processNextSagaStep(event.getSagaId(), event);
        } else if (event.getType() == PaymentEventType.PAYMENT_FAILED) {
            // Uruchom kompensację
            initiateSagaCompensation(event.getSagaId(), event);
        }
    }
}

Monitoring i observability

Production-ready saga implementation wymaga comprehensive monitoring:

@Component
public class SagaMetrics {
    
    private final MeterRegistry meterRegistry;
    private final Counter sagaStartedCounter;
    private final Counter sagaCompletedCounter;
    private final Counter sagaFailedCounter;
    private final Timer sagaDurationTimer;
    
    public SagaMetrics(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
        this.sagaStartedCounter = Counter.builder("saga.started")
            .description("Number of saga transactions started")
            .register(meterRegistry);
        this.sagaCompletedCounter = Counter.builder("saga.completed")
            .description("Number of saga transactions completed successfully")
            .register(meterRegistry);
        this.sagaFailedCounter = Counter.builder("saga.failed")
            .description("Number of saga transactions failed")
            .register(meterRegistry);
        this.sagaDurationTimer = Timer.builder("saga.duration")
            .description("Saga transaction duration")
            .register(meterRegistry);
    }
    
    public void recordSagaStarted(String sagaType) {
        sagaStartedCounter.increment(Tags.of("type", sagaType));
    }
    
    public void recordSagaCompleted(String sagaType, Duration duration) {
        sagaCompletedCounter.increment(Tags.of("type", sagaType));
        sagaDurationTimer.record(duration);
    }
    
    public void recordSagaFailed(String sagaType, String reason) {
        sagaFailedCounter.increment(Tags.of("type", sagaType, "reason", reason));
    }
}

Error handling i retry strategies

Pułapka: Naiwne retry może prowadzić do duplicate processing. Zawsze implementuj idempotent operations i sprawdzaj stan przed powtórzeniem operacji.
@Service
public class ResilientSagaOrchestrator {
    
    @Retryable(value = {TransientException.class}, 
               maxAttempts = 3,
               backoff = @Backoff(delay = 1000, multiplier = 2))
    public void executeSagaStep(SagaStep step) {
        try {
            step.execute();
        } catch (PermanentException e) {
            // Nie retryujemy - od razu kompensacja
            throw e;
        } catch (TransientException e) {
            // Spring Retry automatycznie powtórzy
            throw e;
        }
    }
    
    @Recover
    public void recoverFromFailedStep(TransientException ex, SagaStep step) {
        log.error("Saga step failed after retries, initiating compensation", ex);
        initiateSagaCompensation(step.getSagaId());
    }
    
    // Circuit breaker dla external services
    @CircuitBreaker(name = "payment-service", fallbackMethod = "paymentServiceFallback")
    public PaymentResult callPaymentService(PaymentRequest request) {
        return paymentServiceClient.processPayment(request);
    }
    
    public PaymentResult paymentServiceFallback(PaymentRequest request, Exception ex) {
        // Fallback strategy - może queue for later processing
        log.warn("Payment service unavailable, queuing for later", ex);
        paymentQueue.enqueue(request);
        return PaymentResult.pending(request.getId());
    }
}

Performance considerations

Saga pattern oferuje znacznie lepszą performance niż distributed transactions, ale kosztem eventual consistency. Typowe saga może być 3-5x szybsze od 2PC.

Kluczowe optymalizacje:

@Configuration
public class SagaPerformanceConfig {
    
    @Bean
    @ConfigurationProperties("saga.async")
    public ThreadPoolTaskExecutor sagaExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("saga-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        return executor;
    }
    
    // Async saga step execution
    @Async("sagaExecutor")
    public CompletableFuture executeStepAsync(SagaStep step) {
        return CompletableFuture.runAsync(() -> {
            try {
                step.execute();
            } catch (Exception e) {
                log.error("Async saga step failed", e);
                throw new CompletionException(e);
            }
        });
    }
}
Kiedy wybrać Choreography vs Orchestration?

Choreography dla loose coupling i prostych workflows (3-4 kroki). Orchestration dla complex business logic i gdy potrzebujesz centralnego kontroli nad procesem.

Jak testować saga flows?

Używaj test containers dla integration testów, mock external services, testuj compensation logic osobno. Contract testing z Pact dla inter-service communication.

Co jeśli compensation action się nie powiedzie?

Implementuj retry z exponential backoff, loguj failures do monitoring system, w ostateczności manual intervention. Niektóre compensations mogą wymagać eventual consistency.

Jak zagwarantować exactly-once processing?

Idempotent operations + deduplication keys. Sprawdzaj stan przed wykonaniem operacji. Używaj message ordering w Kafka z partition keys.

Jaki wpływ na consistency model?

Saga zapewnia eventual consistency zamiast immediate consistency. System może być temporarily inconsistent między krokami – projektuj UI/UX z tym na uwadze.

Jak debugować stuck saga transactions?

Event store z complete audit trail, monitoring dashboards z saga state, timeout mechanisms z automatic compensation triggers, health checks dla każdego kroku.

Czy saga pattern scale’uje się w dużych systemach?

Tak, ale wymaga careful design. Partition Kafka topics, horizontal scaling orchestratorów, proper error handling. Netflix używa saga w massive scale.

Przydatne zasoby

🚀 Zadanie dla Ciebie

Zaprojektuj i zaimplementuj saga pattern dla procesu onboarding nowego użytkownika: 1) Create account, 2) Send welcome email, 3) Setup default preferences, 4) Notify analytics service. Uwzględnij compensation actions dla każdego kroku i monitoring metrics. Przetestuj scenariusz partial failure.

Saga pattern revolutionizes jak approached distributed transactions w 2018 roku. W miarę jak mikrousługi stają się standardem, understanding tego wzorca becomes essential dla każdego software architect working z distributed systems.

Zostaw komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *

Przewijanie do góry