Microservices tin cậy: Triển khai Transactional Outbox Pattern với Node.js

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

Ác mộng lúc 2 giờ sáng: Tại sao Dual Write là “kẻ sát nhân thầm lặng”

Tiếng chuông báo trực (pager duty) vang lên chói tai lúc 2 giờ sáng. Hệ thống xử lý đơn hàng của chúng tôi đã gặp sự cố nghiêm trọng. Trong khi cơ sở dữ liệu xác nhận đã trừ tiền khách hàng thành công 15.000 USD, dịch vụ vận chuyển lại im hơi lặng tiếng — nó chưa bao giờ nhận được sự kiện ‘OrderCreated’. Chúng tôi đã sử dụng cách tiếp cận thông thường và ngây thơ: cập nhật cơ sở dữ liệu và gọi ngay broker.publish(). Đây chính là Dual Write (Ghi kép), và nó là công thức dẫn đến việc sai lệch dữ liệu.

Trong một kiến trúc phân tán, bạn không thể đảm bảo rằng hai hệ thống độc lập — như cơ sở dữ liệu SQL và Message Broker (RabbitMQ hoặc Kafka) — sẽ cùng thành công hoặc cùng thất bại như một đơn vị duy nhất. Nếu việc commit cơ sở dữ liệu hoàn tất nhưng xảy ra sự cố mạng trước khi tin nhắn đến được broker, dữ liệu của bạn sẽ mất đồng bộ. Lúc này, bạn có một bản ghi “ma” trong DB mà các microservices còn lại sẽ không bao giờ nhìn thấy.

Để khắc phục điều này, chúng ta cần chuyển sang Transactional Outbox Pattern.

So sánh các phương pháp giao tiếp hướng sự kiện

Khi bạn cần cập nhật cơ sở dữ liệu và thông báo cho các dịch vụ khác, bạn thường có hai lựa chọn:

1. Dual Write (Cách làm rủi ro)

async function createOrder(orderData) {
  await db.orders.create(orderData); // Bước 1: Lưu DB
  await broker.publish('order_created', orderData); // Bước 2: Publish lên Broker
}

Nếu Bước 2 thất bại, Bước 1 không thể rollback một cách dễ dàng. Điều này tạo ra các ốc đảo dữ liệu (data silos) và phá vỡ logic nghiệp vụ trên toàn bộ hệ thống của bạn.

2. Transactional Outbox (Cách làm bền bỉ)

Thay vì gửi trực tiếp đến broker, chúng ta lưu tin nhắn vào một bảng “Outbox” chuyên dụng. Quan trọng là, việc này diễn ra trong cùng một database transaction với logic nghiệp vụ của bạn. Một tiến trình chạy ngầm riêng biệt sau đó sẽ đọc từ bảng này và xử lý việc publish. Điều này đảm bảo rằng nếu đơn hàng được lưu, sự kiện cũng chắc chắn được lưu theo.

Đánh đổi của Outbox Pattern

Không có kiến trúc nào là “viên đạn bạc”. Trong các môi trường production xử lý hơn 1.000 request mỗi giây, pattern này là tiêu chuẩn vàng về độ tin cậy, nhưng nó cũng mang lại những chi phí vận hành mới.

  • Ưu điểm:
    • Tính nguyên tử nghiêm ngặt (Strict Atomicity): Trạng thái nghiệp vụ và sự kiện cùng tồn tại hoặc cùng thất bại.
    • Khả năng phục hồi (Resilience): Tin nhắn luôn an toàn trong DB ngay cả khi broker (Kafka/RabbitMQ) ngoại tuyến để bảo trì.
    • Đảm bảo phân phối (Guaranteed Delivery): Thiết lập này đảm bảo việc phân phối “ít nhất một lần” (at-least-once) tới các dịch vụ tiêu thụ phía sau.
  • Nhược điểm:
    • Độ trễ vận hành: Sẽ có một khoảng trễ nhỏ (thường từ 50ms đến 500ms) giữa lúc commit DB và lúc tin nhắn đến được broker.
    • Thêm các thành phần chuyển động: Bạn phải quản lý một worker chạy ngầm và giám sát sự phát triển của bảng outbox.
    • Xử lý trùng lặp: Vì nó đảm bảo việc phân phối, các dịch vụ tiêu thụ của bạn phải có tính idempotent (đối đẳng) để xử lý các trường hợp gửi lại tin nhắn.

Công nghệ sử dụng

Để triển khai ví dụ này, chúng ta sẽ dùng:

  • Node.js: Runtime cho API và Relay worker.
  • PostgreSQL: Kho lưu trữ chính with hỗ trợ transaction ACID mạnh mẽ.
  • pg: Client PostgreSQL đã được kiểm chứng cho Node.js.
  • Message Broker: Như RabbitMQ hoặc Kafka.

Triển khai từng bước

Bước 1: Schema cho bảng Outbox

Đầu tiên, hãy tạo một bảng đóng vai trò như hàng đợi nội bộ. Chúng ta sử dụng JSONB cho phần payload để cho phép cấu trúc sự kiện linh hoạt.

CREATE TABLE outbox (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  aggregate_type TEXT NOT NULL,
  aggregate_id TEXT NOT NULL,
  type TEXT NOT NULL,
  payload JSONB NOT NULL,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
  processed_at TIMESTAMP WITH TIME ZONE
);

-- Index để tối ưu hiệu năng: tìm các tin nhắn chưa xử lý nhanh hơn
CREATE INDEX idx_outbox_unprocessed ON outbox (created_at) WHERE processed_at IS NULL;

Bước 2: Lưu trữ nguyên tử trong Node.js

Bao bọc logic nghiệp vụ và việc insert outbox trong một transaction duy nhất. Nếu đơn hàng không lưu được, sự kiện sẽ không bao giờ vào outbox. Nếu sự kiện không lưu được, đơn hàng sẽ bị rollback. Tất cả hoặc không có gì.

const { Pool } = require('pg');
const pool = new Pool();

async function createOrder(order) {
  const client = await pool.connect();

  try {
    await client.query('BEGIN');

    // 1. Lưu dữ liệu nghiệp vụ thực tế
    const orderQuery = 'INSERT INTO orders (id, total, status) VALUES ($1, $2, $3)';
    await client.query(orderQuery, [order.id, order.total, 'PENDING']);

    // 2. Đưa sự kiện vào hàng đợi trong cùng một transaction
    const outboxQuery = `
      INSERT INTO outbox (aggregate_type, aggregate_id, type, payload)
      VALUES ($1, $2, $3, $4)
    `;
    const eventPayload = { orderId: order.id, total: order.total };
    await client.query(outboxQuery, ['Order', order.id, 'OrderCreated', JSON.stringify(eventPayload)]);

    await client.query('COMMIT');
  } catch (err) {
    await client.query('ROLLBACK');
    throw err;
  } finally {
    client.release();
  }
}

Bước 3: Relay Worker

Bây giờ chúng ta cần một worker để chuyển tin nhắn từ Postgres sang broker. Chúng ta sử dụng FOR UPDATE SKIP LOCKED. Điều này ngăn chặn nhiều instance worker lấy cùng một tin nhắn cùng lúc, điều này rất quan trọng để mở rộng hệ thống.

async function relayMessages() {
  const client = await pool.connect();

  try {
    // Khóa 10 tin nhắn để các worker khác không nhìn thấy chúng
    const selectQuery = `
      SELECT id, type, payload 
      FROM outbox 
      WHERE processed_at IS NULL 
      ORDER BY created_at ASC 
      LIMIT 10 
      FOR UPDATE SKIP LOCKED
    `;
    
    const res = await client.query(selectQuery);

    for (const row of res.rows) {
      try {
        await broker.publish(row.type, row.payload);

        // Đánh dấu là đã hoàn thành sau khi publish thành công
        await client.query('UPDATE outbox SET processed_at = NOW() WHERE id = $1', [row.id]);
      } catch (publishError) {
        // Nếu broker gặp sự cố, chúng ta giữ nguyên bản ghi cho lần quét sau
        console.error(`Publish thất bại cho ${row.id}`);
      }
    }
  } finally {
    client.release();
  }
}

// Quét mỗi 2 giây
setInterval(relayMessages, 2000);

Xử lý thực tế “Ít nhất một lần”

Outbox pattern đảm bảo tin nhắn sẽ được gửi đi. Tuy nhiên, lỗi mạng vẫn có thể xảy ra sau khi broker nhận được tin nhắn nhưng trước khi worker cập nhật cơ sở dữ liệu. Trong trường hợp này, worker sẽ thử lại và gửi một bản sao trùng lặp.

Các dịch vụ phía sau phải có tính idempotent. Chúng nên kiểm tra một event_id hoặc order_id duy nhất trước khi xử lý. Nếu chúng đã thấy ID đó trước đó, chúng chỉ cần xác nhận (acknowledge) tin nhắn và không làm gì thêm.

Tổng kết

Từ bỏ Dual Write là một cột mốc quan trọng đối với bất kỳ kỹ sư backend nào. Nó loại bỏ một nhóm lớn các lỗi race condition và ngăn ngừa mất dữ liệu khi lưu lượng truy cập đạt đỉnh. Mặc dù cơ chế polling là một điểm khởi đầu tốt, các hệ thống quy mô lớn có thể chuyển sang các công cụ Change Data Capture (CDC) như Debezium. Các công cụ CDC theo dõi Postgres Write-Ahead Log (WAL) để đạt được độ trễ thấp hơn nữa. Tuy nhiên, đối với hầu hết các ứng dụng, phương pháp polling là quá đủ để giúp bạn ngủ ngon mỗi đêm.

Share: