Nguyên tắc số một: không lưu số dư như một con số có thể ghi đè. Nguồn sự thật là sổ cái (ledger) chỉ ghi thêm, số dư là kết quả cộng dồn.
ledger_entries(
id, tx_id, account_id, amount_minor bigint, -- am cho ghi no, duong cho ghi co
currency, created_at,
unique (tx_id, account_id)
)
accounts(id, balance_minor bigint, version int) -- ban ghi nhanh, suy ra tu ledger- Tiền lưu bằng số nguyên đơn vị nhỏ nhất (đồng), không bao giờ dùng
float. - Mỗi giao dịch chuyển tiền tạo ít nhất 2 dòng ledger có tổng bằng 0 (double-entry). Job đối soát chạy định kỳ kiểm tra
sum(amount) = 0theotx_idvàsum(ledger) = accounts.balance.
Chống trừ tiền hai lần: client gửi Idempotency-Key; server lưu key kèm kết quả, request trùng key trả lại đúng kết quả cũ thay vì thực hiện lại. Đây là câu hỏi phụ gần như chắc chắn bị hỏi.
Tranh chấp đồng thời: hai lệnh rút cùng lúc trên một ví. Hai cách:
- SELECT ... FOR UPDATE trên dòng account — đơn giản, đúng, nhưng ví nóng bị xếp hàng.
- Optimistic locking bằng cột version, thất bại thì retry — tốt khi tranh chấp thấp.
Tuyệt đối tránh đọc số dư rồi ghi đè ở tầng ứng dụng: đó là lost update kinh điển.
Nạp/rút qua ngân hàng: trạng thái pending -> settled/failed, chỉ ghi ledger khi có xác nhận từ đối tác, còn webhook phải idempotent và verify chữ ký. Nếu quy trình trải nhiều service (ví, khuyến mãi, đơn hàng) thì dùng saga với bước bù trừ — không có 2PC.
Đánh đổi: ledger append-only làm dữ liệu lớn hơn và đọc số dư chậm hơn nếu tính lại mỗi lần; vì vậy giữ accounts.balance như bản chiếu (projection) cập nhật trong cùng transaction với ledger.