Thiết kế ví điện tử: chuyển tiền an toàn với sổ cái kép (double-entry)

1. Tổng quan

Chuyển tiền giữa hai ví nghe đơn giản: trừ bên này, cộng bên kia. Khi có hàng nghìn request đồng thời, client retry, mạng chập chờn và yêu cầu kiểm toán, cách làm “cập nhật cột balance” sẽ sớm làm sai số dư.

Bài này đi qua thiết kế một ví điện tử dựa trên sổ cái kép (double-entry ledger). Thiết kế gồm yêu cầu, mô hình dữ liệu, API, luồng xử lý, rồi tới các điểm khó: tranh chấp khóa, nhất quán với hệ thống khác và tải đột biến. Đọc xong, bạn có thể dựng một service chuyển tiền bằng Spring Boot 3 và PostgreSQL. Service này không mất tiền, không trừ hai lần và đối soát được.

2. Yêu cầu bài toán

2.1. Chức năng

  • Mỗi người dùng có một hoặc nhiều ví theo loại tiền (VND).
  • Nạp tiền, rút tiền, chuyển tiền giữa hai ví.
  • Xem số dư và lịch sử giao dịch.
  • Một giao dịch không bao giờ được ghi hai lần, kể cả khi client gửi lại request.

2.2. Quy mô và phi chức năng

  • 10 triệu ví, trung bình 500 giao dịch/giây. Đỉnh 5.000 giao dịch/giây khi có flash sale hoặc ngày lương.
  • Độ trễ p99 dưới 300 ms cho API chuyển tiền.
  • Số dư phải nhất quán mạnh. Đọc số dư cũ để quyết định cho phép chuyển là không chấp nhận được.

2.3. Ràng buộc

  • Không bao giờ để số dư âm, trừ các tài khoản hệ thống được phép âm.
  • Mọi thay đổi số dư phải truy vết được về một bút toán. Không sửa, không xóa bút toán. Muốn sửa sai thì ghi bút toán đảo.
  • Tiền lưu bằng số nguyên theo đơn vị nhỏ nhất (đồng). Không dùng double.

3. Sổ cái kép là gì

Mỗi giao dịch gồm ít nhất hai bút toán (entry). Tổng các bút toán của một giao dịch luôn bằng 0. Chuyển 100.000đ từ An sang Bình sinh ra hai bút toán:

transaction T1
  account An    : -100000
  account Binh  : +100000
  ----------------------
  sum           :       0

Nạp tiền cũng theo quy tắc này. Tiền “đến từ đâu đó” là một tài khoản hệ thống, ví dụ BANK_CLEARING, và tài khoản này được phép âm. Nhờ vậy ta có một bất biến (invariant) rất mạnh: tổng mọi bút toán trong hệ thống luôn bằng 0. Nếu tổng khác 0, chắc chắn có bug.

4. Mô hình dữ liệu

CREATE TABLE account (
    id              BIGINT PRIMARY KEY,
    owner_id        BIGINT      NOT NULL,
    currency        CHAR(3)     NOT NULL,
    balance         BIGINT      NOT NULL DEFAULT 0,
    allow_negative  BOOLEAN     NOT NULL DEFAULT FALSE,
    CHECK (allow_negative OR balance >= 0)
);

CREATE TABLE journal_transaction (
    id               UUID PRIMARY KEY,
    idempotency_key  VARCHAR(64) NOT NULL UNIQUE,
    type             VARCHAR(32) NOT NULL,
    created_at       TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE ledger_entry (
    id              BIGSERIAL PRIMARY KEY,
    transaction_id  UUID   NOT NULL REFERENCES journal_transaction(id),
    account_id      BIGINT NOT NULL REFERENCES account(id),
    amount          BIGINT NOT NULL,
    balance_after   BIGINT NOT NULL,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_entry_account ON ledger_entry(account_id, id);

CREATE TABLE outbox_event (
    id          BIGSERIAL PRIMARY KEY,
    topic       VARCHAR(64) NOT NULL,
    payload     JSONB       NOT NULL,
    published   BOOLEAN     NOT NULL DEFAULT FALSE
);

Các bảng trên có vai trò như sau:

  • ledger_entry là nguồn sự thật. Bảng chỉ được ghi thêm (append-only). amount âm là ghi nợ, dương là ghi có.
  • account.balance là bản cache của tổng các bút toán. Ta giữ cột này để kiểm tra số dư nhanh mà không phải SUM hàng triệu dòng.
  • Ràng buộc CHECK là lưới an toàn cuối cùng. Nếu code có bug, database vẫn từ chối số dư âm.
  • idempotency_key UNIQUE chặn việc ghi trùng giao dịch.
  • outbox_event phục vụ việc gửi sự kiện sang Kafka (mục 7.2).

5. API

POST /v1/transfers
Idempotency-Key: 6f1c2a9e-...-client-generated
Content-Type: application/json

{ "fromAccountId": 1001, "toAccountId": 2002, "amount": 100000 }
201 Created
{ "transactionId": "a3f0...", "status": "COMPLETED" }

Client tự sinh Idempotency-Key cho mỗi ý định chuyển tiền và dùng lại đúng key đó khi retry. Server phải trả về cùng kết quả cho cùng một key.

Controller rất mỏng:

public record TransferRequest(@NotNull Long fromAccountId,
                              @NotNull Long toAccountId,
                              @Positive long amount) {}

public record TransferResult(UUID transactionId, String status) {}

@RestController
@RequestMapping("/v1/transfers")
class TransferController {
    private final TransferService service;

    TransferController(TransferService service) { this.service = service; }

    @PostMapping
    ResponseEntity<TransferResult> create(@RequestHeader("Idempotency-Key") String key,
                                          @Valid @RequestBody TransferRequest req) {
        return ResponseEntity.status(HttpStatus.CREATED).body(service.transfer(key, req));
    }
}

6. Luồng xử lý chuyển tiền

Ta dùng spring-boot-starter-jdbc để thấy rõ từng câu SQL:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

Toàn bộ luồng chạy trong một transaction database:

@Service
public class TransferService {
    private final JdbcTemplate jdbc;

    public TransferService(JdbcTemplate jdbc) { this.jdbc = jdbc; }

    @Transactional
    public TransferResult transfer(String key, TransferRequest req) {
        if (req.fromAccountId().equals(req.toAccountId())) {
            throw new IllegalArgumentException("Không thể tự chuyển cho chính mình");
        }
        UUID txId = UUID.randomUUID();
        int inserted = jdbc.update("""
            INSERT INTO journal_transaction(id, idempotency_key, type)
            VALUES (?, ?, 'TRANSFER') ON CONFLICT (idempotency_key) DO NOTHING
            """, txId, key);
        if (inserted == 0) {                                        // (1)
            UUID existing = jdbc.queryForObject(
                "SELECT id FROM journal_transaction WHERE idempotency_key = ?", UUID.class, key);
            return new TransferResult(existing, "COMPLETED");
        }

        long fromBalance = lockAndGet(req.fromAccountId(), req.toAccountId()); // (2)
        if (fromBalance < req.amount()) {
            throw new InsufficientFundsException(req.fromAccountId());          // (3)
        }
        post(txId, req.fromAccountId(), -req.amount());                         // (4)
        post(txId, req.toAccountId(), req.amount());

        jdbc.update("INSERT INTO outbox_event(topic, payload) VALUES ('wallet.transfers', ?::jsonb)",
            """
            {"transactionId":"%s","from":%d,"to":%d,"amount":%d}
            """.formatted(txId, req.fromAccountId(), req.toAccountId(), req.amount()));
        return new TransferResult(txId, "COMPLETED");
    }

    private long lockAndGet(long fromId, long toId) {
        long fromBalance = 0;
        for (long id : new long[]{Math.min(fromId, toId), Math.max(fromId, toId)}) {
            long balance = jdbc.queryForObject(
                "SELECT balance FROM account WHERE id = ? FOR UPDATE", Long.class, id);
            if (id == fromId) fromBalance = balance;
        }
        return fromBalance;
    }

    private void post(UUID txId, long accountId, long amount) {
        Long after = jdbc.queryForObject(
            "UPDATE account SET balance = balance + ? WHERE id = ? RETURNING balance",
            Long.class, amount, accountId);
        jdbc.update("""
            INSERT INTO ledger_entry(transaction_id, account_id, amount, balance_after)
            VALUES (?, ?, ?, ?)""", txId, accountId, amount, after);
    }
}

class InsufficientFundsException extends RuntimeException {
    InsufficientFundsException(long accountId) { super("Số dư không đủ: " + accountId); }
}

Các điểm chính trong đoạn code:

  • (1) Idempotency. ON CONFLICT DO NOTHING trả về 0 dòng nếu key đã tồn tại. Giả sử hai request cùng key chạy song song. PostgreSQL bắt request thứ hai chờ ở unique index cho tới khi request thứ nhất commit. Sau đó request thứ hai nhận 0 dòng và trả lại giao dịch cũ. Không có lần trừ tiền thứ hai.
  • (2) Khóa theo thứ tự id. Luồng A chuyển 1→2 và luồng B chuyển 2→1. Nếu mỗi luồng khóa tài khoản nguồn trước, hai luồng sẽ giữ một khóa và chờ khóa còn lại, tức là deadlock. Khi luôn khóa id nhỏ trước, mọi luồng xin khóa cùng một thứ tự nên không có vòng chờ.
  • (3) Kiểm tra số dư sau khi đã khóa. Kiểm tra trước khi khóa là lỗi kinh điển check-then-act. Hai request cùng đọc thấy “đủ tiền” và cùng trừ.
  • (4) Hai bút toán và outbox nằm chung transaction. Nếu bất kỳ bước nào lỗi, tất cả bị rollback, kể cả dòng journal_transaction. Vì vậy client có thể retry khi bị báo thiếu tiền.

Lưu ý: Đừng dùng @Transactional trên method private hoặc gọi nó từ chính lớp đó (self-invocation). Proxy của Spring sẽ không bọc transaction và toàn bộ cơ chế khóa mất tác dụng.

7. Các điểm khó

7.1. Tranh chấp trên tài khoản nóng (hot account)

Ví của một merchant lớn có thể nhận hàng nghìn khoản thanh toán mỗi giây. Mọi transaction đều xếp hàng chờ FOR UPDATE trên một dòng duy nhất, nên throughput bị chặn ở khoảng vài trăm giao dịch/giây.

Cách giải phổ biến là chia tài khoản nóng thành N sub-account. Khi ghi có, chọn ngẫu nhiên một sub-account. Số dư thật của merchant là tổng các sub-account:

long creditTarget(long merchantAccountId, int shards) {
    int shard = ThreadLocalRandom.current().nextInt(shards);
    return jdbc.queryForObject(
        "SELECT id FROM account WHERE parent_id = ? AND shard_no = ?",
        Long.class, merchantAccountId, shard);
}

Với 16 shard, tranh chấp giảm khoảng 16 lần. Cách này hợp với chiều ghi có, vì cộng tiền không cần kiểm tra số dư. Chiều ghi nợ, ví dụ merchant rút tiền, xảy ra ít hơn nhiều. Bạn có thể gom tiền về tài khoản chính bằng một job định kỳ, và job này cũng là một giao dịch kép.

Không nên shard ví cá nhân thông thường. Ví cá nhân hiếm khi bị tranh chấp, còn shard làm kiểm tra số dư phức tạp hơn hẳn.

7.2. Nhất quán với hệ thống khác: Transactional Outbox

Sau khi chuyển tiền, service thông báo, chống gian lận và tích điểm cần biết. Nếu gọi kafkaTemplate.send() ngay trong transfer(), bạn có thể gặp một trong hai lỗi:

  • Kafka nhận message nhưng DB rollback, khiến người dùng nhận thông báo cho một giao dịch không tồn tại.
  • DB commit nhưng gửi Kafka thất bại, khiến sự kiện bị mất.

Ở mục 6, ta đã ghi sự kiện vào outbox_event trong cùng transaction. Một relay riêng sẽ đọc và đẩy bảng này lên Kafka:

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>
@Component
class OutboxRelay {
    private final JdbcTemplate jdbc;
    private final KafkaTemplate<String, String> kafka;

    OutboxRelay(JdbcTemplate jdbc, KafkaTemplate<String, String> kafka) {
        this.jdbc = jdbc;
        this.kafka = kafka;
    }

    @Scheduled(fixedDelay = 200)
    @Transactional
    public void publish() {
        var rows = jdbc.queryForList("""
            SELECT id, topic, payload::text AS payload FROM outbox_event
            WHERE published = FALSE ORDER BY id LIMIT 100
            FOR UPDATE SKIP LOCKED""");
        for (var row : rows) {
            kafka.send((String) row.get("topic"), (String) row.get("payload")).join();
            jdbc.update("UPDATE outbox_event SET published = TRUE WHERE id = ?", row.get("id"));
        }
    }
}

Nhớ bật @EnableScheduling trên một lớp cấu hình để @Scheduled chạy được. SKIP LOCKED cho phép nhiều instance relay chạy song song mà không lấy trùng dòng.

Cơ chế này đảm bảo at-least-once. Nếu relay chết sau khi gửi nhưng trước khi commit, message sẽ được gửi lại. Vì vậy consumer phải loại trùng theo transactionId.

7.3. Tải đột biến

Khi lượng request tăng gấp 10 lần, thứ gục đầu tiên là connection pool của database chứ không phải CPU. Có ba lớp phòng thủ:

  1. Rate limit theo người dùng ở biên, dùng Redis.
  2. Giới hạn connection pool (HikariCP) để DB không bị quá tải, kèm timeout ngắn để fail fast.
  3. Lùi các tác vụ không khẩn cấp như thông báo và tích điểm sang Kafka. Việc này đã có nhờ outbox.

Ví dụ rate limit cửa sổ cố định 10 request/giây mỗi user, dùng spring-boot-starter-data-redis:

@Component
class TransferRateLimiter {
    private final StringRedisTemplate redis;

    TransferRateLimiter(StringRedisTemplate redis) { this.redis = redis; }

    boolean allow(long userId) {
        String key = "rl:transfer:%d:%d".formatted(userId, Instant.now().getEpochSecond());
        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) redis.expire(key, Duration.ofSeconds(2));
        return count != null && count <= 10;
    }
}
spring.datasource.hikari.maximum-pool-size=40
spring.datasource.hikari.connection-timeout=500
spring.jpa.open-in-view=false

Khi allow() trả về false, trả 429 Too Many Requests. Khi không lấy được connection trong 500 ms, trả 503. Client có Idempotency-Key nên retry an toàn.

Lưu ý: Không dùng Redis để lưu số dư hay để “trừ tiền trước rồi đồng bộ về DB sau”. Redis rất nhanh nhưng không cho bạn transaction ACID cùng với sổ cái. Mất một lần failover là mất tiền.

8. Kiểm thử hành vi đồng thời

Test quan trọng nhất là bắn nhiều request song song vào cùng một ví. Ví A có 50.000đ và nhận 100 lệnh chuyển 1.000đ. Kết quả đúng là đúng 50 lệnh thành công, số dư về 0 và tổng bút toán bằng 0. Test dưới đây cần một PostgreSQL thật, ví dụ qua Testcontainers, và dữ liệu khởi tạo trong @Sql.

@SpringBootTest
@Sql(statements = {
    "TRUNCATE ledger_entry, journal_transaction, outbox_event, account",
    "INSERT INTO account(id, owner_id, currency, balance) VALUES (1, 1, 'VND', 50000), (2, 2, 'VND', 0)"
})
class TransferServiceConcurrencyTest {

    @Autowired TransferService service;
    @Autowired JdbcTemplate jdbc;

    @Test
    void neverOverdraftsUnderConcurrency() throws Exception {
        AtomicInteger success = new AtomicInteger();
        try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 0; i < 100; i++) {
                String key = "k-" + i;
                pool.submit(() -> {
                    try {
                        service.transfer(key, new TransferRequest(1L, 2L, 1_000));
                        success.incrementAndGet();
                    } catch (InsufficientFundsException ignored) { }
                });
            }
        }
        assertThat(success.get()).isEqualTo(50);
        assertThat(jdbc.queryForObject("SELECT balance FROM account WHERE id = 1", Long.class)).isZero();
        assertThat(jdbc.queryForObject("SELECT COALESCE(SUM(amount),0) FROM ledger_entry", Long.class)).isZero();
    }

    @Test
    void sameIdempotencyKeyReturnsSameTransaction() {
        var req = new TransferRequest(1L, 2L, 1_000);
        var first = service.transfer("dup", req);
        var second = service.transfer("dup", req);

        assertThat(second.transactionId()).isEqualTo(first.transactionId());
        assertThat(jdbc.queryForObject("SELECT balance FROM account WHERE id = 1", Long.class))
            .isEqualTo(49_000L);
    }
}

Nếu bỏ FOR UPDATE trong lockAndGet, test đầu sẽ fail ngẫu nhiên. Có lúc success lớn hơn 50, có lúc CHECK constraint ném lỗi. Nếu không có ON CONFLICT, test thứ hai sẽ trừ 2.000đ.

Ngoài test, hãy chạy job đối soát hằng đêm. Job này kiểm tra hai điều: tổng bút toán bằng 0, và account.balance bằng tổng ledger_entry của từng tài khoản:

SELECT a.id, a.balance, COALESCE(SUM(e.amount), 0) AS ledger_sum
FROM account a LEFT JOIN ledger_entry e ON e.account_id = a.id
GROUP BY a.id, a.balance
HAVING a.balance <> COALESCE(SUM(e.amount), 0);

Truy vấn trả về 0 dòng nghĩa là sổ sách khớp.

9. Lỗi hay gặp và khi nào không nên dùng

  • Dùng double/float cho tiền. Phép tính 0.1 + 0.2 không ra 0.3. Hãy dùng long theo đơn vị nhỏ nhất, hoặc BigDecimal nếu cần tỉ giá.
  • Update thẳng balance mà không ghi bút toán. Cách này chạy nhanh lúc đầu, nhưng khi khách khiếu nại bạn không trả lời được “tiền đi đâu”.
  • Sửa hoặc xóa bút toán sai. Hãy ghi bút toán đảo (reversal) thành một giao dịch mới.
  • Khóa không theo thứ tự dẫn tới deadlock dưới tải. PostgreSQL sẽ phá deadlock bằng cách hủy một transaction, và người dùng thấy lỗi ngẫu nhiên.
  • Gọi API bên ngoài khi đang giữ khóa, ví dụ gọi cổng thanh toán ngân hàng. Khóa bị giữ hàng giây và throughput sụp đổ. Hãy tách các luồng liên ngân hàng thành state machine (PENDING → COMPLETED/FAILED) với tài khoản tạm giữ (hold).
  • Không nên dùng thiết kế này cho dữ liệu không phải tiền hay tài sản, ví dụ bộ đếm lượt xem. Ở đó, sai lệch nhỏ chấp nhận được và sổ cái kép chỉ là chi phí thừa.

10. Kết luận

Cốt lõi của một ví điện tử an toàn không nằm ở công nghệ mới mà ở vài nguyên tắc chặt chẽ:

  • Sổ cái kép append-only, với tổng bút toán luôn bằng 0.
  • Idempotency key cho mọi lệnh ghi.
  • Khóa bi quan theo thứ tự cố định, và kiểm tra số dư sau khi đã khóa.
  • CHECK constraint làm lưới an toàn cuối cùng.

Trên nền đó, ta xử lý tài khoản nóng bằng sub-account, đồng bộ với hệ thống khác bằng Transactional Outbox và Kafka, và chống tải đột biến bằng rate limit Redis cùng connection pool có giới hạn.

Khi mở rộng tiếp, bạn có thể tách luồng liên ngân hàng thành state machine với tài khoản tạm giữ. Bạn cũng có thể partition ledger_entry theo thời gian khi bảng lên tới hàng tỉ dòng. Dù mở rộng thế nào, hãy giữ test đồng thời và job đối soát luôn chạy.

system-designspring-bootpostgresqlkafkafintech