Перейти к содержимому
DedicatedPHP Контакт

Как спроектировать transactional outbox в PHP

Паттерн transactional outbox в PHP помогает не терять события между бизнес-транзакцией и очередью. Узнайте, как обрабатывать события, повторять попытки и контролировать их доставку.

Концептуальная схема PHP-транзакции, сохраняющей бизнес-изменение и событие в таблице outbox для асинхронной публикации

Приложение может успешно сохранить заказ и при этом не уведомить платёжную систему, сервис учёта запасов или очередь задач. Возможна и обратная ситуация: уведомление опубликовано, но процесс завершается с ошибкой до подтверждения заказа. В обоих случаях системы оказываются в противоречивом состоянии. Паттерн transactional outbox в PHP решает проблему двойной записи: бизнес-изменение и запись события фиксируются вместе в рамках одной транзакции базы данных.

Outbox не обеспечивает мгновенную доставку и сам по себе не гарантирует, что потребитель обработает событие ровно один раз. Его задача — не допустить, чтобы подтверждённое событие зависело от второй операции записи, которая может завершиться ошибкой независимо от первой. Чтобы правильно применить этот паттерн, нужно также продумать конкурентное чтение, повторные попытки, идемпотентность и повседневную эксплуатацию.

Проблема записи в базу данных и публикации события

Проблема записи в базу данных и публикации события — guía visual de DedicatedPHP

Предположим, операция подтверждает покупку, а затем публикует OrderPlaced в очередь. Если PHP-процесс завершится между этими действиями, покупка будет сохранена, но никто не получит событие. Если сначала опубликовать событие, а затем транзакция базы данных откатится, потребитель может выполнить действие для несуществующей покупки.

SQL-транзакция обычно не охватывает вызов очереди, внешнего API или брокера. Попытка вручную согласовать обе операции не устраняет окно сбоя — она лишь меняет порядок, в котором оно возникает. Паттерн transactional outbox предотвращает неатомарную двойную запись: бизнес-изменение и строка, представляющая событие для публикации, включаются в одну транзакцию.

Минимальная схема таблицы outbox

В таблице должно храниться достаточно данных, чтобы другой процесс мог идентифицировать событие, доставить его и отслеживать его состояние. Начальная схема может включать:

  • Уникальный идентификатор: стабильный и пригодный в качестве ключа идемпотентности, например UUID.
  • Тип события: например, OrderPlaced; если формат меняется, желательно указывать и версию схемы.
  • Полезная нагрузка: сериализованные данные, необходимые потребителю, без лишней конфиденциальной информации.
  • Дата создания: помогает сортировать события, оценивать их возраст и диагностировать задержки.
  • Статус и данные для отслеживания: например, ожидает обработки, захвачено или опубликовано; число попыток, последняя ошибка и дата последней попытки.
  • Данные захвата: идентификатор обработчика и срок действия аренды (lease), если для координации потребителей используется lease.

Конкретная схема зависит от СУБД и объёма данных. Добавьте индексы, оптимизирующие поиск ожидающих обработки событий, и не загружайте всю таблицу ради поиска задач. Определите также, как долго хранить опубликованные события и ошибки: не превращайте outbox в архив бессрочного хранения по умолчанию.

Запись бизнес-данных и события в одной транзакции

В PHP код, изменяющий бизнес-состояние и добавляющий событие, должен использовать одно подключение и одну транзакцию. Пример показывает последовательность действий, а не универсальную схему SQL:

$pdo->beginTransaction();

try {
    // Вставить или обновить заказ, используя это подключение.
    $orderId = saveOrder($pdo, $orderData);

    $eventId = generateUuid();
    $payload = json_encode([
        'event_id' => $eventId,
        'order_id' => $orderId,
    ], JSON_THROW_ON_ERROR);

    $statement = $pdo->prepare(
        'INSERT INTO outbox_events (id, event_type, payload, status, created_at)
         VALUES (:id, :type, :payload, :status, CURRENT_TIMESTAMP)'
    );
    $statement->execute([
        'id' => $eventId,
        'type' => 'OrderPlaced',
        'payload' => $payload,
        'status' => 'pending',
    ]);

    $pdo->commit();
} catch (Throwable $error) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }
    throw $error;
}

Если транзакция фиксируется, сохраняются обе записи; если операция завершается ошибкой, ни одну из них нельзя считать подтверждённой. Не публикуйте сообщение в брокер внутри транзакции, рассчитывая, что это сделает операцию атомарной: внешний вызов не участвует в SQL-коммите.

UUID в примере идентифицирует событие, созданное в этой транзакции, но сам по себе не предотвращает дубли, если запрос бизнес-операции выполняется повторно. Если такой риск существенен — например, запрос может повториться после тайм-аута, — используйте ключ идемпотентности запроса или ограничение уникальности для бизнес-операции. При обнаружении повтора получите результат и событие, уже связанные с исходной операцией, вместо создания ещё одного события с другим UUID.

Обработка ожидающих событий: параллельная работа и восстановление

Обработчик выбирает ограниченную пачку событий, готовых к обработке, захватывает их и публикует в целевую систему. Механизм захвата должен не допускать одновременной обработки одного события несколькими обработчиками или, по крайней мере, позволять безопасно справляться с такими дублями. Доступные варианты зависят от СУБД: блокировка строк, условное обновление статуса или lease с истечением срока действия. Универсального запроса, который корректно решает все случаи, не существует: проверьте семантику блокировок и изоляции используемой базы данных.

Распространённая стратегия — захватить строки на ограниченный срок, опубликовать события вне длительной транзакции и отметить их как опубликованные после подтверждения целевой системы. Если процесс завершится после публикации, но до обновления статуса, срок действия lease истечёт и другой обработчик может опубликовать событие повторно. Если отметить событие опубликованным до публикации и процесс завершится с ошибкой, событие может быть потеряно. Поэтому для попыток публикации может использоваться семантика «как минимум один раз», но не «ровно один раз»: дубли возможны, а получение события целевой системой не гарантируется. Фактическая доставка зависит от политики повторных попыток и от того, станет ли целевая система доступна до исчерпания попыток либо будет ли выполнено ручное восстановление события.

Используйте небольшие или настраиваемые пачки и ограничивайте длительность каждого запуска. Удержание SQL-блокировок во время ожидания ответа внешней системы может ухудшить работу базы данных. Размер пачки, длительность lease и частоту опроса следует настраивать по результатам измерений: большая пачка не всегда повышает производительность и может увеличить задержку или затруднить восстановление.

Идемпотентность, повторные попытки и зависшие события

Потребитель должен корректно обрабатывать повторную доставку одного и того же события. Если бизнес-эффект выполняется в той же базе данных, можно записывать идентификатор события в таблицу обработанных событий с ограничением уникальности и применять эффект в одной транзакции с записью этого идентификатора. В результате при успешной фиксации транзакции идентификатор и эффект сохраняются вместе; при сбое операцию можно повторить, не оставляя подтверждённым только одно из них.

Этот механизм не делает автоматически идемпотентными внешние нетранзакционные эффекты. Если потребитель вызывает API, отправляет письмо или выполняет другое действие вне своей базы данных, сбой между внешним эффектом и локальной записью может привести к повтору. Используйте поддерживаемый целевой системой ключ идемпотентности, специальный механизм координации или подходящий компенсирующий процесс. Если ни один из этих вариантов недоступен, явно учитывайте и обрабатывайте риск дублирования.

Настройте повторные попытки с увеличивающейся задержкой и разумными ограничениями, различая временные ошибки — например, тайм-аут — и постоянные, например некорректную полезную нагрузку. Сохраняйте последнюю ошибку в виде, удобном для расследования, но не записывайте в логи секреты и лишние персональные данные. После исчерпания попыток оставьте событие в заметном состоянии ошибки, не удаляйте его молча.

Восстановление должно быть явным: установите причину, устраните её и решите, нужно ли повторить то же событие или требуется вмешательство. Перед возобновлением обработки убедитесь, что потребитель идемпотентен. Кнопка «Обработать повторно» без дополнительных проверок может привести к повторным внешним эффектам или нарушить ожидаемый порядок. Задокументируйте, кто может запускать повторную обработку и какие действия при этом регистрируются.

Мониторинг, хранение и защита данных

Отслеживайте количество ожидающих событий, возраст самого старого ожидающего события, неудачные попытки, скорость публикации и время от создания до доставки. Растущая очередь может указывать на сбой целевой системы, остановку потребителей, постоянные ошибки или недостаточную производительность. Сигнал тревоги, основанный только на общем количестве событий, может скрыть задержки; возраст событий помогает понять, продвигается ли обработка.

С каждой записью события таблица будет расти. Установите правила хранения опубликованных событий и ошибок и удаляйте их пакетами, чтобы избежать чрезмерной нагрузки на обслуживание. Перед очисткой сохраните данные, необходимые для аудита, поддержки или расследований в соответствии с требованиями бизнеса. Ограничьте доступ к полезной нагрузке, шифруйте конфиденциальные данные или минимизируйте их объём, когда это уместно, и согласуйте правила удаления с политикой хранения.

Когда использовать outbox и когда не усложнять систему

Когда использовать outbox и когда не усложнять систему — guía visual de DedicatedPHP

Outbox полезна, когда подтверждение бизнес-операции должно надёжно запускать асинхронное действие, между системами возможны сбои, а потеря уведомления повлечёт последствия. Этот подход особенно удобен, если приложение уже использует транзакционную базу данных и должно интегрироваться с очередью или внешними сервисами, не связывая доступность этих систем с обработкой каждого запроса.

Outbox может быть не нужна для необязательных или восстанавливаемых задач, если достаточно периодически опрашивать бизнес-состояние или если очередь и хранилище поддерживают подходящую встроенную транзакцию для данного потока. Оценивайте такую альтернативу с учётом её реальных ограничений: не предполагайте, что два сервиса атомарны лишь потому, что входят в один продукт.

У этого паттерна есть и издержки: дополнительная таблица, обработчик, повторные попытки, управление состояниями, хранение и эксплуатационная поддержка. Не усложняйте систему состояний, пока не определены реальные потребности. Начните с идентификаторов, статуса, дат, счётчика попыток и безопасного механизма захвата; расширяйте схему только при наличии конкретных требований к порядку, объёму, аудиту или восстановлению. Правильный выбор — самый простой вариант, который обеспечивает необходимую согласованность и позволяет обнаруживать и устранять сбои.

Хотите применить эти идеи в своем проекте?Давайте обсудим вашу PHP-платформу.
Посмотреть связанные услуги