Vấn đề cốt lõi: The Concurrency Problem

Hãy tưởng tượng tài khoản của khách hàng A có 1.000.000 VNĐ. Cùng một lúc, hai sự kiện xảy ra:

  • Sự kiện 1: Khách hàng rút 800.000 VNĐ tại ATM chi nhánh Hà Nội.
  • Sự kiện 2: Hệ thống Auto-Debit thu phí dịch vụ 500.000 VNĐ.

Nếu cả hai giao dịch cùng đọc số dư 1.000.000 rồi cùng ghi lại kết quả, một trong hai giao dịch sẽ bị ghi đè — khách hàng có thể rút được tổng 1.300.000 VNĐ từ tài khoản chỉ có 1.000.000 VNĐ. Đây là lỗi Lost Update — thảm họa của mọi hệ thống tài chính.


ACID — Bốn tính chất bắt buộc

Tính chấtÝ nghĩa trong Core Banking
Atomicity (Tính nguyên tử)Toàn bộ bút toán Nợ và Có phải cùng thành công hoặc cùng rollback.
Consistency (Tính nhất quán)Sau mọi giao dịch, tổng Nợ = tổng Có. Không có trạng thái trung gian không hợp lệ.
Isolation (Tính cô lập)Hai giao dịch đồng thời không được nhìn thấy kết quả chưa hoàn tất của nhau.
Durability (Tính bền vững)Sau khi hệ thống xác nhận giao dịch, dữ liệu phải được lưu dù server có crash ngay sau đó.

Isolation Levels — Cấp độ cô lập

Đây là kiến thức mà nhiều developer bỏ qua nhưng cực kỳ quan trọng trong Core Banking:

1. READ UNCOMMITTED (Không dùng trong Core Banking)

Có thể đọc dữ liệu chưa được commit của giao dịch khác. Gây ra Dirty Read — đọc được số liệu sẽ bị rollback.

2. READ COMMITTED (Mức tối thiểu chấp nhận được)

Chỉ đọc dữ liệu đã được commit. Nhưng vẫn có thể gặp Non-Repeatable Read — đọc cùng một record hai lần trong cùng transaction có thể cho kết quả khác nhau.

3. REPEATABLE READ (MySQL/InnoDB mặc định)

Dữ liệu đọc trong transaction sẽ không thay đổi suốt vòng đời của transaction. Ngăn Non-Repeatable Read nhưng vẫn có thể gặp Phantom Read.

4. SERIALIZABLE (Mạnh nhất — dùng cho giao dịch quan trọng)

Các transaction được thực hiện tuần tự. Ngăn hoàn toàn Phantom Read. Hiệu năng thấp nhất nhưng an toàn nhất.

-- PostgreSQL: Thiết lập isolation level cho một transaction
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
-- ... thực hiện các thao tác tài chính ...
COMMIT;

Locking Strategies — Chiến lược Khóa

Pessimistic Locking (Khóa bi quan)

Khóa record ngay khi đọc, không để transaction khác chạm vào cho đến khi xong.

-- PostgreSQL: SELECT FOR UPDATE
BEGIN;
SELECT balance, available_balance 
FROM accounts 
WHERE account_number = 'ACC001'
FOR UPDATE;  -- Khóa row này cho đến khi COMMIT hoặc ROLLBACK

-- Kiểm tra số dư
-- UPDATE balance...
-- INSERT into ledger_entries...
COMMIT;

Ưu điểm: Đơn giản, chắc chắn không có race condition. Nhược điểm: Gây contention cao khi nhiều transaction tranh nhau cùng một tài khoản. Nguy cơ deadlock nếu không thiết kế thứ tự lock cẩn thận.

Optimistic Locking (Khóa lạc quan)

Không khóa khi đọc. Trước khi ghi, kiểm tra xem dữ liệu có bị thay đổi từ lần đọc hay không.

-- Thêm cột version vào bảng accounts
ALTER TABLE accounts ADD COLUMN version BIGINT NOT NULL DEFAULT 1;

-- Khi update, kèm điều kiện version
UPDATE accounts
SET 
    current_balance = current_balance - 500000,
    version = version + 1
WHERE 
    account_number = 'ACC001'
    AND version = 5;  -- version phải khớp với lần đọc

-- Nếu affected_rows = 0, nghĩa là có transaction khác đã thay đổi trước
-- → Retry hoặc trả về lỗi Conflict

Ưu điểm: Throughput cao hơn trong môi trường ít xung đột. Nhược điểm: Phải xử lý logic retry ở application layer.

Khi nào dùng cái nào?

Tình huốngNên dùng
Giao dịch ATM, chuyển tiền (xung đột cao)Pessimistic Locking
Cập nhật thông tin profile, lãi suất (xung đột thấp)Optimistic Locking
Trừ điểm loyalty, cộng điểm rewardOptimistic + Retry

Idempotency — Ngăn Giao dịch Trùng Lặp

Mạng có thể bị timeout. Client có thể gửi lại request. Làm thế nào để đảm bảo “chuyển tiền 1 triệu” chỉ xảy ra đúng một lần, dù request được gửi 5 lần?

Giải pháp là Idempotency Key:

CREATE TABLE transaction_requests (
    idempotency_key VARCHAR(64) PRIMARY KEY,  -- UUID do client tạo
    status          VARCHAR(20) NOT NULL DEFAULT 'PROCESSING',
    result          JSONB,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at      TIMESTAMPTZ NOT NULL
);
Logic xử lý:
1. Nhận request kèm Idempotency-Key header
2. Kiểm tra key trong bảng transaction_requests
   → Nếu đã tồn tại và status = 'COMPLETED': trả về kết quả cũ ngay
   → Nếu đang PROCESSING: trả về 409 Conflict (đang xử lý)
   → Nếu chưa tồn tại: INSERT key mới, tiến hành xử lý
3. Sau khi xong: UPDATE status = 'COMPLETED', lưu kết quả

Deadlock — Kẻ thù thầm lặng

Deadlock xảy ra khi Transaction A khóa Account X và đang chờ Account Y, trong khi Transaction B khóa Account Y và đang chờ Account X.

Cách phòng tránh duy nhất trong Core Banking: Luôn lock nhiều tài khoản theo thứ tự xác định (deterministic order):

// Sai: Có thể deadlock
func transfer(fromID, toID string) {
    lockAccount(fromID)  // Tx A lock A trước
    lockAccount(toID)    // Tx B lock B trước → deadlock
}

// Đúng: Luôn lock theo ID nhỏ hơn trước
func transfer(fromID, toID string) {
    first, second := fromID, toID
    if fromID > toID {
        first, second = toID, fromID
    }
    lockAccount(first)
    lockAccount(second)
}

Checklist Database cho Core Banking

  • Lưu tiền bằng BIGINT (đơn vị nhỏ nhất), không bao giờ dùng FLOAT
  • Mọi giao dịch tài chính đều trong một Database Transaction
  • Có cơ chế Idempotency Key để tránh duplicate
  • Lock order nhất quán để tránh deadlock
  • Ledger entries là immutable (chỉ INSERT, không UPDATE/DELETE)
  • Có invariant check định kỳ: SUM(DEBIT) = SUM(CREDIT)

Tiếp theo, chúng ta sẽ xem các ngân hàng số hiện đại giải quyết bài toán này ở quy mô hàng trăm triệu giao dịch/ngày bằng kiến trúc Microservices như thế nào. Đọc tiếp Phần 4 — Kiến trúc Core Banking hiện đại (Microservices & Event-Driven).