Chuyển tiền A → B: không âm, không trừ hai lần, không mất một đồng

5 câu hỏi~30 phútTừ khóa: invariant, ACID, double-entry ledger, idempotency key, SELECT FOR UPDATE, lock ordering, deadlock, saga liên ngân hàng, reconciliation, audit

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:

InvariantNghĩa là
Không âmSố 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ầnClient retry cùng lệnh thì chỉ chuyển một lần
Bảo toànTổng tiền trong hệ thống không đổi: A mất 100 thì B nhận đúng 100
Truy vếtMọ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ƯuNhược
Pessimistic (FOR UPDATE)Đơn giản, đúng, hợp với tiềnTài khoản nóng bị xếp hàng
Optimistic (version)Không giữ lockTranh chấp cao thì retry liên tục
Conditional UPDATE WHERE balance >= :amtMột câu lệnh, không đọc trướcPhả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:

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ầngKiểm traTần suất
Ledger cân bằngMỗi transfer: tổng DEBIT = tổng CREDITGần real-time
Balance vs ledgeraccount.balance = tổng bút toán của accountHằng đêm
Nội bộ vs bên ngoàiGiao dịch liên ngân hàng so với file sao kê từ network/ngân hàng đối tácHằ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

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 UPDATE theo 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 UPDATE in 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


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ũ.