Архитектура

Реализация паттерна Outbox на PHP и MySQL: гарантированная доставка событий в микросервисах

Ruslan Ismailov Опубликовано 12 мин чтения
Р

Введение: проблема двойной записи и потери событий

В микросервисной архитектуре сервисы взаимодействуют через события. Типичный сценарий: после сохранения заказа в базе данных нужно опубликовать событие 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

Архитектура строится на трёх компонентах:

  1. Основной сервис — в одной транзакции сохраняет бизнес-данные и добавляет запись в таблицу outbox.
  2. Таблица outbox — промежуточное хранилище событий внутри той же MySQL-базы.
  3. 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.

Ключевые рекомендации для успешного внедрения:

  1. Всегда записывайте событие в outbox в той же транзакции, что и основные данные — это основа атомарности.
  2. Используйте SELECT FOR UPDATE SKIP LOCKED для безопасного параллельного polling в MySQL 8+.
  3. Сделайте потребителей событий идемпотентными — воркер может доставить событие более одного раза.
  4. Настройте мониторинг: lag очереди и количество failed-записей должны быть в ваших дашбордах.
  5. Регулярно очищайте обработанные записи из таблицы outbox.
  6. Рассмотрите CDC через Debezium только если у вас уже есть Kafka-инфраструктура и команда готова к её операционному обслуживанию.

Начните с простой реализации, измерьте производительность, и масштабируйте по мере роста нагрузки. Паттерн Outbox на PHP и MySQL — это проверенное решение, которое работает надёжно даже в высоконагруженных системах.

Технологии

Теги

Руслан Исмаилов

Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →