Trong kỹ thuật phần mềm, các lỗi giao diện người dùng (UI) có thể làm người dùng khó chịu, nhưng những sai lệch tài chính sẽ giết chết doanh nghiệp và kéo theo những vụ kiện tụng. Việc xây dựng một kiến trúc microservices tài chính vững chắc cho Fintech hoặc Core Banking (Ngân hàng Lõi) là một trong những thử thách kiến trúc khó nhằn nhất mà bạn từng đối mặt.

Cho dù bạn đang quản lý một hệ thống triển khai GitOps hiện đại hay một công cụ định tuyến đơn hàng phức tạp, việc thiết kế cho các hệ thống tài chính đòi hỏi một mức độ nghiêm ngặt hoàn toàn khác. Bài viết này phân tích các Design Patterns (Mẫu Thiết Kế) bắt buộc cần thiết khi xây dựng Microservices Ngân Hàng.


Thử Thách Của Các Giao Dịch Phân Tán Trong Core Banking

Trong kiến trúc Monolith (Nguyên khối), việc chuyển tiền từ Tài khoản A sang Tài khoản B rất đơn giản: bạn bọc cả hai câu lệnh SQL UPDATE bên trong một Giao dịch Cơ sở dữ liệu duy nhất (ACID). Nếu hệ thống gặp sự cố giữa chừng, cơ sở dữ liệu sẽ tự động hoàn tác (rollback) mọi thứ.

Tuy nhiên, trong Microservices, Tài khoản A có thể nằm trong User Service (sử dụng PostgreSQL), trong khi Tài khoản B lại nằm ở Wallet Service (sử dụng MySQL). Không có một cơ sở dữ liệu duy nhất nào có thể bọc giao dịch cho cả hai. Đây được gọi là vấn đề Giao dịch Phân tán (Distributed Transaction).

Nếu bạn trừ tiền thành công từ Tài khoản A nhưng không thể cộng tiền vào Tài khoản B do hết thời gian kết nối mạng (network timeout), tiền của khách hàng sẽ bốc hơi!


Giải Quyết Bài Toán Nhất Quán Với Saga Pattern

Để giải quyết vấn đề này mà không phụ thuộc vào Two-Phase Commit (2PC) — một cơ chế khét tiếng là chậm chạp và gây khóa cơ sở dữ liệu (database locking) — các kiến trúc sư sử dụng Saga Pattern.

Saga là một chuỗi các Giao dịch Cục bộ (Local Transactions). Mỗi Microservice thực thi giao dịch cục bộ của riêng nó và phát ra (emit) một Sự kiện/Thông điệp (Event/Message) để kích hoạt bước tiếp theo trong chuỗi.

Orchestrated Saga (Dựa Trên Dapr Workflows) vs. Choreographed Saga

Có hai cách để thiết kế một Saga:

  1. Choreography: Các service lắng nghe các sự kiện của nhau mà không cần một hệ thống chỉ huy trung tâm. Ví dụ, Service A trừ tiền và phát ra sự kiện MoneyDeducted; Service B bắt sự kiện đó và cộng tiền. Cách này hoạt động tốt cho các luồng ngắn, nhưng khi một giao dịch chuyển khoản trải qua 5-7 services, bạn sẽ tạo ra một mớ bòng bong các sự kiện không thể debug nổi.
  2. Orchestration (Điều phối): Một Coordinator (Bộ điều phối) trung tâm chỉ đạo luồng. Ví dụ, sử dụng Dapr Workflows, Coordinator sẽ ra lệnh: “Service A, hãy trừ tiền”. Khi A xác nhận thành công, Coordinator nói: “Service B, hãy cộng tiền”.

Trong các hệ thống ngân hàng, một Orchestrated Saga là bắt buộc vì nó cung cấp khả năng theo dõi trạng thái giao dịch rõ ràng (Pending, Failed, Success) cần thiết cho quá trình kiểm toán.

Thiết Kế Các Giao Dịch Bù Đắp (Compensating Transactions) Cho Các Sự Cố

Quy tắc tối thượng của Saga là: Nếu bước $N$ thất bại, hệ thống phải tự động gọi các Giao dịch Bù đắp của các bước $N-1, N-2, \dots$ để khôi phục trạng thái trước đó.

Nếu việc cộng tiền vào Ví B thất bại, Coordinator phải gửi một lệnh Refund quay lại Service A để hoàn trả tiền cho khách hàng, đảm bảo Tính Nhất Quán Cuối Cùng (Eventual Consistency).


Triển Khai Ghi Sổ Kép (Double-Entry Bookkeeping) Dưới Dạng Một Sổ Cái Bất Biến

Tại Sao Bạn Tuyệt Đối Không Bao Giờ Được Ghi Đè Trường Số Dư (Balance Field)

Một anti-pattern (phản mẫu) phổ biến trong giới lập trình viên ít kinh nghiệm là lưu trữ số dư của người dùng trong một cột balance bên trong bảng users và liên tục thực thi UPDATE users SET balance = balance + 100.

Đây là một Anti-pattern chết người trong Fintech. Nếu xảy ra sai lệch số dư, bạn sẽ không bao giờ biết tiền đã đi đâu vì lịch sử cộng trừ không được lưu giữ một cách đáng tin cậy.

Thiết Kế Cấu Trúc Bảng Sổ Nhật Ký (Journal) Và Tài Khoản (Account)

Giải pháp tiêu chuẩn vàng là áp dụng Ghi Sổ Kép (Double-Entry Bookkeeping). Mỗi giao dịch tài chính phải được ghi lại dưới dạng một mục sổ nhật ký và phải Bất Biến (Immutable) — bạn không bao giờ được phép UPDATE hay DELETE một bản ghi đã được cam kết (committed).

CREATE TABLE accounts (
    id UUID PRIMARY KEY,
    type VARCHAR(50) -- Customer, Revenue, Suspense
);

CREATE TABLE ledger_entries (
    id UUID PRIMARY KEY,
    transaction_id UUID NOT NULL,
    account_id UUID NOT NULL,
    amount DECIMAL(18,4) NOT NULL, -- Dương (Ghi Nợ), Âm (Ghi Có)
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Quan trọng cho Production: nếu không có index này, hàm SUM(amount) để tính toán
-- số dư sẽ yêu cầu quét toàn bộ bảng (full table scan) qua tất cả các ledger_entries.
CREATE INDEX idx_ledger_account_time ON ledger_entries (account_id, created_at DESC);

Mỗi giao dịch chèn ít nhất hai hàng vào ledger_entries sao cho tổng của cột amount luôn bằng 0. Số dư của khách hàng không phải là một cột được hardcode; nó được tính bằng cách thực thi SUM(amount) WHERE account_id = ? xuyên suốt lịch sử sổ cái của họ.

Lưu Ý Hệ Thống Phân Tán: Trong kiến trúc microservices, các dịch vụ trên các node khác nhau sẽ bị lệch đồng hồ (clock skew). Chỉ dùng created_at TIMESTAMPTZ để sắp xếp thứ tự sự kiện nghiêm ngặt là không đáng tin cậy. Để đảm bảo thứ tự đơn điệu được bảo đảm giữa các dịch vụ phân tán, hãy kết hợp created_at với Snowflake ID hoặc bộ tạo chuỗi tuần tự đơn điệu tương tự đóng vai trò là transaction_id.


Ngăn Chặn Việc Mất Sự Kiện (Event Loss) Bằng Transactional Outbox Pattern

Giải Quyết Vấn Đề Ghi Kép (Viết Vào DB vs. Gửi Sự Kiện)

Trong Saga, sau khi trừ tiền (ghi vào DB), một service phải gửi thông báo đến Kafka/RabbitMQ. Điều gì xảy ra nếu việc ghi DB thành công, nhưng Kafka lại bị sập và không gửi được thông báo? Saga sẽ bị gãy giữa chừng. Đây là Vấn Đề Ghi Kép (Dual-Write Problem).

Transactional Outbox giải quyết triệt để vấn đề này. Khi trừ tiền, service đồng thời lưu payload của Sự kiện vào một bảng outbox_events ngay bên trong cùng một Giao dịch Database với thao tác trừ tiền. Nhờ các thuộc tính ACID cục bộ, cả hai sẽ cùng thành công, hoặc cùng thất bại.

Tận Dụng CDC (Debezium) Để Truy Vấn Bảng Outbox

Thay vì viết một vòng lặp SELECT liên tục để truy vấn bảng outbox_events (gây lãng phí CPU của DB), các hệ thống hiện đại sử dụng công cụ Change Data Capture (CDC) như Debezium.

Debezium đọc trực tiếp từ MySQL Binlog / PostgreSQL WAL. Mỗi khi có một dòng mới được chèn vào bảng Outbox, Debezium lập tức chụp lại dữ liệu đó và bắn thẳng vào Kafka mà không làm ảnh hưởng đến hiệu suất của cơ sở dữ liệu chính.


Đảm Bảo Tính Idempotency (Tính Lũy Đẳng) Cho Các API Ngân Hàng

Do bản chất của mạng lưới phân tán, các thông điệp (Sự kiện) có thể được chuyển phát nhiều lần (At-Least-Once Delivery).

Tưởng tượng sự kiện DeductMoney($100) bị Kafka gửi 3 lần do cơ chế thử lại của mạng. Nếu API của bạn không được thiết kế cẩn thận, khách hàng sẽ bị trừ 300 đô la!

Tất cả các API xử lý giao dịch tài chính phải là Idempotent. Điều này đạt được thông qua:

  • Client gửi kèm một Idempotency-Key (hoặc Transaction ID) duy nhất với mỗi request.
  • Server kiểm tra một bảng processed_transactions: Nếu ID này đã được xử lý thành công, server ngay lập tức trả về kết quả thành công đã lưu (mà không trừ tiền lần nữa).
  • Nếu ID không tồn tại, server tiến hành trừ tiền và ghi ID vào bảng. Cả hai hành động này phải diễn ra trong một Giao dịch DB duy nhất, điều này ngụ ý bạn phải có một chiến lược mở rộng MySQL vững chắc nếu khối lượng giao dịch của bạn quá khổng lồ.

Thiết kế một kiến trúc hệ thống thanh toán không có chỗ cho sự phỏng đoán. Sự kết hợp giữa Saga Orchestration, Sổ cái Bất biến, Outbox Pattern, và các API Idempotent tạo nên bộ giáp vững chắc nhất để bảo vệ hàng triệu đô la trong các giao dịch hàng ngày trên nền tảng của bạn.