Паттерн Outbox в PostgreSQL для надёжных интеграций с API
Узнайте паттерн outbox: как сохранять события в PostgreSQL и надёжно доставлять их в сторонние API с ретраями, порядком и дедупликацией.

Почему интеграции падают, даже если приложение работает
Часто вы видите «успешное» действие в приложении, в то время как интеграция за кулисами тихо провалилась. Запись в базу данных быстрая и надёжная. Вызов стороннего API — нет. В результате у вас появляются две разные реальности: ваша система говорит, что изменение произошло, а внешняя система об этом не узнала.
Типичный пример: клиент оформляет заказ, ваше приложение сохраняет его в PostgreSQL и затем пытается уведомить перевозчика. Если провайдер зависает 20 секунд и запрос терпит неудачу, заказ остаётся реальным, но отправление так и не создаётся.
Пользователи воспринимают это как непонятное, непоследовательное поведение. Отсутствующие события выглядят как «ничего не произошло». Повторные события — как «почему меня списали дважды?». Команды поддержки тоже страдают, потому что трудно понять: проблема у нас, в сети или у партнёра.
Ретраи помогают, но одни только ретраи не гарантируют корректности. Если вы повторяете после таймаута, вы можете отправить то же событие дважды, потому что не знаете, дошёл ли первый запрос до партнёра. Если ретраи идут не по порядку, можно отправить «Order shipped» раньше чем «Order paid».
Эти проблемы обычно возникают из-за обычной конкуренции: несколько воркеров обрабатывают события параллельно, несколько серверов приложения пишут одновременно, и «best effort» очереди меняют порядок при нагрузке. Модели отказов предсказуемы: API падают или тормозят, сеть теряет пакеты, процессы падают в неподходящий момент, а ретраи создают дубликаты, если ничто не обеспечивает идемпотентность.
Паттерн outbox создан именно потому, что такие отказы нормальны.
Что такое паттерн outbox простыми словами
Паттерн outbox прост: когда приложение совершает важное изменение (например, создаёт заказ), оно в той же транзакции записывает небольшую запись «событие для отправки» в таблицу базы данных. Если транзакция коммитится, вы уверены, что и бизнес-данные, и запись события существуют вместе.
После этого отдельный воркер читает таблицу outbox и доставляет эти события до третьих сторон. Если API медленное, упало или таймаутится, основной пользовательский запрос всё равно проходит, потому что он не ждёт внешнего вызова.
Это предотвращает неловкие состояния, когда вы вызываете API внутри обработчика запроса:
- Заказ сохранён, но вызов к API провалился.
- Вызов к API прошёл, но приложение упало до сохранения заказа.
- Пользователь повторил попытку, и вы отправили одно и то же дважды.
Паттерн outbox в основном помогает при потерянных событиях, частичных сбоях (база данных ок, внешний API нет), случайных дублях и безопасных ретраях (вы можете пробовать позже без догадок).
Он не решит всё. Если полезная нагрузка неверна, бизнес-правила ошибочны или сторонний API отвергает данные, вам всё равно нужны валидация, хорошая обработка ошибок и способ просмотреть и исправить упавшие события.
Проектирование таблицы outbox в PostgreSQL
Хорошая таблица outbox специально скучная. Её должно быть легко записывать, читать и сложно испортить.
Ниже — практическая базовая схема, которую можно адаптировать:
create table outbox_events (
id bigserial primary key,
aggregate_id text not null,
event_type text not null,
payload jsonb not null,
status text not null default 'pending',
created_at timestamptz not null default now(),
available_at timestamptz not null default now(),
attempts int not null default 0,
locked_at timestamptz,
locked_by text,
meta jsonb not null default '{}'::jsonb
);
Выбор ID
Использование bigserial (или bigint) упрощает порядок и делает индексы быстрыми. UUID хороши для уникальности между системами, но они не сортируются по времени создания, что делает опрос менее предсказуемым и индексы более тяжёлыми.
Обычный компромисс: оставить id как bigint для порядка и добавить отдельный event_uuid, если нужен стабильный идентификатор для обмена между сервисами.
Индексы, которые важны
Воркеры будут опрашивать одни и те же шаблоны постоянно. Большинству систем нужны:
- Индекс вида
(status, available_at, id)для выборки следующих ожидающих событий в порядке. - Индекс по
(locked_at), если вы планируете истекать старые блокировки. - Индекс вида
(aggregate_id, id), если вы иногда доставляете события в порядке для одного агрегата.
Держите payloads аккуратными
Храните полезную нагрузку небольшой и предсказуемой. Сохраняйте то, что реально нужно получателю, а не всю вашу строку. Добавьте явно версию (например, в meta), чтобы безопасно эволюционировать поля.
Используйте meta для маршрутизации и отладочного контекста: tenant ID, correlation ID, trace ID и ключ дедупликации. Этот дополнительный контекст окупится позже, когда служба поддержки будет выяснять «что случилось с этим заказом?».
Как безопасно сохранять события вместе с бизнес-записью
Самое важное правило простое: записывайте бизнес-данные и outbox-событие в одной и той же транзакции. Если транзакция коммитится, обе записи существуют. Если откатывается — ни одной.
Пример: клиент оформляет заказ. В одной транзакции вы вставляете строку заказа, строки позиций и одну строку outbox вроде order.created. Если какой-то шаг падает, вы не хотите, чтобы событие «created» ушло в мир без самого заказа.
Одно событие или несколько?
Начните с одного события на бизнес-действие, когда это возможно. Так проще рассуждать и дешевле обрабатывать. Разделяйте на несколько событий только тогда, когда разные потребители действительно нуждаются в разном тайминге или полезной нагрузке (например, order.created для фулфилмента и payment.requested для биллинга). Генерация множества событий для одного клика увеличивает число ретраев, сложности порядка и обработку дублей.
Какую полезную нагрузку хранить?
Обычно выбор между:
- Снимком (snapshot): сохраняйте ключевые поля такими, какими они были в момент действия (сумма заказа, валюта, ID клиента). Это избегает дополнительных чтений позже и делает сообщение стабильным.
- Ссылкой (reference ID): храните только ID заказа, а воркер подтягивает детали позже. Это держит outbox компактным, но добавляет чтения и может меняться, если заказ редактируется.
Практичный компромисс — идентификаторы плюс небольшой снимок критичных значений. Это помогает получателям действовать быстро и облегчает отладку.
Держите границу транзакции короткой. Не вызывайте сторонние API внутри той же транзакции.
Доставка событий до сторонних API: цикл воркера
Когда события в outbox, нужен воркер, который их читает и вызывает сторонние API. Это часть, которая превращает паттерн в надёжную интеграцию.
Опрос обычно самый простой вариант. LISTEN/NOTIFY может снизить задержку, но добавляет подвижные части и всё равно нуждается в запасном варианте, когда уведомления пропускаются или воркер перезапускается. Для большинства команд равномерный опрос с небольшой партией проще в эксплуатации и отладке.
Безопасное захватывание строк
Воркеры должны захватывать строки так, чтобы два воркера не обрабатывали одно и то же событие одновременно. В PostgreSQL распространённый подход — выбрать партию с блокировками строк и SKIP LOCKED, затем пометить их как в работе.
Практический поток статусов:
pending: готово к отправкеprocessing: захвачено воркером (используйтеlocked_byиlocked_at)sent: доставлено успешноfailed: остановлено после максимума попыток (или отложено для ручной проверки)
Держите партии небольшими, чтобы не нагружать базу. Партия в 10–100 строк каждые 1–5 секунд — распространённая отправная точка.
Когда вызов успешен — помечайте строку как sent. Если неуспех — увеличивайте attempts, ставьте available_at в будущее (бэкофф), очищайте блокировку и возвращайте её в pending.
Логирование, которое помогает (без утечек секретов)
Хорошие логи делают ошибки управляемыми. Логируйте id из outbox, тип события, имя назначения, число попыток, время выполнения и HTTP-статус или класс ошибки. Избегайте тел запросов, заголовков аутентификации и полных ответов. Для корреляции храните безопасный request ID или хеш вместо сырых тел.
Правила порядка, которые работают в реальных системах
Многие команды начинают с «отправлять события в том порядке, в котором мы их создали». Подводный камень в том, что «тот же порядок» редко глобален. Если вы заставите одну глобальную очередь, один медленный клиент или ненадёжный API будут держать всех остальных в ожидании.
Практическое правило: сохраняйте порядок по группе, а не по всей системе. Выберите ключ группировки, который отражает, как внешняя сторона думает о ваших данных: customer_id, account_id или aggregate_id вроде order_id. Гарантируйте порядок внутри каждой группы и доставляйте много групп параллельно.
Параллельные воркеры без нарушения порядка
Запускайте несколько воркеров, но не допускайте, чтобы два воркера обрабатывали одну и ту же группу одновременно. Обычный подход — всегда доставлять самое раннее несентное событие для данного aggregate_id и позволять параллельность между разными агрегатами.
Держите правила захвата простыми:
- Доставляйте только самое раннее pending-событие для группы.
- Разрешайте параллельность между группами, но не внутри группы.
- Захватил одно событие — отправил — обновил статус — перешёл к следующему.
Когда одно событие блокирует остальные
Рано или поздно появится «ядовитое» событие, которое будет падать часами (неправильная полезная нагрузка, отозванный токен, сбой провайдера). Если вы строго соблюдаете порядок по группе, последующие события этой группы должны ждать, но другие группы должны продолжать.
Рабочая компромиссная стратегия — ограничить число попыток для одного события. После этого помечайте его failed и ставьте на паузу только эту группу до исправления причины. Это не позволит одному сломанному клиенту замедлить всех остальных.
Ретраи, не ухудшающие ситуацию
Ретраи — это то место, где хорошая настройка outbox делает систему надёжной или шумной. Цель простая: повторять, когда есть вероятность успеха, и быстро останавливаться, когда смысла нет.
Используйте экспоненциальный бэкофф и жёсткий лимит. Например: 1 минута, 2 минуты, 4 минуты, 8 минут, затем остановка (или продолжение с максимальной задержкой, например 15 минут). Всегда ставьте максимум попыток, чтобы одно плохое событие не забило систему навсегда.
Не все ошибки стоит ретраить. Чёткие правила:
- Ретрай: сетевые таймауты, сбои соединения, DNS-проблемы и HTTP 429 или 5xx.
- Не ретрай: HTTP 400 (bad request), 401/403 (проблемы аутентификации), 404 (неверный endpoint) или ошибки валидации, которые можно обнаружить до отправки.
Храните состояние ретраев в строке outbox: увеличивайте attempts, ставьте available_at для следующей попытки и записывайте короткое безопасное резюме ошибки (код статуса, класс ошибки, усечённое сообщение). Не храните полные полезные нагрузки или чувствительные данные в полях ошибок.
Лимиты скорости требуют отдельной логики. Если получаете HTTP 429, уважайте Retry-After, если он есть. Иначе делайте более агрессивный бэкофф, чтобы избежать «шторма» ретраев.
Дедупликация и основы идемпотентности
Если вы строите надёжные интеграции, предполагаете, что одно и то же событие может быть отправлено дважды. Воркер может упасть после HTTP-вызова, но до записи успеха. Таймаут может скрыть успешную отправку. Ретрай может перекрыться с медленной первой попыткой. Паттерн outbox уменьшает пропажи, но сам по себе не предотвращает дубликаты.
Самый надёжный подход — идемпотентность: повторная доставка даёт тот же результат, что и одна доставка. При вызове третьего API включайте идемпотентный ключ, стабильный для этого события и этого назначения. Многие API поддерживают заголовок; если нет — положите ключ в тело запроса.
Простой ключ: комбинация назначения и ID события. Для события с ID evt_123 используйте что-то вроде destA:evt_123.
С вашей стороны предотвращайте дубли, храня лог исходящих доставок и вводя уникальное ограничение (destination, event_id). Даже если два воркера соревнуются, только один сможет создать запись «мы отправляем это».
Webhook'и тоже дублируют
Если вы принимаете callback'и (например, «доставка подтверждена»), обрабатывайте их так же. Провайдеры ретраят и можно увидеть одно и то же сообщение несколько раз. Сохраняйте обработанные webhook ID или вычисляйте стабильный хеш от ID сообщения провайдера и отклоняйте повторные.
Как долго хранить данные
Держите строки outbox до тех пор, пока не зафиксирован успех (или окончательная ошибка, которую вы принимаете). Логи доставок держите дольше — они ваш аудиторский след, когда кто-то спрашивает «мы же это отправляли?».
Обычная политика:
- Строки outbox: удалять или архивировать после успеха плюс небольшой временной буфер (дни).
- Логи доставок: хранить неделями или месяцами, в зависимости от комплаенса и нужд поддержки.
- Идемпотентные ключи: хранить как минимум столько, сколько возможны ретраи (и дольше для дубликатов webhook).
Шаг за шагом: реализация паттерна outbox
Решите, что вы будете публиковать. Делайте события небольшими, сфокусированными и простыми для повторной отправки. Хорошее правило — один бизнес-факт на событие с достаточными данными для действия получателя.
Постройте фундамент
Выберите понятные имена событий (например, order.created, order.paid) и версионируйте схему полезной нагрузки (например, v1, v2). Версионирование позволяет добавлять поля без ломки старых потребителей.
Создайте PostgreSQL-таблицу outbox и добавьте индексы для запросов, которые воркер будет выполнять чаще всего, особенно (status, available_at, id).
Обновите поток записи так, чтобы бизнес-изменение и вставка в outbox происходили в одной транзакции. Это основное гарантийное свойство.
Добавьте доставку и контроль
Простой план реализации:
- Определите типы событий и версии полезной нагрузки, которые вы готовы поддерживать долго.
- Создайте таблицу outbox и индексы.
- Вставляйте строку outbox вместе с основным изменением данных.
- Постройте воркер, который захватывает строки, отправляет их третьей стороне и обновляет статус.
- Добавьте планирование ретраев с бэкоффом и состояние
failed, когда попытки исчерпаны.
Добавьте базовые метрики, чтобы замечать проблемы рано: отставание (возраст самого старого несентного события), скорость отправки и доля ошибок.
Простой пример: отправка событий заказа внешним сервисам
Клиент оформляет заказ в приложении. Внешне нужно два действия: биллинг-провайдер должен списать карту, а перевозчик — создать отправление.
С паттерном outbox вы не вызываете эти API внутри запроса оформления. Вместо этого сохраняете заказ и строку outbox в одной транзакции PostgreSQL, чтобы никогда не оказаться в состоянии «заказ сохранён, но нотификация не ушла» (или наоборот).
Типичная строка outbox для события заказа содержит aggregate_id (ID заказа), event_type вроде order.created и JSONB payload с суммами, позициями и адресом доставки.
Воркеры подбирают pending-строки и вызывают внешние сервисы (либо в заданном порядке, либо эмитируя отдельные события вроде payment.requested и shipment.requested). Если один провайдер недоступен, воркер фиксирует попытку, планирует следующий пробный запуск, сдвигая available_at, и идёт дальше. Заказ остаётся в системе, а событие будет повторно отправлено позже, не блокируя новые оформления.
Порядок обычно соблюдается «по заказу» или «по клиенту». Обеспечьте, чтобы события с одним aggregate_id обрабатывались по одной, тогда order.paid не придёт раньше, чем order.created.
Дедупликация не даст случайно списать дважды или создать две отправки. Отсылайте идемпотентный ключ, когда провайдер это поддерживает, и храните запись доставки, чтобы повтор после таймаута не вызвал второй эффект.
Быстрые проверки перед запуском
Прежде чем доверять интеграции критичным операциям, протестируйте края: падения, ретраи, дубли и несколько воркеров.
Проверки, которые ловят типичные ошибки:
- Подтвердите, что строка outbox создаётся в той же транзакции, что и бизнес-изменение.
- Убедитесь, что sender безопасно запускается в нескольких инстансах. Два воркера не должны отправлять одно и то же событие одновременно.
- Если порядок важен, сформулируйте правило в одной фразе и обеспечьте его стабильным ключом.
- Для каждого назначения решите, как предотвращаете дубли и как доказываете «мы это отправляли».
- Опишите выход: после N попыток переводите событие в
failed, храните последний краткий summary ошибки и предоставьте простое действие повторной обработки.
Реальность: Stripe может принять запрос, а воркер упадёт до записи успеха. Без идемпотентности повтор может привести к двойному списанию. С идемпотентностью и сохранённой записью доставки повтор безопасен.
Следующие шаги: выкатывание без сбоев приложения
Эти проекты чаще всего либо успешно выкатываются, либо тормозят на этапе rollout. Начинайте с малого, чтобы увидеть поведение в реальных условиях, не рискуя всей слоем интеграции.
Начните с одной интеграции и одного типа события. Например, сначала шлите только order.created одному поставщику, а всё остальное оставьте как есть. Это даст чистую базу для анализа пропускной способности, задержек и ошибок.
Делайте проблемы видимыми рано. Добавьте дашборды и алёрты по отставанию outbox (сколько событий ждёт и каков возраст самого старого) и по доле ошибок (сколько застряло в retry). Если вы можете ответить на вопрос «мы отстаём прямо сейчас?» за 10 секунд, вы поймаете проблему прежде, чем её заметят пользователи.
Имейте безопасный план повторной обработки до первого инцидента. Решите, что значит «reprocess»: повторить тот же payload, восстановить payload из текущих данных или отправить на ручную проверку. Документируйте, какие случаи безопасно повторно отправлять, а какие требуют вмешательства человека.
Если вы строите это на no-code платформе типа AppMaster (appmaster.io), структура остаётся той же: записываете бизнес-данные и строку outbox вместе в PostgreSQL, затем запускаете отдельный backend-процесс для доставки, ретраев и пометки событий как sent или failed.
Вопросы и ответы
Используйте паттерн outbox, когда действие пользователя обновляет вашу базу данных и одновременно должно запустить работу в другой системе. Он особенно полезен там, где таймауты, ненадёжные сети или сбои у третьих сторон могут приводить к ситуациям «сохранено у нас, но не у них».
Запись бизнес-данных и строки outbox в одной транзакции даёт одно простое гарантированное поведение: либо обе записи существуют, либо ни одной. Это предотвращает частичные ошибки вроде «API ответил успешно, но заказ не сохранился» или «заказ сохранён, но вызов API не прошёл».
Хороший набор полей по умолчанию: id, aggregate_id, event_type, payload, status, created_at, available_at, attempts, плюс поля блокировки типа locked_at и locked_by. Эти поля упрощают отправку, планирование ретраев и безопасную конкуренцию без излишней сложности таблицы.
Базовый индекс — (status, available_at, id), чтобы воркеры быстро брали следующую партию событий, готовых к отправке в порядке. Добавляйте индексы только при действительно нужных запросах, потому что лишние индексы замедляют вставки.
Для большинства команд опрос (polling) — самый простой и предсказуемый путь. Начните с небольших партий и короткого интервала, затем настройте по нагрузке и отставанию; оптимизации вроде LISTEN/NOTIFY можно добавить позже, но простой цикл легче дебажить.
Захватывайте строки с помощью блокировок уровня строки, чтобы два воркера не отправляли одно и то же событие одновременно — обычно через SKIP LOCKED. Далее пометьте строку как processing с меткой времени и ID воркера, отправьте её и в конце обновите статус на sent или верните в pending с будущим available_at.
Используйте экспоненциальный бэкофф с жёстким лимитом попыток и ретрайте только те ошибки, которые, скорее всего, временные. Таймауты, сетевые ошибки и HTTP 429/5xx — кандидаты на повтор; ошибки валидации и большинство 4xx следует считать окончательными, пока данные или конфигурация не будут исправлены.
Не ожидайте exactly-once: дубликаты всё ещё возможны (например, воркер упал после HTTP-вызова, но до записи успеха). Используйте идемпотентный ключ, стабильный для конкретного назначения и события, и храните запись доставки с уникальным ограничением (destination, event_id), чтобы даже при гонках два отправителя не создали двоющуюся запись.
Сохраняйте порядок внутри группы, а не глобально. Используйте ключ группировки вроде aggregate_id (ID заказа) или customer_id, обрабатывайте по одному событию за раз для группы и разрешайте параллелизм между группами — это не даст одному медленному клиенту блокировать всех остальных.
После максимально допустимого числа попыток помечайте событие как failed, сохраняйте краткое безопасное описание ошибки и останавливайте обработку последующих событий для той же группы до ручного вмешательства. Это ограничит зону поражения и предотвратит бесконечные ретраи, позволяя другим группам двигаться дальше.


