Transactional Outbox Pattern Trong Laravel 11 Với RabbitMQ: Đảm Bảo 100% Data Consistency
1. Vấn Đề Chết Người Khi Vừa Ghi DB Vừa Bắn Event Lên Message Broker (Dual-Write Problem)
Trong các hệ thống phân tán hiện đại, một nghiệp vụ đơn lẻ thường yêu cầu hai thao tác kỹ thuật riêng biệt: ghi dữ liệu vào Database và gửi message lên Message Broker (RabbitMQ, Kafka, Redis Queue) để thông báo cho các hệ thống khác.
Hãy tưởng tượng một kịch bản đơn giản: Khi người dùng đăng ký tài khoản mới, bạn cần lưu thông tin user vào bảng users, sau đó gửi event UserRegistered để hệ thống gửi email chào mừng và tạo tài khoản thanh toán bên thứ ba.
Cách làm sai lầm mà hầu hết developers mắc phải:
// ❌ CÁCH SAI - Dual-Write Problem
public function registerUser(array $data)
{
// 1. Lưu vào Database
$user = User::create($data);
// 2. Gửi event sang RabbitMQ
event(new UserRegistered($user)); // Nguy cơ mất message!
return $user;
}
Vấn đề ở đây là gì? Nếu server commit thành công vào Database nhưng gặp lỗi mạng hoặc crash ngay trước khi dispatch job sang RabbitMQ, hệ thống của bạn rơi vào trạng thái không nhất quán (inconsistent). User tồn tại trong Database nhưng event đã bị mất vĩnh viễn. Email chào mừng không được gửi, tài khoản thanh toán không được tạo. User trở thành “bóng ma” trong hệ thống của bạn.
Đây chính là Dual-Write Problem – “thảm họa” của các hệ thống phân tán khi không có cơ chế đảm bảo tính nguyên tử (atomicity) giữa database write và message publish.

💡 Tham khảo thêm: Nếu bạn đang tìm hiểu về Message Broker, bài viết RabbitMQ Work Queues: Giải quyết bài toán Background Tasks sẽ giúp bạn hiểu rõ hơn về vai trò của RabbitMQ trong kiến trúc hệ thống.
2. Nguyên Lý Hoạt Động Của Transactional Outbox Pattern
Transactional Outbox Pattern được định nghĩa bởi Chris Richardson trên microservices.io như sau: Service gửi message trước tiên phải lưu message vào Database như một phần của transaction cập nhật business entities. Một tiến trình riêng biệt sau đó sẽ gửi các message tới Message Broker.
Nói một cách đơn giản: Thay vì cố gắng ghi DB và gửi message trong cùng một request lifecycle, bạn chỉ ghi DB – bao gồm cả message cần gửi – trong một transaction duy nhất. Sau đó, một tiến trình nền (Relay Worker) sẽ đọc message từ DB và gửi sang RabbitMQ.
┌─────────────────────────────────────────────────────────────────────────┐
│ HTTP Request / Command │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ DB::transaction() │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 1. INSERT INTO orders (business data) │ │
│ │ 2. INSERT INTO outbox_messages (event_type, payload, status) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ Cùng COMMIT hoặc cùng ROLLBACK │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Artisan Command: outbox:relay │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ 1. SELECT * FROM outbox_messages WHERE status = 'PENDING' │ │
│ │ 2. Gửi message sang RabbitMQ │ │
│ │ 3. UPDATE status = 'PROCESSED' │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ ↓ Chạy định kỳ (cron job hoặc daemon) │
└─────────────────────────────────────────────────────────────────────────┘

Các thành phần chính của Pattern:
| Thành phần | Vai trò |
|---|---|
| Outbox Table | Bảng trong Database lưu trữ message cần gửi, nằm cùng transaction với business data |
| Relay Worker | Tiến trình nền (Artisan Command) quét bảng outbox, gửi message sang RabbitMQ và cập nhật trạng thái |
| Idempotent Consumer | Phía nhận message phải đảm bảo xử lý được message trùng lặp (At-Least-Once delivery) |
3. Thực Chiến: Triển Khai Outbox Pattern Trong Laravel 11
3.1 Tạo Migration cho bảng outbox_messages
Đầu tiên, tạo migration cho bảng outbox:
php artisan make:migration create_outbox_messages_table
<?php
// database/migrations/xxxx_xx_xx_create_outbox_messages_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('outbox_messages', function (Blueprint $table) {
$table->id();
$table->string('event_type'); // 'OrderCreated', 'UserRegistered'
$table->string('aggregate_type'); // 'order', 'user'
$table->string('aggregate_id'); // ID của entity
$table->json('payload'); // Dữ liệu event (serialized)
$table->json('headers')->nullable(); // Headers cho RabbitMQ
$table->string('status')->default('PENDING'); // PENDING | PROCESSED | FAILED
$table->integer('attempts')->default(0);
$table->timestamp('available_at')->nullable(); // Delay support
$table->timestamp('processed_at')->nullable();
$table->timestamps();
// Indexes for high-performance polling
$table->index(['status', 'created_at']);
$table->index(['aggregate_type', 'aggregate_id']);
});
}
public function down(): void
{
Schema::dropIfExists('outbox_messages');
}
};
Giải thích:
payloadlưu dữ liệu event dạng JSON, tương thích với nhiều message formatsstatusgiúp theo dõi trạng thái:PENDING(chờ gửi),PROCESSED(đã gửi thành công),FAILED(gửi thất bại sau nhiều lần thử)available_athỗ trợ gửi message trễ (delay)- Index
['status', 'created_at']tối ưu truy vấn cho Relay Worker
3.2 Thực thi Database Transaction vừa lưu Order vừa ghi Outbox Record
Tạo Model cho bảng outbox:
<?php
// app/Models/OutboxMessage.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class OutboxMessage extends Model
{
protected $fillable = [
'event_type', 'aggregate_type', 'aggregate_id',
'payload', 'headers', 'status', 'attempts', 'available_at', 'processed_at'
];
protected $casts = [
'payload' => 'array',
'headers' => 'array',
'available_at' => 'datetime',
'processed_at' => 'datetime',
];
public function markAsProcessed(): void
{
$this->update([
'status' => 'PROCESSED',
'processed_at' => now(),
]);
}
public function markAsFailed(): void
{
$this->update([
'status' => 'FAILED',
'attempts' => $this->attempts + 1,
]);
}
public function incrementAttempts(): void
{
$this->increment('attempts');
}
public function isPending(): bool
{
return $this->status === 'PENDING'
&& ($this->available_at === null || $this->available_at <= now());
}
}
OrderService thực thi DB Transaction:
<?php
// app/Services/OrderService.php
namespace App\Services;
use App\Models\Order;
use App\Models\OutboxMessage;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class OrderService
{
public function createOrder(array $orderData, array $eventData): Order
{
return DB::transaction(function () use ($orderData, $eventData) {
// 1. Lưu business entity - Order
$order = Order::create([
'customer_id' => $orderData['customer_id'],
'total_amount' => $orderData['total_amount'],
'status' => 'pending',
'items' => json_encode($orderData['items']),
]);
// 2. Ghi outbox message trong cùng transaction
OutboxMessage::create([
'event_type' => 'OrderCreated',
'aggregate_type' => 'order',
'aggregate_id' => (string) $order->id,
'payload' => [
'order_id' => $order->id,
'customer_id' => $order->customer_id,
'total_amount' => $order->total_amount,
'items' => $orderData['items'],
'event_id' => (string) \Illuminate\Support\Str::uuid(),
'occurred_at' => now()->toIso8601String(),
],
'headers' => [
'x-event-version' => '1.0',
'x-source' => config('app.name'),
],
'status' => 'PENDING',
]);
Log::info('Order created and outbox message stored', [
'order_id' => $order->id,
'event_type' => 'OrderCreated',
]);
return $order;
});
}
}

Giải thích code:
- Sử dụng
DB::transaction()của Laravel 11 để đảm bảo atomicity: cả Order và OutboxMessage cùng được commit hoặc cùng rollback event_idsử dụng UUID giúp Consumer bên nhận dễ dàng implement idempotencyheaderslưu metadata như version, source giúp tracing và versioning
3.3 Viết Artisan Command (outbox:relay) quét và publish tin nhắn sang RabbitMQ
Cài đặt thư viện php-amqplib:
composer require php-amqplib/php-amqplib
Tạo Artisan Command:
php artisan make:command RelayOutboxMessages
<?php
// app/Console/Commands/RelayOutboxMessages.php
namespace App\Console\Commands;
use App\Models\OutboxMessage;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
use PhpAmqpLib\Connection\AMQPStreamConnection;
use PhpAmqpLib\Message\AMQPMessage;
use Illuminate\Support\Facades\DB;
class RelayOutboxMessages extends Command
{
protected $signature = 'outbox:relay
{--limit=100 : Số lượng message tối đa xử lý mỗi lần}
{--sleep=1 : Giây chờ giữa các lần poll}';
protected $description = 'Relay pending outbox messages to RabbitMQ';
private AMQPStreamConnection $rabbitConnection;
private string $exchange;
private string $queue;
public function __construct()
{
parent::__construct();
$this->exchange = config('rabbitmq.exchange', 'laravel.events');
$this->queue = config('rabbitmq.queue', 'laravel.events.queue');
}
public function handle(): int
{
$this->info('🔄 Starting Outbox Relay Worker...');
while (true) {
try {
$this->connectRabbitMQ();
$processed = $this->processPendingMessages();
if ($processed === 0) {
// Không có message, sleep để tránh CPU spike
sleep((int) $this->option('sleep'));
}
} catch (\Exception $e) {
Log::error('Outbox Relay error', [
'error' => $e->getMessage(),
'trace' => $e->getTraceAsString(),
]);
$this->error('Error: ' . $e->getMessage());
sleep(5); // Chờ trước khi retry
}
}
}
private function connectRabbitMQ(): void
{
$host = config('rabbitmq.host', '127.0.0.1');
$port = config('rabbitmq.port', 5672);
$user = config('rabbitmq.user', 'guest');
$password = config('rabbitmq.password', 'guest');
$vhost = config('rabbitmq.vhost', '/');
$this->rabbitConnection = new AMQPStreamConnection($host, $port, $user, $password, $vhost);
}
private function processPendingMessages(): int
{
$limit = (int) $this->option('limit');
// ⚠️ Sử dụng lockForUpdate() để tránh nhiều worker đụng độ cùng 1 record
// Best Practice: Row Locking trong Relay Worker
$messages = OutboxMessage::query()
->where('status', 'PENDING')
->where(function ($query) {
$query->whereNull('available_at')
->orWhere('available_at', '<=', now());
})
->orderBy('created_at', 'asc')
->limit($limit)
->lockForUpdate()
->get();
if ($messages->isEmpty()) {
return 0;
}
$channel = $this->rabbitConnection->channel();
$channel->exchange_declare($this->exchange, 'topic', false, true, false);
$channel->queue_declare($this->queue, false, true, false, false);
$channel->queue_bind($this->queue, $this->exchange, '#');
$processedCount = 0;
foreach ($messages as $message) {
try {
// Tạo message payload
$payload = json_encode([
'event_type' => $message->event_type,
'aggregate_type' => $message->aggregate_type,
'aggregate_id' => $message->aggregate_id,
'data' => $message->payload,
'headers' => $message->headers,
'sent_at' => now()->toIso8601String(),
]);
$amqpMessage = new AMQPMessage($payload, [
'content_type' => 'application/json',
'delivery_mode' => AMQPMessage::DELIVERY_MODE_PERSISTENT,
'headers' => $message->headers ?? [],
'message_id' => $message->payload['event_id'] ?? (string) \Illuminate\Support\Str::uuid(),
'timestamp' => now()->timestamp,
]);
// Publish sang RabbitMQ
$routingKey = $message->event_type;
$channel->basic_publish($amqpMessage, $this->exchange, $routingKey);
// ✅ Đánh dấu PROCESSED sau khi publish thành công
$message->markAsProcessed();
$this->info("✅ Published message: {$message->event_type} (ID: {$message->id})");
$processedCount++;
} catch (\Exception $e) {
Log::error('Failed to publish outbox message', [
'outbox_id' => $message->id,
'error' => $e->getMessage(),
]);
$message->incrementAttempts();
// Sau 3 lần thất bại, đánh dấu FAILED
if ($message->attempts >= 3) {
$message->markAsFailed();
Log::warning('Outbox message marked as FAILED after max attempts', [
'outbox_id' => $message->id,
'attempts' => $message->attempts,
]);
}
}
}
$channel->close();
$this->rabbitConnection->close();
return $processedCount;
}
}
Giải thích code:
lockForUpdate(): Sử dụng Row Locking để tránh nhiều worker cùng xử lý một record【best_practices】basic_publishvớidelivery_mode = 2(PERSISTENT) đảm bảo message không bị mất nếu RabbitMQ restart- Đánh dấu
PROCESSEDsau khi publish thành công – không xóa record ngay lập tức【common_errors】 - Retry với cơ chế exponential: sau 3 lần thất bại, đánh dấu
FAILEDđể monitoring
Cấu hình chạy daemon với Supervisor:
; /etc/supervisor/conf.d/outbox-relay.conf
[program:outbox-relay]
command=php /path/to/project/artisan outbox:relay --limit=50 --sleep=2
process_name=%(program_name)s_%(process_num)02d
numprocs=1
autostart=true
autorestart=true
user=forge
redirect_stderr=true
stdout_logfile=/path/to/project/storage/logs/outbox-relay.log
stopwaitsecs=3600
Cấu hình RabbitMQ trong Laravel (config/rabbitmq.php):
<?php
// config/rabbitmq.php
return [
'host' => env('RABBITMQ_HOST', '127.0.0.1'),
'port' => env('RABBITMQ_PORT', 5672),
'user' => env('RABBITMQ_USER', 'guest'),
'password' => env('RABBITMQ_PASSWORD', 'guest'),
'vhost' => env('RABBITMQ_VHOST', '/'),
'exchange' => env('RABBITMQ_EXCHANGE', 'laravel.events'),
'queue' => env('RABBITMQ_QUEUE', 'laravel.events.queue'),
];
🔗 Mở rộng: Nếu bạn đã quen với Laravel Queue mặc định, hãy xem bài viết Laravel Job Queues: Tối ưu tốc độ phản hồi API để so sánh và hiểu rõ khi nào nên dùng Outbox Pattern thay vì Queue thông thường.
4. Xử Lý Trùng Lặp Tin Nhắn (At-Least-Once Delivery & Idempotent Consumer)

Outbox Pattern đảm bảo At-Least-Once Delivery – message được gửi ít nhất một lần. Tuy nhiên, điều này có nghĩa là Consumer có thể nhận được cùng một message nhiều lần trong các trường hợp:
- Relay Worker crash sau khi publish thành công nhưng chưa kịp đánh dấu
PROCESSED - RabbitMQ gửi lại message do không nhận được ACK
- Network timeout khiến message được gửi lại
Giải pháp: Thiết kế Consumer Idempotent (Khả đằng)
Consumer phía nhận phải đảm bảo xử lý message trùng lặp mà không gây ra tác dụng phụ (side effect) trùng lặp.
<?php
// app/Consumers/OrderCreatedConsumer.php
namespace App\Consumers;
use App\Models\ProcessedEvent;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class OrderCreatedConsumer
{
public function handle(array $message): void
{
$eventId = $message['headers']['event_id'] ?? $message['data']['event_id'] ?? null;
if (!$eventId) {
Log::warning('Event without ID, processing anyway', ['message' => $message]);
$this->processOrderCreated($message);
return;
}
// ✅ Idempotency: Kiểm tra xem event đã xử lý chưa
$processed = ProcessedEvent::where('event_id', $eventId)->first();
if ($processed) {
Log::info('Duplicate event ignored', ['event_id' => $eventId]);
return; // Bỏ qua message trùng lặp
}
DB::transaction(function () use ($message, $eventId) {
// 1. Xử lý business logic
$this->processOrderCreated($message);
// 2. Đánh dấu event đã xử lý
ProcessedEvent::create([
'event_id' => $eventId,
'event_type' => 'OrderCreated',
'processed_at' => now(),
]);
});
}
private function processOrderCreated(array $message): void
{
$orderData = $message['data'];
// Gửi email xác nhận đơn hàng
// Tạo invoice
// Cập nhật inventory
Log::info('OrderCreated processed', ['order_id' => $orderData['order_id']]);
}
}
Bảng processed_events để lưu lịch sử đã xử lý:
Schema::create('processed_events', function (Blueprint $table) {
$table->id();
$table->string('event_id')->unique(); // UUID của event
$table->string('event_type');
$table->timestamp('processed_at');
$table->timestamps();
$table->index('event_id');
});
5. Lỗi Thường Gặp Khi Triển Khai Outbox Pattern
❌ Lỗi 1: Xóa ngay lập tức Record trong Outbox Table sau khi Publish
Vấn đề: Nhiều dev nghĩ rằng sau khi publish thành công, có thể delete() record ngay lập tức để tiết kiệm dung lượng.
Hậu quả: Mất lịch sử Audit Trail và khó gỡ lỗi nếu RabbitMQ gặp sự cố. Không thể biết được message nào đã được gửi, khi nào, và bao nhiêu lần.
✅ Cách khắc phục: Cập nhật trạng thái thành PROCESSED kèm timestamp, đặt Cronjob tự động dọn dẹp (Prune) các record cũ sau 7 ngày【common_errors】.
// App\Console\Commands\PruneOutboxMessages.php
public function handle()
{
OutboxMessage::where('status', 'PROCESSED')
->where('processed_at', '<', now()->subDays(7))
->delete();
}
❌ Lỗi 2: Không sử dụng Row Locking trong Relay Worker
Vấn đề: Khi chạy nhiều instance của Relay Worker (hoặc scale horizontally), nhiều worker có thể cùng lúc lấy và xử lý cùng một record.
Hậu quả: Message bị gửi trùng lặp nhiều lần, gây ra side effects không mong muốn.
✅ Cách khắc phục: Sử dụng lockForUpdate() trong query của Relay Worker【best_practices】.
$messages = OutboxMessage::query()
->where('status', 'PENDING')
->limit($limit)
->lockForUpdate() // 🔒 Khóa row để tránh đụng độ
->get();
❌ Lỗi 3: Polling Database quá thường xuyên
Vấn đề: Chạy outbox:relay với sleep=0 hoặc tần suất quá cao.
Hậu quả: Gây quá tải CPU Database, ảnh hưởng đến performance của toàn hệ thống.
✅ Cách khắc phục: Đặt sleep hợp lý (1-5 giây) tùy theo SLA của hệ thống. Sử dụng --sleep option trong command để điều chỉnh linh hoạt【verification_risks】.
❌ Lỗi 4: Consumer không được thiết kế Idempotent
Vấn đề: Consumer xử lý message mà không kiểm tra xem message đã được xử lý trước đó chưa.
Hậu quả: Order bị tạo trùng, email gửi nhiều lần, tiền bị trừ 2 lần – thảm họa cho hệ thống tài chính.
✅ Cách khắc phục: Luôn thiết kế Consumer đạt tính Idempotent, sử dụng bảng processed_events hoặc Redis để lưu event_id đã xử lý【best_practices】.
6. So Sánh: Polling Publisher (Artisan Command) vs Change Data Capture (Debezium)
| Tiêu chí | Polling Publisher (Artisan Command) | Change Data Capture (Debezium) |
|---|---|---|
| Cách hoạt động | Query định kỳ bảng outbox | Đọc từ Database Transaction Log (binlog/WAL) |
| Độ trễ | Phụ thuộc vào tần suất poll (1-5s) | Gần như real-time (< 100ms) |
| Tác động đến DB | Tăng load do query liên tục | Minimal (đọc log) |
| Độ phức tạp | Thấp, dễ triển khai | Cao, cần运维 Debezium + Kafka |
| Chi phí | Thấp | Cao |
| Phù hợp | Hệ thống vừa và nhỏ, tolerance trễ cao | Hệ thống real-time, quy mô lớn |
Kết luận: Polling Publisher là lựa chọn phù hợp cho đa số dự án Laravel. Debezium chỉ cần thiết khi yêu cầu độ trễ cực thấp và có đội ngũ vận hành chuyên sâu.
7. FAQ – Các Câu Hỏi Thường Gặp
📌 Outbox Pattern có làm chậm câu lệnh INSERT vào Database không?
Trả lời: Có một chút, nhưng không đáng kể. Việc INSERT thêm một record vào bảng outbox_messages làm tăng nhẹ thời gian transaction (khoảng 1-5ms tùy cấu hình). Tuy nhiên, so với việc gọi network đến RabbitMQ trong cùng transaction (có thể mất 50-200ms), Outbox Pattern thực sự nhanh hơn vì chỉ thao tác với Database local và tách network call ra khỏi request lifecycle.
📌 So sánh Polling Publisher (Artisan Command) và Change Data Capture (Debezium)?
Trả lời: Xem bảng so sánh chi tiết ở Mục 6. Tóm lại: Polling Publisher đơn giản, dễ triển khai, phù hợp 80% dự án. Debezium phức tạp hơn nhưng real-time và ít tác động đến DB.
📌 Làm thế nào để đảm bảo thứ tự message khi có nhiều Relay Worker?
Trả lời: Sử dụng orderBy('created_at', 'asc') và chỉ chạy 1 instance của Relay Worker (numprocs=1). Nếu cần scale, sử dụng Partition Key (ví dụ: aggregate_id) để đảm bảo message của cùng một entity được xử lý bởi cùng một worker.
📌 Nên lưu Outbox Table cùng Database với Business Table hay riêng?
Trả lời: Luôn cùng Database để tận dụng ACID transaction. Đây là core của Transactional Outbox Pattern. Nếu lưu riêng, bạn lại quay về bài toán distributed transaction.
📌 Outbox Pattern có thay thế được Laravel Queue không?
Trả lời: Không thay thế hoàn toàn. Outbox Pattern giải quyết vấn đề Dual-Write Consistency giữa DB và Message Broker. Laravel Queue vẫn hữu ích cho các background jobs không yêu cầu transactional guarantee. Trong nhiều trường hợp, bạn có thể kết hợp cả hai: dùng Outbox Pattern cho các event quan trọng, và Laravel Queue cho các task thông thường.
8. Best Practices Tóm Lược
| # | Best Practice | Mức độ |
|---|---|---|
| 1 | Luôn thiết kế Consumer phía bên nhận đạt tính Idempotent | ⭐ Bắt buộc |
| 2 | Sử dụng Row Locking (lockForUpdate()) trong Relay Worker để tránh đụng độ |
⭐ Bắt buộc |
| 3 | Không xóa record sau khi publish, chỉ đánh dấu PROCESSED |
⭐ Bắt buộc |
| 4 | Đặt Cronjob tự động dọn dẹp (Prune) các record cũ sau 7 ngày | ⭐ Khuyến nghị |
| 5 | Sử dụng event_id (UUID) để hỗ trợ idempotency |
⭐ Khuyến nghị |
| 6 | Chạy Relay Worker dưới dạng daemon với Supervisor | ⭐ Khuyến nghị |
| 7 | Log đầy đủ để monitor và debug | ⭐ Khuyến nghị |
| 8 | Cấu hình Retry với Backoff (attempts count) | ⭐ Khuyến nghị |
| 9 | Sử dụng Persistent Messages trong RabbitMQ (delivery_mode=2) |
⭐ Khuyến nghị |
| 10 | Đặt tần suất Polling hợp lý để không quá tải Database | ⚠️ Cần cân nhắc |
9. Kết Luận
Transactional Outbox Pattern là một trong những pattern quan trọng nhất trong kiến trúc hệ thống phân tán, đặc biệt với các hệ thống Ecommerce, Fintech yêu cầu độ chính xác tuyệt đối.
Qua bài viết này, bạn đã hiểu:
- Dual-Write Problem – nguyên nhân gây mất dữ liệu khi kết hợp DB + Message Broker
- Nguyên lý Outbox Pattern – lưu message trong cùng DB transaction, gửi sau bởi Relay Worker
- Cách triển khai trên Laravel 11 – migration, service, artisan command với RabbitMQ
- Xử lý trùng lặp – Idempotent Consumer với bảng
processed_events - Các lỗi thường gặp và cách khắc phục
- Best Practices để vận hành production
Hãy nhớ: Outbox Pattern không phức tạp, nhưng cần được triển khai đúng cách. Một sai sót nhỏ trong thiết kế Consumer (không idempotent) có thể gây ra hậu quả nghiêm trọng cho hệ thống.