Ngừng Bị Dual-Write: Áp Dụng Transactional Outbox Pattern

Ngừng Bị Dual-Write: Áp Dụng Transactional Outbox Pattern

Hướng dẫn chi tiết cách dùng Transactional Outbox Pattern để xử lý triệt để lỗi mất dữ liệu khi gửi event trong DB transaction.

Trong phát triển hệ thống backend, việc phát tín hiệu (publish event) cho các dịch vụ khác sau khi cập nhật database là một thao tác cực kỳ phổ biến. Thế nhưng, nếu viết code theo cách ngây thơ ban đầu, anh em rất dễ rơi vào cái bẫy "Dual-Write" — nguyên nhân hàng đầu gây ra bất đồng bộ dữ liệu âm thầm và cực kỳ khó debug.

Trong bài viết này, mình sẽ cùng anh em phân tích tại sao cách bắn event thông thường lại dễ ăn lỗi trên production, và làm sao để áp dụng Transactional Outbox Pattern để giúp hệ thống chạy chuẩn xác 100%.

Cơn Ác Mộng Mang Tên "Dual-Write"

Hãy tưởng tượng anh em đang xây dựng tính năng đặt hàng cho một trang thương mại điện tử. Khi người dùng bấm "Thanh toán", ứng dụng backend cần thực hiện 2 việc chính:

  1. Lưu đơn hàng mới vào cơ sở dữ liệu PostgreSQL.
  2. Gửi một event order.created sang RabbitMQ hoặc Kafka để dịch vụ kho và dịch vụ gửi email notification tiếp nhận xử lý.

Đoạn code thông thường mà chúng ta hay viết trong Node.js / TypeScript sẽ có dạng như sau:

async function createOrder(orderData: OrderPayload) {
  // Bước 1: Lưu thông tin vào Database
  const order = await db.transaction(async (tx) => {
    return await tx.order.create({ data: orderData });
  });

  // Bước 2: Bắn event sang Message Broker
  await messageBroker.publish('order.created', { orderId: order.id });
}

Đoạn code trên trông rất gọn gàng và dễ hiểu. Tuy nhiên, khi đưa lên hệ thống thật với lưu lượng truy cập cao, nó tồn tại một lỗ hổng kiến trúc nghiêm trọng gọi là Dual-Write Problem.

Bởi vì Database và Message Broker là 2 hệ thống phân tán hoàn toàn độc lập, anh em không thể gom cả 2 thao tác này vào chung một ACID Transaction nguyên tử (atomic) của cơ sở dữ liệu. Điều này dẫn tới 2 kịch bản thảm họa:

  • Database thành công, Broker thất bại: Đơn hàng đã ghi xong vào DB. Đúng lúc ứng dụng định bắn event sang RabbitMQ thì mạng chập chờn hoặc ứng dụng bị OOM (Out Of Memory) crash mất. Kết quả là DB có đơn hàng, nhưng kho không hề biết để giữ hàng, còn người dùng thì không nhận được email.
  • Broker thành công, Database thất bại: Nếu anh em đưa hàm publish vào bên trong khối transaction, event có thể được gửi đi thành công sang RabbitMQ. Thế nhưng ngay sau đó, DB transaction lại bị rollback do vi phạm constraint! Kết quả là các dịch vụ khác đi xử lý một đơn hàng... vốn không hề tồn tại trong DB.

Dù anh em có bọc try/catch hay viết hàm async chạy ngầm thì cũng không bao giờ đảm bảo tính nhất quán (atomicity) tuyệt đối giữa 2 hệ thống độc lập này.

Giải Pháp: Transactional Outbox Pattern

Sơ đồ khái niệm thể hiện đồng bộ dữ liệu giữa bảng outbox trong database và message broker

Transactional Outbox Pattern ra đời để giải quyết triệt để bài toán Dual-Write bằng cách tận dụng chính tính năng ACID transaction sẵn có của cơ sở dữ liệu.

Thay vì gọi trực tiếp sang Message Broker qua mạng ngay trong HTTP request, ứng dụng của chúng ta sẽ ghi thông tin event đó vào một bảng tạm ngay trong database — gọi là bảng Outbox.

Điểm mấu chốt là: việc tạo đơn hàng và việc ghi event vào bảng Outbox được thực hiện trong CÙNG MỘT DB TRANSACTION. Nhờ đó, cả 2 thao tác chỉ có thể cùng thành công (commit) hoặc cùng thất bại (rollback).

Các Bước Triển Khai Thực Chiến

Hãy cùng mình xem cách triển khai pattern này trong thực tế với SQL và TypeScript.

Bước 1: Tạo Bảng Outbox Trong Database

Đầu tiên, tạo một bảng outbox đơn giản trong cơ sở dữ liệu:

CREATE TABLE outbox (
    id UUID PRIMARY KEY DEFAULT gen_random_policy(),
    aggregate_type VARCHAR(255) NOT NULL,
    aggregate_id VARCHAR(255) NOT NULL,
    event_type VARCHAR(255) NOT NULL,
    payload JSONB NOT NULL,
    status VARCHAR(50) DEFAULT 'PENDING',
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX idx_outbox_pending ON outbox(status, created_at) WHERE status = 'PENDING';

Bước 2: Ghi Dữ Liệu Và Event Vào Bảng Trong Cùng Transaction

Cập nhật lại logic tạo đơn hàng. Giờ đây, khi lưu đơn hàng, chúng ta tạo thêm một bản ghi vào bảng outbox trong cùng một khối transaction:

async function createOrderWithOutbox(orderData: OrderPayload) {
  return await db.transaction(async (tx) => {
    // 1. Tạo đơn hàng
    const order = await tx.order.create({ data: orderData });

    // 2. Lưu event vào bảng Outbox TRONG CÙNG TRANSACTION
    await tx.outbox.create({
      data: {
        aggregateType: 'Order',
        aggregateId: order.id,
        eventType: 'order.created',
        payload: { orderId: order.id, amount: order.totalAmount },
        status: 'PENDING',
      },
    });

    return order;
  });
}

Nếu transaction bị rollback vì bất kỳ lý do gì, bản ghi event trong bảng Outbox cũng tự động biến mất. Sẽ không bao giờ có event "rác" hay event "ma" lọt ra ngoài hệ thống!

Bước 3: Đọc Và Gửi Event Bằng Background Worker

Khi event đã được lưu an toàn trong DB, chúng ta sẽ dựng một tiến trình chạy ngầm (Background Worker hoặc Cron job) chuyên quét các bản ghi PENDING trong bảng Outbox để đẩy sang Message Broker:

async function processOutboxMessages() {
  const pendingMessages = await db.outbox.findMany({
    where: { status: 'PENDING' },
    take: 50,
    orderBy: { createdAt: 'asc' },
  });

  for (const message of pendingMessages) {
    try {
      // Gửi event sang RabbitMQ/Kafka
      await messageBroker.publish(message.eventType, message.payload);

      // Cập nhật trạng thái thành PROCESSED
      await db.outbox.update({
        where: { id: message.id },
        data: { status: 'PROCESSED' },
      });
    } catch (error) {
      console.error(`Lỗi khi gửi event ${message.id}:`, error);
      // Xử lý retry hoặc ghi log báo động nếu cần
    }
  }
}

Đối với các hệ thống quy mô lớn có tải cao, thay vì dùng worker query bảng Outbox định kỳ (polling), anh em có thể dùng các công cụ Change Data Capture (CDC) như Debezium để đọc trực tiếp file log giao dịch (WAL) của PostgreSQL và đẩy thẳng sang Kafka mà không làm tăng tải cho DB.

Lưu Ý Quan Trọng: Cần Xử Lý At-Least-Once Delivery

Vì sự cố mạng có thể xảy ra ngay đúng lúc worker cập nhật trạng thái từ PENDING sang PROCESSED, worker có thể sẽ gửi lại cùng một event nhiều hơn một lần.

Do đó, Transactional Outbox Pattern mang bản chất At-Least-Once Delivery (đảm bảo gửi ít nhất 1 lần). Ở phía dịch vụ nhận event (Consumer), anh em bắt buộc phải thiết kế logic Idempotent — tức là dù nhận 1 event 10 lần thì kết quả xử lý vẫn giống như nhận 1 lần.

Lời Kết

Transactional Outbox Pattern là một mẫu kiến trúc kinh điển nhưng vô cùng thực dụng mà bất kỳ backend developer nào cũng nên làm chủ. Bằng cách chuyển đổi các lệnh gọi mạng trực tiếp thành thao tác ghi DB nguyên tử, anh em đã loại bỏ triệt để nguy cơ mất dữ liệu và giúp hệ thống đứng vững ngay cả khi các dịch vụ bên ngoài gặp sự cố.

NT

viết bởi

Nguyên Tech

0

Phản hồi

Đang tải bình luận…

Lattice.

Một không gian để viết dài, đọc chậm, và trò chuyện thật — không thuật toán, không quảng cáo.

© 2026 · Lattice · Đà Nẵng (16°03′ N, 108°12′ E) · v0.1 · system + ink + indigo