Đời Sống & Sự Cố • 10 phút đọc • 18/08/2026

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

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Máy thanh toán thẻ
Ảnh: Mike Mozart from Funny YouTube, USA · CC BY 2.0

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ế:

  1. Request A đọc order.status, thấy "pending".
  2. Request B đọc order.status, cũng thấy "pending" — request A chưa kịp ghi trạng thái mới.
  3. Request A ghi status = "paid", ghi transaction.
  4. 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:

  1. Log correlation theo orderId và 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.
  2. 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.
  3. 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.all với hai request webhook giả giống hệt nhau tới cùng một order đang ở trạng thái pending. 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ầ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ờ.