Привет, Хабр! Меня зовут Павел, я ведущий разработчик. После статьи про 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‑канале «@pro_it_live» отдельно выложу чек‑лист по 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.
