VISEN GROUP
Technology & Engineering

Chống bão Webhook trùng lặp: Thiết kế cơ chế Idempotency chuẩn xác với Redis & PostgreSQL

October 23, 20264 min readUncategorized

Khi tích hợp các cổng thanh toán (Stripe, VNPay, Momo) hoặc dịch vụ bên thứ ba, sự cố lớn nhất không phải là không nhận được webhook, mà là nhận quá nhiều lần cùng một sự kiện (Duplicate Webhooks) do lỗi mạng hoặc timeout ở phía đối tác.

Nếu hệ thống của bạn xử lý cộng tiền hoặc tạo đơn hàng mà không có cơ chế Idempotency (Tính bất biến), khách hàng sẽ bị trừ tiền oan hoặc đơn hàng bị tạo lặp. Dưới đây là kiến trúc chuẩn hóa để vô hiệu hóa hoàn toàn webhook trùng lặp.

1. Bản chất: Vì sao Retry Policy của đối tác tạo ra thảm họa?

Khi server của bạn phản hồi chậm (ví dụ mất 6 giây do nghẽn DB), cổng thanh toán sẽ hiểu nhầm là request thất bại. Họ sẽ tự động kích hoạt cơ chế Retry và bắn lại sự kiện đó từ 3 đến 5 lần trong vài phút tới.

Nếu không khóa cứng định danh sự kiện (Event ID), hệ thống sẽ thực thi luồng kinh doanh nhiều lần cho cùng một giao dịch.

2. Triển khai cơ chế Idempotency 2 lớp (Redis Lock + DB Unique Constraint)

Để đạt độ an toàn tuyệt đối, ta dùng Redis để chặn nhanh ở tầng ngoài (Sub-millisecond) và dùng PostgreSQL Unique Index làm chốt chặn cuối cùng dưới tầng lưu trữ.

TypeScript

import Redis from "ioredis";
import { Request, Response } from "express";

const redis = new Redis(process.env.REDIS_REDIS_URL);

export async function handlePaymentWebhook(req: Request, res: Response) {
  // Lấy Event ID duy nhất do nhà cung cấp bên thứ ba cung cấp
  const eventId = req.headers["x-provider-event-id"] as string || req.body.event_id;
  
  if (!eventId) {
    return res.status(400).json({ error: "Missing event identifier" });
  }

  const lockKey = `webhook:lock:${eventId}`;

  // 1. Chốt chặn Redis: Đánh dấu sự kiện đang được xử lý (SET NX với TTL 60 giây)
  const acquired = await redis.set(lockKey, "PROCESSING", "NX", "EX", 60);

  if (!acquired) {
    // Nếu key đã tồn tại: Sự kiện đang xử lý hoặc đã xử lý xong trước đó
    const status = await redis.get(lockKey);
    return res.status(200).json({ 
      received: true, 
      message: status === "COMPLETED" ? "Duplicate event ignored (Cached)" : "Event is currently processing" 
    });
  }

  try {
    // 2. Thực thi nghiệp vụ cốt lõi dưới Database (đã có Unique Constraint ở bảng transactions)
    await processBusinessLogic(req.body);

    // 3. Đánh dấu hoàn tất trên Redis
    await redis.set(lockKey, "COMPLETED", "EX", 86400); // Lưu vết trong 24 giờ

    return res.status(200).json({ received: true, status: "SUCCESS" });
  } catch (error: any) {
    // Nếu lỗi do trùng khóa dưới DB (Unique Violation), vẫn trả 200 cho đối tác để họ ngừng retry
    if (error.code === '23505') {
      await redis.set(lockKey, "COMPLETED", "EX", 86400);
      return res.status(200).json({ received: true, message: "Duplicate caught by database constraint" });
    }

    // Xóa lock nếu lỗi hệ thống nội bộ để cho phép retry sau
    await redis.del(lockKey);
    return res.status(500).json({ error: "Internal processing failed" });
  }
}

3. Ba quy chuẩn vàng khi vận hành Webhook Worker

  1. Luôn trả về HTTP 200 ngay cả khi lỗi logic nội bộ (trừ lỗi kết nối database): Nếu bạn trả về HTTP 500, đối tác sẽ tiếp tục spam webhook liên tục, làm quá tải hệ thống của bạn.
  2. Lưu vết Event ID vĩnh viễn hoặc tối thiểu 7 ngày: Giúp đội ngũ vận hành dễ dàng đối soát khi có tranh chấp giao dịch từ khách hàng.
  3. Xử lý bất đồng bộ các tác vụ nặng: Webhook chỉ nên ghi nhận sự kiện vào hàng đợi (Queue) rồi trả 200 OK ngay lập tức, tránh việc xử lý logic phức tạp làm quá thời hạn timeout của đối tác.

Kiểm soát chặt chẽ từng sự kiện đến từ bên ngoài là thước đo độ trưởng thành của một kiến trúc backend hướng dịch vụ.

Leave a Reply

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