Outbox Pattern — Không để mất message trong Microservices
Posted on: 4/22/2026 8:13:24 PM
Table of contents
- 1. Dual-Write Problem — Bài toán cốt lõi
- 2. Outbox Pattern — Nguyên lý hoạt động
- 3. Relay Strategies — Đưa message từ DB lên broker
- 4. Triển khai với MassTransit — Outbox có sẵn
- 5. Inbox Pattern — Xử lý message đúng 1 lần
- 6. Vận hành Production — Những điều cần lưu ý
- 7. Kiến trúc tổng thể — Outbox + Inbox end-to-end
- 8. So sánh với các giải pháp khác
- 9. Kết luận
- Tham khảo
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.
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ản | DB Write | Message Publish | Hậu quả |
|---|---|---|---|
| Happy path | ✅ | ✅ | Mọi thứ nhất quán |
| Message mất | ✅ | ❌ | Data tồn tại nhưng downstream không biết → order treo, không gửi email, không trừ kho |
| Data mất | ❌ | ✅ | Downstream 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 Publisher và Transaction 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 Publisher | CDC (Log Tailing) |
|---|---|---|
| Latency | 1-5 giây (tùy interval) | <100ms (near real-time) |
| Database load | Cao — query liên tục | Gần bằng 0 — đọc từ log |
| Độ phức tạp triển khai | Thấp — chỉ cần background service | Cao — cần Debezium/Kafka Connect |
| Ordering guarantee | Cần xử lý thêm | Đảm bảo theo transaction order |
| Infra dependency | Không thêm gì | Kafka + Kafka Connect + Debezium |
| Khi nào dùng | Throughput 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áp | Consistency | Latency | Complexity | Khi nào dùng |
|---|---|---|---|---|
| Outbox Pattern | Strong (atomic) | Trung bình | Trung bình | Default choice cho hầu hết use case |
| Saga Pattern | Eventual | Cao | Cao | Business transactions dài, nhiều service |
| Event Sourcing | Strong | Thấp | Rất cao | Cần audit trail, complex domain |
| 2PC / XA | Strong | Rất cao | Trung bình | Legacy systems, tight coupling chấp nhận được |
| Best effort + retry | Weak | Thấp | Thấp | Non-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
C# 14 Deep Dive — 8 Tính Năng Mới Định Hình Tương Lai .NET
On-Device AI 2026: Chạy LLM Cục Bộ với Ollama, llama.cpp và ONNX Runtime trên .NET 10
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.