Привет, Хабр! Меня зовут Павел, я ведущий разработчик. После статьи про 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” отдельно выложу чек-лист по 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.
Hramoff
смотря какой performance, смотря какая delay