Chuỗi bài (Phần 1 của 8): Series này đào sâu kiến trúc Core Banking production-grade. Bài này tập trung vào nền tảng quan trọng nhất: thiết kế schema cho Double-Entry Ledger và chiến lược concurrency locking. Nếu bạn mới bắt đầu với Core Banking, hãy đọc Core Banking Developer Series trước.

⚠️ Lưu ý: Bài viết này tổng hợp từ documentation chính thức, engineering blogs, và benchmark papers đã công bố. Các con số latency và schema design phản ánh tài liệu nguồn tại thời điểm viết. Hãy verify với kiến trúc sư hoặc lead engineer của team trước khi áp dụng vào hệ thống production.

Double-Entry Ledger Database Schema Là Gì?

Một database schema cho double-entry ledger yêu cầu tính bất biến (immutability), bảo đảm ACID, và cơ chế locking chính xác để tránh race conditions. Các hệ thống hiện đại như TigerBeetle loại bỏ pessimistic locking truyền thống bằng cách sử dụng single-threaded state machine, đạt mức 1,000,000 TPS trên một CPU core duy nhất. Khi scale lên distributed environment, xem thêm Phần 2 — Distributed SQL & ACID Latency cho so sánh TiDB, CockroachDB, và Spanner.


Vấn Đề Cốt Lõi: Tại Sao Ledger Schema Phức Tạp Hơn Bạn Nghĩ?

Hầu hết developer khi mới vào Fintech nghĩ ledger đơn giản chỉ là hai thao tác:

UPDATE accounts SET balance = balance - 1000000 WHERE id = 'A';
UPDATE accounts SET balance = balance + 1000000 WHERE id = 'B';

Đây là thiết kế sai hoàn toàn cho ba lý do:

  1. Không có audit trail: Không thể biết số dư hiện tại hình thành từ những giao dịch nào.
  2. Không immutable: Bất kỳ UPDATE nào cũng phá hủy lịch sử kế toán — vi phạm chuẩn GAAP và Ngân hàng Nhà nước.
  3. Race condition: Hai transactions đồng thời đọc cùng balance → ghi đè nhau → double-spend.

Đúng chuẩn phải là ghi journal entries vào bảng ledger, trong đó mỗi giao dịch tạo ra ít nhất hai bút toán Nợ/Có (double-entry), và tổng phải bằng không.


Mambu GL Schema: Schema Thực Tế Từ Production

Mambu — một trong những Core Banking SaaS hàng đầu — thiết kế bảng GL của họ với các nguyên tắc sau:

Các cột bắt buộc trong gl_journal_entries:

ColumnTypeÝ nghĩa
entryidBIGINT AUTO_INCREMENTPrimary key tuần tự
encodedkeyVARCHAR(36) UNIQUEUUID bất biến của entry — không bao giờ thay đổi
transactionidVARCHAR(36)Link tới transaction gốc
accountkeyVARCHAR(36)Tài khoản bị ảnh hưởng
typeENUM('DEBIT','CREDIT')Loại bút toán
amountDECIMAL(18,4)Số tiền (không âm)
reversalentrykeyVARCHAR(36) NULLTrỏ về entry gốc khi đây là reversal
created_atTIMESTAMPTZTimestamp immutable

Nguyên tắc bất biến của Mambu: Một khi entryid được ghi vào database, không có UPDATE hay DELETE nào được phép. Để sửa sai, hệ thống tạo reversal entry mới trỏ về encodedkey của entry sai qua cột reversalentrykey. Đây chính là cơ chế kiểm toán (audit) thực sự.


TigerBeetle: Kiến Trúc Ledger 1,000,000 TPS

TigerBeetle là database chuyên dụng cho financial ledgers, viết bằng Zig, đạt 1,000,000 TPS trên một CPU core duy nhất bằng cách tránh hoàn toàn database locking thông qua kiến trúc single-threaded state machine.

TigerBeetle Account Struct (128 bytes, C ABI aligned)

// TigerBeetle Account Struct — 128 bytes chính xác, CPU cache-line aligned
pub const Account = extern struct {
    id: u128,                 // 16 bytes: Unique identifier (UUIDv4/v7 hoặc custom monotonic ID)
    debits_pending: u128,     // 16 bytes: Số tiền đang được reserve trong pending transfers
    debits_posted: u128,      // 16 bytes: Tổng debit đã commit hoàn toàn
    credits_pending: u128,    // 16 bytes: Số tiền được reserve ở phía credit
    credits_posted: u128,     // 16 bytes: Tổng credit đã commit hoàn toàn
    user_data_128: u128,      // 16 bytes: Metadata tuỳ chỉnh (ví dụ: customer_id)
    user_data_64: u64,        //  8 bytes: Metadata tuỳ chỉnh
    user_data_32: u32,        //  4 bytes: Metadata tuỳ chỉnh
    reserved: u32 = 0,        //  4 bytes: Padding để đạt đúng 128 bytes
    ledger: u32,              //  4 bytes: Nhóm tài khoản theo loại tiền tệ / asset type
    code: u16,                //  2 bytes: Chart of Accounts code (ví dụ: 1001 = tiền mặt)
    flags: u16,               //  2 bytes: Business rules flags
    timestamp: u64,           //  8 bytes: Nanosecond timestamp (do cluster quản lý)
};

// TigerBeetle Transfer Struct — 128 bytes, cùng alignment
pub const Transfer = extern struct {
    id: u128,                 // 16 bytes: Unique transfer ID
    debit_account_id: u128,   // 16 bytes: Tài khoản bị debit
    credit_account_id: u128,  // 16 bytes: Tài khoản được credit
    amount: u128,             // 16 bytes: Số lượng asset cần chuyển
    pending_id: u128,         // 16 bytes: ID của pending transfer (dùng trong two-phase)
    user_data_128: u128,      // 16 bytes: Metadata tuỳ chỉnh
    user_data_64: u64,        //  8 bytes: Metadata tuỳ chỉnh
    user_data_32: u32,        //  4 bytes: Metadata tuỳ chỉnh
    timeout: u32 = 0,         //  4 bytes: Auto-void timeout tính bằng giây
    ledger: u32,              //  4 bytes: Phải khớp với ledger của cả hai accounts
    code: u16,                //  2 bytes: Category code tuỳ chỉnh
    flags: u16,               //  2 bytes: Config flags (pending, post_pending, void_pending)
    timestamp: u64,           //  8 bytes: Timestamp nanosecond khi commit vào log
};

Tại sao 128 bytes? Để mỗi struct chiếm đúng một CPU cache line (64–128 bytes tùy kiến trúc), tối đa hoá throughput khi batch xử lý. TigerBeetle batch tối đa 8,190 requests mỗi lần vào kernel I/O (io_uring).

Two-Phase Transfer: Math Thực Tế

Khi Transfer có flag pending, database reserve tiền nhưng chưa post:

Phase 1 — Pending (Reserve):

debit_account.debits_pending  += transfer.amount
credit_account.credits_pending += transfer.amount

Phase 2A — Post Pending (Commit thành công):

debit_account.debits_pending  -= transfer.amount
debit_account.debits_posted   += transfer.amount
credit_account.credits_pending -= transfer.amount
credit_account.credits_posted  += transfer.amount

Phase 2B — Void Pending (Hủy):

debit_account.debits_pending  -= transfer.amount
credit_account.credits_pending -= transfer.amount

PostgreSQL DDL: Schema Double-Entry Với Enforcement

Với hệ thống dùng PostgreSQL (không phải TigerBeetle), đây là schema production-grade:

-- Bảng Accounts: Định nghĩa các tài khoản trong Chart of Accounts
CREATE TABLE accounts (
    id              UUID PRIMARY KEY,
    name            VARCHAR(100) NOT NULL,
    currency        CHAR(3) NOT NULL,           -- ISO 4217: 'VND', 'USD', 'JPY'
    debit_balance   NUMERIC(18, 4) DEFAULT 0.0000 NOT NULL 
                    CHECK (debit_balance >= 0),
    credit_balance  NUMERIC(18, 4) DEFAULT 0.0000 NOT NULL 
                    CHECK (credit_balance >= 0),
    type            VARCHAR(20) NOT NULL 
                    CHECK (type IN ('ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE')),
    created_at      TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Bảng Transactions: Header của mỗi nhóm journal entries
CREATE TABLE transactions (
    id              UUID PRIMARY KEY,
    description     VARCHAR(255),
    posted_at       TIMESTAMP WITH TIME ZONE NOT NULL,
    created_at      TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Bảng Entries: Từng bút toán Nợ/Có (các "legs" của một transaction)
CREATE TABLE entries (
    id              UUID PRIMARY KEY,
    transaction_id  UUID NOT NULL REFERENCES transactions(id) ON DELETE CASCADE,
    account_id      UUID NOT NULL REFERENCES accounts(id),
    amount          NUMERIC(18, 4) NOT NULL CHECK (amount <> 0),
    direction       VARCHAR(6) NOT NULL CHECK (direction IN ('DEBIT', 'CREDIT')),
    created_at      TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);

-- Indexes để tăng tốc balance lookup
CREATE INDEX idx_entries_account_id     ON entries(account_id);
CREATE INDEX idx_entries_transaction_id ON entries(transaction_id);

-- Trigger: Enforce balance invariant — tổng DEBIT phải = tổng CREDIT trong cùng transaction
CREATE OR REPLACE FUNCTION verify_transaction_balance()
RETURNS TRIGGER AS $$
DECLARE
    balance_sum NUMERIC(18, 4);
BEGIN
    SELECT COALESCE(
        SUM(CASE WHEN direction = 'DEBIT' THEN amount ELSE -amount END),
        0
    )
    INTO balance_sum
    FROM entries
    WHERE transaction_id = NEW.transaction_id;

    IF balance_sum <> 0 THEN
        RAISE EXCEPTION 
            'Transaction unbalanced: SUM(DEBIT) - SUM(CREDIT) = %. Transaction ID: %',
            balance_sum, NEW.transaction_id;
    END IF;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_verify_balance
AFTER INSERT ON entries
FOR EACH ROW EXECUTE FUNCTION verify_transaction_balance();

Lưu ý: Luôn dùng NUMERIC(18, 4) hoặc BIGINT (đơn vị nhỏ nhất như đồng, cents). Tuyệt đối không dùng FLOAT hay DOUBLE — floating-point precision errors sẽ tích lũy sau hàng triệu giao dịch và làm sổ cái mất cân bằng.


Balance Invariants: Ba Quy Tắc Toán Học

TigerBeetle enforce ba invariants trên mỗi transfer:

1. Quy tắc cơ bản (không âm): $$\text{debits_pending} + \text{debits_posted} \ge 0$$ $$\text{credits_pending} + \text{credits_posted} \ge 0$$

2. Quy tắc Asset Account (tài khoản khách hàng — không được chi quá số có): $$\text{debits_pending} + \text{debits_posted} \le \text{credits_posted}$$

3. Quy tắc Liability Account (vốn ngân hàng — không được vay quá vốn): $$\text{credits_pending} + \text{credits_posted} \le \text{debits_posted}$$


Concurrency Locking: Pessimistic vs Optimistic vs TigerBeetle

Benchmark Thực Tế

StrategyTPS (low contention)TPS (high contention, 1000+ TPS)Rủi ro
Pessimistic Locking (SELECT FOR UPDATE)~5,000 TPS<100 TPS (deadlock risk)Deadlock khi không lock theo thứ tự
Optimistic Locking (version column)~20,000 TPSRetry rate >90%Retry storm, livelock
TigerBeetle Single-Threaded1,000,000 TPS1,000,000 TPS (không thay đổi)Không có locking — sequential by design

Nguồn: TigerBeetle Concepts, ACM benchmark papers.

PostgreSQL Pessimistic Locking (Production Pattern)

BEGIN;

-- Khóa hai accounts theo thứ tự ID để tránh deadlock
-- Quy tắc: LUÔN lock account có ID nhỏ hơn trước
SELECT id, debit_balance, credit_balance
FROM accounts
WHERE id IN ('account-A', 'account-B')
ORDER BY id  -- Deterministic order — ngăn deadlock
FOR UPDATE;

-- Kiểm tra balance đủ không
-- INSERT vào transactions
-- INSERT vào entries (Nợ và Có)
-- UPDATE account balances

COMMIT;

Tại Sao TigerBeetle Không Cần Locking?

TigerBeetle sử dụng single-threaded state machine — toàn bộ ledger chạy trên một CPU core duy nhất với io_uring cho async I/O. Không có concurrent writes, không có locks, không có deadlocks. Tất cả requests được batch và xử lý tuần tự với thứ tự xác định.


Bài Học Từ Hệ Thống Production

Quy tắc bất di bất dịch cho Double-Entry Ledger:

  1. Chỉ INSERT, không UPDATE/DELETE trên ledger entries đã commit.
  2. Mọi giao dịch phải atomic — tất cả entries cùng commit hoặc cùng rollback.
  3. Lưu tiền bằng integer (BIGINT hoặc NUMERIC) — không bao giờ FLOAT.
  4. Verify invariant định kỳ bằng câu query reconciliation.
  5. Lock theo thứ tự xác định khi pessimistic locking nhiều accounts.

Câu query kiểm tra sức khỏe sổ cái (chạy mỗi 5 phút):

-- Phát hiện mọi transaction có SUM(DEBIT) ≠ SUM(CREDIT)
SELECT
    transaction_id,
    SUM(CASE WHEN direction = 'DEBIT' THEN amount ELSE -amount END) AS discrepancy
FROM entries
GROUP BY transaction_id
HAVING SUM(CASE WHEN direction = 'DEBIT' THEN amount ELSE -amount END) <> 0;

-- Kết quả mong đợi: 0 rows. Nếu có rows → P1 alert ngay lập tức.

QA & SDET Testing Strategy

Test 1: Concurrent Double-Spend Prevention

// Chạy 100 goroutines đồng thời rút $10 từ tài khoản có $100
func TestConcurrentWithdrawal(t *testing.T) {
    const (
        numWorkers     = 100
        withdrawAmount = 10_000   // $10 in cents
        initialBalance = 100_000  // $100 in cents
    )
    
    var (
        successCount atomic.Int64
        wg           sync.WaitGroup
    )
    
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            err := withdrawFunds("account-A", withdrawAmount)
            if err == nil {
                successCount.Add(1)
            }
        }()
    }
    wg.Wait()
    
    // Chỉ đúng 10 requests được phép thành công
    assert.Equal(t, int64(10), successCount.Load(),
        "Chỉ 10 withdrawals được phép với balance $100")
    
    // Không có double-spend: balance cuối phải = $0
    balance := getBalance("account-A")
    assert.Equal(t, int64(0), balance, "Balance sau khi rút hết phải = 0")
}

Test 2: Continuous Reconciliation Job

func reconcileAllTransactions(db *sql.DB) ([]UnbalancedTx, error) {
    query := `
        SELECT transaction_id, 
               SUM(CASE WHEN direction = 'DEBIT' THEN amount ELSE -amount END) AS discrepancy
        FROM entries
        GROUP BY transaction_id
        HAVING SUM(CASE WHEN direction = 'DEBIT' THEN amount ELSE -amount END) <> 0
    `
    rows, err := db.Query(query)
    if err != nil {
        return nil, err
    }
    // Parse rows và fire P1 alert nếu có bất kỳ discrepancy nào
    // ...
}

FAQ

TigerBeetle có phù hợp cho mọi ứng dụng Fintech không?

Không nhất thiết. TigerBeetle tối ưu cho high-throughput financial ledger (>100,000 TPS), nhưng thiếu SQL query flexibility. Nếu bạn cần complex reporting queries, joins, hoặc tích hợp với ORM truyền thống, PostgreSQL + double-entry schema vẫn là lựa chọn tốt.

Tại sao không dùng FLOAT để lưu tiền?

Floating-point numbers (IEEE 754) không thể biểu diễn chính xác nhiều thập phân. Ví dụ: 0.1 + 0.2 = 0.30000000000000004 trong hầu hết ngôn ngữ lập trình. Sau hàng triệu phép tính, sai số này tích lũy đủ để làm lệch sổ cái. Dùng NUMERIC(18,4) hoặc BIGINT (lưu theo cents/đồng).

Reversal Entry và Void Entry khác nhau thế nào?

  • Reversal Entry: Tạo entry mới đối chiều, trỏ về entry gốc qua reversalentrykey. Dùng để sửa lỗi sau khi transaction đã settle.
  • Void Pending: Hủy một transfer đang ở trạng thái pending (chưa settle). Chỉ thay đổi debits_pending/credits_pending, không ảnh hưởng posted fields.

Tiếp theo: Phần 2 — Distributed SQL & ACID Latency: TiDB vs CockroachDB vs Spanner — Phân tích chi tiết độ trễ 2PC, TrueTime math, và Percolator lock recovery.