Chuỗi bài (Phần 4 của 8): Bài này xây dựng trên Event Sourcing từ Phần 3. Saga Pattern giải quyết bài toán: “Làm thế nào để đảm bảo tính nhất quán khi giao dịch cần phối hợp nhiều microservices mà không dùng distributed lock hay 2PC?”

Saga Pattern Trong Fintech Là Gì?

Saga là chuỗi các local transactions. Mỗi local transaction cập nhật database của service đó và publish một event/message để trigger local transaction tiếp theo. Nếu một bước fail, Saga thực thi compensating transactions để undo các bước đã thực hiện trước đó — đảm bảo eventual consistency mà không cần distributed lock.

Ví dụ thực tế: Chuyển tiền liên ngân hàng cần phối hợp:

  1. Account Service: Trừ tiền từ tài khoản nguồn (hold/debit)
  2. Payment Gateway Service: Gửi lệnh qua NAPAS/SWIFT
  3. Notification Service: Gửi SMS/Push notification cho khách hàng

Nếu bước 2 fail sau khi bước 1 thành công → cần compensation để hoàn tiền.


Choreography vs Orchestration: Khi Nào Dùng Gì?

Choreography Saga (Event-Driven)

Services giao tiếp bằng events — không có central coordinator:

Account Service          Payment Service         Notification Service
      │                         │                        │
      │──TransferInitiated──────▶│                        │
      │                          │──PaymentSubmitted──────▶│
      │                          │                        │── SMS Sent
      │◀──PaymentCompleted───────│                        │
      │                          │                        │
   (release hold)                                     (done)

Failure case:
      │◀──PaymentFailed──────────│
      │                          │
   (refund source account)

Latency đặc trưng: <5ms per hop vì không có central coordinator network call.

Nhược điểm: Khó track overall saga state, debugging distributed failures phức tạp, cần distributed tracing bắt buộc.

Orchestration Saga (Central Coordinator)

Một Orchestrator điều phối toàn bộ luồng:

       Orchestrator (Temporal/Conductor)
              │
         ┌────┼────────────────┐
         ▼    ▼                ▼
  Account Svc  Payment Svc   Notif Svc

Latency đặc trưng: 10-50ms per hop do có thêm network call đến Orchestrator. Nhưng đổi lại:

  • Toàn bộ saga state được lưu tập trung
  • Dễ debug (query orchestrator state)
  • Retry/timeout logic được quản lý ở một nơi

Comparison Matrix

Tiêu chíChoreographyOrchestration
Latency<5ms/hop10-50ms/hop
DebuggingKhó (distributed tracing cần thiết)Dễ (central state)
CouplingLoose couplingTighter (services biết orchestrator)
Failure handlingComplex (ai chịu trách nhiệm?)Clear (orchestrator retry)
Phù hợp choSimple flows (<3 steps)Complex flows (≥3 steps với compensation)

Khuyến nghị cho Fintech: Dùng Orchestration cho business-critical flows như chuyển tiền. Chi phí 10-50ms latency là đáng đổi để có visibility rõ ràng và compensation chain an toàn.


Temporal Workflow: Go Implementation

Temporal là orchestration engine phổ biến nhất hiện tại cho Saga patterns. Đây là implementation thực tế:

package workflows

import (
    "fmt"
    "time"
    
    "go.temporal.io/sdk/temporal"
    "go.temporal.io/sdk/workflow"
)

// TransferRequest — Input cho saga
type TransferRequest struct {
    TransferID    string
    FromAccountID string
    ToAccountID   string
    Amount        int64  // Stored in cents/smallest unit
    Currency      string
    IdempotencyKey string
}

// TransferWorkflow — Orchestrator Saga
func TransferWorkflow(ctx workflow.Context, req TransferRequest) error {
    logger := workflow.GetLogger(ctx)
    
    // Activity options: timeout + retry policy
    activityOpts := workflow.ActivityOptions{
        StartToCloseTimeout: 5 * time.Second,
        RetryPolicy: &temporal.RetryPolicy{
            InitialInterval:    time.Second,
            BackoffCoefficient: 2.0,
            MaximumInterval:    30 * time.Second,
            MaximumAttempts:    3,
            // Không retry business errors (insufficient funds, etc.)
            NonRetryableErrorTypes: []string{
                "InsufficientFundsError",
                "AccountFrozenError",
                "InvalidAccountError",
            },
        },
    }
    ctx = workflow.WithActivityOptions(ctx, activityOpts)
    
    // === STEP 1: Debit source account (hold funds) ===
    var debitResult DebitResult
    err := workflow.ExecuteActivity(ctx, DebitAccountActivity, req).Get(ctx, &debitResult)
    if err != nil {
        // Step 1 failed — no compensation needed, saga aborted cleanly
        logger.Error("Debit failed, saga aborted", "transferID", req.TransferID, "error", err)
        return fmt.Errorf("debit failed: %w", err)
    }
    
    // === STEP 2: Submit payment through gateway ===
    var paymentResult PaymentResult
    err = workflow.ExecuteActivity(ctx, SubmitPaymentActivity, req).Get(ctx, &paymentResult)
    if err != nil {
        // Step 2 failed — MUST compensate step 1
        logger.Error("Payment failed, executing compensation", "transferID", req.TransferID)
        
        // Execute compensation ASYNC (không chặn main flow)
        compensationCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
            StartToCloseTimeout: 10 * time.Second,
            RetryPolicy: &temporal.RetryPolicy{
                MaximumAttempts: 5, // Try harder for compensations
            },
        })
        compErr := workflow.ExecuteActivity(
            compensationCtx,
            RefundAccountActivity,
            req,
        ).Get(ctx, nil)
        
        if compErr != nil {
            // CRITICAL: Compensation itself failed
            // Log to DLQ and fire human alert
            logger.Error("CRITICAL: Compensation failed",
                "transferID", req.TransferID,
                "compensation_error", compErr)
            // Return special error to trigger DLQ routing
            return fmt.Errorf("compensation_failed: %w", compErr)
        }
        
        return fmt.Errorf("payment failed (refunded): %w", err)
    }
    
    // === STEP 3: Send notification (non-critical, best effort) ===
    notifCtx := workflow.WithActivityOptions(ctx, workflow.ActivityOptions{
        StartToCloseTimeout: 3 * time.Second,
        RetryPolicy: &temporal.RetryPolicy{
            MaximumAttempts: 2,
        },
    })
    // Best effort — không fail workflow nếu notification fail
    _ = workflow.ExecuteActivity(notifCtx, SendNotificationActivity, req).Get(ctx, nil)
    
    logger.Info("Transfer completed successfully", "transferID", req.TransferID)
    return nil
}

Saga Failure Transition Matrix

Đây là bảng phân tích chi tiết các failure scenarios và cách xử lý:

StepFailure PointOrchestration SagaChoreography Saga
Step 1 Fail (Debit)Account insufficient fundsOrchestrator nhận error → mark saga Aborted. Không cần compensation.Service A publish TransferFailed event. Không có compensation.
Step 2 Fail (Payment)Network timeout đến NAPASOrchestrator nhận error → trigger RefundActivity async để revert Step 1.Service B publish PaymentFailed → Service A consume và refund.
Step 3 Fail (Notification)SMS gateway downMark notification as best-effort. Workflow completes successfully.Service C fail silently; payment already completed.
Compensation FailRefund service downOrchestrator retry với exponential backoff → route to DLQ → alert ops team.Refund event sits in DLQ or lost; requires distributed tracing to detect.
Orchestrator CrashTemporal node downTemporal persists saga state to durable storage → auto-resume on recovery.N/A (no orchestrator)

Idempotency Keys Trong Saga

Mỗi bước trong Saga cần idempotency để safe retry:

// Activity với idempotency key
func DebitAccountActivity(ctx context.Context, req TransferRequest) (DebitResult, error) {
    // Kiểm tra: đã xử lý idempotency key này chưa?
    existing, err := checkIdempotencyKey(ctx, req.IdempotencyKey + "_debit")
    if err == nil && existing != nil {
        // Already processed — return cached result
        return *existing, nil
    }
    
    // Bắt đầu xử lý
    result, err := performDebit(ctx, req.FromAccountID, req.Amount)
    if err != nil {
        return DebitResult{}, err
    }
    
    // Lưu result vào idempotency store (Redis, 24h TTL)
    storeIdempotencyResult(ctx, req.IdempotencyKey + "_debit", result, 24*time.Hour)
    
    return result, nil
}

Tiered lock strategy cho webhook idempotency:

5 phút:   pending lock (prevent concurrent processing)
24-48 giờ: result cache (return cached response cho duplicate requests)

Choreography Implementation: Kafka-based

// Account Service — phát event khi hoàn tất step 1
func (s *AccountService) HandleTransferRequest(ctx context.Context, req TransferRequest) {
    // Trong cùng DB transaction:
    err := s.db.WithTransaction(ctx, func(tx *sql.Tx) error {
        // 1. Debit account
        holdFunds(tx, req.FromAccountID, req.Amount)
        // 2. Ghi outbox event
        insertOutboxEvent(tx, "TransferInitiated", req)
        return nil
    })
    
    if err != nil {
        // Publish TransferFailed event
        s.eventBus.Publish("payment.events", TransferFailedEvent{
            TransferID: req.TransferID,
            Reason:     err.Error(),
        })
    }
}

// Payment Service — lắng nghe TransferInitiated event
func (s *PaymentService) HandleTransferInitiated(ctx context.Context, event TransferInitiatedEvent) {
    err := submitToNAPAS(ctx, event)
    if err != nil {
        // Publish failure — Account Service sẽ refund
        s.eventBus.Publish("payment.events", PaymentFailedEvent{
            TransferID: event.TransferID,
            Reason:     err.Error(),
        })
        return
    }
    s.eventBus.Publish("payment.events", PaymentCompletedEvent{
        TransferID: event.TransferID,
    })
}

Dead Letter Queue Strategy

Khi compensation chain thất bại, event phải được route vào DLQ:

// DLQ handler — nhận failed compensation events
type DLQHandler struct {
    alertManager AlertManager
    auditLog     AuditLogger
}

func (h *DLQHandler) HandleFailedCompensation(ctx context.Context, event FailedCompensationEvent) {
    // 1. Ghi vào audit log bất biến
    h.auditLog.LogCritical(ctx, AuditEntry{
        EventType:  "CompensationFailed",
        TransferID: event.TransferID,
        Reason:     event.Reason,
        Timestamp:  time.Now(),
    })
    
    // 2. Fire P1 alert ngay lập tức
    h.alertManager.FireP1Alert(ctx, P1Alert{
        Title:   "CRITICAL: Transfer Compensation Failed",
        Message: fmt.Sprintf("Transfer %s failed compensation. Manual intervention required.", event.TransferID),
        Details: event,
    })
    
    // 3. Không auto-retry — chờ manual review từ ops team
}

QA & SDET Testing Strategy

Test 1: Step 2 Failure + Compensation Verification

func TestStep2FailureCompensation(t *testing.T) {
    // Setup: Mock Payment Service để fail ở step 2
    mockPaymentSvc := &MockPaymentService{ShouldFail: true}
    
    initialBalanceA := getBalance("account-A")
    
    // Execute saga
    err := transferWorkflow.Execute(ctx, TransferRequest{
        From: "account-A", To: "account-B", Amount: 1000000,
    })
    
    // Workflow phải return error
    assert.Error(t, err)
    assert.Contains(t, err.Error(), "payment failed")
    
    // Nhưng compensation phải thành công: balance A về như cũ
    finalBalanceA := getBalance("account-A")
    assert.Equal(t, initialBalanceA, finalBalanceA,
        "Compensation phải hoàn tiền về account A")
    
    // Balance B phải không thay đổi
    assert.Equal(t, originalBalanceB, getBalance("account-B"))
}

Test 2: Double Failure — Step 2 + Compensation

func TestDoubleFaultCompensationDLQ(t *testing.T) {
    // Mock: step 2 fail VÀ compensation activity cũng fail
    mockPaymentSvc := &MockPaymentService{ShouldFail: true}
    mockRefundSvc := &MockRefundService{ShouldFail: true}
    
    dlqEvents := captureDeadLetterQueue()
    
    // Execute saga
    executeTransferWorkflow(ctx, transferReq)
    
    // Chờ retry exhaust
    waitForRetryExhaustion()
    
    // Phải có event trong DLQ
    assert.Greater(t, len(dlqEvents), 0,
        "Failed compensation phải route vào DLQ")
    
    // Phải có P1 alert
    assert.True(t, alertManager.P1AlertFired(),
        "P1 alert phải được fired cho failed compensation")
}

📚 Xem thêm: Event Sourcing & CQRS — Event Sourcing làm nền tảng cho Saga

FAQ

Temporal vs Apache Airflow cho Saga — khác nhau thế nào?

Airflow là workflow orchestrator cho data pipelines (batch, không real-time). Temporal được thiết kế cho durable, real-time business processes với millisecond latency, fault-tolerance, và built-in retry/compensation semantics phù hợp cho financial transactions.

Saga có đảm bảo ACID không?

Không. Saga đảm bảo eventual consistency — không có isolation giữa các steps. Một transaction đọc state “trung gian” (sau step 1 nhưng trước step 2) là hoàn toàn có thể. Đây là trade-off so với 2PC. Với Core Banking, thường dùng Read Committed isolation và accept window of inconsistency (~seconds).

Compensation có luôn thành công không?

Không. Đó là lý do phải có DLQ + manual intervention process. Ví dụ: nếu account bị freeze sau khi đã debit nhưng trước khi refund → compensation không thể hoàn tự động. Cần ops team xử lý thủ công với đầy đủ audit trail.


Tiếp theo: Phần 5 — ISO 20022 & Payment Gateways — Parse pacs.008 XML hiệu quả, mapping XPath sang SQL columns, và chiến lược webhook idempotency.