Ví, cộng/trừ số dư, double spending, ledgerWallets, balances, double spending, ledgers
Wallet API nhận lệnh chuyển kèm request id, ghi hai bút toán và cập nhật số dư trong một transaction trên Postgres, rồi phát sự kiện qua outbox. Job đối soát cộng lại ledger để kiểm tra số dư cache. Chọn “Hai lệnh rút đồng thời” để xem khoá dòng chặn double spending, hoặc “Retry cùng request id” để xem idempotency.The Wallet API takes a transfer with a request id, writes two ledger entries and updates balances in one Postgres transaction, then publishes an event through the outbox. A reconciliation job re-sums the ledger to check the cached balances. Pick “Two concurrent withdrawals” to see the row lock stop double spending, or “Retry with the same request id” to see idempotency.
1User 1001 chuyển 150.000 đ cho user 2002. App sinh request id một lần cho thao tác này.User 1001 sends 150,000 VND to user 2002. The app generates one request id for this action.
Đọc số dư rồi mới trừ ở tầng ứng dụng là race condition. Dùng UPDATE … SET balance = balance − x WHERE balance − held ≥ x, hoặc SELECT … FOR UPDATE rồi kiểm tra trong cùng transaction.Reading the balance and then subtracting in application code is a race. Use UPDATE … SET balance = balance − x WHERE balance − held ≥ x, or SELECT … FOR UPDATE and check inside the same transaction.
A→B và B→A cùng lúc, mỗi bên giữ một khoá và chờ khoá kia. Luôn khoá theo thứ tự id tăng dần; nếu DB vẫn báo deadlock thì retry có backoff.A→B and B→A at once, each holding one lock and waiting for the other. Always lock in ascending id order; if the DB still reports a deadlock, retry with backoff.
Client retry khi timeout dù server đã commit. Bảng transfer có unique(request_id); request lặp trả về kết quả đã lưu. Hai retry chạy song song thì bên sau vướng unique constraint và rollback.Clients retry on timeout even when the server already committed. The transfer table has unique(request_id); a repeated request gets the stored result. Two parallel retries: the later one hits the unique constraint and rolls back.
Ledger kép là nguồn sự thật: mỗi giao dịch có ít nhất hai dòng, tổng bằng 0. Cột balance chỉ là cache có version, cập nhật trong cùng transaction và được đối soát lại từ ledger.The double-entry ledger is the source of truth: each transaction has at least two rows that sum to zero. The balance column is only a versioned cache, updated in the same transaction and re-checked against the ledger.
Tạo hold làm tăng held_minor, số dư khả dụng = balance − held. Capture chuyển hold thành bút toán thật; release hoặc hết hạn thì giảm held. Hold có trạng thái ACTIVE → CAPTURED · RELEASED · EXPIRED.Creating a hold raises held_minor; available = balance − held. Capture turns the hold into real entries; release or expiry lowers held. A hold moves ACTIVE → CAPTURED · RELEASED · EXPIRED.
Không UPDATE hay DELETE dòng ledger. Ghi bút toán đảo tham chiếu giao dịch gốc, nên kiểm toán thấy cả lỗi và lần sửa.Never UPDATE or DELETE a ledger row. Write an offsetting entry that points at the original, so auditors see both the mistake and the fix.
| BảngTable | Cột chínhKey columns | Ghi chúNotes |
|---|---|---|
account | id, user_id, currency, balance_minor, held_minor, version, status, updated_at | CHECK(balance_minor − held_minor ≥ 0); balance là cache của ledgerCHECK(balance_minor − held_minor ≥ 0); balance caches the ledger |
transfer | id, request_id, from_account, to_account, amount_minor, status, created_at | unique(request_id); lưu kết quả để trả lại khi retryunique(request_id); stores the result to replay on retry |
ledger_entry | id, transfer_id, account_id, amount_minor, balance_after, created_at | Chỉ thêm. Âm = ghi nợ, dương = ghi có; sum theo transfer_id = 0Append-only. Negative = debit, positive = credit; sum per transfer_id = 0 |
hold | id, account_id, amount_minor, reference, status, expires_at | ACTIVE → CAPTURED · RELEASED · EXPIREDACTIVE → CAPTURED · RELEASED · EXPIRED |
outbox | id, topic, key, payload, published_at | wallet.transferred, wallet.withdrawn cho notification và sao kêwallet.transferred, wallet.withdrawn for notifications and statements |
Java: chuyển tiền giữa hai ví, khoá theo thứ tự cố định, ghi hai bút toán và outbox. Go: rút tiền không cần SELECT … FOR UPDATE, dựa vào UPDATE có điều kiện và INSERT … ON CONFLICT cho idempotency.Java: a transfer between two wallets that locks in a fixed order and writes two entries plus the outbox. Go: a withdrawal without SELECT … FOR UPDATE, relying on a conditional UPDATE and INSERT … ON CONFLICT for idempotency.
@Service
@RequiredArgsConstructor
public class TransferService {
private final TransferRepository transfers;
private final AccountRepository accounts;
private final LedgerRepository ledger;
private final OutboxRepository outbox;
@Transactional
public Transfer transfer(TransferCommand cmd) {
Optional<Transfer> done = transfers.findByRequestId(cmd.requestId());
if (done.isPresent()) return done.get(); // retry: same answer
long first = Math.min(cmd.fromId(), cmd.toId());
long second = Math.max(cmd.fromId(), cmd.toId());
Account a = accounts.lockById(first); // SELECT … FOR UPDATE
Account b = accounts.lockById(second);
Account from = a.id() == cmd.fromId() ? a : b;
Account to = from == a ? b : a;
long amount = cmd.amountMinor();
if (from.available() < amount) { // balance - held
throw new InsufficientFundsException(from.id(), from.available());
}
Transfer t = transfers.save(Transfer.completed(cmd)); // unique(request_id)
ledger.save(LedgerEntry.of(t, from, -amount));
ledger.save(LedgerEntry.of(t, to, amount));
from.debit(amount); // @Version bumps on flush
to.credit(amount);
outbox.save(OutboxEvent.of("wallet.transferred", t));
return t;
}
}// Withdraw debits a wallet without SELECT … FOR UPDATE:
// the WHERE clause is the check, the row lock taken by UPDATE is the guard.
func (w *Wallet) Withdraw(ctx context.Context, req WithdrawReq) error {
tx, err := w.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var transferID int64
err = tx.QueryRowContext(ctx,
`INSERT INTO transfer(request_id, from_account, to_account, amount_minor, status)
VALUES ($1, $2, $3, $4, 'COMPLETED') ON CONFLICT (request_id) DO NOTHING RETURNING id`,
req.RequestID, req.AccountID, CashOutAccountID, req.Amount).Scan(&transferID)
if errors.Is(err, sql.ErrNoRows) {
return nil // same request id already applied
}
if err != nil {
return err
}
res, err := tx.ExecContext(ctx,
`UPDATE account SET balance_minor = balance_minor - $1, version = version + 1
WHERE id = $2 AND balance_minor - held_minor >= $1`,
req.Amount, req.AccountID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrInsufficientFunds // rollback also drops the transfer row
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO ledger_entry(transfer_id, account_id, amount_minor)
VALUES ($1, $2, $3), ($1, $4, $5)`,
transferID, req.AccountID, -req.Amount, CashOutAccountID, req.Amount); err != nil {
return err
}
return tx.Commit()
}| Cột balance + ledgerBalance column + ledger | Event sourcing thuầnPure event sourcing | |
|---|---|---|
| Đọc số dưReading a balance | Một dòng, O(1)One row, O(1) | Cộng lại sự kiện hoặc đọc snapshotReplay events or read a snapshot |
| Chống chi vượtPreventing overspend | Khoá dòng hoặc UPDATE có điều kiệnRow lock or conditional UPDATE | Kiểm tra version của stream khi appendExpected stream version on append |
| Kiểm toánAudit | Ledger đủ dùng nếu chỉ thêmThe ledger is enough if append-only | Có sẵn, phát lại được mọi thời điểmBuilt in, replayable to any point in time |
| Chọn khiPick it when | Phần lớn ví điện tử, team quen SQLMost wallets, a team at home with SQL | Cần dựng lại trạng thái lịch sử, nhiều read modelYou need historical state and many read models |
Mỗi pattern mở bài học tương ứng trong menu Pattern.Each one opens its lesson in the Patterns menu.
Đưa kiểm tra vào chỗ có khoá: UPDATE … WHERE balance − held ≥ x, hoặc SELECT … FOR UPDATE rồi kiểm tra trong cùng transaction. Thêm CHECK constraint làm lưới an toàn cuối cùng. Không đọc số dư ở ứng dụng rồi mới trừ.Put the check where the lock is: UPDATE … WHERE balance − held ≥ x, or SELECT … FOR UPDATE and check inside the same transaction. Add a CHECK constraint as the last safety net. Never read the balance in the app and subtract afterwards.
A→B khoá A rồi chờ B, đồng thời B→A khoá B rồi chờ A. Khoá theo thứ tự cố định (id tăng dần) loại bỏ vòng chờ. Vẫn bắt lỗi deadlock của DB và retry có backoff.A→B locks A then waits for B while B→A locks B then waits for A. Locking in a fixed order (ascending id) removes the cycle. Still catch the DB's deadlock error and retry with backoff.
Cột balance chỉ cho biết con số hiện tại, không cho biết vì sao. Ledger kép ghi mọi biến động, tổng luôn bằng 0, nên phát hiện được tiền sinh ra hoặc biến mất, và dựng lại số dư ở bất kỳ thời điểm nào.A balance column tells you the current number, not why. The double-entry ledger records every movement and always sums to zero, so you can detect money appearing or vanishing and rebuild any balance at any point in time.
Client sinh request id một lần cho mỗi thao tác của người dùng và giữ nguyên khi retry. Server lưu request id với unique constraint cùng kết quả; gặp lại thì trả kết quả cũ.The client generates one request id per user action and reuses it on retry. The server stores the request id under a unique constraint with the result, and replays that result when it sees the id again.
Phím 1–4 để chọn đáp án, Enter để kiểm tra.Press 1–4 to choose an answer, Enter to check.
Ôn tập ngắt quãng (spaced repetition): thẻ được lặp lại sau 1 → 3 → 7 → 14 → 30 ngày để củng cố trí nhớ dài hạn.Spaced repetition: cards are repeated after 1 → 3 → 7 → 14 → 30 days to build long-term memory.