EventForge
Обработка заказов через outbox и идемпотентные consumers.
Интерактивная архитектура
Доставка заказа и восстановление.
Заказ + Outbox: одна транзакция PostgreSQL
Заказ + Outbox: одна транзакция PostgreSQL
Поступает запрос
Клиент отправляет заказ. Проследите order ID и event ID через транзакцию и доставку.
- Заказ
- не создан
- Outbox
- не записан
- Бизнес-эффект
- 0 изменений
- Kafka offset
- не фиксирован
Принимает запрос и назначает идентификаторы заказу и его событию.
Прочитать все шаги
Зафиксировать заказ, доставить событие, применить изменение и затем зафиксировать offset.
Клиент отправляет заказ. Проследите order ID и event ID через транзакцию и доставку.
API записывает заказ и событие outbox в одной открытой транзакции. Пока ни одна запись не зафиксирована.
Commit надёжно сохраняет обе строки. При rollback relay не увидит ни одну.
Relay читает зафиксированное событие. В реальном relay нужна также безопасная стратегия конкурентного захвата.
Relay публикует event-42 с ключом order-42. В этой модели ключ выбирает партицию p1.
После ответа брокера relay помечает запись отправленной. Эта отметка не атомарна с публикацией.
Worker A владеет p1. Другие участники могут владеть p0 или p2, но не обрабатывают p1 параллельно в этой группе.
Обработчик вставляет ID обработанного события. Новый ID разрешает бизнес-изменение в той же транзакции.
ID и статус заказа фиксируются вместе. Бизнес-эффект один; Kafka offset ещё не фиксирован.
Только после успеха БД consumer фиксирует offset. Это at-least-once с идемпотентным эффектом, не распределённая exactly-once транзакция.
Повторить доставку. Сохранить один эффект.
Проверьте проектируемое восстановление в интерактивной модели. Выберите сбой, затем запустите сценарий или пройдите его по шагам.
Одно событие, две доставки
Детерминированная демонстрация- Логические события
- 0
- Доставки
- 0
- Бизнес-изменения
- 0
Обзор
Проектирую учебную систему обработки заказов с transactional outbox, Kafka и идемпотентными обработчиками.
Задача
Запись заказа в базу и публикация события — независимые операции. Сбой между ними может оставить downstream-сервисы без информации о сохранённом заказе.
Мой вклад
Прорабатываю контракт доставки, окна отказа и стратегию восстановления. Backend в разработке; в этом разборе собраны архитектурные решения.
Решения и компромиссы
- Записывать заказ и outbox в одной транзакции PostgreSQL. Relay публикует зафиксированные записи в Kafka.
- Использовать ID заказа как ключ партиции для сохранения порядка внутри неё. Consumer groups распределяют партиции между обработчиками.
- Атомарно сохранять ID обработанного события и бизнес-изменение. Повторять временные ошибки, а исчерпавшие попытки события отправлять в DLQ.
Доставка at-least-once упрощает восстановление, но допускает дубликаты. Идемпотентность требует надёжного хранилища, outbox — очистки и сопровождения relay. Порядок Kafka действует внутри партиции, не глобально.
Две записи. Окно для сбоя.
В этой схеме PostgreSQL и Kafka не разделяют прикладную транзакцию. Между записью заказа и публикацией остаётся окно: процесс может умереть после commit БД, но до отправки брокеру. Обратный порядок лишь меняет направление несогласованности.
Проектирую надёжное хранение заказа, восстановление публикации, порядок внутри партиции и безопасную повторную доставку. Цель — at-least-once доставка с идемпотентным бизнес-эффектом.
Outbox закрывает конкретное окно.
Заказ и outbox записываются одной локальной транзакцией. Relay читает зафиксированные записи, отправляет исходный ID и ключ заказа, затем отмечает подтверждение. Если он падает после отправки, запись публикуется снова. Дубликат — осознанная цена восстановления.
Для нескольких relay-workers нужны захват строк, ограничение активной работы и очистка outbox. Сравниваю polling с блокировками и CDC: более простое развёртывание против меньшего числа опросов и дополнительной инфраструктуры.
-- Illustrative SQL, not extracted production code
BEGIN;
INSERT INTO orders (id, status) VALUES ($1, 'created');
INSERT INTO outbox (event_id, order_id, payload)
VALUES ($2, $1, $3);
COMMIT;
-- Publish only committed rows. Mark sent after broker ack.Одна фиксация БД защищает два связанных факта. Публикация в Kafka происходит позже.
Сохранённое изменение может прийти снова.
Самый показательный сбой — после бизнес-транзакции, но до фиксации offset Kafka. Новый worker получает запись повторно. Уникальное ограничение на (consumer, event_id) защищает от второго эффекта; in-memory set теряет защиту при рестарте.
Метка обработанного события и бизнес-изменение должны находиться в одной транзакции. При конфликте уникальности изменение не выполняется. Offset фиксируется только после надёжного решения. Offset не является атомарным продолжением commit PostgreSQL.
-- Illustrative handler transaction
BEGIN;
-- Requires UNIQUE (consumer, event_id).
WITH accepted AS (
INSERT INTO processed_events (consumer, event_id)
VALUES ($1, $2) ON CONFLICT DO NOTHING
RETURNING event_id
)
UPDATE orders SET status = 'processed'
WHERE id = $3 AND EXISTS (SELECT 1 FROM accepted);
COMMIT;
-- Then commit Kafka offset; never before DB success.Результат INSERT разрешает изменение. Если ожидаемый заказ не найден, транзакцию нужно откатить, не сохраняя метку неприменённого эффекта.
Порядок в партиции и стратегия повторов.
Стабильный ключ заказа направляет связанные события в одну партицию Kafka. В каждый момент ей владеет один участник consumer group. Это не глобальный порядок; число consumer сверх числа партиций не добавляет параллелизма топику.
Inline-повторы приостанавливают партицию и сохраняют порядок, но медленная запись блокирует следующие. Retry-топики уменьшают блокировку, меняя семантику порядка. Для DLQ необходимо подтверждение до продвижения исходного offset; поздний replay может нарушить порядок относительно последующих событий.
Проверять окна отказа.
Предлагаемая матрица backend-тестов: остановить relay после ответа брокера до отметки sent; остановить consumer после БД до offset; доставить дубликаты конкурентно; исчерпать повторы. Проверять надёжные строки и offsets, не только HTTP-ответы.
В backend-тестах планирую проверять сохранённое бизнес-состояние после каждого искусственного сбоя. Конкурентные дубликаты должны оставлять одну метку обработки и одно изменение; рестарт не должен менять этот результат.
Метрики очереди и восстановления.
Планирую отслеживать возраст старейшей outbox-записи, ошибки публикации, consumer lag, конфликты идемпотентности, повторы и возраст DLQ. Order ID и event ID связывают трассы, но не попадают в высококардинальные labels метрик.
Процедура восстановления должна определять владельца разбора DLQ, сохранение ID при replay и допустимые повторные бизнес-состояния. Без этого DLQ — хранилище, а не механизм восстановления.
Результаты и следующие шаги
В проектируемой схеме повторная доставка сохраняет ID события, а метка обработки фиксируется вместе с бизнес-изменением. Следующий этап — интеграционные backend-тесты на окна отказа.
Главный вывод
Гарантия доставки — контракт с операционными последствиями. Обработка дубликатов, повторы, наблюдаемость и восстановление должны входить в проектирование с самого начала.