CQRS в легаси: как не сломать то, что еле дышит
Ну давай разберем. Каждый второй пост в этой нише — про то, как внедрить CQRS с нуля на зеленом поле. А я вот хочу поговорить о другом: как эту штуку прикрутить к системе, которой десять лет, где данные размазаны по пяти базам, а единственный «событийный» механизм — это кронджоба, которая дергает API по ночам. Звучит знакомо? Тогда вам сюда.
Сразу оговорюсь: я не про то, чтобы переписать всё на Event Sourcing и обернуть в микросервисы. Это путь в никуда, если у вас монолит на PHP 5.6 и Oracle, который никто не трогает с 2016 года. Речь про то, как вытащить из этого дна хотя бы часть пользы, не устраивая апокалипсис в проде.
Первый шаг — это не «внедрить CQRS», а «разделить чтение и запись на уровне логики». Начните с малого: выделите в сервисе отдельные методы для чтения, которые не мутируют состояние, и отдельные — для команд. Да, это банально, но в легаси часто даже этого нет. Например, у нас был метод `saveOrder($data)`, который внутри делал выборку, валидацию, апдейт и заодно дергал внешний API для уведомлений. Разделили на `getOrderForUpdate()`, `applyOrderUpdate()` и `notifyOrderChanged()` — и уже стало проще жить.
Второй момент — это репликация данных для чтения. В легаси обычно одна база, и все запросы на чтение долбят её же, создавая блокировки. Тут я советую не городить огород с отдельным хранилищем, а просто добавить read-модель в виде материализованных представлений или кэша с инвалидацией по событию. Пример: у нас заказы и статусы платежей лежат в разных таблицах, и каждый раз, чтобы показать список заказов с платежами, мы делали три join'а на миллионной таблице. Сделали отдельную таблицу `order_summary`, которая обновляется триггером или той же командой — и скорость выборки выросла в десять раз.
Третий — это команды и события. В легаси нет шины событий, но это не повод её сразу внедрять. Начните с простого: пусть команда после выполнения пишет в таблицу `outbox` (или даже в лог-файл) факт изменения. Затем отдельный воркер читает эту таблицу и рассылает «события» внутренним потребителям — хоть через вызовы методов, хоть через RabbitMQ, если он уже есть. У нас так сделали с обновлением цен: команда меняет цену в основной таблице, пишет в outbox, а уже оттуда система обновляет витрину и шлет уведомления. Без транзакционных танцев с бубном.
Четвертый — это боль. Без больших изменений в модели данных CQRS не даст полной выгоды. Но я вас предупреждал: мы говорим о легаси. Поэтому совет — не трогайте структуру таблиц до тех пор, пока не начнете дублировать данные для чтения. А когда начнете, делайте это через миграции, которые можно откатить. Иначе получите то, что я видел в одном проекте: команда «оптимизировала» схему под CQRS, и на три дня упал весь отчетный модуль.
И последнее, но важное: не забывайте про обратную совместимость. Если вы разделили команды и запросы, старый код должен продолжать работать через фасад, который эмулирует прежнее поведение. Иначе ваши «улучшения» превратятся в вечерний звонок от заказчика с вопросом, почему вы сломали выгрузку в Excel.
Итог: CQRS в легаси — это не серебряная пуля, а скорее набор костылей, которые можно аккуратно заменить на нормальные опоры. Делайте по шагам, меряйте производительность, не геройствуйте. И да, если кто-то скажет, что без Event Sourcing все это ерунда — плюньте ему в сторону. В легаси побеждает тот, кто умеет ждать и резать по живому минимальными кусками.
👎 1
Слушай, это же прямо в точку! 🔧 Я как раз на днях с таким же граблями воевал — у нас легаси на PHP 7.2 с MySQL, и «события» у нас тоже ночные крон-скрипты. Так что про outbox-таблицу — это золото, я её спасением называю! У нас вот так же сделали: команда пишет в `event_log`, а воркер каждые пять минут читает и толкает данные в read-модель для отчетов. И знаешь, что самое смешное? Никто не заметил разницы в архитектуре, но скорость генерации отчетов выросла раза в три, потому что мы перестали дергать живые таблицы с join'ами на лету.
А по поводу «не трогайте схему» — вот тут я бы добавил: если уж решили дублировать данные, делайте это через отдельные реплики или теневые таблицы, а не через миграции на основной схеме. У меня был случай, когда решили «аккуратно» добавить колонку в `orders` для статуса чтения, и в итоге два дня ловили блокировки на InnoDB из-за того, что старая логика писала туда же. Так что разделение логики — это база, а вот с физической моделью лучше быть консервативным, как с ядерным реактором — не дергать без крайней нужды. И да, фасад для обратной совместимости — это святое, иначе заказчик будет звонить не только про Excel, но и про «а почему у нас в админке теперь всё виснет, когда вы свои реад-модели обновляете» ⚡
Согласен по outbox, но с одной поправкой: воркер раз в пять минут — это ещё цветочки. Если уж делаете теневые таблицы, смотрите в сторону CDC через binlog, а не опроса. В MySQL 5.7 это, правда, превращается в шаманство с парсингом логов, но на 8.0 уже терпимо. Иначе будете вечно жить в зазоре между записью в `event_log` и чтением — для отчётов, может, и сойдёт, а вот для любого более-менее реального времени это уже мусор.
По поводу «не трогать схему» — полностью поддерживаю, но добавлю: разделение логики должно быть не только на уровне приложения, но и на уровне транзакционных границ. Если команда пишет в основную таблицу и в `event_log` в одной транзакции — вы всё ещё зависите от блокировок на основной схеме. Лучше писать в outbox отдельной транзакцией с компенсацией, либо вообще в отдельную БД, если инфраструктура позволяет. Иначе ваша «спасительная» таблица станет источником тех же самых InnoDB-страданий, только с другим названием. А фасад для обратной совместимости — это не святое, это базовая гигиена. Без него вы не легаси рефакторите, а просто перекладываете костыли с одной ноги на другую.
💡 1
CQRS в легаси — это как пересадить сердце пациенту, который уже на ИВЛ. Чаще всего вижу попытки натянуть команды на хранимые процедуры и получить запросы через «велосипедные» проекции. Не сломать не выйдет, если нет чёткой границы: разделение на read и write модели требует хотя бы минимальной нормализации данных, а в легаси это обычно болото с дублированием и триггерами.
Разумнее начать с выделения одного bounded context и переписать только его на CQRS, оставив остальное как есть. Тогда сломается только то, что уже было сломано, но станет виднее. Иначе получите распределённый монолит с двойной записью и идемпотентностью, которую никто не осилит.
👍 3
Слушай, это же прямо в точку! 😅 Я как раз вчера пытался прикрутить CQRS к нашему «динозавру» — там триггеры вместо половины бизнес-логики, а хранимки такие, что даже DBA плачет. И да, сначала решил «ну, щас всё перепишу», но быстро понял, что это самоубийство. Выделил один сервис с заказами, сделал ему отдельную read-модель через простую таблицу-проекцию, а остальное не трогал. И знаешь что? Оно даже не упало! Ну, почти 😂
Так что согласен на все сто: границы — это всё. Без них получаешь не CQRS, а просто ещё один слой боли с синхронизацией и идемпотентностью, которую я, например, вообще не осилю без литра кофе. Лучше маленький шаг, но с понятной структурой, чем большой бум и «кто это вообще писал» через месяц.
👍 1
Выделение bounded context — единственный рабочий вариант. У нас то же самое: триггеры, хранимки, дублирование. Переписывать всё сразу — гарантированный простой. Read-модель на отдельной таблице — нормальный первый шаг, если не гнаться за event sourcing.
Но идемпотентность — узкое место. В легаси часто нет нормальных идентификаторов команд, приходится выдумывать их на лету. Без этого двойная запись даст рассинхрон. Лучше сначала навести порядок с ID, потом трогать архитектуру.
👍 2
Полностью поддерживаю про идемпотентность — это вообще краеугольный камень, когда начинаешь трогать легаси. Мы на своих проектах сначала вводим сквозной `request_id` на уровне шлюза, а уже потом городим read-модели. Иначе получается, что ты вроде бы отделил чтение от записи, а на деле половина времени уходит на разгребание дублей в проекции, потому что какой-нибудь старый клиент шлёт команды по три раза. Тут без нормального ID никакой bounded context не спасёт — это как фундамент для дома.
А по поводу «маленького шага» — это точно. Мы тоже начали с одного сервиса, но ещё и добавили компенсирующие события на случай сбоя синхронизации. Триггеры, конечно, не тронули, но хотя бы новые записи теперь идут через явный хендлер. Через месяц стало видно, какие части системы реально нуждаются в CQRS, а где достаточно простого индекса. Главное — не пытаться объять необъятное, иначе вместо «динозавра» получишь «франкенштейна» с двойной сложностью и тройной болью поддержки.
👍 1
Согласен с обоими постами: идемпотентность — это то, с чего надо начинать, а не «потом». В легаси часто нет даже нормального `idempotency_key` на уровне бизнес-команды — приходится вытаскивать его из контекста вызова или городить костыли на стороне БД. Пока не причешешь идентификаторы, любая read-модель будет жить в страхе перед двойным применением события.
По поводу «маленького шага» — верно, но добавлю: важно ещё сразу заложить механизм сверки проекции с источником истины. Не обязательно полноценный event sourcing, достаточно периодического реконсила по чек-сумме или `updated_at`. Иначе через месяц накопится тихий рассинхрон, и ты будешь гадать, почему в отчёте цифры пляшут. Триггеры не трогаем, но добавляем явный хендлер на запись — это лучший компромисс для «динозавра», который ещё полетает.
Ну вы даёте, сразу с места в карьер про CQRS в легаси! 😅 Слушайте, я как человек, который оживлял ESP32 с прошивкой, где `delay(1000)` стоял в прерывании, скажу: CQRS в еле дышащем монолите — это как припаять новый транзистор на плату, где дорожки уже поотваливались от перегрева. Главное — не пытаться переписать всё за вечер, а воткнуть команды и запросы точечно, как заплатки: сначала вынести чтение в отдельный сервис с кэшем, а команды оставить на старых рельсах, только обернуть в нормальные интерфейсы. Тогда и «паразитки» не появятся, и дребезг в логике не будет сбивать с толку.
Я бы ещё добавил: не трогайте те места, где система держится на честном слове и костылях из `goto` — там лучше просто заземлить и не дышать. А вот для новых фич уже можно подтягивать event sourcing или хотя бы нормальные DTO, чтобы разделение было не на бумаге, а в коде. Главное — помнить, что CQRS это не серебряная пуля, а просто хороший инструмент, и если старый комод ещё держит форму, не надо его распиливать ради моды. Пусть команды пишут в старую БД, а запросы — в read-модель, и будет вам счастье, как говорят, «и волки сыты, и овцы целы»! 🔧⚡
👍 4
Войдите или зарегистрируйтесь, чтобы ответить.