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:
- Account Service: Trừ tiền từ tài khoản nguồn (hold/debit)
- Payment Gateway Service: Gửi lệnh qua NAPAS/SWIFT
- 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í | Choreography | Orchestration |
|---|---|---|
| Latency | <5ms/hop | 10-50ms/hop |
| Debugging | Khó (distributed tracing cần thiết) | Dễ (central state) |
| Coupling | Loose coupling | Tighter (services biết orchestrator) |
| Failure handling | Complex (ai chịu trách nhiệm?) | Clear (orchestrator retry) |
| Phù hợp cho | Simple 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ý:
| Step | Failure Point | Orchestration Saga | Choreography Saga |
|---|---|---|---|
| Step 1 Fail (Debit) | Account insufficient funds | Orchestrator 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 NAPAS | Orchestrator nhận error → trigger RefundActivity async để revert Step 1. | Service B publish PaymentFailed → Service A consume và refund. |
| Step 3 Fail (Notification) | SMS gateway down | Mark notification as best-effort. Workflow completes successfully. | Service C fail silently; payment already completed. |
| Compensation Fail | Refund service down | Orchestrator retry với exponential backoff → route to DLQ → alert ops team. | Refund event sits in DLQ or lost; requires distributed tracing to detect. |
| Orchestrator Crash | Temporal node down | Temporal 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.