VISEN GROUP
Technology & Engineering

Chuyện gì xảy ra khi cổng thanh toán bắn 3 Webhook cùng 1 giây?

October 9, 20264 min readUncategorized

Hầu hết lập trình viên chỉ test webhook trong điều kiện lý tưởng: bấm thanh toán trên sandbox, nhận 1 request, cộng tiền thành công. Nhưng ra môi trường thật, mạng chập chờn 2 giây là cổng thanh toán (VietQR, Stripe, MoMo) sẽ lập tức retry.

Kết quả: 3 request mang cùng một mã giao dịch lao vào server gần như cùng một mili-giây. Nếu code theo kiểu đọc DB thấy chưa thanh toán rồi mới ghi nhận, race condition sẽ biến 1 đơn hàng thành 2–3 đơn xuất kho.

Muốn hệ thống “miễn nhiễm” với việc bắn trùng (Idempotent), đây là cách thiết kế luồng xử lý chuẩn mực.

Luồng xử lý một Webhook chuẩn: Nhanh – Chắc – Tách biệt

Đừng gộp toàn bộ logic từ kiểm tra, ghi nhận tiền đến gửi mail vào một hàm duy nhất. Luồng chuẩn phải đi qua 3 trạm kiểm soát:

[Webhook tới] 
      │
      ▼
Trạm 1: HMAC Signature (Giả mạo? -> Chặn ngay)
      │
      ▼
Trạm 2: Atomic Lock tại Redis (Đang xử lý? -> Bỏ qua, trả 200)
      │
      ▼
Trạm 3: DB Unique Transaction (Đã xong trước đó? -> Rollback)
      │
      ▼
Đẩy Event vào Queue & Trả ngay 200 OK (< 200ms)

1. Trạm gác cổng: Chặn request giả mạo trước khi đụng vào logic

Không tốn một chu kỳ CPU nào để parse JSON nếu request đó không có chữ ký hợp lệ từ cổng thanh toán.

Dùng hàm so sánh an toàn thời gian (timingSafeEqual) để triệt tiêu hoàn toàn nguy cơ bị tấn công dò thời gian (Timing Attack):ế chặn trùng ở tầng dữ liệu, việc “bán 1 đơn nhưng xử lý 2 lần” là điều chắc chắn xảy ra.

import crypto from "crypto";

function isValidWebhook(rawBody: string, signature: string, secret: string): boolean {
  const hash = crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(hash));
}

2. Xử lý Race Codition: Khóa nguyên tử (Atomic Lock)

Khi 2 request đến cùng thời điểm, cả hai đều đọc thấy trạng thái đơn là PENDING. Bạn không thể dựa vào câu lệnh SELECT để kiểm tra.

Giải pháp là dùng Redis SET NX (chỉ gán nếu khóa chưa tồn tại) để biến nó thành chiếc cổng xoay 1 người:

    const lockKey = `lock:payment:${transactionId}`;
    
    // Khóa trong 30 giây: Chỉ duy nhất 1 request đầu tiên lấy được lock này
    const isFirstRequest = await redis.set(lockKey, "LOCKED", "NX", "EX", 30);
    
    if (!isFirstRequest) {
      // Request thứ 2 hoặc 3 tới: Trả về 200 OK ngay để đối tác ngừng retry
      return res.status(200).json({ message: "Request already in progress" });
    }

    3. Vòng bảo vệ cuối: Để Data từ chối thay vì code ứng dụng

    Ứng dụng có thể crash, Redis có thể mất kết nối, nhưng Database thì không được phép sai số dư.

    Hãy quẳng gánh nặng kiểm tra trùng lặp xuống tầng lưu trữ bằng ràng buộc duy nhất (UNIQUE constraint):

    ALTER TABLE payments ADD CONSTRAINT uk_transaction_id UNIQUE (transaction_id);

    Khi có lỗi logic ở tầng code khiến 2 lệnh INSERT cùng chạy, database engine sẽ tự động ném lỗi vi phạm khóa chính và hủy bỏ giao dịch (Rollback) ngay lập tức. Dữ liệu tài chính luôn nguyên vẹn.

    Nguyên tắc sống còn: Trả lời đối tác trước, làm việc nặng sau

    Sai lầm lớn nhất khiến các cổng thanh toán liên tục retry là: hệ thống của bạn phản hồi quá chậm.

    • Đừng gửi email xác nhận.
    • Đừng gọi API trừ tồn kho hay AI Agent phân tích đơn.
    • Đừng xuất hóa đơn điện tử ngay trong luồng request này.

    Việc cần làm của endpoint webhook chỉ gói gọn trong: Xác thực chữ ký -> Ghi nhận trạng thái thanh toán -> Ném sự kiện vào Message Queue (như Redis Streams, BullMQ hoặc Cloudflare Queues) -> Bắn về HTTP 200 trong dưới 200ms.

    Mọi việc nặng nhọc hãy để các Worker ngầm phía sau thong thả xử lý.

    Xử lý tiền bạc trong lập trình không có chỗ cho sự “hy vọng mạng ổn định”. Thiết kế phòng thủ ngay từ đầu giúp bạn kê cao gối ngủ mà không sợ một sáng thức dậy thấy tài khoản công ty bị trừ tiền gấp đôi.

    Leave a Reply

    Your email address will not be published. Required fields are marked *