EventForge
Событие пришло дважды. Что должно произойти дальше?
Интерактивная архитектура
Одно событие. Все последствия.
Иллюстративная архитектура · в работе · без живого backend
Поступает запрос
Клиент создаёт заказ. Это иллюстрация, а не трафик живого backend.
- Заказ
- не создан
- Outbox
- не записан
- Бизнес-эффект
- 0 изменений
- Kafka offset
- не фиксирован
Принимает запрос клиента. В модели order-42 и event-42 — иллюстративные идентификаторы.
Прочитать все шаги
Зафиксировать заказ, доставить событие, применить изменение и затем зафиксировать offset.
Клиент создаёт заказ. Это иллюстрация, а не трафик живого backend.
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 транзакция.
Обзор
Учебное исследование обработки заказов: transactional outbox, брокер сообщений и идемпотентные обработчики. Схема ниже — иллюстративная модель, а не развёрнутая система.
Задача
Запись заказа в базу и публикация события — независимые операции. Сбой между ними может оставить downstream-сервисы без информации о сохранённом заказе.
Мой вклад
Здесь определены учебные цели и архитектура. Реализация, развёртывание и завершённые этапы пока не подтверждены; проект представлен как работа в процессе.
Решения и компромиссы
- Записывать заказ и outbox в одной транзакции PostgreSQL. Relay публикует зафиксированные записи в Kafka.
- Использовать ID заказа как ключ партиции для сохранения порядка внутри неё. Consumer groups распределяют партиции между обработчиками.
- Атомарно сохранять ID обработанного события и бизнес-изменение. Повторять временные ошибки, а исчерпавшие попытки события отправлять в DLQ.
Доставка at-least-once упрощает восстановление, но допускает дубликаты. Идемпотентность требует надёжного хранилища, outbox — очистки и сопровождения relay. Порядок Kafka действует внутри партиции, не глобально.
Две записи. Окно для сбоя.
В этой схеме PostgreSQL и Kafka не разделяют прикладную транзакцию. Между записью заказа и публикацией остаётся окно: процесс может умереть после commit БД, но до отправки брокеру. Обратный порядок лишь меняет направление несогласованности.
Учебные ограничения явны: надёжное состояние заказа, восстанавливаемое намерение публикации, порядок внутри партиции и безопасная повторная доставка. End-to-end exactly-once не заявлено. Полнота реализации и production-нагрузка не подтверждены.
Outbox закрывает конкретное окно.
Заказ и outbox записываются одной локальной транзакцией. Relay читает зафиксированные записи, отправляет исходный ID и ключ заказа, затем отмечает подтверждение. Если он падает после отправки, запись публикуется снова. Дубликат — осознанная цена восстановления.
Реальному relay нужны конкурентный захват, backpressure, очистка и наблюдение за возрастом неотправленных строк. 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, не результаты тестов предоставленного Go-репозитория. Браузерные тесты проверяют объясняющую модель: паузу, replay, узлы, все сценарии, очистку при навигации и reduced motion.
Наблюдать очередь, а не красивый счётчик.
Полезные проектные сигналы: возраст старейшей outbox-записи, ошибки публикации, consumer lag, конфликты идемпотентности, повторы и возраст DLQ. Связывать order ID и event ID в трассах; не помещать высокую кардинальность в labels метрик. Это не живая телеметрия.
Процедура восстановления должна определять владельца разбора DLQ, сохранение ID при replay и допустимые повторные бизнес-состояния. Без этого DLQ — хранилище, а не механизм восстановления.
Что говорят данные
Production-результаты и бенчмарки не заявлены. Интерактивная модель объясняет ожидаемое поведение, но не подтверждает работу реализации на Go/Kafka.
Главный вывод
Гарантия доставки — контракт с операционными последствиями. Обработка дубликатов, повторы, наблюдаемость и восстановление должны входить в проектирование с самого начала.