🇬🇧 Read the English version of this article on tanhdev.com

Answer-first: Orchestrated Saga Pattern với Temporal Go SDK quản lý các giao dịch tài chính phân tán bằng bộ điều phối tập trung vận hành trên cơ chế Event Sourcing Replay bền bỉ (Durable Execution). Temporal lưu trữ chuỗi lịch sử sự kiện tất định (deterministic workflow history), tự động thực thi chuỗi giao dịch bồi hoàn (compensating transactions) theo cơ chế LIFO (Last-In First-Out) thông qua workflow.NewDisconnectedContext khi xảy ra sự cố, và ngăn chặn giao dịch trùng lặp bằng Idempotency Keys kết hợp chính sách Retry lũy tiến.

💳 Bài viết này thuộc chuyên đề kiến trúc thanh toán và ngân hàng. Xem thêm tại Series Kiến Trúc Core Banking Phân Tán.


1. Nỗi Đau Giao Dịch Phân Tán Trong Hệ Thống Core Banking & FinTech

Trong kiến trúc Monolith truyền thống, việc bảo toàn tính chất ACID (Atomicity, Consistency, Isolation, Durability) của một giao dịch tài chính vô cùng đơn giản: Bạn mở một Database Transaction, thực thi các lệnh UPDATE, và gọi COMMIT. Nếu có sự cố, database engine sẽ ROLLBACK toàn bộ.

Nhưng khi bước vào thế giới Microservices Ngân Hàng, nghiệp vụ chuyển tiền liên ngân hàng bị xé nhỏ qua nhiều dịch vụ độc lập với cơ sở dữ liệu riêng biệt:

  • Dịch vụ Tài khoản (CASA Service): Trừ tiền người gửi.
  • Cổng Thanh Toán Quốc Gia (NAPAS / SWIFT Gateway): Chuyển tiền liên ngân hàng.
  • Dịch vụ Sổ Cái (Core Ledger Service): Ghi nhận biến động kế toán kép.
  • Dịch vụ Thông Báo (Notification Service): Gửi SMS OTP / Biến động số dư.
flowchart TD
    Client["Khách Hàng Thực Hiện Chuyển Tiền"] --> Gateway["API Gateway"]
    Gateway --> CASA["Dịch Vụ CASA (Trừ Tiền Khách A)"]
    CASA --> NAPAS["Cổng Thanh Toán NAPAS (Gửi Lệnh Đi)"]
    NAPAS --> Ledger["Sổ Cái (Ledger)"]
    
    NAPAS -.->|"SỰ CỐ MẠNG: Timeout 60s, Không Rõ Trạng Thái!"| NetworkPartition["Rủi Ro Lệch Số Dư Khủng Khiếp"]

1.1. Sự Bế Tắc Của Two-Phase Commit (2PC) và Khóa Phân Tán

  1. Two-Phase Commit (2PC): Đòi hỏi tất cả các cơ sở dữ liệu tham gia phải giữ khóa đồng bộ (Synchronous Locking) trong suốt giai đoạn chuẩn bị (Prepare Phase). Nếu một microservice bị chậm hoặc mất kết nối, toàn bộ hệ thống sẽ bị khóa cứng (Deadlock), làm sụp đổ throughput của ngân hàng.
  2. Khóa phân tán (Distributed Locks với Redis/ZooKeeper): Rất khó để cài đặt đúng trong môi trường phân tán. Độ lệch đồng hồ (Clock Drift) và hiện tượng Garbage Collection Stop-The-World có thể làm hết hạn khóa sớm, dẫn đến tình trạng hai tiến trình cùng cập nhật số dư đồng thời (Double-Spending).

1.2. Choreography vs. Orchestration: Vì Sao FinTech Bắt Buộc Phải Dùng Orchestrated Saga?

  • Choreographed Saga (Dựa trên Kafka Events): Không có bộ điều phối trung tâm. Mỗi service tự nghe sự kiện từ Kafka để làm việc tiếp. Mô hình này rất nhanh chóng biến thành một “cơn ác mộng mạng nhện” (Spaghetti Events). Khi một giao dịch chuyển tiền bị treo, không ai biết nó đang dừng ở bước nào, cực kỳ khó kiểm toán và debug.
  • Orchestrated Saga (Temporal): Có một cỗ máy điều phối tập trung nắm giữ toàn bộ trạng thái của quy trình. Toàn bộ chuỗi nghiệp vụ được viết bằng mã lệnh Go tường minh, có lịch sử kiểm toán chi tiết và dễ dàng xử lý bồi hoàn.

2. Temporal: Hiện Thực Hóa Triết Lý Thực Thi Bền Bỉ (Durable Execution)

Khác với các workflow engine truyền thống vốn phải lưu biến trạng thái vào MySQL sau mỗi dòng code, Temporal vận hành trên cơ chế Event Sourcing Replay.

sequenceDiagram
    autonumber
    actor User as Khách Hàng
    participant Worker1 as Temporal Go Worker 1
    participant Server as Temporal Server (Event Sourcing)
    participant Worker2 as Temporal Go Worker 2 (Thay thế khi Worker 1 chết)
    participant NAPAS as Hệ Thống NAPAS

    User->>Worker1: Khởi động TransferMoneyWorkflow
    Worker1->>Server: Ghi nhận WorkflowExecutionStarted
    Worker1->>Server: Schedule Activity: DebitSenderAccount
    Server-->>Worker1: ActivityTaskCompleted (Đã trừ 5.000.000đ)
    
    Worker1->>NAPAS: Gọi API chuyển tiền...
    Note over Worker1: SỰ CỐ: Worker 1 bị OOMKilled hoặc mất điện!

    Server->>Server: Phát hiện Worker 1 timeout (Heartbeat mất)
    Server->>Worker2: Gán lại Workflow dở dang cho Worker 2
    
    rect rgb(240, 248, 255)
        note over Worker2, Server: Quá Trình Replay Lịch Sử Tất Định
        Worker2->>Worker2: Chạy lại code từ dòng 1
        Worker2->>Server: Gặp DebitSenderAccount -> Temporal chặn lại, lấy kết quả cũ từ DB
        Worker2->>Worker2: Khôi phục trạng thái bộ nhớ chính xác 100%
    end

    Worker2->>NAPAS: Tiếp tục thực thi bước gọi NAPAS an toàn!

Ràng Buộc Khắt Khe Về Tính Tất Định (Workflow Determinism)

Vì cơ chế phục hồi hoàn toàn dựa vào việc chạy lại code từ đầu trên lịch sử sự kiện cũ, mã Workflow trong Go bắt buộc phải tất định tuyệt đối (Deterministic):

Điều Cấm Kỵ Trong Temporal Go WorkflowLý Do Vi Phạm Tính Tất ĐịnhGiải Pháp Chuẩn Mực
Gọi time.Now() trực tiếpTrả về mốc thời gian khác nhau ở mỗi lần Replay.Sử dụng workflow.Now(ctx).
Gọi time.Sleep()Đóng băng thread của worker, không thể serialize.Sử dụng workflow.Sleep(ctx, duration).
Sinh số ngẫu nhiên rand.Int()Kết quả phân nhánh rẽ khác nhau giữa các lần chạy.Sử dụng workflow.GetRandom(ctx).
Chạy lệnh go func() (Goroutine trần)Scheduler của Go không đảm bảo thứ tự thực thi.Sử dụng workflow.Go(ctx, func(ctx workflow.Context)).
Lặp qua Go mapThứ tự duyệt key của map trong Go là ngẫu nhiên!Sắp xếp key vào slice trước khi duyệt.
Gọi mạng / HTTP trực tiếp trong WorkflowVi phạm nguyên tắc phân tách; mạng có thể lag.Đóng gói toàn bộ cuộc gọi I/O vào Activity.

3. Chiến Lược Giao Dịch Bồi Hoàn LIFO & Disconnected Context

Khi một bước trong chuỗi giao dịch chuyển tiền bị lỗi không thể phục hồi (ví dụ tài khoản người nhận tại ngân hàng đối tác đã bị phong tỏa), Saga phải kích hoạt chuỗi giao dịch bồi hoàn (Compensations) theo thứ tự ngược lại: Last-In First-Out (LIFO).

flowchart TD
    Start["Bắt Đầu Giao Dịch Chuyển Tiền 10.000.000đ"] --> Step1["Bước 1: Trừ Tiền Tài Khoản Gửi (CASA Debit)"]
    Step1 --> Step2["Bước 2: Ghi Nợ Bút Toán Sổ Cái Tạm Thời (Ledger Hold)"]
    Step2 --> Step3["Bước 3: Gọi Cổng NAPAS Sang Ngân Hàng Đích"]
    
    Step3 -->|"NAPAS Báo Lỗi: Tài Khoản Đích Không Tồn Tại"| TriggerSaga["KÍCH HOẠT CHUỖI BỒI TRÙ SAGA (LIFO)"]
    
    subgraph CompensationStack ["Ngăn Xếp Bồi Hoàn LIFO"]
        TriggerSaga --> Comp2["Bồi Hoàn Bước 2: Hủy Bút Toán Sổ Cái (Ledger Release)"]
        Comp2 --> Comp1["Bồi Hoàn Bước 1: Hoàn Tiền Tài Khoản Gửi (CASA Credit)"]
    end

    Comp1 --> Failed["Giao Dịch Thất Bại An Toàn: 0 Lệch Tiền, 0 Mất Mát"]

Tử Huyệt Của Việc Quên Dùng workflow.NewDisconnectedContext

Nếu Workflow cha bị hủy bởi người dùng hoặc bị hết hạn thời gian thực thi (Execution Timeout), toàn bộ ctx của workflow sẽ chuyển sang trạng thái Canceled.

Nếu bạn gọi activity bồi hoàn bằng ctx đã bị hủy, Temporal SDK sẽ từ chối thực thi và trả về lỗi context canceled. Hậu quả: Tiền của khách đã bị trừ nhưng không bao giờ được hoàn lại!

Để giải quyết, bạn bắt buộc phải bọc context bồi hoàn bằng workflow.NewDisconnectedContext:

// Tách biệt context bồi hoàn khỏi sự hủy bỏ của context cha
disconnectedCtx, _ := workflow.NewDisconnectedContext(ctx)
err := workflow.ExecuteActivity(disconnectedCtx, a.RefundSenderAccount, req).Get(disconnectedCtx, nil)

4. Mã Nguồn Production: Temporal Go Saga Orchestrator Hoàn Chỉnh

Dưới đây là đoạn mã nguồn hoàn chỉnh của một Saga Workflow điều phối chuyển tiền ngân hàng, tích hợp ngăn xếp bồi hoàn LIFO, Disconnected Context, và Activity có tính lũy đẳng:

package moneytransfer

import (
	"context"
	"errors"
	"fmt"
	"time"

	"go.temporal.io/sdk/activity"
	"go.temporal.io/sdk/temporal"
	"go.temporal.io/sdk/workflow"
)

// TransferRequest chứa thông tin lệnh chuyển tiền ngân hàng
type TransferRequest struct {
	TransactionID   string  `json:"transaction_id"`
	SenderAccount   string  `json:"sender_account"`
	ReceiverAccount string  `json:"receiver_account"`
	Amount          float64 `json:"amount"`
}

// CompensationFunc đại diện cho một hàm bồi trừ được lưu vào stack
type CompensationFunc func(ctx workflow.Context) error

// MoneyTransferWorkflow điều phối toàn bộ chuỗi giao dịch chuyển tiền chuẩn FinTech
func MoneyTransferWorkflow(ctx workflow.Context, req TransferRequest) (err error) {
	// Cấu hình Activity Retry Policy
	activityOptions := workflow.ActivityOptions{
		StartToCloseTimeout: 10 * time.Second,
		RetryPolicy: &temporal.RetryPolicy{
			InitialInterval:    1 * time.Second,
			BackoffCoefficient: 2.0,
			MaximumInterval:    30 * time.Second,
			MaximumAttempts:    5,
			NonRetryableErrorTypes: []string{
				"INSUFFICIENT_FUNDS",
				"ACCOUNT_FROZEN",
				"INVALID_ACCOUNT",
			},
		},
	}
	ctx = workflow.WithActivityOptions(ctx, activityOptions)

	var compensations []CompensationFunc

	// Hàm defer đảm bảo tự động thực thi bồi hoàn theo thứ tự ngược (LIFO) khi có lỗi
	defer func() {
		if err != nil {
			// Bắt buộc dùng Disconnected Context để không bị chặn bởi timeout/cancel
			disconnectedCtx, _ := workflow.NewDisconnectedContext(ctx)
			workflow.GetLogger(ctx).Warn("Giao dịch phát sinh sự cố. Đang thực thi chuỗi bồi hoàn LIFO...")

			for i := len(compensations) - 1; i >= 0; i-- {
				compErr := compensations[i](disconnectedCtx)
				if compErr != nil {
					workflow.GetLogger(ctx).Error("Bồi hoàn thất bại nghiêm trọng! Cần can thiệp thủ công", "error", compErr)
				}
			}
		}
	}()

	var a *BankingActivities

	// -------------------------------------------------------------
	// BƯỚC 1: TRỪ TIỀN TÀI KHOẢN NGƯỜI GỬI (CASA DEBIT)
	// -------------------------------------------------------------
	err = workflow.ExecuteActivity(ctx, a.DebitSender, req).Get(ctx, nil)
	if err != nil {
		return fmt.Errorf("bước trừ tiền thất bại: %w", err)
	}

	// Đăng ký hành động bồi hoàn tương ứng cho Bước 1
	compensations = append(compensations, func(compCtx workflow.Context) error {
		return workflow.ExecuteActivity(compCtx, a.RefundSender, req).Get(compCtx, nil)
	})

	// -------------------------------------------------------------
	// BƯỚC 2: GHI NHẬN BÚT TOÁN TẠM GIỮ TRÊN SỔ CÁI KẾ TOÁN
	// -------------------------------------------------------------
	err = workflow.ExecuteActivity(ctx, a.HoldLedgerEntry, req).Get(ctx, nil)
	if err != nil {
		return fmt.Errorf("bước ghi sổ cái tạm giữ thất bại: %w", err)
	}

	// Đăng ký hành động bồi hoàn cho Bước 2
	compensations = append(compensations, func(compCtx workflow.Context) error {
		return workflow.ExecuteActivity(compCtx, a.ReleaseLedgerHold, req).Get(compCtx, nil)
	})

	// -------------------------------------------------------------
	// BƯỚC 3: GỬI LỆNH QUA CỔNG THANH TOÁN NAPAS SANG NGÂN HÀNG ĐÍCH
	// -------------------------------------------------------------
	err = workflow.ExecuteActivity(ctx, a.SendToExternalBank, req).Get(ctx, nil)
	if err != nil {
		// Bước này gặp lỗi sẽ kích hoạt chuỗi bồi hoàn trong defer block
		return fmt.Errorf("chuyển tiền liên ngân hàng qua NAPAS thất bại: %w", err)
	}

	// -------------------------------------------------------------
	// BƯỚC 4: HOÀN TẤT BÚT TOÁN SỔ CÁI CHÍNH THỨC
	// -------------------------------------------------------------
	err = workflow.ExecuteActivity(ctx, a.FinalizeLedger, req).Get(ctx, nil)
	if err != nil {
		return fmt.Errorf("bước chốt sổ cái thất bại: %w", err)
	}

	workflow.GetLogger(ctx).Info("Chuyển tiền thành công tuyệt đối!", "tx_id", req.TransactionID)
	return nil
}

// BankingActivities chứa các phương thức tương tác thực tế với Database và mạng
type BankingActivities struct{}

func (ba *BankingActivities) DebitSender(ctx context.Context, req TransferRequest) error {
	logger := activity.GetLogger(ctx)
	logger.Info("Đang thực thi trừ tiền người gửi", "tx_id", req.TransactionID, "sender", req.SenderAccount)
	// Thực thi câu lệnh SQL với Idempotency Key
	return nil
}

func (ba *BankingActivities) RefundSender(ctx context.Context, req TransferRequest) error {
	logger := activity.GetLogger(ctx)
	logger.Warn("Đang thực thi hoàn tiền người gửi", "tx_id", req.TransactionID, "sender", req.SenderAccount)
	return nil
}

func (ba *BankingActivities) HoldLedgerEntry(ctx context.Context, req TransferRequest) error {
	return nil
}

func (ba *BankingActivities) ReleaseLedgerHold(ctx context.Context, req TransferRequest) error {
	return nil
}

func (ba *BankingActivities) SendToExternalBank(ctx context.Context, req TransferRequest) error {
	// Giả lập kịch bản cổng ngân hàng đối tác phản hồi lỗi tài khoản đích bị khóa
	if req.ReceiverAccount == "INVALID_ACCOUNT_99" {
		return temporal.NewNonRetryableApplicationError("Tài khoản đối tác không hợp lệ", "INVALID_ACCOUNT", errors.New("err"))
	}
	return nil
}

func (ba *BankingActivities) FinalizeLedger(ctx context.Context, req TransferRequest) error {
	return nil
}

5. Bảng So Sánh Các Mô Hình Điều Phối Giao Dịch Phân Tán

Tiêu Chí Kỹ ThuậtTwo-Phase Commit (2PC)Choreographed Saga (Kafka Pure)Dapr Workflow EngineTemporal Go SDK (Orchestrated)
Kiểm Soát Trạng TháiKhóa đồng bộ tập trungHoàn toàn phân tánTập trung qua Dapr ActorTập trung qua Event Sourcing
Throughput Hệ ThốngCực kỳ thấp (<100 TPS)Rất cao (>50.000 TPS)Cao (~5.000 TPS)Rất cao (>25.000 TPS/Worker)
Khả Năng Kiểm ToánKhóCực khó (mất dấu vết)TốtHoàn hảo (Lưu vết từng State Change)
Xử Lý Bồi Hoàn (Rollback)Tự động qua DB EnginePhức tạp, dễ sótHỗ trợ qua SDKTự động theo stack LIFO với Disconnected Ctx
Khả Năng Phục Hồi CrashDễ gây khóa treoThủ công qua offsetKhá tốtTự động Replay không mất dữ liệu

6. Câu Hỏi Thường Gặp (FAQ)

Tại sao Temporal lại an toàn hơn cơ chế Choreographed Saga dựa trên Kafka?

Trong Choreographed Saga, các service giao tiếp thông qua việc bắn và nghe sự kiện lẫn nhau trên Kafka. Khi một giao dịch gặp lỗi giữa chừng, việc kích hoạt và kiểm soát chuỗi bồi hoàn diễn ra rất lộn xộn, dễ dẫn đến tình trạng bão sự kiện (event storm) hoặc mất dấu vết giao dịch. Temporal cung cấp một bộ điều phối tập trung viết bằng mã lệnh tường minh, cho phép theo dõi trực quan trạng thái giao dịch và đảm bảo 100% các bước bồi hoàn được thực thi trọn vẹn.

Điều gì xảy ra nếu Worker Node bị sập (OOM hoặc mất điện) khi đang chạy Workflow?

Temporal Server sẽ phát hiện ra sự cố thông qua cơ chế mất tín hiệu nhịp tim (Heartbeat Timeout). Server sẽ tự động gán Workflow dở dang đó cho một Worker Node khỏe mạnh khác. Worker mới này sẽ tải lại toàn bộ lịch sử sự kiện từ cơ sở dữ liệu và chạy lại mã nguồn từ đầu (Deterministic Replay). Đối với các Activity đã chạy thành công trước đó, Temporal sẽ lấy kết quả từ lịch sử mà không thực thi lại, giúp luồng xử lý tiếp tục bình thường mà không bị nhân đôi giao dịch.

Tại sao bắt buộc phải sử dụng workflow.NewDisconnectedContext khi bồi trừ?

Nếu Workflow cha bị hủy bởi người dùng hoặc bị timeout, context cha sẽ bị hủy (canceled). Nếu bạn gọi các Activity bồi trừ bằng context đã bị hủy, hệ thống sẽ từ chối thực thi, dẫn đến việc tiền của khách hàng bị trừ nhưng không được hoàn lại. workflow.NewDisconnectedContext tạo ra một context độc lập mới hoàn toàn miễn nhiễm với lệnh hủy của context cha, bảo đảm lệnh bồi hoàn luôn luôn chạy đến đích.

Làm thế nào để nâng cấp một Workflow đang chạy dở trên Production mà không làm hỏng tính tất định?

Bạn không được phép trực tiếp sửa đổi hoặc xóa các nhánh rẽ trong code của một Workflow đang hoạt động. Thay vào đó, bạn phải sử dụng API workflow.GetVersion(ctx, changeID, minVersion, maxVersion) của Temporal SDK. Hàm này ghi nhận phiên bản code vào lịch sử sự kiện, cho phép Worker chạy nhánh code mới cho các giao dịch mới sinh ra, trong khi vẫn thực thi nhánh code cũ cho các sự kiện Replay của những giao dịch đang chạy dở.

7. Tài Liệu Kỹ Thuật Liên Quan


🤝 Kết nối với tôi

Bạn đang gặp phải những thách thức tương tự về kiến trúc hệ thống, mở rộng quy mô (scaling) hay dịch chuyển (migration)? Hãy kết nối với tôi trên LinkedIn, theo dõi GitHub của tôi, hoặc gửi một email để trao đổi nhé.