🇬🇧 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.NewDisconnectedContextkhi 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
- 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.
- 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 Workflow | Lý Do Vi Phạm Tính Tất Định | Giải Pháp Chuẩn Mực |
|---|---|---|
Gọi time.Now() trực tiếp | Trả 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 map | Thứ 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 Workflow | Vi 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ật | Two-Phase Commit (2PC) | Choreographed Saga (Kafka Pure) | Dapr Workflow Engine | Temporal Go SDK (Orchestrated) |
|---|---|---|---|---|
| Kiểm Soát Trạng Thái | Khóa đồng bộ tập trung | Hoàn toàn phân tán | Tập trung qua Dapr Actor | Tập trung qua Event Sourcing |
| Throughput Hệ Thống | Cự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án | Khó | Cực khó (mất dấu vết) | Tốt | Hoàn hảo (Lưu vết từng State Change) |
| Xử Lý Bồi Hoàn (Rollback) | Tự động qua DB Engine | Phức tạp, dễ sót | Hỗ trợ qua SDK | Tự động theo stack LIFO với Disconnected Ctx |
| Khả Năng Phục Hồi Crash | Dễ gây khóa treo | Thủ công qua offset | Khá tốt | Tự độ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?
Đ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?
Tại sao bắt buộc phải sử dụng workflow.NewDisconnectedContext khi bồi trừ?
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?
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
- Triển Khai Giao Dịch Phân Tán Với Temporal: Xem bản hướng dẫn mở rộng chi tiết tại Triển Khai Giao Dịch Phân Tán trong Go với Temporal Saga Pattern.
- Kiến Trúc Microservices Ngân Hàng: Thiết kế Bounded Context ngân hàng tại Kiến Trúc Microservices Trong Ngân Hàng Số Hiện Đại.
- Event-Driven Với Dapr: Khám phá giải pháp điều phối thay thế tại Làm Chủ Event-Driven Architecture Với Dapr & Go.
- Dapr Workflow Saga Guide: Hướng dẫn so sánh chi tiết tại Dapr Workflow Saga Orchestration Guide.
- Chuyên Đề Core Banking: Xem trọn bộ tại Series Kiến Trúc Core Banking Phân Tán.
