Архитектурные решения в TSKLab: event-driven, CQRS и где это реально окупилось

Архитектурные решения в TSKLab: event-driven, CQRS и где это реально окупилось

Архитектурные шаблоны редко приносят пользу сами по себе — ценность появляется только тогда, когда они решают конкретную боль: снижают связанность, упрощают масштабирование, ускоряют выдачу данных или делают систему управляемой под нагрузкой. В TSKLab мы смотрим на event-driven и CQRS именно так: не как на «модные слова», а как на инструменты, которые должны окупаться на реальных сценариях. За несколько лет мы перебрали десятки архитектурных решений в собственных проектах — от каталога технологий до внутренних инженерных пайплайнов — и выработали довольно прагматичный взгляд на то, когда эти шаблоны действительно вытягивают проект, а когда только добавляют лишней сложности.

Что такое event-driven архитектура простыми словами

Event-driven архитектура — это подход, в котором система реагирует на события, а не только на прямые синхронные запросы. Событие — это факт, который уже произошёл: «заказ создан», «платёж подтверждён», «устройство ушло в офлайн», «файл загружен». Подписанные компоненты получают событие и действуют независимо друг от друга.

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

Чем это отличается от обычного request-response

В классической схеме сервис А вызывает сервис Б напрямую и ждёт ответ. Это просто, но быстро приводит к плотной связности: любой сбой в цепочке становится проблемой всей операции. В event-driven модели сервис-источник фиксирует факт и публикует событие, а остальные сервисы обрабатывают его асинхронно.

На практике это даёт:

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

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

Где этот подход особенно полезен

  • уведомления и триггеры;
  • интеграции между сервисами;
  • обработка цепочек бизнес-событий;
  • телеметрия и IoT;
  • фоновые процессы, которые не должны тормозить основной сценарий;
  • системы с высокой асимметрией чтения и записи.

Особо отмечу последний пункт — асимметрия чтения и записи. Это тот случай, когда event-driven естественно подводит к CQRS, потому что события становятся идеальным триггером для обновления оптимизированных читательских моделей.

CQRS: зачем вообще разделять чтение и запись

CQRS расшифровывается как Command Query Responsibility Segregation. Если сказать по-простому, это разделение моделей для изменения данных и для их чтения. Команды меняют состояние, запросы только читают его. В хорошей реализации это не просто «разные методы», а действительно разные модели и часто разные хранилища.

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

Идея CQRS без лишней теории

Одна и та же структура данных плохо подходит и для сложной валидации записи, и для быстрых выборок под интерфейс. CQRS предлагает не заставлять один слой делать всё сразу. Запись остаётся строгой, транзакционной и ориентированной на правила бизнеса. Чтение становится удобным для конкретных экранов, отчётов, фильтров и API-ответов.

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

Когда CQRS окупается

CQRS почти всегда оправдан там, где:

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

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

Event-driven и CQRS вместе: почему они часто идут парой

Хотя это разные вещи, вместе они работают особенно хорошо. Event-driven архитектура отвечает за обмен фактами между компонентами, а CQRS помогает правильно разложить внутреннюю модель системы на запись и чтение.

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

Как выглядит типовая схема

  1. Пользователь или внешний сервис отправляет команду.
  2. Сервис проверяет правила и сохраняет изменение.
  3. После успешной операции публикуется событие.
  4. Один или несколько обработчиков обновляют проекции для чтения.
  5. UI, отчёты и интеграции читают уже готовые представления.

Эта схема хорошо ложится на микросервисную архитектуру, но работает и в модульных монолитах — мы проверяли оба варианта. В монолите события могут ходить через внутреннюю шину, а проекции для чтения обновляться в отдельных потоках. Главное — выдержать принцип: запись не знает о чтении, чтение не влияет на запись.

Что важно понимать про согласованность

При такой схеме чтение часто становится eventual consistency — данные на витрине могут обновляться не мгновенно. Для многих систем это нормальная плата за производительность и отказоустойчивость. Но если пользователь ожидает немедленный итог операции на том же экране, это нужно проектировать заранее.

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

Где это реально окупилось в TSKLab

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

Сценарий Что болело до перехода Что изменили Что получили
Асинхронная обработка инженерных событий Синхронные цепочки тормозили основной поток Перевели часть действий в события Меньше блокировок и проще развивать новые реакции
Каталог технологий и карточки инструментов Один универсальный запрос плохо тянул разные формы выдачи Разделили запись и чтение Упростили фильтры, рейтинги и подборки
Обновление материалов и метаданных Изменение одной сущности касалось нескольких подсистем Ввели событийную синхронизацию Снизили связность и количество прямых зависимостей
Отчётность и внутренние витрины Сложные join-запросы на живой базе Сформировали отдельные read-модели Быстрее отклик интерфейсов и меньше нагрузка на primary DB

Каждый из этих сценариев прошёл через этап измерений до и после. Например, по каталогу технологий время ответа на сложные фильтрованные запросы сократилось в 4-6 раз после перехода на отдельные read-модели. А количество прямых зависимостей между модулями после введения событийной синхронизации уменьшилось примерно на треть — мы считали по количеству импортов и вызовов между подсистемами.

Практические признаки, что архитектура уже «просится» на event-driven или CQRS

Есть несколько признаков, которые обычно видны без глубокой диагностики. За годы работы в лаборатории мы научились распознавать их довольно рано — часто ещё до того, как система становится совсем неповоротливой.

Event-driven стоит рассмотреть, если:

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

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

CQRS стоит рассмотреть, если:

  • чтение данных сильно сложнее, чем запись;
  • для разных экранов нужны разные формы одного и того же объекта;
  • одна и та же таблица одновременно обслуживает транзакции, поиск и аналитику;
  • разработчики тратят много времени на оптимизацию SELECT-запросов вместо развития бизнес-логики.

Ещё один практический индикатор: если вы начинаете создавать представления в базе данных специально под конкретные экраны, а ORM генерирует запросы на пять экранов JOIN-ов — вы уже фактически строите read-модели, просто делаете это неявно и неэффективно. CQRS в такой ситуации просто легализует и упорядочивает то, что уже происходит.

Где шаблоны не окупятся

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

Плохие кандидаты на внедрение

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

Последний пункт особенно важен. Если доменная модель ещё плывёт, вы будете переписывать не только бизнес-логику, но и события, и read-модели, и обработчики. Цена изменений вырастает кратно. Мы в TSKLab обычно ждём, пока модель устаканится хотя бы на 70-80%, и только потом начинаем думать о разделении.

Чем это обычно заканчивается

  • усложнением дебага;
  • ростом количества промежуточных сущностей;
  • неочевидными багами из-за задержки обновления read-моделей;
  • увеличением порога входа для новых разработчиков.

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

Что нужно предусмотреть заранее

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

Минимальный набор требований

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

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

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

Как проверить, что решение действительно работает

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

Полезные метрики

  • время ответа на пользовательский запрос;
  • latency между событием и обновлением read-модели;
  • количество ручных обходных сценариев;
  • число точек прямой зависимости между сервисами;
  • время восстановления после сбоя;
  • стоимость добавления нового представления данных.

Последняя метрика — стоимость добавления нового представления — для нас оказалась одной из самых показательных. Если до внедрения CQRS на создание нового отчёта или экрана уходило три дня и четыре правки в разных модулях, а после — полдня и изменения только в read-модели, окупаемость видна невооружённым глазом.

Простой критерий успеха

Если после внедрения стало:

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

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

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

Типовые ошибки при внедрении

1. Пытаться сразу сделать «идеальный» event-driven

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

2. Смешивать события и команды

Команда — это просьба изменить состояние. Событие — уже произошедший факт. Это разные вещи, и путать их вредно. На практике это проявляется в именовании: команды мы называем в повелительном наклонении (CreateOrder, UpdateProfile), события — в прошедшем времени (OrderCreated, ProfileUpdated). Это не просто стилистика, это дисциплина мышления.

3. Не проектировать читательские модели отдельно

Если read-модель просто дублирует write-модель, смысл CQRS резко падает. Читательская модель должна быть заточена под конкретный сценарий использования — денормализованная, агрегированная, с предвычисленными значениями. Иначе вы просто разносите одни и те же данные по двум местам без всякой выгоды.

4. Игнорировать eventual consistency

Пользователь не должен удивляться тому, что результат операции появляется не мгновенно. Это надо объяснять и учитывать в UX. Мы в своих интерфейсах либо показываем оптимистичное обновление сразу после команды, либо явно индицируем процесс синхронизации. Прятать задержку и надеяться, что никто не заметит — плохая стратегия.

5. Не думать о наблюдаемости

Событийная система без трассировки и логирования быстро превращается в чёрный ящик. Минимальный набор: correlation id, который проносится от команды через событие до обновления read-модели, логирование на каждом шаге и дашборд с лагами очередей. Без этого отладка превращается в гадание.

Практический чек-лист перед выбором

  • Есть ли у нас реальные боли со связанностью сервисов?
  • Есть ли сценарии, где запись и чтение живут по разным законам?
  • Готова ли команда поддерживать асинхронную обработку?
  • Понимаем ли мы, где допустима задержка в данных?
  • Есть ли у нас метрики, по которым можно измерить эффект?
  • Действительно ли новая архитектура упростит развитие продукта, а не усложнит его?

Этот чек-лист мы используем на старте любого архитектурного обсуждения в лаборатории. Если хотя бы на два вопроса ответ «нет» или «не уверены» — с внедрением лучше подождать. Архитектурные решения должны приниматься на основе данных и реальных проблем, а не потому что «все так делают» или «в докладе на конференции красиво рассказали».

Вывод

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

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

FAQ

Чем event-driven архитектура отличается от CQRS?

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

Нужно ли использовать event sourcing вместе с CQRS?

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

Когда CQRS точно не нужен?

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

Почему read-модель может отставать от записи?

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

Что важнее при внедрении event-driven системы?

Наблюдаемость, идемпотентность, версионирование событий и понятные контракты между компонентами. Без этого система быстро становится трудно поддерживаемой. Если вы не можете ответить на вопрос «где сейчас находится событие и почему оно не обработалось» — у вас проблема. Инвестируйте в трассировку и мониторинг с первого дня внедрения, потом это сэкономит недели отладки.