Outbox Pattern — Không để mất message trong Microservices

Posted on: 4/22/2026 8:13:24 PM

Trong kiến trúc microservices, một trong những bài toán khó nhất không phải là scale hay deploy — mà là đảm bảo dữ liệu và message luôn nhất quán. Khi một service vừa lưu dữ liệu vào database vừa publish event lên message broker, chuyện gì xảy ra nếu một trong hai thao tác thất bại? Đây chính là dual-write problem — và Outbox Pattern là giải pháp được thiết kế để triệt tiêu hoàn toàn vấn đề này.

67% Microservices gặp data inconsistency do dual-write
0 Message bị mất khi dùng Outbox Pattern
<50ms Latency trung bình với CDC relay
At-least-once Delivery guarantee mặc định

1. Dual-Write Problem — Bài toán cốt lõi

Hãy hình dung một service xử lý đơn hàng. Khi khách đặt hàng, service cần làm 2 việc: (1) lưu đơn hàng vào database và (2) publish event OrderCreated lên message broker (RabbitMQ, Kafka...) để các service khác xử lý tiếp (gửi email, trừ kho, thanh toán...).

sequenceDiagram
    participant S as Order Service
    participant DB as Database
    participant MQ as Message Broker
    S->>DB: INSERT order
    Note over DB: ✅ Thành công
    S->>MQ: Publish OrderCreated
    Note over MQ: ❌ Broker down!
    Note over S: DB có order nhưng
không ai biết order tồn tại

Hình 1: Dual-write failure — database commit thành công nhưng message publish thất bại

Có 3 kịch bản xảy ra khi thực hiện 2 thao tác ghi riêng biệt:

Kịch bảnDB WriteMessage PublishHậu quả
Happy pathMọi thứ nhất quán
Message mấtData tồn tại nhưng downstream không biết → order treo, không gửi email, không trừ kho
Data mấtDownstream xử lý phantom event → trừ kho cho order không tồn tại

Tại sao distributed transaction (2PC) không giải quyết được?

Two-Phase Commit (2PC) yêu cầu cả database và message broker đều support XA transactions. Hầu hết message broker hiện đại (RabbitMQ, Kafka, Azure Service Bus) không hỗ trợ XA. Ngay cả khi hỗ trợ, 2PC có latency cao, throughput thấp, và tạo single point of failure ở coordinator. Trong microservices, 2PC được coi là anti-pattern.

2. Outbox Pattern — Nguyên lý hoạt động

Ý tưởng cốt lõi rất đơn giản: thay vì ghi vào 2 hệ thống khác nhau, chỉ ghi vào 1 hệ thống duy nhất — database. Message cần publish sẽ được lưu vào một bảng OutboxMessage trong cùng transaction với business data. Sau đó, một tiến trình riêng biệt (relay/publisher) sẽ đọc từ bảng outbox và publish lên message broker.

graph LR
    A["Order Service"] -->|"BEGIN TRANSACTION"| B["Database"]
    B --> C["INSERT Order"]
    B --> D["INSERT OutboxMessage"]
    B -->|"COMMIT"| E["✅ Atomic"]
    F["Outbox Relay"] -->|"Poll / CDC"| B
    F -->|"Publish"| G["Message Broker"]
    G --> H["Inventory Service"]
    G --> I["Email Service"]
    G --> J["Payment Service"]
    style A fill:#e94560,stroke:#fff,color:#fff
    style E fill:#4CAF50,stroke:#fff,color:#fff
    style F fill:#2c3e50,stroke:#e94560,color:#fff
    style G fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style B fill:#f8f9fa,stroke:#e94560,color:#2c3e50
    style H fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
    style I fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50
    style J fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50

Hình 2: Outbox Pattern — ghi business data và message vào cùng một transaction

2.1. Schema bảng Outbox

Bảng outbox cần chứa đủ thông tin để relay có thể publish message mà không cần biết business logic:

CREATE TABLE OutboxMessage (
    Id              UNIQUEIDENTIFIER PRIMARY KEY DEFAULT NEWSEQUENTIALID(),
    OccurredOn      DATETIME2        NOT NULL DEFAULT SYSUTCDATETIME(),
    MessageType     NVARCHAR(256)    NOT NULL,  -- e.g. 'OrderCreated'
    Payload         NVARCHAR(MAX)    NOT NULL,  -- JSON serialized event
    CorrelationId   NVARCHAR(128)    NULL,
    Destination     NVARCHAR(256)    NULL,       -- routing key / topic
    ProcessedOn     DATETIME2        NULL,       -- NULL = chưa publish
    RetryCount      INT              NOT NULL DEFAULT 0,
    Error           NVARCHAR(MAX)    NULL
);

CREATE INDEX IX_OutboxMessage_Unprocessed
    ON OutboxMessage (OccurredOn)
    WHERE ProcessedOn IS NULL;

Tại sao dùng NEWSEQUENTIALID() thay vì NEWID()?

NEWSEQUENTIALID() tạo GUID tăng dần, giữ cho clustered index không bị fragmentation. Với bảng outbox có throughput cao (hàng nghìn row/giây), điều này ảnh hưởng đáng kể đến hiệu năng INSERT và scan.

2.2. Write Path — Ghi trong cùng transaction

Đây là phần quan trọng nhất: business data và outbox message phải nằm trong cùng một database transaction. Nếu transaction rollback, cả hai đều rollback — không bao giờ có tình trạng data mất mà message vẫn được publish, hoặc ngược lại.

// C# — EF Core 10 + .NET 10
public class OrderService(AppDbContext db)
{
    public async Task<Order> CreateOrderAsync(CreateOrderCommand cmd)
    {
        var order = new Order
        {
            CustomerId = cmd.CustomerId,
            Items = cmd.Items.Select(i => new OrderItem
            {
                ProductId = i.ProductId,
                Quantity = i.Quantity,
                UnitPrice = i.UnitPrice
            }).ToList(),
            TotalAmount = cmd.Items.Sum(i => i.Quantity * i.UnitPrice),
            Status = OrderStatus.Created
        };

        db.Orders.Add(order);

        // Ghi outbox message trong CÙNG DbContext (cùng transaction)
        db.OutboxMessages.Add(new OutboxMessage
        {
            MessageType = nameof(OrderCreatedEvent),
            Payload = JsonSerializer.Serialize(new OrderCreatedEvent
            {
                OrderId = order.Id,
                CustomerId = order.CustomerId,
                TotalAmount = order.TotalAmount,
                Items = order.Items.Select(i => new OrderItemDto
                {
                    ProductId = i.ProductId,
                    Quantity = i.Quantity
                }).ToList()
            }),
            CorrelationId = cmd.CorrelationId,
            Destination = "order-events"
        });

        await db.SaveChangesAsync(); // 1 transaction, atomic
        return order;
    }
}

3. Relay Strategies — Đưa message từ DB lên broker

Sau khi message đã an toàn trong bảng outbox, bước tiếp theo là đưa chúng lên message broker. Có 2 chiến lược chính: Polling PublisherTransaction Log Tailing (CDC).

3.1. Polling Publisher

Cách đơn giản nhất: một background service định kỳ query bảng outbox, lấy các message chưa xử lý, publish lên broker, rồi đánh dấu đã xử lý.

sequenceDiagram
    participant R as Outbox Relay
    participant DB as Database
    participant MQ as Message Broker
    loop Mỗi 1-5 giây
        R->>DB: SELECT ... WHERE ProcessedOn IS NULL
        DB-->>R: Batch messages
        R->>MQ: Publish từng message
        MQ-->>R: ACK
        R->>DB: UPDATE ProcessedOn = NOW()
    end

Hình 3: Polling Publisher — đơn giản nhưng có trade-off về latency

// .NET 10 — Background service polling outbox
public class OutboxPollingService(
    IServiceScopeFactory scopeFactory,
    IPublishEndpoint bus,
    ILogger<OutboxPollingService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                using var scope = scopeFactory.CreateScope();
                var db = scope.ServiceProvider
                    .GetRequiredService<AppDbContext>();

                var messages = await db.OutboxMessages
                    .Where(m => m.ProcessedOn == null)
                    .OrderBy(m => m.OccurredOn)
                    .Take(100)
                    .ToListAsync(ct);

                foreach (var msg in messages)
                {
                    var eventType = Type.GetType(msg.MessageType);
                    var eventObj = JsonSerializer.Deserialize(
                        msg.Payload, eventType!);

                    await bus.Publish(eventObj!, ct);

                    msg.ProcessedOn = DateTime.UtcNow;
                }

                await db.SaveChangesAsync(ct);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Outbox relay error");
            }

            await Task.Delay(TimeSpan.FromSeconds(2), ct);
        }
    }
}

Vấn đề với Polling Publisher

Latency: message có thể delay 1-5 giây tùy polling interval. Database load: query liên tục tạo pressure lên DB. Scaling: nhiều instance cùng poll sẽ gây duplicate publish nếu không có distributed lock. Giải pháp: dùng SELECT ... WITH (UPDLOCK, READPAST) trong SQL Server hoặc FOR UPDATE SKIP LOCKED trong PostgreSQL.

3.2. Transaction Log Tailing (CDC)

Thay vì poll database, CDC (Change Data Capture) đọc trực tiếp từ transaction log của database. Khi có row mới trong bảng outbox, CDC stream ngay lập tức đến relay mà không cần query.

graph LR
    A["Database
Transaction Log"] -->|"CDC Stream"| B["Debezium /
SQL Server CDC"] B -->|"Outbox event"| C["Kafka Connect"] C --> D["Kafka Topic:
order-events"] D --> E["Inventory Service"] D --> F["Email Service"] style A fill:#2c3e50,stroke:#e94560,color:#fff style B fill:#e94560,stroke:#fff,color:#fff style C fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50

Hình 4: CDC-based relay — latency cực thấp, không tạo load lên database

Tiêu chíPolling PublisherCDC (Log Tailing)
Latency1-5 giây (tùy interval)<100ms (near real-time)
Database loadCao — query liên tụcGần bằng 0 — đọc từ log
Độ phức tạp triển khaiThấp — chỉ cần background serviceCao — cần Debezium/Kafka Connect
Ordering guaranteeCần xử lý thêmĐảm bảo theo transaction order
Infra dependencyKhông thêm gìKafka + Kafka Connect + Debezium
Khi nào dùngThroughput thấp-trung bình, team nhỏThroughput cao, yêu cầu latency thấp

4. Triển khai với MassTransit — Outbox có sẵn

MassTransit (thư viện message bus phổ biến nhất trong .NET) đã tích hợp sẵn Transactional Outbox từ phiên bản 8+. Bạn không cần tự viết bảng outbox hay relay — MassTransit lo hết.

4.1. Cấu hình MassTransit Outbox

// Program.cs — .NET 10
builder.Services.AddMassTransit(x =>
{
    x.AddConsumers(typeof(Program).Assembly);

    x.AddEntityFrameworkOutbox<AppDbContext>(o =>
    {
        o.UseSqlServer();           // hoặc UsePostgres()
        o.UseBusOutbox();           // enable outbox cho publish
        o.QueryDelay = TimeSpan.FromSeconds(1);
        o.DuplicateDetectionWindow = TimeSpan.FromMinutes(5);
    });

    x.UsingRabbitMq((ctx, cfg) =>
    {
        cfg.Host("rabbitmq://localhost");
        cfg.ConfigureEndpoints(ctx);
    });
});

builder.Services.AddDbContext<AppDbContext>(opts =>
    opts.UseSqlServer(connectionString));

4.2. Sử dụng trong business logic

Khi đã cấu hình outbox, code business không thay đổi gì — MassTransit tự động intercept Publish() và ghi vào outbox thay vì gửi thẳng lên broker:

public class CreateOrderConsumer(
    AppDbContext db,
    IPublishEndpoint publisher) : IConsumer<CreateOrderCommand>
{
    public async Task Consume(ConsumeContext<CreateOrderCommand> context)
    {
        var order = new Order
        {
            CustomerId = context.Message.CustomerId,
            TotalAmount = context.Message.TotalAmount,
            Status = OrderStatus.Created
        };

        db.Orders.Add(order);

        // MassTransit ghi vào OutboxMessage table,
        // KHÔNG publish trực tiếp lên RabbitMQ
        await publisher.Publish(new OrderCreatedEvent
        {
            OrderId = order.Id,
            CustomerId = order.CustomerId,
            TotalAmount = order.TotalAmount
        });

        await db.SaveChangesAsync();
        // Transaction commit → cả Order và OutboxMessage đều persist
        // MassTransit relay sẽ tự publish lên RabbitMQ sau
    }
}

MassTransit tự quản lý bảng outbox

Khi dùng AddEntityFrameworkOutbox, MassTransit tạo 3 bảng: InboxState, OutboxState, và OutboxMessage. Bạn chỉ cần chạy dotnet ef migrations add AddOutbox rồi dotnet ef database update. Relay chạy tự động trong background, bạn không cần viết background service riêng.

5. Inbox Pattern — Xử lý message đúng 1 lần

Outbox Pattern đảm bảo at-least-once delivery — message chắc chắn sẽ được publish, nhưng có thể bị publish nhiều lần (khi relay crash sau publish nhưng trước khi đánh dấu ProcessedOn). Phía consumer cần Inbox Pattern để đảm bảo idempotency.

sequenceDiagram
    participant MQ as Message Broker
    participant C as Consumer
    participant DB as Database
    MQ->>C: OrderCreated (MessageId: abc-123)
    C->>DB: SELECT FROM InboxMessage WHERE Id = 'abc-123'
    alt Đã xử lý
        DB-->>C: EXISTS
        C->>MQ: ACK (skip)
    else Chưa xử lý
        DB-->>C: NOT EXISTS
        C->>DB: BEGIN TRANSACTION
        C->>DB: INSERT InboxMessage(Id='abc-123')
        C->>DB: Xử lý business logic
        C->>DB: COMMIT
        C->>MQ: ACK
    end

Hình 5: Inbox Pattern — deduplicate message ở phía consumer

// Inbox table
CREATE TABLE InboxMessage (
    MessageId      UNIQUEIDENTIFIER PRIMARY KEY,
    ConsumerType   NVARCHAR(256)    NOT NULL,
    ReceivedOn     DATETIME2        NOT NULL DEFAULT SYSUTCDATETIME(),
    ProcessedOn    DATETIME2        NULL,
    CONSTRAINT UQ_Inbox UNIQUE (MessageId, ConsumerType)
);
// Idempotent consumer pattern
public class OrderCreatedHandler(AppDbContext db) : IConsumer<OrderCreatedEvent>
{
    public async Task Consume(ConsumeContext<OrderCreatedEvent> ctx)
    {
        var messageId = ctx.MessageId!.Value;

        var alreadyProcessed = await db.InboxMessages
            .AnyAsync(i => i.MessageId == messageId
                && i.ConsumerType == nameof(OrderCreatedHandler));

        if (alreadyProcessed) return;

        await using var tx = await db.Database
            .BeginTransactionAsync();

        db.InboxMessages.Add(new InboxMessage
        {
            MessageId = messageId,
            ConsumerType = nameof(OrderCreatedHandler)
        });

        // Business logic: trừ inventory
        var order = ctx.Message;
        foreach (var item in order.Items)
        {
            var product = await db.Products
                .FirstAsync(p => p.Id == item.ProductId);
            product.Stock -= item.Quantity;
        }

        await db.SaveChangesAsync();
        await tx.CommitAsync();
    }
}

6. Vận hành Production — Những điều cần lưu ý

6.1. Cleanup — Dọn dẹp outbox table

Bảng outbox sẽ phình to theo thời gian. Cần job định kỳ xóa message đã xử lý:

-- Xóa message đã xử lý hơn 7 ngày trước (batch delete tránh lock)
WHILE 1 = 1
BEGIN
    DELETE TOP (5000) FROM OutboxMessage
    WHERE ProcessedOn IS NOT NULL
      AND ProcessedOn < DATEADD(DAY, -7, SYSUTCDATETIME());

    IF @@ROWCOUNT < 5000 BREAK;
    WAITFOR DELAY '00:00:01'; -- tránh lock escalation
END

6.2. Message Ordering

Outbox đảm bảo causal ordering trong cùng một transaction, nhưng giữa các transaction khác nhau thì không. Nếu cần strict ordering theo entity (tất cả event của Order #123 phải đúng thứ tự), hãy dùng partition key = OrderId khi publish lên Kafka.

6.3. Monitoring — Phát hiện sớm vấn đề

Metric quan trọng nhất cần monitor: outbox lag — số lượng message chưa xử lý và thời gian message cũ nhất đang chờ.

-- Query monitoring: outbox health check
SELECT
    COUNT(*) AS PendingMessages,
    MIN(OccurredOn) AS OldestPending,
    DATEDIFF(SECOND, MIN(OccurredOn), SYSUTCDATETIME()) AS LagSeconds,
    MAX(RetryCount) AS MaxRetries
FROM OutboxMessage
WHERE ProcessedOn IS NULL;

-- Alert nếu lag > 30 giây hoặc pending > 1000

6.4. Dead Letter — Xử lý message lỗi

Sau N lần retry thất bại, message nên được chuyển vào dead-letter để không block các message khác:

// Trong relay service
if (msg.RetryCount >= 5)
{
    msg.Error = $"Max retries exceeded. Last error: {ex.Message}";
    msg.ProcessedOn = DateTime.UtcNow; // đánh dấu đã xử lý
    // Publish metric/alert cho team
    logger.LogCritical("Outbox message {Id} dead-lettered after {Retries} retries",
        msg.Id, msg.RetryCount);
    continue;
}
msg.RetryCount++;

7. Kiến trúc tổng thể — Outbox + Inbox end-to-end

graph TB
    subgraph "Order Service"
        A["API Controller"] --> B["Order Service"]
        B --> C["DbContext
SaveChanges"] C --> D["Orders Table"] C --> E["Outbox Table"] end subgraph "Relay" F["Outbox Relay
(Poll / CDC)"] --> E F --> G["RabbitMQ / Kafka"] end subgraph "Inventory Service" G --> H["Consumer"] H --> I["Inbox Check"] I --> J["Business Logic"] J --> K["Products Table"] J --> L["Inbox Table"] end style A fill:#e94560,stroke:#fff,color:#fff style F fill:#2c3e50,stroke:#e94560,color:#fff style H fill:#e94560,stroke:#fff,color:#fff style G fill:#f8f9fa,stroke:#e94560,color:#2c3e50 style D fill:#f8f9fa,stroke:#2c3e50,color:#2c3e50 style E fill:#4CAF50,stroke:#fff,color:#fff style L fill:#4CAF50,stroke:#fff,color:#fff

Hình 6: Kiến trúc end-to-end Outbox + Inbox Pattern

8. So sánh với các giải pháp khác

Giải phápConsistencyLatencyComplexityKhi nào dùng
Outbox PatternStrong (atomic)Trung bìnhTrung bìnhDefault choice cho hầu hết use case
Saga PatternEventualCaoCaoBusiness transactions dài, nhiều service
Event SourcingStrongThấpRất caoCần audit trail, complex domain
2PC / XAStrongRất caoTrung bìnhLegacy systems, tight coupling chấp nhận được
Best effort + retryWeakThấpThấpNon-critical notifications, analytics events

Khi nào KHÔNG cần Outbox Pattern?

Nếu message bị mất không gây hậu quả nghiêm trọng (analytics events, non-critical notifications), thì best-effort publish với retry là đủ. Outbox Pattern thêm complexity — chỉ dùng khi business yêu cầu không được phép mất message.

9. Kết luận

Outbox Pattern không phải giải pháp mới — nó đã tồn tại hàng thập kỷ trong thế giới enterprise. Nhưng với sự phát triển của microservices, nó trở nên quan trọng hơn bao giờ hết. Điểm mấu chốt cần nhớ:

  • Không bao giờ ghi đồng thời vào 2 hệ thống khác nhau — luôn ghi vào 1 rồi relay sang hệ thống còn lại
  • Polling đơn giản nhưng CDC mạnh hơn — bắt đầu với polling, upgrade lên CDC khi throughput tăng
  • Outbox + Inbox = exactly-once semantics — at-least-once delivery + idempotent consumer
  • MassTransit trong .NET đã tích hợp sẵn, không cần tự build
  • Monitor outbox lag — đây là metric sống còn của hệ thống

Bắt đầu từ Outbox Pattern với polling publisher. Khi hệ thống scale, chuyển sang CDC với Debezium. Khi cần exactly-once, thêm Inbox Pattern. Đó là lộ trình thực tế cho mọi dự án microservices.

Tham khảo