Harbor

Hệ thống thanh toán

KhóIdempotencySổ cái képĐối soátWebhook bất đồng bộGiống Stripe, MoMo, cổng VNPay

Hiểu đề

Hệ thống thanh toán là gì? Cổng thanh toán đứng giữa cửa hàng (merchant) và các ngân hàng, tổ chức thẻ, ví. Merchant gọi API để thu tiền của khách, hệ thống làm việc với nhà cung cấp thanh toán (PSP), ghi sổ cái và báo kết quả lại cho merchant qua webhook.

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

  1. Merchant có thể tạo một yêu cầu thanh toán qua API và nhận trạng thái cuối cùng (thành công/thất bại)
  2. Khách có thể trả bằng thẻ, ví điện tử hoặc QR chuyển khoản ngân hàng
  3. Merchant nhận webhook mỗi khi trạng thái thanh toán thay đổi
  4. Merchant có thể hoàn tiền toàn phần hoặc một phần
  5. Merchant xem được lịch sử giao dịch và số dư chờ quyết toán

Yêu cầu phi chức năng

  • Không trừ tiền hai lần: 0 giao dịch trùng do client hoặc hệ thống thử lại
  • Sổ cái luôn cân (tổng nợ = tổng có), lệch với PSP được phát hiện trong tối đa 24 h
  • API tạo thanh toán sẵn sàng 99,95%, p95 < 300 ms (chưa tính thời gian khách ở trang PSP)
  • Webhook đến merchant p95 < 5 s, thử lại tối đa 3 ngày nếu merchant lỗi
  • Không lưu số thẻ ở các service thường; giữ phạm vi PCI DSS nhỏ nhất có thể

Ngoài phạm vi: Đăng ký và KYC merchant; Quy đổi ngoại tệ, thanh toán xuyên biên giới; Quy trình xử lý khiếu nại/chargeback chi tiết; Chi tiết chuyển tiền quyết toán (payout) ra tài khoản merchant.

Thực thể và API

Merchant
Cửa hàng dùng cổng: API key, URL webhook, khóa ký webhook, biểu phí.
Payment
Một yêu cầu thu tiền: merchant_id, order_ref, số tiền (số nguyên, đơn vị nhỏ nhất), tiền tệ, trạng thái CREATED/PROCESSING/SUCCEEDED/FAILED/UNKNOWN.
PaymentAttempt
Một lần gọi PSP cho Payment: provider, mã tham chiếu duy nhất gửi sang PSP, mã giao dịch PSP trả về, kết quả.
JournalEntry + Posting
Sổ cái kép: mỗi JournalEntry gồm ≥ 2 Posting (account_id, nợ/có, số tiền), tổng bằng 0. Chỉ thêm, không sửa.
IdempotencyKey
(merchant_id, key) duy nhất, hash của request, response đã trả, trạng thái, hạn 24 h.
Refund
Hoàn tiền gắn với Payment: số tiền, lý do, trạng thái, mã giao dịch PSP.
MethodEndpointGhi chú
POST/v1/paymentsTạo thanh toán. Bắt buộc header Idempotency-Key. Trả payment_id và URL/QR để khách thanh toán.
GET/v1/payments/{id}Đọc trạng thái hiện tại. Merchant dùng để kiểm tra khi lỡ webhook.
POST/v1/payments/{id}/refundsHoàn tiền toàn phần/một phần, cũng cần Idempotency-Key.
POST/v1/providers/{psp}/webhookPSP gọi về. Kiểm tra chữ ký, xử lý idempotent theo mã giao dịch PSP.
GET/v1/balance?from=&to=Số dư và bút toán của merchant, đọc từ replica.

Thiết kế tổng thể

Đi lần lượt từng yêu cầu chức năng. Mục tiêu ở đây là chạy đúng, việc tối ưu để dành cho phần đào sâu.

1) Merchant có thể tạo yêu cầu thanh toán

Bước tạo chỉ ghi nhận ý định thu tiền, chưa chạm tới tiền. Idempotency-Key được ghi cùng transaction với Payment nên gửi lại bao nhiêu lần cũng chỉ có một Payment.

  1. 1Server merchant gọi POST /v1/payments kèm Idempotency-Key và order_ref
  2. 2API xác thực API key, rate limit theo merchant
  3. 3Trong 1 transaction: INSERT idempotency_keys (UNIQUE merchant_id + key) và INSERT payments trạng thái CREATED
  4. 4Trả payment_id và URL trang thanh toán (hoặc QR); lần gửi lại cùng key nhận đúng response cũ

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97), mục "Writing idempotent message handlers" (tạo order với ID do client cấp là idempotent) ; Grokking Modern System Design · ch.19 Rate Limiter ↗ · bài "System Design: The Rate Limiter", mục "Why do we need a rate limiter?" ; Chưa có nguồn trong thư viện: hành vi của PSP/cổng thanh toán (webhook, mã tham chiếu, file quyết toán) và PCI DSS không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

2) Khách có thể trả bằng thẻ, ví hoặc QR

Khách nhập thẻ trên trang/iframe của PSP nên số thẻ không đi qua server của mình. Kết quả về bất đồng bộ qua webhook của PSP, còn redirect của trình duyệt chỉ để hiển thị.

  1. 1API ghi PaymentAttempt với mã tham chiếu duy nhất TRƯỚC khi gọi PSP, Payment sang PROCESSING
  2. 2Khách nhập thẻ/OTP hoặc quét QR trên trang PSP, PSP trả token hoặc kết quả
  3. 3PSP gọi webhook: kiểm tra chữ ký, tìm attempt theo mã tham chiếu, bỏ qua nếu đã xử lý
  4. 4Trong 1 transaction: Payment sang SUCCEEDED, ghi JournalEntry (nợ phải thu PSP, có phải trả merchant, có doanh thu phí), ghi outbox
  5. 5Trình duyệt quay về trang merchant, merchant đọc trạng thái từ API hoặc chờ webhook

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100), mục "Using a database table as a message queue" (cập nhật DB và ghi outbox trong cùng transaction) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3.2 Countermeasures for handling the lack of isolation (trạng thái *_PENDING, tr. 128–132) ; Chưa có nguồn trong thư viện: hành vi của PSP/cổng thanh toán (webhook, mã tham chiếu, file quyết toán) và PCI DSS không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

3) Merchant nhận webhook khi trạng thái đổi

Gọi HTTP sang merchant có thể chậm hoặc lỗi nên không làm trong transaction thanh toán. Outbox đảm bảo sự kiện không mất, worker gửi lại với backoff.

  1. 1Outbox relay đọc bản ghi mới, đẩy vào queue
  2. 2Worker POST sang URL merchant, ký HMAC kèm event_id và timestamp
  3. 3Lỗi hoặc timeout thì thử lại theo backoff mũ (1 phút, 5 phút, 30 phút... đến 3 ngày), quá hạn vào dead-letter
  4. 4Merchant xử lý idempotent theo event_id, trả 2xx

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.7 Transactional messaging (tr. 97–100) ; Building Microservices ↗ (.zip) · ch.4 Integration · mục "Complexities of Asynchronous Architectures" (giới hạn retry, dead letter queue, tr. 57–58) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97) ; Chưa có nguồn trong thư viện: chữ ký HMAC của webhook và lịch backoff mũ không có trong sách

4) Hoàn tiền và đối soát

Hoàn tiền là một Payment ngược chiều với bút toán đảo, không sửa bút toán cũ. Mỗi ngày so khớp sổ cái với file quyết toán của PSP để bắt mọi lệch.

  1. 1POST /refunds kiểm tra tổng đã hoàn ≤ số đã thu, ghi Refund PENDING và bút toán tạm giữ
  2. 2Gọi API hoàn tiền của PSP với mã tham chiếu duy nhất, xác nhận qua webhook
  3. 3Job đối soát tải file quyết toán của PSP, so từng dòng với PaymentAttempt và bút toán
  4. 4Dòng lệch (PSP có mà mình không, số tiền khác, trạng thái khác) vào hàng chờ xử lý ngoại lệ

Nguồn: Chưa có nguồn trong thư viện: sổ cái kép (double-entry) là kiến thức kế toán, không có trong sách của thư viện ; Microservices Patterns ↗ (.zip) · ch.4 · §4.1.3 Using the Saga pattern to maintain data consistency (bù trừ bằng thao tác ngược, tr. 114–117) ; Chưa có nguồn trong thư viện: hành vi của PSP/cổng thanh toán (webhook, mã tham chiếu, file quyết toán) và PCI DSS không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

Hạ tầng theo quy mô

Cùng một sản phẩm, ba mức người dùng. Chọn từng mức để xem sơ đồ, phép tính và cấu hình server thay đổi thế nào.

Người dùng
10.000 DAU · ≈ 20.000 giao dịch/ngày cho vài trăm shop
Tải đỉnh
≈ 2,3 giao dịch/s, ≈ 12 req/s đỉnh
Chi phí ước lượng
≈ 150–400 USD/tháng (ước lượng, chưa tính phí PSP)

Cổng cho vài trăm shop nhỏ. Hai instance API và một Postgres có standby là đủ; tính đúng quan trọng hơn hiệu năng nên mọi thứ nằm trong transaction của Postgres.

Người dùngKhách / shopDịch vụPayment APIBên ngoàiPSP / ngân hàngDatabasePostgreSQL HABên ngoàiServer merchantWorkerCron đối soáttạo thanh toánnhập thẻ (iframe)gọi + webhooksổ cái + outboxwebhookfile quyết toánso khớp

Ước lượng

  • Giao dịch: 20.000 / 86.400 ≈ 0,23 giao dịch/s, đỉnh ×10 ≈ 2,3 giao dịch/s
  • API: mỗi giao dịch ≈ 5 request (tạo, xác nhận, webhook PSP, 2 lần hỏi trạng thái) → 2,3 × 5 ≈ 12 req/s đỉnh
  • Instance: ceil(12 / 600) = 1 → chạy 2 để dự phòng
  • Sổ cái: 20.000 × 4 posting × 200 B = 16 MB/ngày × 365 ≈ 5,8 GB/năm
  • Payment + attempt + idempotency: 20.000 × 2 KB = 40 MB/ngày × 365 ≈ 14,6 GB/năm
  • Webhook merchant: 20.000 × 2 sự kiện = 40.000/ngày ≈ 0,46/s, job table trong Postgres là đủ

Nguồn con số: Grokking Modern System Design · ch.5 Back-of-the-envelope Calculations ↗ · bài "Examples of Resource Estimation", mục "Number of servers required", "Storage requirements" ; Quy tắc ước lượng của khoá học · /hoc/chon-server (500 req/s mỗi vCPU, chạy 60% CPU, Redis ×1,3; số request mỗi giao dịch, đỉnh ×10 và kích thước bản ghi là giả định của khoá học)

Bảng hạ tầng

Thành phầnChọnSố lượngCấu hìnhVì sao
APINode.js (NestJS), Docker trên Cloud Run22 vCPU · 4 GB RAM12 req/s chỉ cần 1 instance, chạy 2 để không mất thanh toán khi một cái lỗi.
DatabasePostgreSQL managed HA1 primary + 1 standby2 vCPU · 8 GB · 100 GB SSDKhoảng 20 GB/năm là nhỏ, nhưng sổ cái cần ACID và standby đồng bộ vì mất dữ liệu là mất tiền.
Outbox + webhookBảng outbox + pg-boss chạy trong API1 (trong API)Dùng chung instance API0,46 webhook/s, chưa cần queue riêng; outbox vẫn đảm bảo không mất sự kiện.
Đối soátCron (Cloud Scheduler) hằng ngày + mỗi 5 phút1Job ngắn trên Cloud RunHằng ngày so file PSP với sổ cái; mỗi 5 phút hỏi lại các attempt UNKNOWN.
PSPVNPay / MoMo / Stripe, trang thanh toán hosted1–2Dịch vụ bên ngoàiSố thẻ không đi qua server mình, phạm vi PCI ở mức thấp nhất.
Bí mậtSecret Manager / KMS1ManagedGiữ khóa PSP và khóa ký webhook, không để trong biến môi trường thô.
Thay đổi so với mức trước
  • Điểm xuất phát: một API, một Postgres HA, PSP lo phần thẻ
  • Idempotency key, sổ cái kép và outbox có ngay từ ngày đầu vì sửa sau rất đắt
  • Đối soát chạy bằng cron, lệch thì báo cho người xử lý
Nút thắt tiếp theo

Khi lượng giao dịch tăng 100 lần, gửi webhook trong process API làm chậm API khi merchant phản hồi chậm, và một PSP duy nhất là điểm chết của cả hệ thống.

Cấu hình và chi phí là ước lượng để học và trình bày trong phỏng vấn. Xem cách tính ở bài Chọn server.

So sánh nhanh ba mức

Thành phầnMức 1Mức 2Mức 3
Người dùng10.000 DAU · ≈ 20.000 giao dịch/ngày cho vài trăm shop1 triệu DAU · ≈ 2 triệu giao dịch/ngày50 triệu DAU · ≈ 50 triệu giao dịch/ngày (ví điện tử lớn)
Chi phí≈ 150–400 USD/tháng (ước lượng, chưa tính phí PSP)≈ 3.000–8.000 USD/tháng (ước lượng, chưa tính phí PSP)≈ 60.000–150.000 USD/tháng (ước lượng, chưa tính phí PSP)
API
Node.js (NestJS), Docker trên Cloud Run
2 × 2 vCPU · 4 GB RAM
Chưa cầnChưa cần
Database
PostgreSQL managed HA
1 primary + 1 standby × 2 vCPU · 8 GB · 100 GB SSD
PostgreSQL HA (Aurora / Cloud SQL)
1 primary + 1 standby + 1 read replica × 8 vCPU · 32 GB · 1 TB SSD
Chưa cần
Outbox + webhook
Bảng outbox + pg-boss chạy trong API
1 (trong API) × Dùng chung instance API
Chưa cầnChưa cần
Đối soát
Cron (Cloud Scheduler) hằng ngày + mỗi 5 phút
1 × Job ngắn trên Cloud Run
Job batch + worker quét UNKNOWN
1 × 2 vCPU · 4 GB RAM
Chưa cần
PSP
VNPay / MoMo / Stripe, trang thanh toán hosted
1–2 × Dịch vụ bên ngoài
2–3 nhà cung cấp (Napas, VNPay, Stripe...)
2–3 × Dịch vụ bên ngoài, adapter riêng
Chưa cần
Bí mật
Secret Manager / KMS
1 × Managed
Chưa cầnChưa cần
GatewayChưa cần
Cloud Load Balancer + WAF
1 × TLS, rate limit theo API key
Envoy / Kong + WAF, 3 AZ
6 pod × 4 vCPU · 8 GB mỗi pod
Payment APIChưa cần
Node.js (NestJS), container autoscale
3–6 (autoscale) × 2 vCPU · 4 GB RAM
Node.js trên Kubernetes, 3 AZ
49 pod (HPA) × 2 vCPU · 4 GB mỗi pod
RedisChưa cần
ElastiCache / Memorystore
1 primary + 1 replica × 2 vCPU · 4 GB
Chưa cần
QueueChưa cần
Amazon SQS / Google Pub/Sub
1 × Có dead-letter queue
Chưa cần
Worker webhookChưa cần
Node.js consumer
2 × 1 vCPU · 2 GB RAM
Node.js consumer trên Kubernetes
24 pod × 1 vCPU · 2 GB mỗi pod
Chống gian lậnChưa cầnChưa cần
Service chấm điểm (luật + model)
10 pod × 4 vCPU · 8 GB mỗi pod
Vault thẻ (PCI)Chưa cầnChưa cần
Service riêng + KMS/HSM, VPC tách
3 pod × 2 vCPU · 4 GB mỗi pod
Sổ cáiChưa cầnChưa cần
PostgreSQL chia shard theo account_id (Citus hoặc shard ở tầng ứng dụng)
8 shard × (1 primary + 2 replica) × 16 vCPU · 64 GB · 4 TB NVMe
Redis ClusterChưa cầnChưa cần
ElastiCache Redis Cluster
3 shard × (1 primary + 1 replica) × 4 vCPU · 16 GB mỗi node
KafkaChưa cầnChưa cần
MSK / Confluent, 3 AZ
6 broker × Topic payments 24 partition, RF 3
Đối soát + kho dữ liệuChưa cầnChưa cần
Spark / BigQuery job, dữ liệu cũ trên S3
Cụm job theo lịch × Autoscale

Đào sâu

Mỗi câu hỏi đi từ cách đơn giản đến cách người phỏng vấn mong đợi ở cấp senior.

1) Server merchant bị timeout và gửi lại POST /payments. Làm sao chỉ tạo một thanh toán?

Cách chưa tốt
Kiểm tra 'đã có payment cho order_ref này chưa' rồi mới INSERT

Hai request gửi gần như cùng lúc đều đọc thấy chưa có, rồi cả hai cùng INSERT. Đây là lỗi check-then-act kinh điển.

Đánh đổi: Chạy đúng khi test tay, sai đúng lúc mạng chập chờn và client thử lại dồn dập.

Nguồn: Microservices Patterns ↗ (.zip) · ch.4 · §4.3.1 Overview of anomalies (hai tác vụ chạy song song cho kết quả sai, tr. 127–128) ; Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–96)

Cách tốt
Idempotency-Key trong Redis: SET key NX EX 86400

Request đầu SET NX thành công thì xử lý, request sau thấy key đã có thì trả kết quả lưu kèm. Nhanh và chặn được phần lớn trùng lặp.

Đánh đổi: Redis và DB là hai hệ riêng: server chết sau khi SET mà trước khi ghi DB thì key treo, Redis failover có thể mất key và để lọt request trùng.

Nguồn: Redis in Action ↗ · ch.6 · §6.2.3 Building a lock in Redis (SETNX, tr. 120–123) ; Redis in Action ↗ · ch.4 · §4.3 Handling system failures (tr. 75–78: dữ liệu Redis có thể mất khi failover)

Cách tốt nhất
Bảng idempotency_keys trong cùng DB với Payment, UNIQUE(merchant_id, key), lưu hash request và response

INSERT key cùng transaction với Payment nên chỉ một request thắng, ràng buộc UNIQUE làm việc đó thay cho code. Request trùng key và trùng body nhận lại response cũ; trùng key nhưng khác body trả 422; request đầu chưa xong trả 409 để client thử lại sau. Đây là cách bảng PROCESSED_MESSAGE dùng để bỏ message trùng (Microservices Patterns §3.3.6). Redis SET NX chỉ đặt phía trước để lọc nhanh khi bị spam.

Đánh đổi: Thêm một lần ghi mỗi request và phải dọn key quá 24 h; đổi lại tính đúng không phụ thuộc Redis.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97), mục "Tracking messages and discarding duplicates" (bảng PROCESSED_MESSAGE, INSERT fail khi trùng, tr. 96–97) ; Grokking Modern System Design · ch.9 Databases ↗ · bài "Types of Databases", mục "Relational databases" › "Concurrency"

2) Lưu tiền và số dư thế nào để không bao giờ lệch và luôn kiểm toán được?

Cách chưa tốt
Cột balance trong bảng merchants, UPDATE balance = balance + x

Đơn giản nhưng không có lịch sử: số dư sai thì không biết sai từ giao dịch nào. Đọc rồi ghi ở tầng ứng dụng còn dễ mất cập nhật khi chạy song song.

Đánh đổi: Không qua được bất kỳ đợt kiểm toán nào, sửa lỗi phải đoán.

Nguồn: Chưa có nguồn trong thư viện: sổ cái kép (double-entry) là kiến thức kế toán, không có trong sách của thư viện ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3.1 Overview of anomalies (lost updates, tr. 127–128)

Cách tốt
Bảng transactions + balance, cập nhật trong 1 transaction có SELECT ... FOR UPDATE

Mỗi biến động ghi một dòng transaction và cập nhật balance cùng lúc. Có lịch sử, không mất cập nhật.

Đánh đổi: Ghi sổ đơn: không chứng minh được tiền không tự sinh ra hay biến mất, và một merchant lớn khóa một dòng balance thành điểm nghẽn.

Nguồn: Grokking Modern System Design · ch.9 Databases ↗ · bài "Types of Databases", mục "Relational databases" › "Concurrency" ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3.1 Overview of anomalies (lost updates)

Cách tốt nhất
Sổ cái kép chỉ thêm (append-only double-entry)

Mỗi sự kiện tiền là một JournalEntry gồm nhiều Posting có tổng bằng 0, ví dụ khách trả 100.000đ: nợ 'phải thu PSP' 100.000, có 'phải trả merchant' 98.000, có 'doanh thu phí' 2.000. Không UPDATE hay DELETE; sai thì ghi bút toán đảo. Số tiền là số nguyên ở đơn vị nhỏ nhất, không dùng float. Số dư là tổng posting, có bảng snapshot số dư theo version để đọc nhanh. Kiểm tra tổng bằng 0 cho từng entry ngay trong transaction.

Đánh đổi: Nhiều dòng hơn và phải giữ snapshot số dư; tài khoản nóng (merchant rất lớn) cần chia thành nhiều tài khoản con rồi gộp định kỳ.

Nguồn: Chưa có nguồn trong thư viện: sổ cái kép (double-entry) là kiến thức kế toán, không có trong sách của thư viện ; Microservices Patterns ↗ (.zip) · ch.6 · §6.1.2 Overview of event sourcing và §6.1.5 Using snapshots to improve performance (chỉ ghi thêm event, snapshot để đọc nhanh, tr. 186–197)

3) Gọi PSP bị timeout. Tiền đã bị trừ hay chưa, và làm sao không trừ hai lần mà cũng không bỏ sót?

Cách chưa tốt
Timeout thì báo lỗi cho merchant, để khách bấm thanh toán lại

Timeout không có nghĩa là thất bại: PSP có thể đã trừ tiền nhưng phản hồi bị mất. Lần bấm lại tạo một charge mới với mã mới.

Đánh đổi: Khách bị trừ tiền hai lần, đội hỗ trợ phải hoàn tiền tay.

Nguồn: Building Microservices ↗ (.zip) · ch.5 Splitting the Monolith · mục "Transactional Boundaries" ("Try Again Later", "Abort the Entire Operation", tr. 89–92) ; Grokking Modern System Design · ch.3 Abstractions ↗ · bài "The Spectrum of Failure Models", mục "Omission failures"

Cách tốt
Gửi mã tham chiếu duy nhất sang PSP và chỉ thử lại với đúng mã đó

Phần lớn PSP từ chối hoặc trả lại kết quả cũ nếu nhận trùng mã đơn/idempotency key. Thử lại cùng mã thì không thành charge mới, kết quả cuối lấy từ webhook.

Đánh đổi: Webhook có thể mất, đến muộn hoặc sai thứ tự; không có gì bắt các thanh toán treo ở trạng thái không rõ.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97) ; Building Microservices ↗ (.zip) · ch.11 Microservices at Scale · mục "Idempotency" (tr. 215) ; Chưa có nguồn trong thư viện: hành vi của PSP/cổng thanh toán (webhook, mã tham chiếu, file quyết toán) và PCI DSS không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

Cách tốt nhất
Máy trạng thái + ghi attempt trước khi gọi + hỏi trạng thái chủ động + đối soát hằng ngày

Ghi PaymentAttempt với mã tham chiếu trước khi gọi PSP. Timeout thì chuyển UNKNOWN, tuyệt đối không tạo attempt mới với mã khác. Webhook đã kiểm chữ ký được xử lý idempotent theo mã giao dịch PSP và chỉ cho phép chuyển trạng thái hợp lệ (SUCCEEDED không quay về PROCESSING). Worker quét attempt UNKNOWN/PROCESSING quá 5 phút và gọi API tra cứu của PSP. Cuối ngày đối soát 3 bên: sổ cái, file quyết toán PSP, sao kê ngân hàng; tiền đã thu mà đơn đã hủy thì tự hoàn.

Đánh đổi: Một số thanh toán mất vài phút mới có trạng thái cuối, merchant phải xử lý trạng thái 'đang xử lý'; cần người vận hành hàng chờ ngoại lệ.

Nguồn: Microservices Patterns ↗ (.zip) · ch.3 · §3.3.6 Handling duplicate messages (tr. 95–97) ; Microservices Patterns ↗ (.zip) · ch.4 · §4.3.2 Countermeasures for handling the lack of isolation (*_PENDING) ; Grokking Modern System Design · ch.23 Distributed Task Scheduler ↗ · bài "Design Considerations", mục "Task idempotency" ; Chưa có nguồn trong thư viện: hành vi của PSP/cổng thanh toán (webhook, mã tham chiếu, file quyết toán) và PCI DSS không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

4) Có nên tự nhận và lưu số thẻ? Thu hẹp phạm vi PCI DSS thế nào?

Cách chưa tốt
API nhận số thẻ, mã hóa rồi lưu trong DB chính

Mọi service, log, backup và người có quyền truy cập DB đều nằm trong phạm vi PCI. Chỉ cần một dòng log in ra request body là lộ số thẻ.

Đánh đổi: Chi phí audit và vận hành rất lớn, rủi ro rò rỉ cao, không đáng với hầu hết công ty.

⚠ Chưa có nguồn trong thư viện: hành vi của PSP/cổng thanh toán (webhook, mã tham chiếu, file quyết toán) và PCI DSS không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

Cách tốt
Trang thanh toán hoặc iframe (hosted fields) của PSP, server chỉ nhận token

Số thẻ đi thẳng từ trình duyệt sang PSP, server chỉ thấy token và 4 số cuối. Phạm vi PCI thu về mức tự đánh giá đơn giản nhất.

Đánh đổi: Token thuộc về PSP nên khó đổi hoặc chạy song song nhiều PSP; giao diện bị giới hạn theo PSP.

⚠ Chưa có nguồn trong thư viện: hành vi của PSP/cổng thanh toán (webhook, mã tham chiếu, file quyết toán) và PCI DSS không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản

Cách tốt nhất
Vault thẻ riêng trong vùng mạng PCI tách biệt, các service khác chỉ thấy token của mình

Khi cần định tuyến nhiều PSP hoặc kết nối thẳng ngân hàng, đặt một service vault nhỏ trong VPC riêng: nhận số thẻ, mã hóa envelope bằng KMS/HSM, trả token nội bộ. CVV không bao giờ lưu. Chỉ vault được detokenize để chuyển cho PSP. Payment API, sổ cái, log, analytics chỉ thấy token nên nằm ngoài phạm vi audit.

Đánh đổi: Vẫn phải audit vault và vận hành HSM; chỉ đáng khi quy mô đủ lớn để tiết kiệm phí nhờ định tuyến nhiều PSP.

⚠ Chưa có nguồn trong thư viện: hành vi của PSP/cổng thanh toán (webhook, mã tham chiếu, file quyết toán) và PCI DSS không có trong sách nào của thư viện; chỉ nêu khái niệm cơ bản ; Chưa có nguồn trong thư viện: vault thẻ, KMS/HSM và mã hóa envelope không có trong sách; không đưa số liệu cụ thể

Trong phỏng vấn

Nên nói
  • “Tôi coi tính đúng là yêu cầu số một: mọi API ghi tiền đều cần Idempotency-Key lưu cùng transaction với dữ liệu.”
  • “Tiền nằm trong sổ cái kép chỉ thêm, sai thì ghi bút toán đảo, số tiền là số nguyên.”
  • “Timeout khi gọi PSP là trạng thái UNKNOWN chứ không phải thất bại; tôi hỏi lại PSP và đối soát hằng ngày.”
  • “Số thẻ không đi qua server thường: dùng trang hosted của PSP, hoặc vault riêng khi quy mô đủ lớn.”
Bẫy hay gặp
  • Dùng float cho số tiền.
  • Coi timeout là thất bại và tạo charge mới khi thử lại.
  • Tin redirect của trình duyệt thay cho webhook đã kiểm chữ ký.
  • Gửi webhook cho merchant ngay trong transaction thanh toán.

Đọc thêm

Quy trình nhiều bước
Giữ dữ liệu nhất quán khi một nghiệp vụ đi qua nhiều service: saga choreography và orchestration, hành động bù trừ, trạng thái PENDING, transactional outbox, idempotent consumer và workflow engine như Temporal.
PostgreSQL
PostgreSQL là lựa chọn mặc định an toàn cho phần lớn hệ thống: ACID, index phong phú, replication, partitioning và hệ extension mạnh. Bài này giải thích MVCC, các loại index, mức isolation, replication, connection pooling, bảng sizing theo cấu hình và dấu hiệu nên rời một node Postgres.
Kafka
Kafka là log phân tán bền vững: topic chia partition, consumer đọc theo offset và có thể đọc lại. Bài này đi qua thứ tự theo partition, consumer group, replication với ISR và acks, giới hạn của exactly-once, cách chọn số partition và so sánh với SQS, Pub/Sub, RabbitMQ.
Thiết kế API
Cách thiết kế API trong phỏng vấn: REST theo resource, so sánh REST, GraphQL và gRPC, phân trang offset và cursor, idempotency key, versioning, định dạng lỗi, xác thực bằng session, JWT, OAuth, rate limit, kèm ví dụ NestJS và Express.
Change data capture (CDC)
Change data capture đọc mọi thay đổi trong database và phát thành luồng sự kiện. Bài này so sánh CDC dựa trên log, trigger và polling, giải thích Debezium với Kafka, quan hệ với transactional outbox, các use case và những bẫy về thứ tự và schema.
Sharding
Chia dữ liệu theo chiều dọc và chiều ngang, cách chọn shard key và tránh hotspot, so sánh range, hash và directory, resharding, query và transaction xuyên shard, khi nào chưa nên shard và một Postgres chịu được tới đâu.

Sách trong thư viện của bạn. Số chương có thể lệch nhẹ so với bản PDF.