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:
- 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.
- Không immutable: Bất kỳ
UPDATEnà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. - 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:
| Column | Type | Ý nghĩa |
|---|---|---|
entryid | BIGINT AUTO_INCREMENT | Primary key tuần tự |
encodedkey | VARCHAR(36) UNIQUE | UUID bất biến của entry — không bao giờ thay đổi |
transactionid | VARCHAR(36) | Link tới transaction gốc |
accountkey | VARCHAR(36) | Tài khoản bị ảnh hưởng |
type | ENUM('DEBIT','CREDIT') | Loại bút toán |
amount | DECIMAL(18,4) | Số tiền (không âm) |
reversalentrykey | VARCHAR(36) NULL | Trỏ về entry gốc khi đây là reversal |
created_at | TIMESTAMPTZ | Timestamp 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ặcBIGINT(đơn vị nhỏ nhất như đồng, cents). Tuyệt đối không dùngFLOAThayDOUBLE— 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ế
| Strategy | TPS (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 TPS | Retry rate >90% | Retry storm, livelock |
| TigerBeetle Single-Threaded | 1,000,000 TPS | 1,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:
- Chỉ INSERT, không UPDATE/DELETE trên ledger entries đã commit.
- Mọi giao dịch phải atomic — tất cả entries cùng commit hoặc cùng rollback.
- Lưu tiền bằng integer (BIGINT hoặc NUMERIC) — không bao giờ FLOAT.
- Verify invariant định kỳ bằng câu query reconciliation.
- 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 đổidebits_pending/credits_pending, không ảnh hưởngpostedfields.
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.