Привет, Хабр! Меня зовут Павел, я ведущий разработчик. После статьи про Kafka хочется продолжить тему продовых интеграций, но без попытки написать архитектурную энциклопедию на 40 минут чтения.
Сегодня про схему, которая на диаграмме выглядит очень спокойно:
Write DB -> Outbox -> Kafka -> Consumer -> Read DB
Одна база принимает изменения. Другая отвечает на чтение. Между ними события. На словах — красота. В проде — lag, backfill, дубли, версии событий и вопрос от бизнеса: “Почему я нажал сохранить, а в отчете еще старое?”
Зачем вообще разделять чтение и запись
Обычно все начинается с одной базы. Это нормально. Одна база часто живет долго и счастливо, пока ее не начинают одновременно просить:
-
быстро принимать изменения;
-
держать транзакции и бизнес-инварианты;
-
строить тяжелые отчеты;
-
отдавать списки с фильтрами;
-
делать поиск по полям, которые вчера “точно не понадобятся”;
-
не грустить под нагрузкой.
Если проблема только в том, что чтение давит на primary, иногда достаточно read replica. Это более простой путь.
Но replica повторяет структуру write-базы. Если для чтения нужна другая форма данных — денормализованная, предрассчитанная, заточенная под экран, поиск или отчет — появляется смысл в отдельной read model.
Коротко:
|
Подход |
Когда подходит |
|---|---|
|
Read replica |
Нужно разгрузить чтение, схема данных та же |
|
Read model |
Нужно другое представление данных под запросы |
|
Отдельная read DB |
Нужен другой движок: ClickHouse, Elastic, Mongo, отдельный Postgres |
Важно: CQRS не обязан означать две физические базы. Но в этой статье говорим именно про вариант, где write model и read model живут в разных хранилищах.
Как это обычно выглядит
Поток записи:
-
Command API принимает команду.
-
В write DB меняется бизнес-сущность.
-
В той же транзакции пишется запись в outbox.
-
Outbox relay публикует событие в Kafka.
-
Consumer читает событие.
-
Consumer обновляет read DB.
-
Query API читает из read DB.
Почему outbox пишется в той же транзакции, что и бизнес-изменение?
Потому что иначе есть неприятный разрыв:
|
Сценарий |
Что случилось |
|---|---|
|
БД обновили, Kafka отправить не успели |
Изменение есть, события нет |
|
Kafka отправили, БД откатилась |
Событие есть, изменения нет |
|
Бизнес-строка и outbox в одной транзакции |
Если изменение зафиксировано, событие не забыли |
Outbox не делает всю систему magically exactly-once. Он решает конкретную задачу: событие не теряется относительно изменения в базе.
Главная цена: eventual consistency
После разделения write DB и read DB появляется окно несогласованности.
Пользователь нажал “сохранить”. Write DB уже обновлена. API ответил 200 OK. Но read DB еще не обновилась: событию нужно пройти outbox, Kafka, consumer и projection logic.
Это нормально, если окно измерено и согласовано. Это плохо, если команда делает вид, что окна нет.
Не надо объяснять бизнесу так:
У нас CQRS, поэтому read side eventually consistent.
Лучше так:
Изменение сохраняется сразу. В отчетах и поиске оно появляется с задержкой. Нормальное окно — до N секунд. Если больше, это инцидент, у нас есть метрика и алерт.
Eventual consistency — это не баг сам по себе. Баг — не знать, насколько eventual ваша consistency.
Что мониторить
Один Kafka lag не отвечает на все вопросы. Consumer может отставать по сообщениям, а пользователь страдает от задержки в секундах. Или наоборот: сообщений мало, но одно старое событие застряло и портит read model.
Минимальный набор метрик:
|
Метрика |
Зачем |
|---|---|
|
Outbox size |
Relay жив или копит долг |
|
Oldest outbox age |
Сколько самое старое событие ждет публикации |
|
Kafka consumer lag |
Сколько сообщений осталось обработать |
|
Event age |
Насколько старое событие сейчас применяем |
|
Projection errors |
Не сломался ли consumer read model |
|
DLQ size |
Сколько событий ушло в ручной разбор |
|
Read model freshness |
Насколько read DB отстает от write DB |
Самая честная бизнес-метрика:
сколько объектов в read model отстает от write model больше N секунд.
Она неприятная. Поэтому полезная.
Дубли: consumer должен быть идемпотентным
Kafka может отдать событие повторно. Типичный сценарий:
-
Consumer прочитал событие.
-
Обновил read DB.
-
Упал до commit offset.
-
После рестарта получил то же событие еще раз.
Это нормальная цена at-least-once обработки. Если handler не идемпотентный, read model может получить дубль, неверный счетчик или старое состояние поверх нового.
Базовая защита:
CREATE TABLE inbox_messages ( event_id UUID PRIMARY KEY, processed_at TIMESTAMP NOT NULL);
Логика простая:
-
Начать транзакцию в read DB.
-
Проверить
event_idв inbox. -
Если уже обработан — пропустить.
-
Если новый — применить изменение к read model.
-
Записать
event_idв inbox. -
Зафиксировать транзакцию.
-
Commit offset.
Это не единственный способ, но принцип важен: повторная доставка не должна менять результат повторно.
Порядок событий
Kafka сохраняет порядок внутри partition, а не глобально по всему topic. Поэтому, если события одного агрегата должны применяться последовательно, ключ сообщения обычно выбирают по агрегату:
key = orderId
И все равно лучше иметь версию:
{ "eventId": "bda67a8d-9f11-4e49-98d9-4f5f0a6d10a1", "aggregateId": "order-123", "version": 42, "occurredAt": "2026-06-16T10:00:00Z", "type": "OrderStatusChanged"}
Версия помогает consumer’у понять, что делать:
|
Что пришло |
Реакция |
|---|---|
|
Следующая версия |
Применить |
|
Уже примененная версия |
Пропустить |
|
Старая версия |
Пропустить или отправить в диагностику |
|
Разрыв версий |
Остановить обработку/отправить в retry |
Без версии read model может молча принять старое событие поверх нового. А молчаливые ошибки в read model особенно прекрасны: все работает, просто неправильно.
Когда так делать не надо
Разделять write/read DB не стоит, если:
-
одна база спокойно справляется;
-
проблему решает индекс, query tuning или replica;
-
бизнес требует строгий read-after-write на всех экранах;
-
нет плана backfill/replay;
-
команда не готова владеть outbox, consumer, DLQ и мониторингом;
-
никто не может ответить, какое окно freshness считается нормальным.
CQRS не должен появляться потому, что схема стала красивее. Красивые схемы не отвечают на алерты.
Короткий чек-лист
Перед отдельной read DB я бы спросил:
-
Что именно болит: CPU, IO, locks, latency, сложность запросов?
-
Почему read replica не подходит?
-
Какое допустимое окно eventual consistency?
-
Где хранится outbox?
-
Как consumer переживает дубли?
-
Есть ли
eventId,aggregateId,version,occurredAt? -
Как пересобрать read model с нуля?
-
Что мониторим: lag, freshness, DLQ, outbox age?
Главная мысль:
Две базы — это не “одна для записи, другая для красоты”. Это контракт: write side отвечает за истину и изменения, read side отвечает за быстрое чтение и измеряемую свежесть.
В Telegram-канале “Продовый оффсет” отдельно выложу чек-лист по read model, шаблон метрик freshness и пример outbox/inbox-таблиц для .NET + Kafka.
На что опирался
-
Microsoft: CQRS pattern — разделение read/write моделей, trade-off’ы и eventual consistency.
-
Debezium: Outbox Event Router — структура outbox-события,
eventId,aggregateIdкак message key. -
Confluent/Kafka: Message Delivery Guarantees — at-least-once, offset commit и дубли при падении consumer’а.
-
Confluent/Kafka: Introduction to Apache Kafka — partition, event key и порядок чтения внутри partition.
ссылка на оригинал статьи https://habr.com/ru/articles/1048352/