Реализация CQRS-паттерна в инфраструктуре

Когда чтение и запись конкурируют за одни и те же ресурсы, сервис проседает под нагрузкой. Мы проектируем и внедряем контур CQRS под ключ: разводим потоки, убираем взаимное влияние и передаём команде готовые схемы и регламенты. Нагрузка на запись перестаёт определять скорость отклика.
Часто задаваемые вопросы

CQRS в инфраструктуре: где ломается разделение чтения и записи

CQRS — это не библиотека и не отдельный сервис, а разделение двух контуров с разными требованиями к отказу. Команды пишут через транзакционную модель: строгая согласованность, короткая блокировка, предсказуемая задержка. Чтение обслуживают проекции: их можно пересобрать, они терпимы к отставанию и денормализованы под конкретный экран.

Основная масса инцидентов живёт на стыке. Рассинхрон проекций проявляется как чтение после записи: пользователь отправил команду, интерфейс запросил модель и получил предыдущее состояние. Лечится это не паузой перед запросом, а версией агрегата в ответе на команду — клиент читает до тех пор, пока проекция не догонит эту версию.

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

Деградация записи под аналитической нагрузкой почти всегда следствие общей инфраструктуры: тяжёлые отчёты занимают пул соединений и диск, хвостовая задержка записи растёт нелинейно. Поэтому контур чтения выносим на отдельные реплики и хранилища — например, проекции на PostgreSQL со своим профилем нагрузки, — а на запись ставим таймауты, ограничение одновременных запросов и явный отказ вместо накопления очереди.

Что даёт CQRS-контур: запись, чтение и класс устранённых отказов

Разделение записи и чтения даёт два независимых контура, и каждый измеряется своей метрикой. Команды пишут в нормализованное хранилище с синхронной репликацией (в нашем контуре — PostgreSQL): время отклика записи укладывается в доли секунды и не зависит от того, сколько читателей одновременно строят отчёты. Чтение обслуживают денормализованные проекции, которые наполняются асинхронно из журнала событий.

Отказоустойчивость чтения держится на том, что проекции выводятся из журнала и не являются источником истины. Узел чтения можно потерять целиком: приём заявок продолжается, а проекция восстанавливается проигрыванием событий с последней сохранённой позиции. Время восстановления тогда определяется суточным объёмом журнала, а не размером основной базы и скоростью её развёртывания.

Нагрузка на запись перестаёт конкурировать с аналитикой. Тяжёлые выборки и выгрузки уходят на отдельные реплики, поэтому пиковый трафик отчётов больше не блокирует транзакции. Меняется и класс отказов: вместо «база легла под отчётным запросом» получаем деградацию одной проекции, которая догоняет поток за минуты и не мешает оформлению заказов.

Стоимость простоя считаем в потерянных операциях, а не в часах полной недоступности. Пока недоступен контур записи, новые заявки не принимаются — но чтение истории, статусы и личный кабинет продолжают работать, потому что идут по своим репликам. Время отклика записи, время восстановления и доля отказов чтения — три метрики, по которым мы принимаем работу после внедрения.

Варианты разделения чтения и записи: сравнение подходов

Разделение чтения и записи — не переключатель, а шкала: от раздельных ролей внутри одной базы до журнала событий с асинхронными проекциями. Каждый шаг вправо снимает нагрузку с транзакционной записи, но добавляет задержку между записанным и прочитанным. Выбор диктует требование к свежести данных: где-то достаточно «через секунду», где-то счёт идёт на доли секунды.

Вариант Когда подходит Ограничения
Раздельные роли в одной базе Аналитика и админка мешают транзакционной записи Общий ресурс: тяжёлый отчёт всё ещё тормозит запись
Реплика для чтения Нагрузка в основном на чтение, отставание в секунды допустимо Задержка репликации: чтение сразу после записи требует маршрута на мастер
Материализованные представления Тяжёлые агрегаты, устаревание данных приемлемо Пересчёт упирается в блокировки; набор представлений жёстко привязан к запросам
Журнал событий и асинхронные проекции Несколько моделей чтения, нужна история и пересборка Согласованность в конечном счёте: дубли, повторная обработка, отставание проекции

Согласованность в конечном счёте — не дефект, а параметр: её задержку задают явно и проверяют по метрике отставания проекций. Начинаем с раздельных ролей там, где этого хватает, и уходим в журнал событий только там, где нужна история и пересборка моделей чтения.

Порядок внедрения: от аудита схемы данных до передачи регламентов

Внедрение CQRS начинается не с кода, а с описания того, какие запросы обслуживает система и где запись мешает чтению. Без этого шага разделение моделей превращается в два хранилища с расходящимися данными и ручной сверкой.

Маршрут внедрения

  1. Аудит схемы данных. Снимаем карту сущностей: что только читается, что постоянно пишется, где операции конкурируют за блокировки строк. На выходе — перечень сценариев с профилем нагрузки по каждому.
  2. Схема потоков команд. Фиксируем границы агрегатов и то, какая команда порождает какое событие. Здесь же решаем, какие изменения допустимо применять асинхронно, а какие обязаны быть строго согласованными.
  3. Проектирование моделей чтения. Под каждый экран и отчёт — своё представление с нужными индексами, без универсальной таблицы «на всё». Отдельно оговариваем допустимое отставание проекции для каждого сценария.
  4. План внедрения. Идём по одному ограниченному контексту, сохраняя прежний путь записи как запасной. Пока новый контур не подтверждён, откат возможен переключением без потери данных.
  5. Настройка синхронизации. Определяем очередь, порядок доставки событий, идемпотентность обработчиков и поведение при повторной доставке. Для сбойных сообщений — отдельная ветка с разбором, чтобы одно событие не блокировало поток.
  6. Проверка на нагрузке. Прогоняем запись и чтение раздельно: смотрим отставание проекций под пиком, поведение при недоступности потребителя и время прогрева представлений после перестроения.
  7. Передача регламентов. Отдаём инструкции по переигрыванию событий, восстановлению проекции с нуля и разбору расхождений, а также набор метрик состояния контура для дежурной смены.

Каждый шаг заканчивается артефактом: схемой, конфигурацией или регламентом. Так внедрение остаётся управляемым, а эксплуатация не зависит от авторов решения.

Комплект на выходе: схемы потоков, политики доступа, регламенты

Комплект документации — часть результата, а не приложение к нему. Без него контур CQRS работает, но менять его команда заказчика не может: непонятно, где пишется истина, кто читает проекции и что делать при расхождении. Поэтому вместе с кодом передаём артефакты, каждый из которых закрывает свой класс задач при эксплуатации.

  • Карта потоков событий — какие команды их порождают, кто потребители, какой порядок доставки гарантирован и чем гасится повторная обработка.
  • Схема данных — разделение стороны записи и стороны чтения, модель проекций, ключи и индексы под конкретные запросы экранов.
  • Документация по проекциям — источник событий, правило перестроения, нормальное отставание от потока, порядок полного пересчёта без остановки чтения.
  • Политики доступа — роли на отправку команд и на чтение, разграничение по агрегатам, отдельное право на перестроение проекции.
  • Регламент сопровождения — как менять схему событий, как версионировать события и что делать с потребителями, ещё не перешедшими на новую версию.
  • Порядок разбора инцидентов — признаки расхождения чтения и записи, шаги диагностики по журналам, откат на предыдущую версию проекции.
  • Схема развёртывания — состав узлов, параметры брокера, очереди повторов и лимиты, после которых подключается инженер.

Комплект живёт в репозитории заказчика и обновляется вместе с кодом. Проверяем его на приёмке просто: если по нему нельзя пересобрать проекцию или ввести в работу нового инженера — задача не закрыта.

Кейс: разделение чтения и записи в высоконагруженном сервисе

«Сервис начинался с одного узла: и запись, и чтение шли в одну и ту же таблицу. На пике транзакции записи упирались в блокировки — время отклика записи росло с секунды до десятка, а доля отказов чтения доходила до 8%. Хуже всего обстояло с отказами: потеря узла означала ручное восстановление и до получаса недоступности.

Модели развели. Запись осталась в транзакционном хранилище PostgreSQL, чтение ушло на отдельные денормализованные проекции, которые обновляются из журнала изменений. Обработчики сделали идемпотентными, а журнал — источником, из которого проекцию можно пересобрать целиком после сбоя. Читающие реплики переключаются автоматически, без ручного вмешательства.

Что изменилось по метрикам. Время отклика записи на пике сократилось в 6–8 раз и перестало зависеть от объёма чтений. Доля отказов чтения ушла ниже десятых долей процента: запрос больше не ждёт блокировок, а падение отдельной реплики не роняет выдачу. Восстановление после сбоя узла — автоматическое, за пару минут, без потери подтверждённых записей.

Отдельно вынесли наблюдаемость: отставание проекций, глубина очереди и расхождение между моделями видны на панели мониторинга, поэтому деградация ловится раньше, чем её заметит пользователь.»

Чем CQRS отличается от реплики для чтения?

Реплика для чтения — это та же модель записи, размноженная по узлам: те же таблицы, те же ограничения, тот же контракт. Меняется только то, кто отвечает на запросы, а расхождение ограничено отставанием реплики — обычно это доли секунды, и для большинства отчётных запросов оно незаметно. Разделение моделей начинается там, где требования чтения и записи расходятся настолько, что одна схема их больше не обслуживает.

В CQRS модель записи отвечает за состояние и инварианты: транзакции, проверки, блокировки. Модель чтения — отдельная проекция под конкретные запросы: денормализованные таблицы, заранее посчитанные агрегаты, поисковые структуры. Строится она из потока изменений, а не из копии таблиц, и живёт по своим правилам.

Отсюда ключевое отличие. У реплики отставание — это задержка копирования при одном и том же смысле данных. У CQRS модели расходятся по определению: проекция не является источником истины, её можно пересобрать с нуля, а ошибка в расчёте лечится пересчётом, а не восстановлением из копии. Плата за это — асинхронность.

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

Технические сомнения: согласованность, дубли команд, миграция

Возражения к CQRS снимаются на этапе проектирования, а не после запуска: у каждого из них есть конкретное инженерное решение, которое проверяется тестом или нагрузкой. Ниже — ответы на те, что чаще всего всплывают перед стартом работ.

Как гарантируется идемпотентность команд?

Каждая команда приходит с уникальным ключом, а обработчик ведёт журнал уже применённых ключей. Повторная доставка при повторе не создаёт вторую запись: операция либо уже применена, либо принимается как новая.

Что с согласованностью данных между записью и чтением?

Проекция догоняет журнал с задержкой в доли секунды, и это окно измеримо — его видно на метриках. Для критичных сценариев чтение идёт с ожиданием нужной версии проекции либо по самому журналу, а интерфейс показывает «принято, обрабатывается» вместо устаревших данных.

Можно ли перестроить проекции без простоя?

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

Как проходит миграция без остановки записи?

Расширяем схему, пишем в старый и новый формат одновременно, переносим историю фоновыми пакетами, сверяем контрольные суммы по ключам и только потом убираем старый путь. Каждый шаг обратим и не блокирует запись.

Что произойдёт при недоступности транспорта событий?

Событие пишется в той же транзакции, что и изменение состояния, а публикует его отдельный воркер с подтверждением доставки. Неподтверждённые отправляются повторно, получатель отбрасывает дубли по идентификатору события.

Обсудим задачу: что нужно для оценки и плана работ

Чтобы оценка работ опиралась на факты, а не на предположения, нам нужен короткий разбор текущего контура: где проходит запись, где чтение и как они связаны между собой. Чем конкретнее исходные данные, тем точнее план внедрения и тем меньше допущений в сроках и объёме изменений.

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

Что заметно ускоряет подготовку:

  • схема контура: сервисы, точки записи и чтения, как сейчас идут запросы;
  • профиль нагрузки: доля чтения и записи, пики, терпимость к отставанию проекций;
  • требования к согласованности: где допустимо чтение с задержкой, а где нужна актуальность;
  • хранилища и очереди: что уже используется, ограничения по доступам и средам;
  • выкат и откат: как выпускаются изменения, есть ли набор регрессионных проверок;
  • критерии приёмки: по каким метрикам поймём, что решение работает;
  • ограничения: регламенты, окна работ, кто принимает решения на вашей стороне.

В ответ присылаем оценку по этапам, план внедрения с порядком работ и точками отката, а также состав работ с границами ответственности. Опишите задачу в удобном виде — вернёмся с оценкой и планом.

Наши компетенции:

Последние работы

  • Разработка сайта компании B2B ADVANCE
    Разработка сайта компании B2B ADVANCE
    1542
  • Разработка веб-приложения для компании FEEDME
    Разработка веб-приложения для компании FEEDME
    1394
  • Разработка веб-сайта для компании БЕЛФИНГРУПП
    Разработка веб-сайта для компании БЕЛФИНГРУПП
    1081
  • Разработка интернет магазина для компании FURNORO
    Разработка интернет магазина для компании FURNORO
    1369
  • Разработка логотипа компании B2B Advance
    Разработка логотипа компании B2B Advance
    816
  • Разработка веб-приложения для компании Enviok
    Разработка веб-приложения для компании Enviok
    1142