handbookНаписать

patterns/enterprise/outbox/practice/README.md · практика · 4 языка · 23 теста

Практика: надёжный отправитель outbox (Outbox relay) ​

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

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

Задача ​

Сервис заказов уже пишет события в таблицу outbox, а фоновый отправитель раз в секунду переносит их в брокер. Первая версия отправителя публиковала строки по одной и на любой ошибке останавливалась. Потом начались странности: служба доставки дважды создала отгрузку на один заказ, а однажды получила OrderPaid раньше OrderPlaced и отбросила оплату как «заказ не найден».

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

Что урезано ​

  • Таблица outbox — словарь в памяти. Транзакцию с бизнес-данными показывает базовый пример, здесь только отправка.
  • Сообщение — номер, заказ (агрегат) и тип события, без полезной нагрузки и заголовков.
  • Брокер — подделка FlakyBroker: доставляет сообщение получателю прямо в вызове и по сценарию отказывает на N-й попытке до доставки (Down) или после неё (LostAck).
  • Проходы отправителя вызывает тест. В проекте это таймер или цикл в фоновом процессе, пауза между проходами и предел попыток.
  • Таблица обработанных id у получателя — множество в памяти. В проекте это таблица с уникальным ключом в базе получателя, и запись в неё идёт в одной транзакции с его изменениями.

Решение ​

Хранилище OutboxTable отдаёт неотправленные сообщения в порядке записи, не больше limit за раз, и удаляет из выборки помеченные markSent.

Отправитель OutboxRelay за один проход runOnce:

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

Ретрай — это следующий проход: упавшее сообщение снова окажется первым в выборке. Получатель IdempotentConsumer хранит id обработанных сообщений и повтор пропускает, увеличивая счётчик дублей.

Структура ​

unsent, markSent

publish

deliver

OutboxMessage

+id: int

+aggregateId: string

+type: string

OutboxTable

-unsent: map

+add(aggregateId, type)

+unsent(limit) : OutboxMessage[]

+markSent(id)

«interface»

MessageBroker

+publish(message)

FlakyBroker

-deliver: fn

-failures: map

-attempt: int

+publish(message)

OutboxRelay

-batchSize: int

+runOnce() : int[]

IdempotentConsumer

-processed: set

+log: string[]

+duplicates: int

+handle(message)

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

Брокер и сбои ​

Подделка брокера FlakyBroker считает попытки публикации и по сценарию failures (номер попытки -> сбой) ведёт себя по-разному. Down — брокер не ответил, сообщение до получателя не дошло. LostAck — сообщение доставлено, но отправитель получил ошибку: для него это неотличимо от Down, поэтому он не помечает строку и повторяет. Получатель видит то же сообщение второй раз, с тем же id.

Получатель ​

Получатель не может отличить новое сообщение от повтора по содержимому: два одинаковых события «оплачено» бывают и честными. Отличает их id сообщения, присвоенный в outbox: он один и тот же при каждой повторной публикации. Получатель сначала проверяет id в таблице обработанных, потом записывает его и делает работу. Так «как минимум один раз» на стороне брокера превращается в «ровно один раз» на стороне результата.

Как это собирается ​

Отправитель - отдельный фоновый процесс. Его корень сборки читает из конфига размер пачки и предел проходов, создаёт OutboxRelay над той же таблицей, куда сервис заказов пишет события, и клиент брокера. По ту сторону брокера сидит служба доставки с IdempotentConsumer: в примере брокер передаёт ей сообщения через deliver, в приложении это отдельный сервис со своей таблицей обработанных id.

Цикл процесса зовёт runOnce, пока в таблице есть неотправленное, но не больше заданного числа проходов (в приложении между проходами пауза). На первом проходе подтверждение для order-1 OrderPlaced теряется: сообщение уже доставлено, но отправитель этого не знает, оставляет строку и блокирует order-1 до конца прохода, поэтому OrderPaid не обгоняет OrderPlaced. Сообщение order-2 уходит. Второй проход публикует 1 повторно, затем 3 и 4. Служба доставки видит повтор id 1 и пропускает его: события каждого заказа пришли по порядку и ровно один раз. Путь вызова: цикл процесса -> отправитель -> outbox -> брокер -> получатель.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

Получатель одинаков по смыслу везде: множество обработанных id, журнал "order-1 OrderPlaced" и счётчик дублей. В тестах один и тот же набор: order-1 OrderPlaced (1), order-2 OrderPlaced (2), order-1 OrderPaid (3).

ЯзыкОсобенность
PHPСбой — enum Failure; получатель подключается к брокеру через first-class callable $consumer->handle(...)
GoRunOnce возвращает отправленные id и errors.Join всех отказов прохода; Unsent отдаёт копию среза, потому что MarkSent удаляет из него во время обхода
PythonEnum для сбоев, dict хранит порядок вставки; pytest.param(..., id=...) даёт случаям читаемые имена
TypeScriptСбой — объединение строк "down" | "lost-ack", сценарий — Record<number, Failure>; Map хранит порядок вставки

Запуск: make test T=patterns/enterprise/outbox/practice

Что проверяют тесты ​

Тесты одинаковы во всех языках: одна таблица из пяти случаев, в каждом два прохода отправителя, проверяются id, отправленные в каждом проходе, журнал получателя и число дублей.

  • Всё за один проход: [1, 2, 3], потом пусто; журнал в порядке записи.
  • Брокер отказал на первой попытке: в первом проходе ушло только [2] — сообщение 3 того же заказа не обогнало упавшее 1. Во втором проходе [1, 3]: ретрай сработал, порядок внутри order-1 сохранён, а order-2 не ждал.
  • Отказ на третьей попытке: [1, 2], потом [3] — сбой посреди прохода не трогает уже отправленное.
  • Потеря подтверждения: получатель уже получил сообщение 1, но отправитель его не отметил и повторил. Получатель видит дубль, пропускает его (duplicates = 1), в журнале каждое событие один раз.
  • Пачка из двух: [1, 2], потом [3].

Где граница ​

  • Отправитель опрашивает таблицу. Когда сообщений много и задержка важна, вместо опроса читают журнал транзакций базы (CDC: Debezium для PostgreSQL и MySQL): каждая вставка в outbox сама становится событием, без запросов и отметок. Таблица и гарантии те же, меняется только способ её читать.
  • Отправленные строки копятся. Их удаляют сразу при отметке или фоновой очисткой старше N дней; хранить дольше стоит, только если по outbox расследуют инциденты. Таблицу обработанных id у получателя тоже чистят по сроку, который больше максимального времени повторной доставки.
  • Сообщение, которое падает всегда (не сериализуется, брокер отвергает формат), заблокирует свой заказ навсегда. Нужен предел попыток и «карантин» (dead letter) с оповещением.
  • Порядок здесь держится в одном экземпляре отправителя. Если отправителей несколько, их разводят по агрегатам (партиции по aggregateId, блокировка строк FOR UPDATE SKIP LOCKED), иначе двое возьмут сообщения одного заказа.
  • Если событие не обязано дойти или получатель в том же процессе, всё это лишнее: хватит диспетчера из практики Наблюдателя.

Отличия между языками ​

  • PHP — enum Failure без значений хватает для сценария; array<int, Failure> с ключами-попытками читается как таблица. Массив unsent с ключом id хранит порядок вставки, unset удаляет за O(1), а foreach в отправителе идёт по копии из unsent($limit).
  • Go — ошибки не глотаются: RunOnce собирает отказы в errors.Join, каждый обёрнут с номером сообщения, а ErrPublishFailed находится через errors.Is. Нулевое значение Consumer готово к работе: карта создаётся при первом сообщении.
  • Python — failure is Failure.DOWN сравнивает члены Enum по идентичности; dict с порядком вставки заменяет упорядоченную таблицу, list(...)[:limit] даёт пачку-копию.
  • TypeScript — catch получает unknown, поэтому отправитель проверяет instanceof PublishFailed и пробрасывает остальное. С noUncheckedIndexedAccess чтение failures[attempt] имеет тип Failure | undefined, и отсутствие сбоя не нужно обрабатывать отдельно.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS