Thiết kế hệ thống thanh toán: idempotency, đối soát và hoàn tiền

1. Tổng quan

Bài toán thanh toán khác các bài CRUD thông thường ở một điểm: sai một lần là mất tiền thật. Client retry có thể khiến khách bị trừ tiền hai lần. Cổng thanh toán (PSP) có thể timeout dù giao dịch đã thành công. Hai yêu cầu hoàn tiền chạy song song có thể hoàn vượt số đã thu.

Bài này đi qua một thiết kế cụ thể gồm yêu cầu, mô hình dữ liệu, API và luồng xử lý. Sau đó bài giải quyết ba điểm khó: idempotency, đối soát (reconciliation) và hoàn tiền (refund). Đọc xong, bạn có thể tự dựng phần lõi bằng Spring Boot 3 và PostgreSQL, kèm test chứng minh hệ thống không hoàn tiền vượt mức khi có tranh chấp.

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

2.1. Yêu cầu chức năng

  • Merchant tạo thanh toán cho một Order. Hệ thống gọi PSP để trừ tiền thẻ hoặc ví.
  • Merchant hoàn tiền toàn phần hoặc từng phần. Tổng tiền hoàn không vượt số đã thu.
  • Hằng ngày, hệ thống đối soát dữ liệu nội bộ với file settlement của PSP.
  • Hệ thống phát sự kiện (PaymentCaptured, RefundSucceeded…) cho các service khác như Order và Accounting.

2.2. Quy mô và ràng buộc

  • Trung bình 200 giao dịch/giây. Đỉnh flash sale khoảng 5.000 giao dịch/giây trong vài phút.
  • Không bao giờ trừ tiền hai lần cho cùng một ý định thanh toán. Đây là ràng buộc cứng.
  • Ưu tiên nhất quán hơn sẵn sàng: thà trả lỗi cho client còn hơn ghi sai số tiền.
  • PSP là hệ thống bên ngoài. Nó có thể chậm, timeout, hoặc gửi webhook trễ hay trùng.

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

Tiền luôn lưu bằng đơn vị nhỏ nhất (VND thì là đồng, USD thì là cent) với kiểu BIGINT. Không dùng double.

CREATE TABLE payments (
    id              UUID PRIMARY KEY,
    merchant_id     TEXT        NOT NULL,
    order_id        TEXT        NOT NULL,
    amount          BIGINT      NOT NULL CHECK (amount > 0),
    currency        CHAR(3)     NOT NULL,
    status          TEXT        NOT NULL, -- CREATED, PROCESSING, CAPTURED, FAILED, UNKNOWN
    refunded_amount BIGINT      NOT NULL DEFAULT 0,
    psp_reference   TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (refunded_amount <= amount)
);

CREATE TABLE refunds (
    id          UUID PRIMARY KEY,
    payment_id  UUID   NOT NULL REFERENCES payments(id),
    amount      BIGINT NOT NULL CHECK (amount > 0),
    status      TEXT   NOT NULL, -- PENDING, SUCCEEDED, FAILED
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE idempotency_keys (
    merchant_id   TEXT NOT NULL,
    idem_key      TEXT NOT NULL,
    request_hash  TEXT NOT NULL,
    status        TEXT NOT NULL, -- IN_PROGRESS, COMPLETED
    response_code INT,
    response_body TEXT,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (merchant_id, idem_key)
);

CREATE TABLE outbox (
    id         BIGSERIAL PRIMARY KEY,
    topic      TEXT  NOT NULL,
    payload    JSONB NOT NULL,
    sent_at    TIMESTAMPTZ
);

Một vài điểm đáng chú ý:

  • CHECK (refunded_amount <= amount) là lưới an toàn cuối cùng ở tầng database. Code có bug thì database vẫn chặn.
  • Khóa chính (merchant_id, idem_key) của bảng idempotency_keys là cốt lõi của cơ chế chống trùng.
  • Bảng outbox giúp phát sự kiện ra Kafka mà không cần distributed transaction (mục 6).

4. Thiết kế API

POST /v1/payments
Headers: Idempotency-Key: 7f3c1e2a-...
Body:    { "orderId": "ORD-1001", "amount": 250000, "currency": "VND", "source": "tok_visa" }
→ 201  { "id": "...", "status": "CAPTURED" }

POST /v1/payments/{id}/refunds
Headers: Idempotency-Key: 9a8b...
Body:    { "amount": 100000 }
→ 202  { "refundId": "...", "status": "PENDING" }

GET  /v1/payments/{id}

Mọi API thay đổi trạng thái đều bắt buộc có Idempotency-Key do client sinh. Client gửi lại cùng key khi retry. Hoàn tiền trả 202 Accepted vì PSP xử lý refund bất đồng bộ.

5. Idempotency: chống trừ tiền hai lần

5.1. Ý tưởng

Khi nhận request, ta ghi key vào database trước khi làm bất cứ việc gì. Có ba khả năng:

  1. Key chưa tồn tại: ghi thành công, xử lý tiếp.
  2. Key đã COMPLETED: trả lại đúng response đã lưu, không gọi PSP lần nữa.
  3. Key đang IN_PROGRESS: một request khác cùng key đang chạy. Trả 409 Conflict để client thử lại sau.

Ngoài ra ta lưu thêm hash của body. Nếu client dùng lại key cho một body khác (ví dụ amount khác), đó là lỗi phía client và ta trả 422.

5.2. Cài đặt

Dependency cần dùng:

<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>
@Service
public class IdempotencyService {

    private final JdbcTemplate jdbc;

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

    public record StoredResponse(int code, String body) {}

    public Optional<StoredResponse> begin(String merchantId, String key, String requestHash) {
        int inserted = jdbc.update("""
            INSERT INTO idempotency_keys (merchant_id, idem_key, request_hash, status)
            VALUES (?, ?, ?, 'IN_PROGRESS')
            ON CONFLICT (merchant_id, idem_key) DO NOTHING
            """, merchantId, key, requestHash);
        if (inserted == 1) {
            return Optional.empty(); // request mới, được phép xử lý
        }
        Map<String, Object> row = jdbc.queryForMap("""
            SELECT request_hash, status, response_code, response_body
            FROM idempotency_keys WHERE merchant_id = ? AND idem_key = ?
            """, merchantId, key);
        if (!requestHash.equals(row.get("request_hash"))) {
            throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Key reused with different body");
        }
        if ("IN_PROGRESS".equals(row.get("status"))) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "Request is being processed");
        }
        return Optional.of(new StoredResponse((Integer) row.get("response_code"), (String) row.get("response_body")));
    }

    public void complete(String merchantId, String key, int code, String body) {
        jdbc.update("""
            UPDATE idempotency_keys SET status = 'COMPLETED', response_code = ?, response_body = ?
            WHERE merchant_id = ? AND idem_key = ?
            """, code, body, merchantId, key);
    }
}

ON CONFLICT DO NOTHING biến thao tác “kiểm tra rồi ghi” thành một câu lệnh nguyên tử. Hai request trùng key đến cùng lúc thì chỉ một request được inserted == 1. Request còn lại rơi vào nhánh đọc lại và nhận 409. Không có race condition kiểu “cả hai cùng thấy key chưa tồn tại”.

Lưu ý: Đừng dùng mỗi Redis SETNX làm nguồn sự thật cho idempotency của thanh toán. Redis failover hoặc key bị evict là bạn mất dấu, và tiền bị trừ hai lần. Redis chỉ nên là lớp cache phía trước, còn database mới là trọng tài.

5.3. Idempotency cả ở phía PSP

Request của bạn được bảo vệ, nhưng lời gọi từ bạn sang PSP cũng có thể bị retry. Hầu hết PSP hỗ trợ idempotency key riêng. Hãy truyền chính payments.id làm key đó:

PspResult result = pspClient.charge(new ChargeRequest(
        payment.id().toString(), // idempotency key gửi sang PSP
        payment.amount(),
        payment.currency(),
        sourceToken));

Khi gọi PSP bị timeout, bạn không biết tiền đã bị trừ hay chưa. Đừng đánh dấu FAILED. Hãy đặt trạng thái UNKNOWN, sau đó cho một job hỏi lại PSP bằng payments.id. Đánh dấu FAILED rồi cho client thử lại với key mới chính là con đường kinh điển dẫn tới trừ tiền hai lần.

6. Nhất quán giữa database và Kafka: Transactional Outbox

Sau khi thu tiền, ta cần cập nhật payments và phát sự kiện PaymentCaptured. Ghi database thành công nhưng gửi Kafka thất bại (hoặc ngược lại) sẽ làm Order service lệch trạng thái. Cách giải là ghi sự kiện vào bảng outbox trong cùng transaction:

@Transactional
public void markCaptured(UUID paymentId, String pspReference) {
    jdbc.update("UPDATE payments SET status = 'CAPTURED', psp_reference = ? WHERE id = ? AND status IN ('PROCESSING','UNKNOWN')",
            pspReference, paymentId);
    jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('payment-events', ?::jsonb)",
            """
            {"type":"PaymentCaptured","paymentId":"%s"}""".formatted(paymentId));
}

Một relay (job định kỳ hoặc Debezium CDC) đọc các dòng sent_at IS NULL, gửi sang Kafka rồi cập nhật sent_at. Relay có thể gửi trùng, nên consumer phải idempotent, ví dụ lưu paymentId đã xử lý. Điều kiện status IN (...) trong câu UPDATE giúp webhook trùng từ PSP không ghi đè một trạng thái cuối khác.

7. Hoàn tiền và tranh chấp đồng thời

7.1. Vấn đề

Payment 100.000đ. Nhân viên CSKH và một job tự động cùng gửi yêu cầu hoàn 60.000đ. Nếu code làm theo kiểu “đọc refunded_amount, kiểm tra, rồi ghi”, cả hai đều thấy 0 + 60.000 <= 100.000 và cùng được duyệt. Kết quả là bạn hoàn 120.000đ.

7.2. Giải pháp: conditional update

Gộp kiểm tra và cập nhật vào một câu UPDATE có điều kiện:

@Service
public class RefundService {

    private final JdbcTemplate jdbc;

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

    @Transactional
    public UUID requestRefund(UUID paymentId, long amount) {
        int reserved = jdbc.update("""
            UPDATE payments SET refunded_amount = refunded_amount + ?
            WHERE id = ? AND status = 'CAPTURED' AND refunded_amount + ? <= amount
            """, amount, paymentId, amount);
        if (reserved == 0) {
            throw new IllegalStateException("Refund exceeds refundable amount or payment not captured");
        }
        UUID refundId = UUID.randomUUID();
        jdbc.update("INSERT INTO refunds (id, payment_id, amount, status) VALUES (?, ?, ?, 'PENDING')",
                refundId, paymentId, amount);
        jdbc.update("INSERT INTO outbox (topic, payload) VALUES ('refund-commands', ?::jsonb)",
                """
                {"refundId":"%s","paymentId":"%s","amount":%d}""".formatted(refundId, paymentId, amount));
        return refundId;
    }
}

PostgreSQL khóa dòng payments khi UPDATE. Transaction thứ hai phải đợi transaction đầu commit. Sau đó nó đánh giá lại điều kiện WHERE trên dữ liệu mới, thấy 60.000 + 60.000 > 100.000 và nhận reserved == 0. Đây là hành vi mặc định ở mức READ COMMITTED, không cần SERIALIZABLE.

Ta “giữ chỗ” số tiền trước, rồi mới gọi PSP bất đồng bộ qua outbox. Nếu PSP từ chối refund, consumer sẽ trả lại phần đã giữ:

UPDATE payments SET refunded_amount = refunded_amount - :amount WHERE id = :paymentId;
UPDATE refunds  SET status = 'FAILED' WHERE id = :refundId AND status = 'PENDING';

Hai câu này cần nằm trong cùng một transaction. Điều kiện status = 'PENDING' đảm bảo một webhook thất bại bị gửi trùng không trả lại tiền giữ chỗ hai lần. Muốn chặt hơn, bạn chỉ chạy câu đầu khi câu thứ hai cập nhật được đúng một dòng.

7.3. Test tranh chấp

Dependency test:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-testcontainers</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.testcontainers</groupId>
    <artifactId>postgresql</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest
@Testcontainers
class RefundServiceTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired RefundService refundService;
    @Autowired JdbcTemplate jdbc;

    @Test
    void concurrentRefunds_neverExceedCapturedAmount() throws Exception {
        UUID paymentId = UUID.randomUUID();
        jdbc.update("""
            INSERT INTO payments (id, merchant_id, order_id, amount, currency, status)
            VALUES (?, 'shop-1', 'ORD-1001', 100000, 'VND', 'CAPTURED')
            """, paymentId);

        Callable<Boolean> refund60k = () -> {
            try {
                refundService.requestRefund(paymentId, 60_000);
                return true;
            } catch (IllegalStateException e) {
                return false;
            }
        };

        List<Boolean> results;
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            results = executor.invokeAll(List.of(refund60k, refund60k))
                    .stream().map(Future::resultNow).toList();
        }

        assertThat(results).containsExactlyInAnyOrder(true, false);
        Long refunded = jdbc.queryForObject(
                "SELECT refunded_amount FROM payments WHERE id = ?", Long.class, paymentId);
        assertThat(refunded).isEqualTo(60_000L);
    }
}

Test giả định schema ở mục 3 được nạp qua src/test/resources/schema.sql (cùng với spring.sql.init.mode=always) hoặc qua Flyway. Hai virtual thread gửi refund cùng lúc. Kết quả mong đợi luôn ổn định:

results          = [true, false]   (thứ tự bất kỳ)
refunded_amount  = 60000

Thử thay câu UPDATE bằng cách đọc rồi so sánh trong Java, bạn sẽ thấy test thỉnh thoảng fail. Đó chính là race condition mà thiết kế này loại bỏ.

8. Đối soát (Reconciliation)

Dù bạn cẩn thận đến đâu, dữ liệu nội bộ và PSP vẫn có thể lệch. Webhook có thể bị mất, có thể còn giao dịch UNKNOWN chưa giải quyết, hoặc PSP tự hoàn tiền do chargeback. Đối soát là tuyến phòng thủ cuối cùng.

Luồng hằng ngày:

  1. Tải file settlement của PSP và nạp vào bảng tạm psp_settlements(psp_reference, amount, type).
  2. So khớp với dữ liệu nội bộ bằng FULL OUTER JOIN.
  3. Ghi các chênh lệch vào bảng recon_breaks để xử lý tự động hoặc thủ công.
INSERT INTO recon_breaks (psp_reference, internal_amount, psp_amount, reason)
SELECT COALESCE(p.psp_reference, s.psp_reference),
       p.amount,
       s.amount,
       CASE
           WHEN p.psp_reference IS NULL THEN 'MISSING_INTERNAL'
           WHEN s.psp_reference IS NULL THEN 'MISSING_AT_PSP'
           ELSE 'AMOUNT_MISMATCH'
       END
FROM (SELECT * FROM payments
      WHERE status = 'CAPTURED' AND created_at >= :day AND created_at < :day + INTERVAL '1 day') p
FULL OUTER JOIN (SELECT * FROM psp_settlements WHERE type = 'CHARGE') s
       ON p.psp_reference = s.psp_reference
WHERE p.psp_reference IS NULL
   OR s.psp_reference IS NULL
   OR p.amount <> s.amount;

Ý nghĩa từng loại chênh lệch:

  • MISSING_INTERNAL: PSP đã thu tiền nhưng phía bạn không có bản ghi CAPTURED. Thường là giao dịch UNKNOWN hoặc webhook bị mất. Xử lý bằng cách cập nhật trạng thái, hoặc hoàn tiền nếu order đã bị hủy.
  • MISSING_AT_PSP: phía bạn ghi nhận đã thu nhưng PSP không có. Đây là trường hợp nghiêm trọng vì có thể bạn đã giao hàng mà không nhận được tiền.
  • AMOUNT_MISMATCH: thường do phí, tỷ giá, hoặc bug làm tròn.

Lưu ý: Hãy chú ý cửa sổ thời gian. Giao dịch lúc 23:59 có thể nằm trong file settlement của ngày hôm sau. Nên đối soát với độ trễ T+1 hoặc T+2, và chỉ đẩy một break lên con người sau khi nó tồn tại qua hai lần chạy liên tiếp.

9. Tải đột biến

Lúc flash sale, tải tăng 25 lần. Một vài kỹ thuật giúp hệ thống chịu được:

  • Rate limit theo merchant bằng Redis (INCR + EXPIRE theo cửa sổ một giây). Cách này bảo vệ hệ thống khỏi một merchant “ồn ào”. Ở đây Redis chỉ dùng để giới hạn, không quyết định tính đúng đắn của giao dịch.
  • Tách phần đồng bộ và bất đồng bộ. Đường đồng bộ chỉ gồm ghi idempotency key, tạo payment và gọi PSP authorize. Refund, gửi email và cập nhật accounting đều đi qua outbox → Kafka. Partition Kafka theo paymentId để giữ thứ tự sự kiện của từng payment.
  • Circuit breaker và timeout ngắn khi gọi PSP (ví dụ Resilience4j). Khi PSP chậm, ta fail nhanh và trả 503 kèm Retry-After, thay vì để thread pool cạn kiệt. Client retry với cùng Idempotency-Key sẽ an toàn nhờ mục 5.
  • Dọn bảng idempotency_keys định kỳ (giữ 24–72 giờ) và partition theo ngày, tránh để bảng phình to làm chậm thao tác INSERT.

10. Lỗi hay gặp

  • Dùng double/float cho tiền. Hãy dùng long theo đơn vị nhỏ nhất, hoặc BigDecimal khi cần tỷ giá.
  • Coi timeout là thất bại. Timeout nghĩa là chưa biết, nên phải đặt UNKNOWN rồi truy vấn lại PSP.
  • Sinh idempotency key ở server. Key phải do client sinh và giữ nguyên qua các lần retry.
  • Kiểm tra số dư refund trong Java rồi mới UPDATE. Hãy để database làm việc đó trong một câu lệnh.
  • Gửi Kafka bên trong @Transactional rồi mong nó rollback cùng database. Nó sẽ không rollback. Hãy dùng outbox.
  • Bỏ qua đối soát vì “code đã đúng”. Hệ thống bên ngoài không nằm trong tầm kiểm soát của bạn.

11. Kết luận

Bài viết đã thiết kế phần lõi của một hệ thống thanh toán dựa trên ba nguyên tắc. Thứ nhất, idempotency được đảm bảo bằng ràng buộc unique trong database, cả ở API của bạn lẫn khi gọi PSP. Thứ hai, tranh chấp khi hoàn tiền được giải quyết bằng conditional update cộng với CHECK constraint, và có test đồng thời chứng minh. Thứ ba, nhất quán với thế giới bên ngoài dựa vào transactional outbox và đối soát hằng ngày.

Điểm chung của cả ba là đẩy các bất biến quan trọng xuống tầng database, nơi chúng được thực thi nguyên tử. Đồng thời, hãy luôn giả định mạng, PSP và chính code của bạn đều có thể sai, và chuẩn bị sẵn cơ chế phát hiện và sửa sai.

system-designpaymentidempotencyspring-bootpostgresql