Chuyển tiền A → B: không âm, không trừ hai lần, không mất một đồng
Tiếp đi: thiết kế chuyển tiền giữa hai tài khoản ngân hàng.
Bài này khó hơn Payment. Payment chủ yếu là gọi provider bên ngoài. Ở đây chính mình giữ tiền, nên mọi lỗi nhỏ là lệch tiền thật. Nó nối ACID + concurrency + payment.
Account A: 1,000 ── chuyển 100 ──► Account B
1. Requirements và invariant
Trước khi vẽ gì, nói rõ những điều không bao giờ được sai:
| Invariant | Nghĩa là |
|---|---|
| Không âm | Số dư A không xuống dưới 0 (hoặc dưới hạn mức thấu chi) |
| Không trừ hai lần | Client retry cùng lệnh thì chỉ chuyển một lần |
| Bảo toàn | Tổng tiền trong hệ thống không đổi: A mất 100 thì B nhận đúng 100 |
| Truy vết | Mọi thay đổi số dư đều có bút toán, không sửa, không xoá |
Non-functional: consistency quan trọng hơn availability. Thà từ chối giao dịch còn hơn chuyển sai.
2. Ước lượng
10M tài khoản, 5M giao dịch/ngày ≈ 60 TPS, đỉnh (ngày lương) ~600 TPS
Mỗi giao dịch = 1 transfer + 2 ledger entry
→ Một PostgreSQL primary chịu tốt. Bài khó ở correctness, không ở scale.
3. API
POST /transfers
Idempotency-Key: 7f3c... (client sinh một lần cho một lệnh)
{ "fromAccount":"A", "toAccount":"B", "amount":"100.00", "currency":"VND" }
→ 201 { "transferId":"t1", "status":"COMPLETED" }
→ 422 { "error":"INSUFFICIENT_FUNDS" }
GET /transfers/t1
GET /accounts/A/statement?from=...
Tiền dùng BigDecimal / NUMERIC(19,2), hoặc số nguyên đơn vị nhỏ nhất. Không bao giờ dùng double.
4. Data model: double-entry ledger
account (id, currency, balance NUMERIC(19,2), version, status)
transfer (id, idempotency_key UNIQUE, from_acc, to_acc, amount, status, created_at)
ledger_entry (id, transfer_id, account_id, direction DEBIT/CREDIT, amount, balance_after, created_at)
-- chỉ INSERT, không UPDATE/DELETE
transfer t1: 100
ledger: A DEBIT 100 balance_after 900
ledger: B CREDIT 100 balance_after 600
tổng DEBIT = tổng CREDIT ← luôn đúng với mọi transfer
account.balance chỉ là số dư cache để kiểm tra nhanh. Sự thật là ledger: SUM(credit) − SUM(debit) phải bằng balance.
5. Kiến trúc
Client
│ Idempotency-Key
▼
API Gateway (auth, rate limit)
▼
Transfer Service ──► PostgreSQL (account, transfer, ledger_entry, outbox) ── 1 transaction
│ │
│ Outbox → Kafka → Notification, Fraud, Analytics
▼
Reconciliation Job (hằng đêm + gần real-time)
Audit log (append-only, lưu riêng, không sửa được)
6. Code: chuyển tiền nội bộ trong một transaction
@Service
@RequiredArgsConstructor
public class TransferService {
private final AccountRepository accounts;
private final TransferRepository transfers;
private final LedgerRepository ledger;
private final OutboxRepository outbox;
@Transactional
public Transfer transfer(String idemKey, TransferCommand cmd) {
Optional<Transfer> existing = transfers.findByIdempotencyKey(idemKey);
if (existing.isPresent()) return existing.get(); // retry → trả kết quả cũ
// Lock ordering: luôn khoá account id nhỏ trước để không deadlock
String first = min(cmd.from(), cmd.to());
String second = max(cmd.from(), cmd.to());
Account a1 = accounts.findByIdForUpdate(first).orElseThrow(); // SELECT ... FOR UPDATE
Account a2 = accounts.findByIdForUpdate(second).orElseThrow();
Account from = a1.getId().equals(cmd.from()) ? a1 : a2;
Account to = from == a1 ? a2 : a1;
if (from.getBalance().compareTo(cmd.amount()) < 0) throw new InsufficientFundsException();
from.debit(cmd.amount());
to.credit(cmd.amount());
Transfer t = transfers.save(Transfer.completed(idemKey, cmd)); // UNIQUE(idempotency_key)
ledger.save(LedgerEntry.debit(t, from));
ledger.save(LedgerEntry.credit(t, to));
outbox.save(OutboxEvent.of("TransferCompleted", t));
return t;
}
}
Hai request cùng idempotency key chạy song song: cả hai không thấy bản ghi cũ, nhưng chỉ một INSERT qua được unique constraint. Bên thua rollback, bắt DataIntegrityViolationException rồi đọc lại kết quả của bên thắng.
7. So sánh cách cập nhật số dư
| Cách | Ưu | Nhược |
|---|---|---|
Pessimistic (FOR UPDATE) | Đơn giản, đúng, hợp với tiền | Tài khoản nóng bị xếp hàng |
Optimistic (version) | Không giữ lock | Tranh chấp cao thì retry liên tục |
Conditional UPDATE WHERE balance >= :amt | Một câu lệnh, không đọc trước | Phải kiểm tra affected rows, vẫn cần thứ tự khi đụng 2 dòng |
Chi tiết pessimistic/optimistic có ở bài Ticket Booking.
Sao phải khoá theo thứ tự account id? Mình khoá tài khoản gửi trước rồi tới tài khoản nhận cho tự nhiên không được à?
Được về logic, nhưng sẽ deadlock khi có hai lệnh chuyển ngược chiều cùng lúc.
T1: A → B T2: B → A
lock A ✓ lock B ✓
lock B ⏳ (T2 đang giữ) lock A ⏳ (T1 đang giữ)
└──────── chờ nhau mãi ────────┘ 💀 deadlock
PostgreSQL sẽ phát hiện và huỷ một transaction, nhưng đó là lỗi, tốn thời gian, và dưới tải cao xảy ra liên tục.
Lock ordering: luôn khoá id nhỏ trước
T1: A → B lock A, lock B
T2: B → A lock A ⏳ (chờ T1 xong), lock B
→ không bao giờ có vòng chờ
Nguyên tắc chung (xem Deadlock): deadlock cần vòng chờ; mọi transaction lấy lock theo cùng một thứ tự thì không thể có vòng.
Thêm hai lớp bảo vệ: lock_timeout ngắn (vd 2s) để không treo lâu, và retry có giới hạn cho lỗi deadlock/serialization (an toàn vì đã có idempotency key).
Chuyển sang ngân hàng khác thì không chung database. Lúc đó làm sao đảm bảo không mất tiền?
Đúng, không thể có ACID trải qua hai ngân hàng. Ta dùng saga với các bước có trạng thái rõ ràng, cộng tài khoản trung gian (suspense/clearing account).
1. HOLD : trừ A 100, ghi có vào "tài khoản chờ chuyển đi" (1 transaction nội bộ)
2. SEND : gửi lệnh tới ngân hàng B qua network liên ngân hàng (kèm transferId làm idempotency key)
3a. SUCCESS : ghi nợ tài khoản chờ, ghi có tài khoản thanh toán bù trừ → COMPLETED
3b. REJECT : bút toán đảo: trả 100 về A → FAILED (compensation)
3c. TIMEOUT : trạng thái UNKNOWN → hỏi lại trạng thái / chờ callback, KHÔNG tự hoàn tiền
INITIATED → FUNDS_HELD → SENT → COMPLETED
├──→ REJECTED → REVERSED
└──→ UNKNOWN → (query) → COMPLETED / REVERSED
Hai điểm hay bị hỏi:
- Timeout không có nghĩa là thất bại. Hoàn tiền ngay khi timeout có thể làm B nhận tiền mà A cũng được trả lại. Phải query trạng thái hoặc chờ đối soát.
- Bồi hoàn cũng là bút toán mới, không xoá bút toán cũ. Ledger chỉ có INSERT.
Saga và cách tổ chức orchestration xem ở Saga; gửi event không mất xem Outbox.
Code đã có transaction, lock, idempotency rồi thì có cần đối soát và audit nữa không?
Cần. Code đúng chỉ bảo vệ những lỗi mình đã nghĩ tới. Đối soát phát hiện những lỗi mình chưa nghĩ tới: bug, sửa tay trong DB, hệ thống ngoài báo khác.
1. Ba tầng đối soát
| Tầng | Kiểm tra | Tần suất |
|---|---|---|
| Ledger cân bằng | Mỗi transfer: tổng DEBIT = tổng CREDIT | Gần real-time |
| Balance vs ledger | account.balance = tổng bút toán của account | Hằng đêm |
| Nội bộ vs bên ngoài | Giao dịch liên ngân hàng so với file sao kê từ network/ngân hàng đối tác | Hằng ngày |
SELECT a.id, a.balance,
SUM(CASE WHEN l.direction='CREDIT' THEN l.amount ELSE -l.amount END) AS ledger_balance
FROM account a JOIN ledger_entry l ON l.account_id = a.id
GROUP BY a.id, a.balance
HAVING a.balance <> SUM(CASE WHEN l.direction='CREDIT' THEN l.amount ELSE -l.amount END);
Lệch thì không tự sửa âm thầm: tạo case, cảnh báo, người duyệt rồi tạo bút toán điều chỉnh.
2. Audit
- Ledger append-only: chặn UPDATE/DELETE bằng quyền DB.
- Audit log ghi ai làm gì, lúc nào, từ đâu; lưu ở nơi riêng (WORM storage).
- Giữ dữ liệu theo quy định (thường 5–10 năm).
Phỏng vấn hỏi "thiết kế chuyển tiền giữa hai tài khoản" thì trả lời sao?
Q1. Làm sao đảm bảo chuyển tiền nội bộ đúng?
🇻🇳 Tiếng Việt:
Em làm trong một DB transaction: kiểm tra idempotency key, khoá hai tài khoản bằng
SELECT FOR UPDATEtheo thứ tự id để tránh deadlock, kiểm tra số dư, rồi ghi hai bút toán double-entry và một outbox event. Ledger chỉ INSERT, số dư trên account chỉ là cache của ledger.
🇬🇧 English:
I do it in one database transaction: check the idempotency key, lock both accounts with
SELECT FOR UPDATEin id order to avoid deadlocks, check the balance, then write two double-entry ledger records and an outbox event. The ledger is append-only, and the account balance is just a cached view of it.
Q2. Client retry vì timeout, làm sao không trừ hai lần?
🇻🇳 Tiếng Việt:
Client gửi Idempotency-Key, server lưu nó với unique constraint trong cùng transaction với bút toán. Request lặp lại sẽ trả kết quả cũ. Hai request song song thì chỉ một cái insert được, cái còn lại rollback và đọc kết quả.
🇬🇧 English:
The client sends an Idempotency-Key, and the server stores it with a unique constraint in the same transaction as the ledger entries. A retried request returns the original result. If two arrive concurrently, only one insert succeeds and the other rolls back and reads the stored result.
Q3. Chuyển liên ngân hàng bị timeout thì làm gì?
🇻🇳 Tiếng Việt:
Em dùng saga: giữ tiền vào tài khoản chờ, gửi lệnh có idempotency key, rồi chờ kết quả. Timeout thì để trạng thái UNKNOWN, query lại hoặc chờ callback, không tự hoàn tiền. Bị từ chối thì ghi bút toán đảo. Cuối ngày đối soát với file của đối tác.
🇬🇧 English:
I use a saga: move the funds into a suspense account, send the instruction with an idempotency key, and wait for the outcome. On timeout the status becomes UNKNOWN and we query or wait for a callback, never refunding automatically. On rejection we post a reversing entry. At the end of the day we reconcile against the partner's settlement file.
Cách nhớ ngắn gọn
- Nói invariant trước: không âm, không hai lần, bảo toàn, truy vết.
- Nội bộ: 1 transaction + FOR UPDATE theo thứ tự id + double-entry + idempotency key.
- Liên ngân hàng: saga + tài khoản chờ; timeout = UNKNOWN, không phải FAILED.
- Đối soát và audit luôn có, vì code đúng chưa đủ.
Tự kiểm tra
Double-entry bookkeeping là gì?
Mỗi giao dịch ghi ít nhất hai bút toán, tổng ghi nợ bằng tổng ghi có. Tiền không tự sinh ra hay biến mất.
Vì sao khoá hai tài khoản theo thứ tự id?
Để mọi transaction lấy lock cùng thứ tự, không có vòng chờ nên không deadlock khi A→B và B→A chạy cùng lúc.
Vì sao không dùng double cho số tiền?
Số dấu phẩy động sai số khi biểu diễn thập phân. Dùng BigDecimal/NUMERIC hoặc số nguyên đơn vị nhỏ nhất.
Chuyển liên ngân hàng bị timeout có nên hoàn tiền ngay?
Không. Kết quả chưa biết (UNKNOWN). Phải query trạng thái hoặc đối soát, nếu không có thể vừa chuyển vừa hoàn.
Đối soát phát hiện lệch thì làm gì?
Cảnh báo, tạo case cho người duyệt, rồi ghi bút toán điều chỉnh. Không sửa hay xoá bút toán cũ.