Практика: надёжный отправитель outbox (Outbox relay)
Брокер то отказывает, то теряет подтверждения, а события одного заказа должны прийти по порядку и ровно один раз в смысле результата: отправитель повторяет, получатель отбрасывает дубли.
Базовый пример: Outbox. Там отправитель за один проход публикует всё, а брокер либо работает, либо нет. Здесь брокер падает на конкретной попытке, подтверждение теряется уже после доставки, отправитель берёт сообщения пачками и не даёт событиям одного заказа обогнать друг друга, а получатель обрабатывает каждое сообщение один раз, сколько бы раз оно ни пришло.
Задача
Сервис заказов уже пишет события в таблицу outbox, а фоновый отправитель раз в секунду переносит их в брокер. Первая версия отправителя публиковала строки по одной и на любой ошибке останавливалась. Потом начались странности: служба доставки дважды создала отгрузку на один заказ, а однажды получила OrderPaid раньше OrderPlaced и отбросила оплату как «заказ не найден».
Дубль появился так: брокер принял сообщение, но ответ потерялся по сети. Отправитель счёл публикацию неудачной, не отметил строку и на следующем проходе отправил её снова. Это не ошибка отправителя, а свойство схемы: пока отметка «отправлено» и публикация живут в разных системах, доставка бывает только «как минимум один раз». Перестановка же случилась, когда отправитель научился пропускать упавшее сообщение и идти дальше: следующее событие того же заказа ушло раньше.
Что урезано
- Таблица outbox — словарь в памяти. Транзакцию с бизнес-данными показывает базовый пример, здесь только отправка.
- Сообщение — номер, заказ (агрегат) и тип события, без полезной нагрузки и заголовков.
- Брокер — подделка
FlakyBroker: доставляет сообщение получателю прямо в вызове и по сценарию отказывает на N-й попытке до доставки (Down) или после неё (LostAck). - Проходы отправителя вызывает тест. В проекте это таймер или цикл в фоновом процессе, пауза между проходами и предел попыток.
- Таблица обработанных
idу получателя — множество в памяти. В проекте это таблица с уникальным ключом в базе получателя, и запись в неё идёт в одной транзакции с его изменениями.
Решение
Хранилище OutboxTable отдаёт неотправленные сообщения в порядке записи, не больше limit за раз, и удаляет из выборки помеченные markSent.
Отправитель OutboxRelay за один проход runOnce:
- берёт пачку из
batchSizeсамых старых неотправленных сообщений; - публикует их по порядку и помечает отправленным каждое, на которое брокер ответил успехом;
- при отказе брокера оставляет сообщение неотправленным и запоминает его агрегат как заблокированный: остальные сообщения этого заказа в этом проходе пропускаются, чтобы не обогнать упавшее;
- сообщения других заказов публикуются дальше: один сбойный заказ не держит всех.
Ретрай — это следующий проход: упавшее сообщение снова окажется первым в выборке. Получатель IdempotentConsumer хранит id обработанных сообщений и повтор пропускает, увеличивая счётчик дублей.
Структура
Отправитель держит на время прохода одно множество: заблокированные агрегаты. Между проходами он ничего не помнит, всё состояние — в таблице: что не помечено, то и надо отправить. Поэтому отправитель можно перезапустить в любой момент, и он продолжит с того же места.
Брокер и сбои
Подделка брокера 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(...) |
| Go | RunOnce возвращает отправленные id и errors.Join всех отказов прохода; Unsent отдаёт копию среза, потому что MarkSent удаляет из него во время обхода |
| Python | Enum для сбоев, 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, и отсутствие сбоя не нужно обрабатывать отдельно.
Связанные темы
- Outbox (базовый пример)
- Практика Наблюдателя: доменные события заказа — события в памяти без гарантий доставки; outbox добавляет к ним «не потерять»