Trừ tiền xong API lỗi, API xong DB sập: Tuyệt kỹ xử lý Distributed Transaction
Chào anh em, lại là mình đây. Trong sự nghiệp làm Backend, có những bài toán nhìn qua thì "dễ như ăn kẹo", nhưng khi đụng tay vào làm thực tế thì mới thấy nó là cả một bầu trời "trap". Một trong số đó chính là Thanh Toán
Kịch bản rất đơn giản:
- User nhấn nút Thanh Toán.
- Bạn trừ tiền trong DB của mình (Service A).
- Bạn gọi API bên đối tác (Service B - Stripe, Momo, Zalopay...) để thực hiện giao dịch.
Nghe thì có vẻ chỉ cần 2 dòng code và 1 cái try-catch là xong nhỉ? Nhưng đời không như là mơ. Sẽ thế nào nếu:
- Bạn trừ tiền xong, gọi API bên B thì... Timeout. Bạn không biết bên B đã xử lý chưa?
- Bạn gọi API bên B thành công, nhưng chưa kịp cập nhật trạng thái trong database nhà mình thì.... Server sập, DB chết.
1. Tại sao Transaction truyền thống lại bất lực?
Thông thường, chúng ta hay dùng Database Transaction để đảm bảo tính Atomic. Nhưng Transaction chỉ có tác dụng trong "ao làng" DB của bạn thôi. Khi bạn gọi một API bên thứ ba, nó nằm ngoài tầm kiểm soát của BEGIN và COMMIT.
Nếu bạn đặt call API bên trong DB Transaction:
// Đừng làm thế này!
await db.transaction(async (tx) => {
await tx.user.decrementBalance(amount);
await callPaymentAPI(orderId); // API mà chậm là nó treo luôn DB connection của bạn
});
Đây là công thức của thảm họa. Nếu API mất 10 giây để phản hồi, DB connection của bạn sẽ bị chiếm dụng trong 10 giây đó. Traffic cao một chút là sập toàn tập
2. Chiến thuật "Transactional Outbox Pattern"
Để giải quyết vấn đề này một cách chuyên nghiệp, chúng ta không gọi API ngay lập tức. Thay vào đó, chúng ta sử dụng Transactional Outbox Pattern.
Bước 1: Trạng thái hóa giao dịch
Thay vì trừ tiền ngay, hãy tạo một bản ghi giao dịch với trạng thái PENDING.
- Mở DB Transaction.
- Trừ tiền User (hoặc đóng băng số dư)
- Ghi một message vào bảng Outbox (hoặc bảng PaymentTasks) ngay trong cùng Transaction đó.
- COMMIT
Bước 2: Worker xử lý riêng biệt
Một Worker (hoặc Cronjob) sẽ quét bảng Outbox này để thực hiện việc gọi API sang bên thứ ba.
- Nếu gọi API thành công -> Update trạng thái COMPLETED
- Nếu gọi API thất bại/Timeout -> Retry.
3. Idempotency (Tính bù trừ) - Chìa khóa vàng
Nếu Worker của bạn Retry, làm sao để chắc chắn bên phía Service B không thực hiện giao dịch đó lần thứ 2 2 ? Câu trả lời là: Idempotency Key. * Mọi API thanh toán chuẩn chỉnh đều yêu cầu một Header gọi là X-Idempotency-Key (hoặc request_id).
- Bạn hãy dùng chính order_id hoặc transaction_id từ hệ thống của mình để gửi sang.
- Phía Service B, khi nhận được 2 request có cùng Key, họ sẽ chỉ xử lý request đầu tiên và trả về kết quả cũ cho request thứ hai.
Lưu ý cực quan trọng: Đừng bao giờ tạo Idempotency Key ngẫu nhiên mỗi lần Retry. Nó phải là duy nhất và cố định cho mỗi một ý định giao dịch của người dùng.
4. Đối phó với "Hố đen" Timeout
Đây là case khoai nhất: Bạn gọi API, chờ mãi không thấy gì, rồi nhận lỗi Timeout. Lúc này, bạn không được phép kết luận là giao dịch thất bại.
Quy tắc xử lý:
-
Retry với Exponential Backoff: Đừng retry liên tục ngay lập tức. Hãy đợi 1s, 2s, 4s, 8s... để tránh làm "ngộp" hệ thống đối tác.
-
Cơ chế Check Status: Trước khi Retry một giao dịch bị Timeout, hãy gọi API GET /payment-status/{idempotency_key} để hỏi bên B xem họ đã nhận được chưa.
Nếu họ bảo "Xong rồi" -> Update DB mình thành công.
Nếu họ bảo "Chưa thấy gì" -> Lúc này mới thực hiện gọi lại (Retry)
5. Bản thiết kế tổng thể (The "Bulletproof" Design)
Để anh em dễ hình dung, đây là luồng đi chuẩn:
- Client gửi yêu cầu thanh toán.
- Server kiểm tra số dư -> Ghi DB (Trừ tiền + Lưu Task vào Outbox) -> Trả về "Đang xử lý".
- Worker lấy Task ra:
- Sinh Idempotency-Key.
- Gọi API đối tác.
- Nếu thành công: Đánh dấu SUCCESS.
- Nếu lỗi: Kiểm tra mã lỗi. Nếu là lỗi 4xx (Sai dữ liệu) thì dừng. Nếu lỗi 5xx hoặc Timeout thì đưa vào hàng đợi Retry.
- Xử lý hậu kỳ: Sau N lần retry không thành công, chuyển trạng thái FAILED và thực hiện Revert tiền lại cho User (Compensation Logic).
Lời kết
Xử lý tiền bạc chưa bao giờ là dễ dàng. Sự im lặng của một API Timeout đôi khi còn đáng sợ hơn cả một lỗi Internal Server Error.
Nguyên tắc cốt lõi là: Đừng tin vào mạng lưới, hãy tin vào trạng thái trong Database của bạn và luôn thiết kế để có thể chạy lại (Retry) mà không gây ra tác dụng phụ (Idempotency).
Hy vọng bài viết này giúp anh em ngủ ngon hơn khi làm các hệ thống liên quan đến tài chính. Có câu hỏi hay case nào oái oăm hơn, hãy comment phía dưới chúng ta cùng đàm đạo nhé!
Happy coding!
All rights reserved