open atlas
↑ К треку
Архитектурные паттерны ARCH · 09 · 03

Согласованность в конечном счёте как архитектурный выбор

Проектирование для eventual consistency означает решение проблемы двойной записи через transactional outbox, идемпотентность консюмеров и явный дизайн read-your-writes. Согласованность — структурное решение, а не послесловие.

ARCH Senior ◷ 25 min
Уровень
ОсновыJuniorMiddleSenior

OrderService биллинговой платформы сохранял состояние заказа в PostgreSQL, а затем публиковал событие OrderPlaced в Kafka. Простая схема. Но под нагрузкой сервис периодически падал после записи в базу данных и до публикации в Kafka. Событие терялось. Downstream-сервисы — инвентарь, платёж, доставка — никогда его не получали. Заказы зависали в промежуточном состоянии: подтверждённые в базе данных, невидимые для всех остальных. Команда добавила логику ретраев. Теперь после падения и перезапуска сервис иногда публиковал событие дважды. Downstream-сервисы обрабатывали один и тот же заказ дважды, двойным списанием клиентов. Проблема двойной записи — два независимых I/O-операции без гарантии атомарности — имела два режима сбоя, и исправление одного обнажало другой.

Проблема двойной записи

Каждый раз, когда сервис должен обновить собственное состояние И уведомить остальную систему, он сталкивается с двумя независимыми I/O-операциями:

  1. Запись в базу данных (источник истины сервиса)
  2. Публикация в брокер сообщений (сигнал для downstream-сервисов)

Эти две записи не имеют гарантии атомарности. Возможен любой из четырёх исходов:

Запись в БДПубликация в брокерЭффект
успехуспехкорректно
успехсбойсобытие потеряно — downstream завис
сбойуспехфантомное событие — downstream действует на несохранённые данные
сбойсбойчистый сбой — без вреда, ретрай безопасен

Третий случай (фантомное событие) особенно опасен: downstream-сервисы обрабатывают событие для заказа, которого нет в базе данных источника. В биллинге это означает списание клиента за несуществующий заказ.

Первые два случая сбоя — потерянные события и фантомные события — нельзя устранить добавлением логики ретраев. Логика ретраев, повторно публикующая после падения, исправляет «событие потеряно при падении», но вводит «событие опубликовано дважды при ретрае». Проблема двойной записи структурная, а не операционная.

Викторина

Разработчик предлагает решить проблему двойной записи, оборачивая запись в БД и публикацию в Kafka в try/catch: при сбое публикации откатывать транзакцию БД. Старший инженер говорит, что это не решает проблему. Почему?

Transactional Outbox

Transactional outbox решает проблему двойной записи, убирая вторую запись из пути обработки запроса сервиса. Вместо прямой публикации в Kafka сервис записывает событие в таблицу outbox в той же транзакции базы данных, что и обновление состояния. Транзакция БД является единственным источником истины.

BEGIN;
INSERT INTO orders (id, customer_id, status) VALUES ($1, $2, 'pending');
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload)
  VALUES (gen_random_uuid(), 'order', $1, 'OrderPlaced', $3);
COMMIT;

Если транзакция БД коммитится, и строка заказа, и строка outbox долговечно записаны. Если она падает, ни одна не записана. Атомарность гарантирована базой данных.

Отдельный relay-процесс outbox читает незаопубликованные строки outbox и публикует их в Kafka. После успешной публикации relay помечает строки как обработанные. Relay может быть:

  • На основе polling: фоновый поток или сервис, запрашивающий SELECT * FROM outbox WHERE published_at IS NULL LIMIT 100 с интервалом
  • На основе CDC (change-data-capture): инструменты вроде Debezium читают WAL (write-ahead log) базы данных и стримят вставки в outbox напрямую в Kafka без polling
lesson.inset.note

Паттерн outbox перекладывает проблему надёжности на relay. Relay гарантирует at-least-once доставку: читает строку outbox, публикует в Kafka и помечает строку как обработанную. Если relay падает после публикации, но до пометки, он повторно опубликует при перезапуске. Это намеренно — это источник гарантии at-least-once. Идемпотентные консюмеры обрабатывают дубликат. Вот почему идемпотентность структурна, а не опциональна: корректность relay outbox зависит от того, что консюмеры терпимы к дубликатам.

Викторина

Биллинговая платформа реализует transactional outbox. Разработчик говорит: «Теперь, когда outbox гарантирует exactly-once доставку, нашим консюмерам не нужно обрабатывать дубликаты». Что не так с этим утверждением?

Идемпотентные консюмеры

At-least-once доставка — не сбой доставки, а гарантия. Каждый консюмер в событийно-ориентированной системе должен быть спроектирован для обработки одного и того же события несколько раз с получением того же результата, что и при однократной обработке.

Стандартный структурный подход: поддерживать таблицу дедупликации, записывающую обработанные ID событий. Перед обработкой события проверить, есть ли его ID уже в таблице. Если да — пропустить. Если нет — обработать и вставить.

BEGIN;
-- Проверка дубликата
SELECT 1 FROM processed_events WHERE event_id = $1;
-- Если строки нет: обработать событие и записать его
INSERT INTO processed_events (event_id, processed_at) VALUES ($1, NOW());
UPDATE inventory SET reserved = reserved + $2 WHERE product_id = $3;
COMMIT;

Проверка дедупликации и бизнес-обновление должны быть в одной транзакции. Если они разделены, падение между бизнес-обновлением и вставкой записи дедупликации порождает обработанное событие без записи дедупликации — следующая доставка обработает его снова.

Read-your-writes при eventual consistency

После отправки заказа пользователь ожидает увидеть его немедленно в дашборде. Если дашборд читает из асинхронной проекции, пополняемой событиями, заказ может не появиться несколько сотен миллисекунд. Пользователь отправил, получил успешный ответ, а UI показывает пусто.

Решения:

  • Read-after-write из стороны записи: для собственных недавних записей пользователя — читать напрямую из сервиса, владеющего состоянием (модель записи), минуя асинхронную проекцию. Требует, чтобы UI знал, у какого сервиса запрашивать данные о собственных недавних данных пользователя.
  • Оптимистичное обновление UI: обновить UI немедленно при отправке, до обработки события downstream. При отклонении сервером — откатить UI. Это не исправляет underlying eventual consistency, но скрывает от пользователя его задержку.
  • Sticky sessions к одной реплике: для read-реплик модели записи маршрутизировать чтения пользователя после записи к той же реплике, получившей запись, пока не догонит репликация.
Викторина

Команда строит дашборд платежей, читающий из read-модели, обновляемой событиями Kafka. Продакт-менеджер сообщает: «После подтверждения платежа пользователи иногда не видят его в дашборде 2-3 секунды». Лид команды говорит, что это «работает как спроектировано». Разработчик хочет это исправить. Какое исправление архитектурно корректно?

Вспомните перед уходом
  1. 01
    Каковы два опасных режима сбоя наивного паттерна двойной записи (запись в БД, затем публикация в брокер)?
  2. 02
    Опишите transactional outbox в одном абзаце: что записывается куда и кто отвечает за доставку?
  3. 03
    Почему проверка дедупликации и бизнес-обновление в идемпотентном консюмере должны быть в одной транзакции базы данных?
Итог

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

Transactional outbox полностью убирает вторую запись из пути обработки запроса. Событие записывается как строка outbox в той же транзакции базы данных, что и обновление состояния. Relay обрабатывает доставку асинхронно. Атомарность гарантирована базой данных; в конечном счёте доставка гарантирована поведением повтора relay.

Поведение повтора relay является источником at-least-once доставки — а at-least-once означает, что консюмеры будут получать дубликаты. Идемпотентность — не nice-to-have; это контракт, делающий паттерн outbox рабочим. Консюмер, обрабатывающий одно и то же событие дважды и производящий двойное списание, не является идемпотентным. Таблица дедупликации, транзакционно привязанная к бизнес-обновлению, является структурным исправлением.

Eventual consistency при таком дизайне — осознанный выбор: система принимает, что downstream-состояние отстаёт от источника на миллисекунды-секунды, в обмен на доступность, пропускную способность и свободу эволюции сервисов независимо. Read-your-writes — наиболее частый видимый пользователю симптом этого отставания, и он требует своего структурного ответа — а не sleep и надежды.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.