Реализация паттерна Outbox на PHP и MySQL: гарантированная доставка событий в микросервисах
Введение: проблема двойной записи и потери событий
В микросервисной архитектуре сервисы взаимодействуют через события. Типичный сценарий: после сохранения заказа в базе данных нужно опубликовать событие OrderCreated в брокер сообщений (RabbitMQ, Kafka и т.д.). На первый взгляд задача тривиальна — сохранили запись, отправили событие. Но здесь и прячется проблема двойной записи в базе данных.
Рассмотрим наивный подход:
// Сохраняем заказ
$db->exec("INSERT INTO orders (id, status) VALUES (1, 'created')");
// Публикуем событие в брокер
$broker->publish('order.created', ['order_id' => 1]);
Между этими двумя операциями нет атомарности. Если приложение упадёт после INSERT, но до публикации — событие будет потеряно. Если брокер недоступен — событие тоже не дойдёт. В результате состояние БД и состояние других сервисов расходятся, возникают трудноотлаживаемые несоответствия данных.
Именно для решения этой проблемы существует паттерн Transactional Outbox.
Что такое паттерн Transactional Outbox и зачем он нужен
Паттерн Outbox (также называемый Transactional Outbox) — это архитектурный шаблон, гарантирующий атомарную запись бизнес-данных и событий, которые нужно опубликовать. Суть проста: вместо немедленной публикации события в брокер мы сохраняем его в специальную таблицу outbox внутри той же транзакции MySQL, что и основные данные. Отдельный процесс (polling-воркер) читает эту таблицу и публикует события в брокер.
Ключевые преимущества паттерна:
- Гарантированная доставка событий — событие сохраняется атомарно с бизнес-данными, его невозможно потерять из-за сбоя приложения.
- Отсутствие зависимости от брокера в момент обработки запроса — если брокер недоступен, запрос всё равно выполняется успешно.
- Идемпотентность — можно безопасно повторять публикацию при сбоях воркера.
- Простота — не требует распределённых транзакций или двухфазного коммита.
Паттерн особенно актуален в микросервисах на PHP, где транзакционная надёжность критична, а использование распределённых транзакций нежелательно.
Архитектурная схема: атомарная запись в MySQL
Архитектура строится на трёх компонентах:
- Основной сервис — в одной транзакции сохраняет бизнес-данные и добавляет запись в таблицу
outbox. - Таблица outbox — промежуточное хранилище событий внутри той же MySQL-базы.
- Polling-воркер — фоновый процесс, который периодически читает необработанные записи из
outboxи публикует их в брокер, после чего помечает как обработанные.
Важно понимать: гарантия атомарности достигается именно за счёт того, что запись в outbox происходит в той же транзакции MySQL, что и INSERT/UPDATE основных данных. Либо обе операции успешны, либо обе откатываются.
Пошаговая реализация на PHP и Laravel
Структура таблицы outbox
Создадим таблицу outbox в MySQL. Она должна хранить тип события, полезную нагрузку, статус обработки и временны́е метки:
CREATE TABLE outbox (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(255) NOT NULL,
payload JSON NOT NULL,
status ENUM('pending', 'processing', 'processed', 'failed')
DEFAULT 'pending' NOT NULL,
attempts TINYINT UNSIGNED DEFAULT 0 NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
processed_at DATETIME(3) NULL,
INDEX idx_status_created (status, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
Индекс idx_status_created критически важен для производительности polling-запросов — воркер всегда выбирает строки по status = 'pending', отсортированные по времени создания.
Запись событий в одной транзакции (чистый PHP)
Пример без фреймворка с использованием PDO:
$pdo->beginTransaction();
try {
// 1. Основная бизнес-операция
$stmt = $pdo->prepare(
"INSERT INTO orders (user_id, status, total) VALUES (?, 'created', ?)"
);
$stmt->execute([$userId, $total]);
$orderId = $pdo->lastInsertId();
// 2. Запись события в outbox в той же транзакции
$payload = json_encode([
'order_id' => $orderId,
'user_id' => $userId,
'total' => $total,
], JSON_THROW_ON_ERROR);
$outbox = $pdo->prepare(
"INSERT INTO outbox (event_type, payload) VALUES ('OrderCreated', ?)"
);
$outbox->execute([$payload]);
$pdo->commit();
} catch (\Throwable $e) {
$pdo->rollBack();
throw $e;
}
Оба INSERT выполняются в одной транзакции — атомарность гарантирована.
Запись событий в Laravel
В Laravel реализация выглядит ещё лаконичнее благодаря Eloquent и методу DB::transaction():
use Illuminate\Support\Facades\DB;
DB::transaction(function () use ($userId, $total) {
$order = Order::create([
'user_id' => $userId,
'status' => 'created',
'total' => $total,
]);
DB::table('outbox')->insert([
'event_type' => 'OrderCreated',
'payload' => json_encode([
'order_id' => $order->id,
'user_id' => $userId,
'total' => $total,
], JSON_THROW_ON_ERROR),
'created_at' => now(),
]);
});
Polling-воркер на PHP
Воркер запускается как отдельный долгоживущий процесс (например, через supervisord). Его задача — периодически забирать пачку необработанных событий и публиковать их:
while (true) {
$pdo->beginTransaction();
try {
// Захватываем до 10 строк с блокировкой
$stmt = $pdo->query(
"SELECT id, event_type, payload
FROM outbox
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT 10
FOR UPDATE SKIP LOCKED"
);
$events = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($events)) {
$pdo->rollBack();
sleep(1); // Пауза при пустой очереди
continue;
}
$ids = array_column($events, 'id');
// Помечаем как processing
$placeholders = implode(',', array_fill(0, count($ids), '?'));
$pdo->prepare(
"UPDATE outbox SET status = 'processing' WHERE id IN ($placeholders)"
)->execute($ids);
$pdo->commit();
// Публикуем события в брокер (вне транзакции)
foreach ($events as $event) {
try {
$broker->publish(
$event['event_type'],
json_decode($event['payload'], true, 512, JSON_THROW_ON_ERROR)
);
// Помечаем как processed
$pdo->prepare(
"UPDATE outbox
SET status = 'processed', processed_at = NOW(3)
WHERE id = ?"
)->execute([$event['id']]);
} catch (\Throwable $e) {
// При ошибке — увеличиваем счётчик попыток
$pdo->prepare(
"UPDATE outbox
SET status = 'pending', attempts = attempts + 1
WHERE id = ?"
)->execute([$event['id']]);
error_log("Outbox publish failed for id={$event['id']}: " . $e->getMessage());
}
}
} catch (\Throwable $e) {
$pdo->rollBack();
error_log('Outbox worker error: ' . $e->getMessage());
sleep(5);
}
}
Оптимизация polling-воркера
SELECT FOR UPDATE SKIP LOCKED в MySQL 8+
Конструкция SELECT FOR UPDATE SKIP LOCKED появилась в MySQL 8.0 и является ключевым инструментом для масштабирования воркера. Она позволяет нескольким инстансам воркера параллельно обрабатывать очередь, не блокируя друг друга: каждый инстанс пропускает строки, уже заблокированные другим процессом.
Без SKIP LOCKED второй воркер будет ожидать снятия блокировки — это узкое место. С SKIP LOCKED каждый воркер мгновенно получает свою пачку строк и работает независимо.
Интервалы опроса и адаптивный polling
Статичный sleep(1) — не всегда оптимален. При пустой очереди можно применять экспоненциальный back-off: увеличивать паузу до 5–10 секунд, чтобы снизить нагрузку на MySQL. При появлении событий — немедленно сбрасывать интервал до минимума.
Обработка дублей и идемпотентность
Воркер может доставить событие дважды (например, если упал после публикации в брокер, но до обновления статуса). Поэтому потребители событий должны быть идемпотентны. Для этого полезно передавать id записи из таблицы outbox как уникальный идентификатор события — потребитель может дедуплицировать по нему.
Также стоит добавить ограничение на максимальное число попыток: если attempts >= 5, переводить запись в статус failed и алертить команду.
Мониторинг outbox-таблицы
Без мониторинга паттерн Outbox теряет смысл — вы не узнаете о накопившейся очереди или застрявших событиях.
Ключевые метрики для отслеживания:
- Количество строк со статусом
pending— если растёт, воркер не справляется или остановился. - Количество строк со статусом
failed— требует немедленного внимания. - Lag (задержка) — разница между
created_atсамой старойpending-записи и текущим временем. - Throughput воркера — количество обработанных событий в секунду.
Запрос для получения текущего состояния очереди:
SELECT
status,
COUNT(*) AS count,
MIN(created_at) AS oldest,
MAX(attempts) AS max_attempts
FROM outbox
WHERE status IN ('pending', 'processing', 'failed')
GROUP BY status;
Эти метрики легко экспортировать в Prometheus через кастомный exporter или встроить в Laravel Horizon / Telescope. Настройте алерт: если lag превышает 60 секунд или количество failed-записей больше нуля — отправляйте уведомление в Slack/PagerDuty.
При росте очереди сначала проверьте: работает ли воркер, нет ли проблем с подключением к брокеру, не упёрся ли MySQL в лимиты соединений.
Сравнение с альтернативами
Change Data Capture (CDC) через Debezium
CDC — более продвинутый подход: Debezium читает бинарный лог MySQL (binlog) и автоматически публикует изменения строк в Kafka. Не требует изменения кода приложения и polling-воркера. Но у него есть цена: сложность инфраструктуры (нужен Kafka Connect, Zookeeper/KRaft), необходимость настройки репликации MySQL, ограничения на типы данных и схемы.
Паттерн Outbox с polling-воркером проще в реализации и операционном обслуживании для большинства команд, особенно если Kafka уже не используется в проекте.
Прямая публикация событий
Публиковать событие напрямую из кода сервиса (без outbox) — самый простой подход, но он не даёт никаких гарантий доставки. Подходит только для некритичных уведомлений, где потеря события допустима.
Двухфазный коммит (2PC)
Теоретически решает проблему, но на практике крайне сложен в реализации, плохо масштабируется и создаёт точки отказа. В микросервисах на PHP практически не применяется.
Реальные кейсы и подводные камни
На практике при внедрении паттерна Outbox на PHP и MySQL команды сталкиваются с рядом нетривиальных проблем:
- Рост таблицы outbox — необходимо периодически чистить обработанные записи. Простой cron-job с
DELETE FROM outbox WHERE status = 'processed' AND processed_at < NOW() - INTERVAL 7 DAYрешает проблему, но не забудьте про партиционирование при высоких нагрузках. - Порядок событий — polling-воркер обрабатывает события в порядке
created_at, но при параллельных воркерах порядок внутри одного агрегата может нарушиться. Решение: роутинг событий по ключу (например,order_id) к одному инстансу воркера. - Большие payload — JSON-поле типа
JSONв MySQL хранит данные эффективно, но не храните в событии весь объект целиком. Лучше передавать идентификатор и тип события, а потребитель сам подтянет актуальные данные (паттерн Event-Carried State Transfer vs. Event Notification). - Длинные транзакции — если основная бизнес-транзакция занимает много времени, строка в
outboxдолго остаётся невидимой для воркера. Следите за продолжительностью транзакций. - Несколько outbox-таблиц — при высокой нагрузке имеет смысл разнести события разных доменов по отдельным таблицам, чтобы избежать конкуренции за блокировки.
Паттерн Outbox — это не серебряная пуля, а осознанный компромисс между сложностью и надёжностью. Он добавляет latency в доставку событий (на время polling-интервала), но взамен даёт атомарность и устойчивость к сбоям.
Заключение и рекомендации
Паттерн Transactional Outbox — один из наиболее практичных способов обеспечить гарантированную доставку событий в микросервисах на PHP без распределённых транзакций. Его реализация на MySQL требует минимума инфраструктуры и хорошо вписывается в существующие PHP-проекты, в том числе на Laravel.
Ключевые рекомендации для успешного внедрения:
- Всегда записывайте событие в
outboxв той же транзакции, что и основные данные — это основа атомарности. - Используйте
SELECT FOR UPDATE SKIP LOCKEDдля безопасного параллельного polling в MySQL 8+. - Сделайте потребителей событий идемпотентными — воркер может доставить событие более одного раза.
- Настройте мониторинг: lag очереди и количество
failed-записей должны быть в ваших дашбордах. - Регулярно очищайте обработанные записи из таблицы
outbox. - Рассмотрите CDC через Debezium только если у вас уже есть Kafka-инфраструктура и команда готова к её операционному обслуживанию.
Начните с простой реализации, измерьте производительность, и масштабируйте по мере роста нагрузки. Паттерн Outbox на PHP и MySQL — это проверенное решение, которое работает надёжно даже в высоконагруженных системах.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →