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

Рассмотрим другой сценарий в сервисе пользователей. Вы сохраняете данные о новом пользователе и уведомляете другие сервисы. Не отправите уведомление — и пользователи могут не получить доступ к ключевым функциям. Отправите уведомление до подтверждения записи — и другие сервисы начнут действовать на основе несуществующих данных.

Это реальные риски распределённых систем. Как сделать так, чтобы фиксация транзакций в базе данных и отправка событий либо обе завершались успешно, либо не происходили вовсе?

Здесь помогает паттерн Transactional Outbox, или просто outbox-паттерн: элегантное решение для поддержания согласованности данных на границах сервисов.

Как это работает

Паттерн Transactional Outbox добавляет два компонента, чтобы гарантировать согласованность между изменениями данных и отправкой событий в распределённых системах: таблицу outbox (Outbox Table) и relay-процесс (Relay Process).

Таблица исходящих — это дополнительная таблица в базе данных, используемая для временного хранения событий, которые нужно опубликовать. Эта таблица находится в той же базе, что и основные данные сервиса, и заполняется в рамках той же транзакции.

Например, в сервисе пользователей, когда создаётся новый пользователь, приложение выполняет две операции в одной транзакции:

  • Добавляет новую запись пользователя в таблицу users.

  • Добавляет соответствующее событие UserCreated в таблицу outbox.

Объединяя обе записи в одну транзакцию, мы гарантируем, что либо выполнятся обе операции, либо обе будут отменены — это обеспечивается ACID-свойствами транзакций (атомарность, согласованность, изоляция, устойчивость).

Строгого стандарта для схемы таблицы outbox нет, однако базовая структура может выглядеть так:

Название столбца

Тип

Описание

id

INT / UUID

Уникальный идентификатор события в outbox

event_type

STRING

Тип события (например, UserCreated)

payload

JSON / TEXT

Сериализованное тело события

created_at

TIMESTAMP

Время сохранения события

sent_at

TIMESTAMP NULLABLE

Время отправки события (может быть NULL)

Эту схему можно расширить дополнительными полями, например:

  • entity_id и entity_name для отслеживания доменного объекта

  • retries, status или error_message для отладки и расширенной обработки ошибок

  • correlation_id для трассировки

Процесс ретрансляции — это фоновый процесс (его также называют обработчиком outbox или диспетчером событий). Он периодически опрашивает таблицу outbox на предмет неотправленных событий и публикует их в систему обмена сообщениями (например, RabbitMQ, AWS EventBridge, Azure Service Bus, Kafka и т. п.). После успешной отправки процесс либо помечает запись как доставленную, либо удаляет её.

Чтобы лучше понять архитектуру и поток событий, ниже приведены две диаграммы: диаграмма компонентов (Components Diagram) и диаграмма последовательностей (Sequence Diagram).

Рисунок 1. Диаграмма компонентов — иллюстрирует основные части паттерна: сервис, таблицу outbox, процесс ретрансляции и систему обмена сообщениями.
Рисунок 1. Диаграмма компонентов — иллюстрирует основные части паттерна: сервис, таблицу outbox, процесс ретрансляции и систему обмена сообщениями.
Рисунок 2. Диаграмма последовательностей — показывает пошаговый жизненный цикл изменения: от фиксации транзакции до отправки события.
Рисунок 2. Диаграмма последовательностей — показывает пошаговый жизненный цикл изменения: от фиксации транзакции до отправки события.

Написано множество статей, объясняющих паттерн Transactional Outbox, и есть немало библиотек, которые помогают реализовать его с минимальными усилиями. 

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

Готовность к продакшену

Безопасный выбор неотправленных событий в процессе ретрансляции

На первый взгляд выбор неотправленных событий кажется простой задачей — достаточно выполнить SELECT, чтобы забрать небольшой пакет (например, 10 строк) из таблицы outbox. Но как только мы добавляем несколько экземпляров процесса ретрансляции ради масштабируемости или отказоустойчивости, всё усложняется.

Нам нужно гарантировать, что:

  • Каждое событие берётся в обработку только одним процессом.

  • Процессы ретрансляции не блокируют друг друга и не мешают без необходимости.

Чтобы этого добиться, нужно добавить блокировки на уровне строк в запросы выборки. Это позволяет нескольким ретрансляторам работать параллельно и безопасно, не выбирая одни и те же строки.

PostgreSQL предлагает аккуратное решение с использованием FOR UPDATE SKIP LOCKED:

SELECT * FROM outbox
WHERE sent_at IS NULL
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;
  • FOR UPDATE: блокирует выбранные строки на время транзакции.

  • SKIP LOCKED: пропускает строки, которые уже заблокированы другими транзакциями.

В MySQL 8.0 и новее поддерживается тот же синтаксис FOR UPDATE SKIP LOCKED, поэтому подход применим напрямую.

В SQL Server схожего поведения можно добиться с помощью подсказок блокировок:

SELECT TOP 10 *
FROM outbox WITH (ROWLOCK, UPDLOCK, READPAST)
WHERE sent_at IS NULL
ORDER BY created_at;
  • ROWLOCK: принудительно использует блокировку на уровне строк (вместо страницы или таблицы).

  • READPAST: пропускает строки, уже заблокированные другими транзакциями.

  • UPDLOCK: захватывает блокировки обновления вместо разделяемых блокировок.

Выбор корректной стратегии блокировок на уровне строк помогает сделать процесс ретрансляции безопасным и надёжным под нагрузкой.

Обработка сбоев в середине пакета

Предположим, процесс ретрансляции забирает пакет событий, начинает их отправлять и падает на полпути. Что тогда?

Хорошие новости: если мы используем FOR UPDATE SKIP LOCKED (или эквивалент), те необработанные строки снова станут видимыми, как только транзакция откатится или соединение с базой будет закрыто. Пока мы помечаем события как отправленные только после подтверждения доставки от брокера, всё в порядке — ничего не потеряется.

Обратная сторона? Некоторые события могут отправиться повторно. Это нормально, если консюмеры умеют обрабатывать дубликаты (см. идемпотентность), но об этом стоит помнить.

Выделение процесса ретрансляции из API-сервиса

Во многих реализациях, особенно следующих настройкам по умолчанию библиотек вроде MassTransit, процесс ретрансляции часто запускают вместе с основным сервисом: фоновый воркер, отвечающий за публикацию событий из outbox, работает внутри главного API-сервиса. Такая схема удобна и хорошо подходит для небольших систем с невысокой нагрузкой или малым числом реплик (обычно меньше пяти).

Однако этот подход плохо масштабируется.

При горизонтальном масштабировании сервиса — скажем, до 10, 15 или даже 30 реплик — каждый экземпляр запускает свою копию логики ретрансляции. Это приводит к избыточному и дублирующемуся опросу: все экземпляры через равные интервалы (пусть даже скромные 25–50 мс) делают запросы к таблице outbox. То, что казалось безобидной фоновой задачей, превращается в поток запросов к базе.

Это создаёт две критические проблемы:

  • Нагрузка на базу и конкуренция за блокировки. Каждый экземпляр процесса ретрансляции пытается захватить блокировки на таблице outbox, чтобы безопасно «забронировать» и обработать события. Эти блокировки небесплатны. В одном из моих сервисов на Amazon Aurora я заметил, что база тратит значительную долю CPU не на выполнение запросов, а на управление блокировками.

  • Несовпадение профилей масштабирования. Корень проблемы — в архитектуре. Трафик API (REST API) и процесс ретрансляции масштабируются по-разному. API масштабируется вместе с пользовательским спросом, из-за чего может потребоваться много экземпляров. Процесс ретрансляции событий из outbox, напротив, зависит от скорости записи — то есть от числа новых событий, попадающих в таблицу outbox. Во многих реальных системах одного экземпляра ретранслятора достаточно даже при высоких объёмах событий. Масштабирование сверх этого обычно не даёт выгод и лишь добавляет накладные расходы на блокировки и конкуренцию.

Лучший подход — выделить процесс ретрансляции из экземпляра API и запускать их как независимые единицы развертывания. Они по-прежнему могут жить в одной кодовой базе, но должны развёртываться с разными конфигурациями. Например, в одном деплойменте процесс ретрансляции включён, а в другом — отключён, и он обслуживает только HTTP-трафик. Так каждый компонент масштабируется по своим правилам. MassTransit поддерживает эту модель. Запуск процесса ретрансляции при старте можно контролировать настройкой.

Обработка доставки «как минимум один раз»

Легко упустить из виду, но паттерн Transactional Outbox гарантирует доставку по модели «как минимум один раз», а не «ровно один раз». Это означает, что из-за кратковременных сбоев (например, проблемы сети, тайм-ауты брокера, повторные попытки отправки) консюмеры могут получить дубликаты событий. Если дальнейшая обработка к этому не готова, есть риск неконсистентного состояния или многократного срабатывания действий (например, повторная отправка писем или двойные списания).

Есть два основных способа, которые помогут с этим справиться:

  • Сделать логику идемпотентной. Спроектируйте консюмеров так, чтобы они безопасно обрабатывали одно и то же событие более одного раза. Например, вставлять запись только при её отсутствии.

  • Явно дедуплицировать события. Вести отдельную таблицу (иначе называемую таблицей inbox) для хранения идентификаторов обработанных событий в течение заданного срока хранения (например, час, шесть часов или даже несколько дней). Перед обработкой события консюмер проверяет эту таблицу: если идентификатор найден — событие пропускается; иначе — обрабатывается и идентификатор сохраняется.

Идентификатор события в таблице inbox должен быть глобально уникальным, а не просто идентификатором доменной сущности. Одна и та же сущность (например, пользователь или товар) со временем может порождать множество различных событий, и каждое из них нужно отслеживать независимо.

Обработка доставки событий не по порядку

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

  • Нескольких процессов ретрансляции. Когда несколько экземпляров параллельно читают и отправляют события, порядок доставки не гарантируется.

  • Нескольких экземпляров консюмеров. Параллельная обработка может привести к тому, что события будут обрабатываться в ином порядке, чем были созданы.

  • Из-за асинхронных повторных попыток или разной сетевой задержки одни события могут запаздывать, а другие — нет.

Что можно сделать для обработки этого:

  • Включать метку времени в полезную нагрузку события и разрешать конфликты по времени события.

  • Включать номер версии сущности (или последовательный идентификатор) в полезную нагрузку события и разрешать конфликты по версии.

  • Использовать брокеры сообщений с гарантиями порядка (например, партиции Kafka или сессии Azure Service Bus), если возможна группировка сообщений по ключу сущности. Тем не менее, этого самого по себе недостаточно, чтобы устранить проблемы на стороне отправителя: вставки в таблицу outbox не по порядку или состояния гонки при фиксации транзакций всё равно могут приводить к нарушению порядка событий.

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

Не забывайте о сопровождении базы данных

Таблицы outbox (и inbox) имеют склонность незаметно расти «в фоне». С каждым новым или обработанным событием добавляется новая строка. Сначала это легко упустить. В системах со средней нагрузкой и нормальными индексами производительность может оставаться приемлемой даже при сотнях тысяч записей.

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

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

Единого графика не существует: какие-то системы чистят каждые несколько часов, другие — ежедневно. Важно, чтобы очистка была автоматической и регулярной. Сложной она быть не обязана. Достаточной, чтобы поддерживать стабильную работу и не допускать незаметной деградации со временем.

Наблюдаемость

Паттерн Transactional Outbox неявно вводит в систему вторичную очередь — ту, что существует внутри базы данных и независима от системы обмена сообщениями. Большинство команд уже мониторят свои брокеры сообщений (например, дашборды RabbitMQ, экспортёры Kafka, метрики Azure), но саму outbox часто не наблюдают, особенно если она реализована вручную или с помощью «лёгких» библиотек.

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

Минимальный набор метрик, которые стоит отслеживать:

  • Средний «возраст» неотправленных событий — показывает, как долго события ожидают отправки.

  • Скорость поступления событий — насколько быстро новые события добавляются в outbox.

  • Скорость обработки/выгрузки — насколько быстро ретранслятор забирает и отправляет события.

  • Ошибки внутри процесса ретрансляции.

Когда эти метрики «расходятся» — например, растёт возраст событий или приток стабильно опережает отток, — это сигнал о проблеме: ретранслятор падает, перегружен или неправильно настроен.

Современные рантаймы вроде .NET упрощают инструментирование такой телеметрии через OpenTelemetry, экспортёры Prometheus или Application Insights. Не гадайте — наблюдайте. Настройте алерты, визуализируйте тренды, задайте базовые уровни.

Такая видимость особенно важна, потому что outbox-паттерн работает в модели итоговой согласованности (eventual consistency). Для одних доменов задержка в несколько секунд приемлема. Для других даже короткие задержки несут реальный риск.

Однажды я работал с системой внутренней валюты. Пользователи тратили эту валюту на вознаграждения. Логика списаний была построена на конвейере с итоговой согласованностью и паттерном Transactional Outbox. Хотя у нас были предохранители, в частности, откат транзакций при нехватке баллов, критичной оказалась временная составляющая. Пользователь мог инициировать несколько транзакций до того, как первая медленная операция списания завершилась — и фактически «перерасходовать» свой баланс. Поскольку часть вознаграждений была в виде реальных товаров, это привело к серьёзному инциденту.

Если бы мы мониторили саму очередь в таблице outbox, а не только внешний брокер, мы бы заметили задержку заранее, ограничили скорость действий пользователей или приостановили обработку. Вместо этого бэклог тихо рос.

Корневая причина была в отсутствии разделения между API и процессом ретрансляции.

Практическое правило: когда в системе появляется новая очередь (явная или неявная), необходимо добавить и соответствующий мониторинг. Без него вы движетесь вслепую.

Подведем итоги

Паттерн Transactional Outbox на первый взгляд кажется простым. Записали событие в таблицу — отправили позже. Но, как и во многих аспектах распределённых систем, дьявол кроется в деталях. Чтобы он надёжно работал в продакшене, нужно продумать:

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

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

Для большинства команд, особенно на старте, лучший шаг — использовать готовую библиотеку. Такие библиотеки, как MassTransit для .NET или Axon Framework для Java, берут на себя шаблонный код и множество граничных случаев, позволяя сосредоточиться на предметной логике. Если они вписываются в ваш стек и ограничения — используйте их. Только убедитесь, что понимаете их поведение в продакшене, особенно в части гарантий доставки, масштабирования и наблюдаемости.

В этой статье мы рассмотрели классическую схему outbox — запись в базу данных и регулярный опрос базы внутри процесса ретрансляции. Но это не единственный путь.

В следующей статье рассмотрим другие варианты реализации — способы уменьшить задержки и лучше масштабироваться. Stay tuned.


Системно разобрать архитектурные подходы, шаблоны, SOLID и рефакторинг на реальных кейсах можно на курсе «Архитектура и шаблоны проектирования». Курс не привязан к конкретному языку и помогает разработчикам и DevOps-инженерам проектировать масштабируемые, устойчивые системы, а не просто писать отдельные сервисы. Чтобы понять, подойдет ли вам программа курса, пройдите вступительный тест.

А чтобы узнать больше о формате обучения и познакомиться с преподавателями, приходите на бесплатные демо-уроки:

  • 25 ноября: «Шаблон проектирования "Заместитель" (Proxy)». Записаться

  • 11 декабря: «Объектная модель без боли: как превратить хаос требований в стройную архитектуру». Записаться

  • 16 декабря: «Потоковые приложения с использованием Apache Kafka: от событий к реальному времени». Записаться

Комментарии (0)