Debug Một Race Condition Ẩn Trong Hệ Thống Thanh Toán Real-time
Bài này có bản tiếng Anh. Read in English →
Nguyen Hai Trinh • Senior Software Engineer @ SmileViet
Có một loại bug mình ghét nhất. Không sai ở logic nghiệp vụ, chỉ sai ở khoảng thời gian rất ngắn giữa hai thao tác tưởng chừng tuần tự.
Ca này là một race condition. Vài booking bị ghi nhận thanh toán hai lần.
Hiện tượng: một đơn, hai lần ghi tiền
Đội vận hành báo lên: một số booking tour có trạng thái paid nhưng số tiền ghi trong bảng transactions lại gấp đôi giá trị đơn hàng. Không phải booking nào cũng dính, tỷ lệ khoảng 0.3%.
Nhỏ thôi. Nhưng đủ để khách mất lòng tin, và kế toán phải ngồi đối soát bằng tay.
Logic xử lý thanh toán lúc đó trông rất chặt chẽ:
async function handlePaymentWebhook(payload: PaymentWebhookPayload) {
const order = await orderRepo.findById(payload.orderId);
if (order.status === "paid") {
// Đã xử lý rồi, bỏ qua
return;
}
await orderRepo.markAsPaid(order.id, payload.amount);
await ledgerRepo.recordTransaction(order.id, payload.amount);
}
Nhìn qua thì an toàn: kiểm tra trạng thái trước, chỉ xử lý nếu chưa paid. Vậy lỗi ở đâu?
Vạch trần race condition
Provider thanh toán mà hệ thống tích hợp (một trong số các cổng Adyen/Stripe) có cơ chế retry webhook nếu không nhận được response 200 trong một khoảng thời gian ngắn. Mạng chậm, hoặc service đang bận xử lý request khác là đủ. Provider bắn hai webhook gần như đồng thời cho cùng một payment event.
Hai request handlePaymentWebhook khi đó chạy song song. Trình tự thực tế:
- Request A đọc
order.status, thấy"pending". - Request B đọc
order.status, cũng thấy"pending"— request A chưa kịp ghi trạng thái mới. - Request A ghi
status = "paid", ghi transaction. - Request B vẫn tưởng đơn chưa trả, ghi tiếp
status = "paid"(vô hại) và ghi thêm một transaction nữa. Bảng ledger có hai dòng tiền cho một đơn.
Nhìn theo trục thời gian thì nó gọn như thế này:
sequenceDiagram participant P as Payment provider participant A as Request A participant B as Request B participant D as Database P->>A: webhook 1 Note over P: không nhận response 200 kịp, retry P->>B: webhook 2, gửi gần như đồng thời A->>D: đọc order.status D-->>A: "pending" B->>D: đọc order.status D-->>B: "pending" A->>D: ghi status = paid, ghi transaction B->>D: ghi status = paid, ghi transaction thứ hai Note over D: một đơn, hai dòng tiền
Pattern này có tên: check-then-act. Kiểm tra rồi mới hành động, mà không có gì bảo đảm không ai chen ngang giữa hai bước. Ở mức ứng dụng đơn luồng, code trông hoàn toàn hợp lý. Nhưng khi có nhiều instance service chạy song song — hoặc chỉ cần Node.js interleaving giữa các request bất đồng bộ — khoảng hở giữa đọc và ghi là đủ để hai luồng cùng lọt qua điều kiện kiểm tra.
Debug race condition: dựng lại timeline từ log
Race condition không tái hiện ổn định, nên khó bắt. Cách mình đi:
- Log correlation theo
orderIdvàrequestId: log thời điểm nhận webhook, thời điểm đọc trạng thái, thời điểm ghi trạng thái, tất cả kèm timestamp mili-giây. Có log rồi thì so được timeline của các booking lỗi, và thấy chênh lệch giữa hai lần webhook chỉ khoảng 40-120ms. - Kiểm tra log phía payment provider: xác nhận đúng là có hai lần gửi webhook cho cùng một
payment_intent_id, không phải do bug ở phía mình tự gọi lặp. - Tái hiện local bằng cách bắn 2 request đồng thời: dùng một script nhỏ gọi
Promise.allvới hai request webhook giả giống hệt nhau tới cùng một order đang ở trạng tháipending. Chỉ cần vài lần chạy là tái hiện được chính xác bug — xác nhận giả thuyết race condition.
Sửa: đẩy ràng buộc xuống database
if (order.status === "paid") return; ở tầng ứng dụng không đủ, vì nó không atomic. Giữa lúc đọc và lúc ghi, một luồng khác đã đổi trạng thái mất rồi. Chỗ đảm bảo được tính duy nhất thật sự là database.
Bọn mình thêm hai lớp.
Lớp 1: unique constraint trên bảng ledger
Constraint dựa trên orderId + providerEventId — ID sự kiện duy nhất mà payment provider gửi kèm mỗi webhook:
ALTER TABLE transactions
ADD CONSTRAINT uniq_order_provider_event
UNIQUE (order_id, provider_event_id);
Nhờ nó, hai webhook có lọt qua bước kiểm tra ở application layer thì lần ghi thứ hai vào transactions vẫn bị database từ chối bằng lỗi unique violation. Code chỉ cần bắt lỗi đó và coi như đã xử lý rồi:
async function handlePaymentWebhook(payload: PaymentWebhookPayload) {
try {
await db.transaction(async (trx) => {
await trx("transactions").insert({
orderId: payload.orderId,
providerEventId: payload.eventId,
amount: payload.amount,
});
await trx("orders").where({ id: payload.orderId }).update({ status: "paid" });
});
} catch (err) {
if (isUniqueViolation(err)) {
// Webhook trùng lặp, đã xử lý ở lần gọi trước đó — bỏ qua an toàn
return;
}
throw err;
}
}
Lớp 2: advisory lock theo orderId
Với thao tác đọc-sửa-ghi phức tạp hơn — ví dụ cộng dồn số lượng chỗ còn trống của một tour — unique constraint đơn giản không diễn đạt hết logic nghiệp vụ. Lúc đó mình dùng advisory lock:
await db.transaction(async (trx) => {
// Khoá advisory theo orderId, tự động release khi transaction kết thúc
await trx.raw("SELECT pg_advisory_xact_lock(hashtext(?))", [payload.orderId]);
const order = await trx("orders").where({ id: payload.orderId }).first();
if (order.status === "paid") return;
await trx("orders").where({ id: payload.orderId }).update({ status: "paid" });
});
pg_advisory_xact_lock giữ cho chỉ một transaction được thao tác trên cùng một orderId tại một thời điểm. Transaction khác phải chờ tới khi transaction đang giữ lock commit hoặc rollback. Biến in-memory hay mutex trong Node.js không làm được việc này khi có nhiều instance service chạy song song. Advisory lock thì được, vì PostgreSQL quản lý tập trung.
Vài thứ mình giữ lại sau ca race condition
- Idempotency key không phải lúc nào cũng do client sinh ra. Với webhook, chính
providerEventIdđóng vai trò đó. Tận dụng nó ở tầng unique constraint rẻ hơn nhiều so với tự xây cơ chế idempotency riêng. - Check-then-act ở application layer không bao giờ atomic nếu có khả năng chạy song song. Ràng buộc phải nằm ở tầng bảo đảm được atomicity thật sự, thường là database.
- Webhook nên được coi là đến trùng lặp và đến không theo thứ tự. Đó là giả định mặc định khi tích hợp với bất kỳ payment provider nào, không phải trường hợp đặc biệt.
- Chạy lại tuần tự sẽ không tái hiện được race condition. Muốn thấy nó, phải chủ động tạo điều kiện chạy song song giống production —
Promise.all, hoặc công cụ load-test bắn request đồng thời.
Ca deadlock lúc 3h sáng trước đây cũng bắt đầu từ một request bị gọi lại. Khác đường, chung một chỗ chết.
Còn một câu mình chưa trả lời được: provider retry bao nhiêu lần mỗi ngày? Code hiện tại bắt unique violation rồi bỏ qua, nên con số đó chưa được ghi lại ở đâu cả.
Đọc tiếp
- Kiến Trúc Multi-Provider Payment Gateway: Xử Lý Stripe, Adyen và Các Cổng Thanh Toán Khu Vực Châu ÁKỹ Thuật Chuyên Sâu
- Thiết kế Booking System cho Travel Marketplace: Saga Pattern và Event-Driven Workflow trong Medusa v2Kỹ Thuật Chuyên Sâu
- Chuyện trực On-call lúc 3h sáng: Khi Database deadlock và bài học về IdempotencyĐời Sống & Sự Cố
Cần người cho dự án contract hoặc freelance?
Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.