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

Как это обычно выглядит

Write DB, Outbox, Kafka и Read DB
Write DB, Outbox, Kafka и Read DB

Поток записи:

  1. Command API принимает команду.

  2. В write DB меняется бизнес-сущность.

  3. В той же транзакции пишется запись в outbox.

  4. Outbox relay публикует событие в Kafka.

  5. Consumer читает событие.

  6. Consumer обновляет read DB.

  7. 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.

Окно eventual consistency
Окно eventual consistency

Это нормально, если окно измерено и согласовано. Это плохо, если команда делает вид, что окна нет.

Не надо объяснять бизнесу так:

У нас 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 может отдать событие повторно. Типичный сценарий:

  1. Consumer прочитал событие.

  2. Обновил read DB.

  3. Упал до commit offset.

  4. После рестарта получил то же событие еще раз.

Это нормальная цена at-least-once обработки. Если handler не идемпотентный, read model может получить дубль, неверный счетчик или старое состояние поверх нового.

Idempotent consumer и повторная доставка
Idempotent consumer и повторная доставка

Базовая защита:

CREATE TABLE inbox_messages (
    event_id UUID PRIMARY KEY,
    processed_at TIMESTAMP NOT NULL
);

Логика простая:

  1. Начать транзакцию в read DB.

  2. Проверить event_id в inbox.

  3. Если уже обработан — пропустить.

  4. Если новый — применить изменение к read model.

  5. Записать event_id в inbox.

  6. Зафиксировать транзакцию.

  7. 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.

На что опирался

Комментарии (2)


  1. Hramoff
    17.06.2026 06:36

    У нас CQRS, поэтому read side eventually consistent.

    смотря какой performance, смотря какая delay


  1. NiQkrya
    17.06.2026 06:36

    Спасибо за статью!