Level 5: thiết kế Payment System
Tiếp đi: Level 5, Payment.
Booking dẫn thẳng sang Payment. Đây là bài mà "timeout = failed" sẽ làm hệ thống mất tiền thật. Insight lớn nhất: phải model trạng thái UNKNOWN và reconcile, thay vì đoán kết quả.
1. Đề bài và requirements
Thiết kế chức năng thanh toán cho Booking/Order qua một Payment Provider/Bank bên ngoài.
User → Booking Service → Payment Service → Payment Provider / Bank → 100,000đ
- 1 booking chỉ charge đúng một lần.
- Payment SUCCESS → booking thành công; FAILED → release ghế.
- Hoàn tiền (refund) khi nghiệp vụ phía sau thất bại.
- Mọi chuyển động tiền phải audit được.
2. Estimate
Peak: ~1,000 payment/s (đợt mở bán vé)
Provider latency: p50 800ms, p99 vài giây, đôi khi timeout
Mỗi payment ≈ 1 row payment + 2-4 ledger entry + 1-3 webhook event
Throughput không khó. Cái khó là correctness khi network, provider và app có thể hỏng ở bất kỳ bước nào.
3. API
POST /payments
Idempotency-Key: PAY-BOOKING-123
{ "bookingId": "BOOKING-123", "amount": 100000, "currency": "VND" }
→ 202 { "paymentId": "...", "status": "PROCESSING" }
GET /payments/{id} → status hiện tại
POST /payments/webhook ← provider gọi (verify chữ ký HMAC)
POST /payments/{id}/refunds Idempotency-Key: REFUND-BOOKING-123
4. Data model
CREATE TABLE payment (
id UUID PRIMARY KEY,
booking_id VARCHAR(64) NOT NULL,
idempotency_key VARCHAR(64) NOT NULL,
amount BIGINT NOT NULL, -- đơn vị nhỏ nhất, không dùng double
currency CHAR(3) NOT NULL,
status VARCHAR(20) NOT NULL, -- CREATED, PROCESSING, SUCCEEDED, FAILED, UNKNOWN
provider_reference VARCHAR(100),
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
CONSTRAINT uk_payment_idem UNIQUE (idempotency_key)
);
CREATE TABLE processed_provider_event (
provider_event_id VARCHAR(100) PRIMARY KEY, -- chặn webhook trùng
received_at TIMESTAMP NOT NULL
);
CREATE TABLE ledger_entry (
id BIGSERIAL PRIMARY KEY,
transaction_id UUID NOT NULL, -- một money movement
account VARCHAR(50) NOT NULL, -- CUSTOMER_RECEIVABLE, MERCHANT_PAYABLE, ...
direction VARCHAR(6) NOT NULL, -- DEBIT / CREDIT
amount BIGINT NOT NULL CHECK (amount > 0),
created_at TIMESTAMP NOT NULL
); -- append-only, không UPDATE/DELETE
CREATE TABLE outbox ( id UUID PRIMARY KEY, type VARCHAR(50), payload JSONB, created_at TIMESTAMP, published_at TIMESTAMP );
5. Problem 1: user bấm Pay hai lần → idempotency key + UNIQUE
click PAY, click PAY, frontend timeout rồi retry
→ mỗi request một charge → $100 $100 $100 💥
Client gửi Idempotency-Key gắn với business operation (thanh toán booking 123), không phải với từng HTTP request. Đừng chỉ check bằng Java:
if (!repo.existsByIdempotencyKey(key)) { repo.save(...); } ❌ hai instance cùng thấy "chưa có"
Để DB chặn bằng UNIQUE:
public Payment startPayment(String idemKey, PaymentRequest req) {
try {
return txTemplate.execute(s -> repo.saveAndFlush(Payment.created(idemKey, req)));
} catch (DataIntegrityViolationException dup) {
Payment existing = repo.findByIdempotencyKey(idemKey).orElseThrow();
if (!existing.matches(req)) {
throw new ResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY, "Key reused with different body");
}
return existing; // trả kết quả/trạng thái cũ, KHÔNG charge lại
}
}
Truyền tiếp key này sang provider (hầu hết payment provider nhận idempotency key) để chính provider cũng không charge 2 lần. Lý thuyết idempotency ở Bài 8.1.
6. Problem 2: provider timeout → UNKNOWN
Payment → Bank: charge $100 ✅
response lost
Payment: TIMEOUT → không biết bank đã trừ tiền chưa
timeout ≠ failed. Đánh FAILED rồi retry có thể double charge; release ghế thì user mất tiền mà không có vé.
CREATED
↓
PROCESSING
/ | \
SUCCEEDED FAILED UNKNOWN ← timeout / 5xx mơ hồ
│
webhook / status query / reconciliation
↓
SUCCEEDED | FAILED
public void charge(Payment p) {
repo.transition(p.getId(), CREATED, PROCESSING); // commit trước khi gọi ra ngoài
try {
ProviderResult r = provider.charge(p.getIdempotencyKey(), p.getAmount(), p.getCurrency());
if (r.succeeded()) completeSucceeded(p.getId(), r.reference()); // ghi ledger + outbox
else completeFailed(p.getId(), r.errorCode()); // lỗi rõ ràng: thẻ bị từ chối
} catch (ResourceAccessException | ProviderServerErrorException ex) {
repo.transition(p.getId(), PROCESSING, UNKNOWN); // trung thực: chưa biết
}
}
UNKNOWN không phải lỗi thiết kế. Nó cho thấy hệ thống trung thực với những gì nó biết.
7. Problem 3–4: webhook trùng và webhook bị mất
Webhook trùng: provider gửi PAYMENT_SUCCESS 3 lần. Handler phải idempotent: lưu provider_event_id và chuyển trạng thái có điều kiện, cả hai trong một transaction.
@PostMapping("/payments/webhook")
public ResponseEntity<Void> webhook(@RequestHeader("X-Signature") String sig, @RequestBody String body) {
if (!signatureVerifier.isValid(body, sig)) return ResponseEntity.status(401).build();
ProviderEvent e = parse(body);
webhookService.handle(e);
return ResponseEntity.ok().build(); // 2xx để provider ngừng gửi lại
}
@Transactional
public void handle(ProviderEvent e) {
if (eventRepo.insertIfAbsent(e.id()) == 0) return; // đã xử lý event này
if (e.type() == SUCCEEDED) {
// chỉ đi tiếp từ PROCESSING/UNKNOWN, không lùi trạng thái đã cuối
if (repo.transitionFrom(e.paymentId(), Set.of(PROCESSING, UNKNOWN), SUCCEEDED) == 1) {
ledger.recordCharge(repo.findById(e.paymentId()).orElseThrow());
outbox.save("PAYMENT_SUCCEEDED", e.paymentId());
}
}
}
Webhook bị mất: không dựa 100% vào webhook. Chạy reconciliation:
@Scheduled(fixedDelay = 30_000)
public void reconcile() {
Instant cutoff = Instant.now().minusSeconds(60);
for (Payment p : repo.findStuck(Set.of(PROCESSING, UNKNOWN), cutoff)) {
ProviderStatus s = provider.queryStatus(p.getIdempotencyKey());
switch (s) {
case SUCCEEDED -> completeSucceeded(p.getId(), s.reference());
case FAILED -> completeFailed(p.getId(), "PROVIDER_FAILED");
case NOT_FOUND -> { /* provider chưa từng nhận: an toàn để FAILED hoặc retry cùng key */ }
default -> { /* vẫn pending: để lần sau, quá X giờ thì alert người xử lý */ }
}
}
}
Thêm một lớp nữa: đối soát cuối ngày với settlement file của provider để bắt mọi lệch.
8. Problem 5: có @Transactional rồi sao vẫn phức tạp?
Xem câu hỏi phụ bên dưới. Tóm tắt: transaction chỉ cover DB local, không cover HTTP call tới bank.
9. Problem 6: payment thành công nhưng booking fail → Saga / refund
Payment SUCCEEDED (user -100K) → Booking Service 💥 (ghế đã EXPIRED)
Không thể ROLLBACK bank.charge()
| Action | Compensation |
|---|---|
| Reserve seat | Release seat |
| Charge payment | Refund payment |
| Create shipment | Cancel shipment |
Refund là một financial operation mới, không phải rollback: cũng cần idempotency key (REFUND-BOOKING-123), state machine riêng, retry và reconciliation. Chi tiết Saga ở Bài 14.6.
10. Problem 7: DB update xong nhưng publish event fail → Outbox
UPDATE payment → SUCCEEDED ✅
publish PAYMENT_SUCCEEDED ❌ app crash
→ Booking không bao giờ biết để chuyển BOOKED
BEGIN
UPDATE payment SET status='SUCCEEDED'
INSERT ledger_entry ...
INSERT outbox (PAYMENT_SUCCEEDED)
COMMIT
Relay (poller / Debezium CDC) → Kafka/SQS → Booking consumer (idempotent)
Outbox cho at-least-once, nên consumer vẫn phải idempotent. Chi tiết ở Bài 14.7.
11. Problem 8: tiền thay đổi cần lịch sử → ledger double-entry
Chỉ lưu balance = 1,500,000 → có bug thì không biết vì sao.
Ledger (append-only):
Deposit +2,000,000
Payment #100 -200,000
Payment #101 -100,000
Refund #100 +200,000
Payment #102 -400,000
-------------------------
Balance 1,500,000 (derived)
Double-entry: mỗi money movement gồm ít nhất hai entry, tổng DEBIT = tổng CREDIT. Tiền không tự sinh ra hay mất đi, chỉ đi từ account này sang account khác.
Charge 100,000 (transaction T1):
DEBIT CUSTOMER_RECEIVABLE 100,000
CREDIT MERCHANT_PAYABLE 100,000
Refund (transaction T2): ghi entry đảo chiều, KHÔNG sửa/xóa T1
DEBIT MERCHANT_PAYABLE 100,000
CREDIT CUSTOMER_RECEIVABLE 100,000
Kiểm tra: SELECT transaction_id FROM ledger_entry GROUP BY transaction_id
HAVING SUM(CASE direction WHEN 'DEBIT' THEN amount ELSE -amount END) <> 0; -- phải rỗng
@Transactional(propagation = Propagation.MANDATORY) // phải chạy trong transaction cập nhật payment
public void recordCharge(Payment p) {
UUID tx = UUID.randomUUID();
ledgerRepo.saveAll(List.of(
LedgerEntry.debit(tx, "CUSTOMER_RECEIVABLE", p.getAmount()),
LedgerEntry.credit(tx, "MERCHANT_PAYABLE", p.getAmount())));
}
Tiền lưu bằng BIGINT đơn vị nhỏ nhất hoặc BigDecimal, không bao giờ double.
12. Full architecture
Booking / Order Service
│ POST /payments (Idempotency-Key)
▼
Payment Service ──────────→ Payment Provider
│ │ │ timeout, retry cùng key, │
│ │ │ circuit breaker │ webhook (HMAC)
│ │ └──── Webhook Handler ◀───────┘
│ │
Payment DB (payment, ledger, outbox, processed_event)
│
Outbox relay → Broker → Booking (BOOKED / release + refund)
│
Reconciliation job ⇄ Provider status API / settlement file
Metrics: UNKNOWN count & age, webhook lag, reconciliation mismatch, refund rate
| Vai trò | AWS | Azure |
|---|---|---|
| API | API Gateway | API Management |
| Compute | ECS / EKS | AKS / App Service |
| DB | RDS / Aurora | Azure SQL / PostgreSQL |
| Queue / events | SQS, EventBridge, MSK | Service Bus, Event Grid |
| Secrets (API key provider) | Secrets Manager | Key Vault |
| Monitoring | CloudWatch | Azure Monitor |
@Transactional, sao không để Spring rollback hết?Mình đã có @Transactional bọc cả hàm pay() rồi, lỗi thì Spring rollback hết, sao vẫn phức tạp vậy?
Bạn hiểu gần đúng, chỉ cần chỉnh một điểm: Spring rollback được DB, không rollback được bank.
@Transactional
public void pay() {
paymentRepository.save(payment); // DB
provider.charge(); // HTTP tới bank
payment.markSucceeded(); // DB
}
DB insert ✓ → Bank charge 100K ✓ → 💥 app crash
DB: ROLLBACK → không có payment record
Bank: user -100K ✓
@Transactional chỉ đảm bảo atomicity cho resource trong transaction đó, thường là DB local. HTTP call sang bank không tham gia. Còn tệ hơn: gọi HTTP trong transaction là giữ connection DB suốt thời gian chờ bank.
Cách đúng: chia thành các transaction ngắn và commit trạng thái trước khi gọi ra ngoài:
TX1: INSERT payment(PROCESSING) → COMMIT
call provider (ngoài transaction, có idempotency key)
TX2: UPDATE → SUCCEEDED / FAILED / UNKNOWN + ledger + outbox → COMMIT
App chết giữa chừng thì record vẫn ở PROCESSING, reconciliation sẽ tìm và hỏi provider. Không có gì "biến mất". Các bẫy khác của @Transactional xem Bài 4.3.3.
Nếu DB của mình ghi PAYMENT_FAILED mà bank nói đã CHARGED thì tin ai? Làm sao hệ thống tự phát hiện và sửa?
Không chọn một bên rồi coi bên kia không tồn tại. Mỗi bên là authoritative cho một thứ:
| Ai | Là nguồn sự thật cho |
|---|---|
| Provider / bank | Thực tế tiền đã bị trừ hay chưa (external financial reality) |
| Hệ thống mình | Business state: booking, ledger nội bộ, refund đã yêu cầu chưa |
Việc của mình là reconcile hai thế giới:
Reconciliation job / settlement file
Our DB: PAY-123 = FAILED Bank: PAY-123 = CHARGED
↓ mismatch
1. Ghi nhận lệch (audit), alert
2. Sửa business state theo rule:
booking còn giữ được → SUCCEEDED → BOOKED
booking đã mất ghế → SUCCEEDED → refund (Saga)
3. Ghi ledger entry tương ứng (không sửa entry cũ)
Nhưng tốt nhất là không để rơi vào FAILED sai ngay từ đầu: chỉ đánh FAILED khi provider trả lỗi rõ ràng; timeout thì UNKNOWN. Mismatch dạng này thường là dấu hiệu code đang coi timeout là failed.
Phỏng vấn hỏi "Design a payment system" thì trả lời sao?
Q1. How do you make sure a customer is charged only once?
🇻🇳 Tiếng Việt:
Mỗi thao tác thanh toán có một idempotency key gắn với nghiệp vụ, lưu với UNIQUE constraint trong DB, không chỉ check bằng code. Request trùng trả lại kết quả cũ. Em cũng truyền key đó sang provider để chính provider không charge lần hai khi em retry.
🇬🇧 English:
Every payment operation has a business-level idempotency key stored with a UNIQUE constraint in the database, not just checked in code. Duplicate requests return the existing result. I also pass the same key to the provider, so a retry never creates a second charge on their side.
Q2. The provider call times out. What do you do?
🇻🇳 Tiếng Việt:
Timeout không có nghĩa là thất bại. Em chuyển payment sang UNKNOWN, không retry mù và không release tài nguyên. Kết quả được xác định bằng webhook, gọi API query status, và job reconciliation định kỳ; webhook handler idempotent theo event ID.
🇬🇧 English:
A timeout is not a failure. I move the payment to UNKNOWN, don't retry blindly and don't release resources. The outcome is resolved through webhooks, status queries and a periodic reconciliation job, and the webhook handler is idempotent on the provider event ID.
Q3. Payment succeeded but the booking failed. Can you roll back?
🇻🇳 Tiếng Việt:
Không rollback được charge của bank vì nó nằm ngoài transaction DB. Em dùng Saga: bước bù trừ là refund, bản thân refund cũng idempotent và có state riêng. Các event giữa service phát bằng transactional outbox để không mất event khi DB đã commit.
🇬🇧 English:
We can't roll back a bank charge because it is outside our database transaction. I use a Saga where the compensating step is a refund, which is itself idempotent with its own state. Events between services go through a transactional outbox so they are not lost after the database commits.
Q4. Why a ledger instead of a balance column?
🇻🇳 Tiếng Việt:
Ledger là bản ghi append-only của mọi chuyển động tiền, nên audit và điều tra được. Theo double-entry, mỗi giao dịch có debit và credit bằng nhau, tổng luôn cân. Sửa sai bằng entry đảo chiều chứ không update hay delete. Balance là giá trị suy ra từ ledger.
🇬🇧 English:
A ledger is an append-only record of every money movement, so it is auditable. With double-entry, each transaction has equal debits and credits, so the books always balance. Corrections are reversing entries, never updates or deletes. Balances are derived from the ledger.
Cách nhớ ngắn gọn
- Double request → idempotency key + UNIQUE, truyền key sang provider.
- Timeout → UNKNOWN; webhook + status query + reconciliation mới quyết định.
@Transactionalkhông cover bank → transaction ngắn, commit trước khi gọi ra ngoài.- Cross-service fail → Saga/refund; DB + event → Outbox.
- Tiền → ledger append-only double-entry, balance là derived.
Tự kiểm tra
Vì sao if (!exists) insert() không đủ để chống double charge?
Hai instance có thể cùng thấy "chưa có" rồi cùng insert. Phải để DB enforce bằng UNIQUE(idempotency_key).
Khi nào đánh FAILED, khi nào UNKNOWN?
FAILED khi provider trả lỗi rõ ràng (thẻ bị từ chối). UNKNOWN khi timeout hoặc lỗi mơ hồ: tiền có thể đã bị trừ.
Webhook đã có, sao vẫn cần reconciliation?
Webhook có thể bị mất hoặc đến muộn. Job reconciliation chủ động hỏi provider cho các payment PROCESSING/UNKNOWN quá lâu.
Refund có phải rollback không?
Không. Đó là một financial operation mới với idempotency, state, retry riêng, và ghi ledger entry đảo chiều.
Double-entry đảm bảo điều gì?
Mỗi transaction có tổng debit = tổng credit, tiền chỉ di chuyển giữa các account, nên có thể kiểm tra sổ luôn cân và audit được.