Ошибки в архитектуре ПО и как их избежать. Часть 2
По просьбе DOU IT-специалисты поделились ошибками, с которыми приходилось сталкиваться, в построении архитектуры ПО, выборе технологий, их использовании.
В первой части мы уже рассмотрели кейсы о выборе технологий на основе личных преференций, об Event driven state machine, неправильной настройке ORM. Во второй части говорим о согласовании нефункциональных требований, использовании хайповых технологий, особенностях TypeORM и прочем.
Иллюстрация Алины Самолюк
Александр, Senior Embedded Engineer в Sirin Software
Использовали СУБД на встроенном накопителе
Самая большая архитектурная ошибка моя и моей команды возникла на первом серьезном проекте. Это была embedded-разработка, где перед нами стояла такая задача: у нас было три девайса, один из которых должен был быть сервером, а два других — клиентами, отражающими GUI с кнопками.
GUI был непростой: имел структуру дерева-меню с вложенными уровнями. Его ключевой задачей было раскрасить эти уровни, в соответствии с различными правилами, в разные статусы, исходя из собранных данных. Данные собирались как с наших устройств, так и со сторонних, реализовывающих поддерживаемые протоколы, по заданным в конфиге критериям.
Рассмотрев эту задачу, мы с лидом решили, что лучше будет хранить все данные на сервере, а на клиентах только показывать. Потому что, во-первых, меню должно было выглядеть на всех девайсах одинаково. Во-вторых, на смену статусов были завязаны определенные действия. А в-третьих, MVC (именно так этот аргумент нашего лида и звучал). Других причин принять такое решение уже не помню, но все они казались важными, логичными и правильными.
Следующий вопрос, который встал перед нами: как хранить? В масштабе системы данных было потенциально много, они записывались и читались на десятках различных сервисов и клиентов. Следовательно, необходимо было управлять этим процессом и обеспечивать целостность информации.
Именно здесь и возникла архитектурная ошибка: для решения этой задачи мы подняли на встроенном накопителе реляционную СУБД. Просчет статусов дерева пришлось выполнять там же, а с клиентов — только читать готовые, потому что некоторые действия, в зависимости от статуса, должен был выполнять сам сервер (и, следовательно, знать эти статусы).
Быстро это, конечно, не работало. Цепочка действий выглядела так:
Сервис1 считывает данные и записывает в базу → сервис2 считывает данные и разрисовывает дерево → сервис2 записывает дерево обратно в базу → клиент загружает дерево из базы и показывает. Повторять как можно чаще. Сервис1 существовал в десятке экземпляров (для разных источников данных), процессор был одноядерный ARM с частотой примерно в 800 МГц, поэтому СУБД такое издевательство над собой переносила на удивление плохо. Задержка между переключением сенсора и изменением картинки GUI достигала секунды и более, что было абсолютно неприемлемо.
Здесь нужно было остановиться и решить задачу другим способом, но сдаваться просто так мы не стали, да и усилий в версию с базой вложили немало. Так эта ошибка привела к ряду дальнейших, построенных на ней, как на шатком фундаменте, в виде эдакой перевернутой пирамиды Хеопса.
Гениальнейшим из всех принятых для повышения быстродействия решений я гордо считаю собственное предложение избавиться от сервиса2 (того, что раскрашивал дерево по конфигу), писать конфиг сразу в базу и там же проводить все расчеты на триггерах и SQL-функциях. В результате мы получили несколько приличных страниц сложного SQL, делающего истинную «черную магию с кровавыми жертвоприношениями» (например, парсер неравенств, ведь условия для статусов могли быть заданы выражениями, на regexp и табличном стеке), но таки работающего и выполняющего задачи достаточно быстро. А элементы, требующие наискорейшего отображения, мы вынесли на клиенты, чем замаскировали чрезвычайную медлительность базы, державшей процессор загруженным на 80% и более.
Это лишь один пример дальнейших проблем и их «гениальных» решений. Когда у нас все-таки появился веб-сервис, всю логику девайсов нужно было показывать на нем и оттуда конфигурировать, поэтому структура его базы повторяла структуру девайсов с пугающей точностью, и таблиц было где-то на полсотни больше. И хотя единообразие структуры было уже правильным (иначе синхронизировать было бы на порядок сложнее), оно означало вынужденное «заражение» той первой роковой ошибкой еще и нашего облака, что стало очередным блоком в монументе архитектурным фараонам.
Избавиться от базы все согласились лишь спустя много месяцев и достаточное количество проблем. Последней каплей на этом пути стало требование поддерживать не три, а шесть девайсов — с двумя синхронизированными серверами и четырьмя клиентами. В этом случае добиться адекватных задержек уже никак не удалось.
Как ни странно, собственный сервис синхронизации данных, написанный нормальным языком программирования и вполне нам подконтрольный, стал гораздо лучшим решением. Но все подпертые за долгую жизнь проекта «костыли» для базы мы теперь должны были поддерживать: хоть никакого смысла в них уже не было, на них все и держалось...
Пирамида продолжала стоять.
Вывод. Самые болезненные архитектурные ошибки — те, на основе которых уже невозможно построить что-то стабильное. Отказаться от того, во что уже вложено много времени и усилий, всегда трудно, но обычно потери на переход «прямо сейчас» будут гораздо меньше, чем «потом». Заменить кирпич проще, когда пирамида на нем еще не стоит.
Олег Ботвенко, ex-CTO в Yellow Stone
Излишний оптимизм и легкомыслие по отношению к новым хайповым технологиям
Этот кейс случился во времена бурного хайпа по поводу NoSQL, когда многие воспринимали это направление как святой Грааль, который поможет решить все основные проблемы разработки. Особенно за NoSQL топили те, кто не очень хорошо знал и понимал реляционную модель и, собственно, SQL.
Самым модным и популярным представителем этого направления была тогда MongoDB, которой было пару лет с момента выхода на широкий рынок. И вот компания, в которую я пришел в качестве архитектора, как раз была в разгаре переезда основного хранилища из MySQL в MongoDB.
Я быстро осознал нереалистичность и ошибочность данного шага и стал отговаривать команду от этого. Нужно ли говорить, сколько стресса, непонимания и конфликтов это за собой повлекло? Но в конце концов мне это удалось, мы выкинули написанный к тому моменту код и вернулись на MySQL.
Но MongoDB мы все же внедрили и стали использовать для хранилища логов (у нас был подход — логировать все, это позволяло быстро расследовать сложные кейсы в продакшене). И когда наша база логов выросла до порядка 600 Гб и нагрузка на запись заставила расшарить ее на четыре машины, мы столкнулись со множеством нюансов и проблем в работе MongoDB, о которых не имели представления на старте.
Оказалось, что MongoDB по надежности, предсказуемости и отлаженности даже близко не стояла рядом с MySQL. Среди проблем, которые всплыли, — нелинейная деградация производительности, много блокирующих операций, нестабильная работа в целом под нагрузкой.
В основном команда признала, что отказ от MongoDB в качестве основной операционной базы спас нас от космического объема геморроя.
Вывод. С тех пор я сформулировал для себя следующее правило внедрения новых технологий в продукте: в продакшен можно отдавать только те технологии, в отладку которых вложено время, чтобы достичь качества, сопоставимого с MySQL, PostgreSQL, Nginx, Apache и другими системами такого уровня. Кроме того, в команде должно быть не менее трех человек с опытом работы с этой системой в продакшене и с личной ответственностью за ее работоспособность.
От этого правила можно отступить, если нет другого выбора. Или если мы технологию внедряем в каком-то небольшом некритическом сервисе, где даунтайм не страшен и мы сможем с ней поиграться, пока не накопим продакшн-опыт.
Олег Боровик, Senior Solutions Architect в IntellectEU
Не учли и не согласовали нефункциональные требования
Любимое высказывание среди архитекторов — it depends. После этого обычно следует множество уточняющих вопросов. То, что отвечает условиям и ограничениям одного проекта, в другом может считаться неоптимальным или недостаточно безопасным решением. Именно поэтому для архитектора коммуникационные навыки столь же важны, как и технические.
Неучтенный constraint, неправильно приоритезированные quality attributes, несогласованные NFRs приводят к выбору неправильных подходов и технологий, а это — к потере времени и денег, а иногда и к закрытию проекта.
Одним из таких случаев в моем опыте был интеграционный проект в SOA-инфраструктуре клиента для регистрации в разных подсистемах (для линии продуктов): почте, календаре, хранилище документов и так далее. Мы использовали Event Sourcing/CQRS на базе CassandraDB — решение очень чувствительное к изменениям в domain model (как оказалось позже). Каждый входящий клиентский запрос генерировал цепочку immutable ивентов, которые делали процесс прозрачным и позволяли относительно легко восстановить нужное состояние с помощью функциональных конструкций. Но при этом создавалось множество новых объектов в исходном коде с не очень прозрачными связями.
После старта разработки и примерно 5 месяцев работы выяснилось, что количество сервисов для интеграции не было правильно учтено (их общее количество только в одном департаменте в то время составило больше 200). К тому же в другой части компании в это время внедрили новый стандарт работы с сервисами (со своими библиотеками), который оказался обязательным для новых систем. Как результат — полный рефакторинг и потеря времени. Хорошо, что система еще была в разработке и не пришлось мигрировать данные пользователей и поддерживать разные версии событий.
Выводы
- Архитектор должен иметь полную картину того, что происходит и что может повлиять на проект.
- При выборе архитектурного подхода надо учитывать риски — как технические, так и организационные.
- Именно event sourcing можно выбирать только в случаях минимально возможных изменений бизнес-модели. Изменения после выхода в продакшн стоят очень дорого.
Андрей Андрийко, Engineering Manager в Uptech
Использовали дефолтные механизмы TypeORM для подтягивания реляций
В нашей работе мы часто пишем на TypeScript и используем одну из самых популярных ORM для Node.js — TypeORM.
Основная идея в том, что с помощью декораторов вы описываете маппинг таблицы базы данных на класс в TypeScript. В целом выглядит просто:
@Entity({ name: 'whatever_item' }) export class WhateverItemEntity { @PrimaryColumn({ name: 'id' }) id: string; // ... @OneToMany(type => PhotoEntity, photo => photo.listing) photos?: PhotoEntity[]; // ... }
Одна из базовых функций ORM — это работа с реляциями между сущностями. Реляции в TypeORM также описываются через декораторы, как показано в примере выше. Как и в других ORM, в TypeORM есть eager loading и lazy loading. Больше о том, как работают реляции, можно почитать тут. Но в этой документации нет одной важной детали: как именно ORM достает реляции из базы (JOIN, Sub-select, отдельные запросы). На эти грабли мы и напоролись.
Что произошло: мы разрабатывали marketplace, и наша основная страница — это список товаров, каждый их которых имел описание и картинки, а каждая картинка приходила в разных размерах (чтобы отображать их на разных страницах в оптимальном разрешении). Достать данные для такой страницы просто: выбрать товары по фильтрам и подгрузить его зависимости (в нашем случае фото).
Работая с TypeORM, мы решили не использовать lazy loading через промисы, так как в документации есть фраза «Note : ...This is non-standard technique and considered experimental in TypeORM». К тому же, если не использовать eager loading явно, реляции не будут подгружаться. Это позволяло нам самим указывать, в каких случаях подгрузить реляции, а в каких нет.
Базовый способ подтягивания реляций в TypeORM — repository.find({ relations: [’photos’] }). Изначально мы не обратили внимания, какой именно SQL-запрос/запросы составляет TypeORM, и начали использовать этот подход. Уже позже заметили, что на dev environment страница грузится достаточно долго —
Немного разобравшись, локализировали проблему: это был запрос, который составляла наша ORM. Она подтягивала все реляции через LEFT JOIN. Сам по себе объект товара имел много свойств. Также к нему подтягивались его изображения через JOIN (~15 штук), и к каждому изображению фото в разных размерах (еще около 6). В итоге для страницы в 20 товаров из базы доставалось 1800 строк. Колонок в этом результате было слишком много. Большинство данных в колонках дублировались, так как таблица товаров была очень большая и к каждому товару подбиралось еще 90 строк.
Основные проблемы здесь очевидны:
- Передаем по сети много «лишних» данных → долгий ответ от базы.
- Много строк → TypeORM долго маппит результат в объекты.
Бонус: каждая ссылка на картинке подписывалась AWS-ключами для доступа из S3, что удлиняло ее в несколько раз.
В итоге JSON response на
Чтобы решить проблему, нужно было менять способ работы с реляциями. Lazy loading в том виде, в котором он был в TypeORM, нам не нравился. При этом делать отдельные запросы для реляций по всему коду не хотелось, ведь зачем нам тогда ORM? Нам было важно изолировать работу с базой данных в отдельной части приложения, но при этом сохранить гибкость и управлять реляциями, которые надо доставать.
Мы решили написать вспомогательный инструмент, который бы позволил улучшить работу с реляциями, и назвали его Preloader. Разрабатывая эту утилиту, исходили из следующих фактов:
- запросы по PK (primary key) быстрые;
- JOIN’ы для many-to-one реляций медленные;
- если сгруппировать похожие запросы в один batch-запрос, будет быстрее (меньше сетевого оверхеда);
- метаинформация Entity из TypeORM поможет не дублировать маппинги к таблицам.
Note: почему использовать Preloader, а не просто предложенные инструменты TypeORM, такие как query builder, и выполнять через него subselect/join? Потому что в таком случае надо писать отдельный запрос на каждый случай. Плюс, если подтягивать реляции одним запросом, SQL отдаст большую таблицу с большим количество дублирующихся данных, как описано выше. И тогда часть проблемы останется нерешенной.
Как работал Preloader
С помощью метаданных из TypeORM Entity Preloader находил поля-реляции и маппинг на колонки. При исполнении запроса он брал реляции, которые надо подтянуть, и доставал их отдельными запросами по PK. Через утилиту Dataloader похожие запросы группировались в один и выполнялись. Дальше реляции маппились к нужным родительским сущностям.
В коде это выглядело следующим образом:
const items = await itemsRepository.findByIds([ 'id1', 'id2' ]); await new Preloader(ItemEntity, items).preload({ photos: [ 'sizedPhotos' ]);
В результате выполнялось три запроса:
SELECT * FROM items WHERE id IN (...)
SELECT * FROM photo WHERE id IN (...)
SELECT * FROM photos_sized WHERE id IN (...)
Вместо SELECT * FROM items LEFT JOIN photo …
LEFT JOIN photos_sized ...
После всех оптимизаций (также мы убрали вставку ссылок на картинки и уменьшили количество размеров, которые отдаем для каждого фото) response time страницы уменьшился до
Выводы
- Всегда нужно понимать, как работает ваш инструмент, будь то фреймворк, ORM или что-то другое. Это поможет использовать его с максимальной эффективностью.
- Логирование и мониторинг — must have.
- Больше запросов не означает худший перформанс.
- Не стоит бояться расширить возможности инструмента самостоятельно, написав утилиту поверх него. Это не так долго и дорого, как может показаться на первый взгляд.
Отправка асинхронных задач на выполнение до коммита транзакций
С развитием вашего приложения бизнес-логика становится все сложнее и системе необходимо выполнять больше действий. Представьте, что вы разрабатываете интернет-магазин. У вас обязательно будет функция заказа. Это сложное действие, которое включает в себя много этапов:
- сгенерировать номер заказа;
- проверить наличие товара и забронировать товар;
- провести оплату;
- отправить чек покупателю на имейл и так далее.
Выполнять все эти действия синхронно в рамках запроса пользователя неэффективно. Так, сделав заказ, человек будет секунд
Однако в таком подходе есть нюанс: что если транзакция откатилась из-за ошибки, а вы уже отправили асинхронные задачи на выполнение? Мы столкнулись именно с такой проблемой.
Так мы отправляли ивенты в Kafka до завершения основной работы. В некоторых случаях, если транзакция не удавалась, ивент уже шел в Kafka, а другие сервисы ссылались на данные, которых по факту нет (не сохранились). Это приводило к нежелательным ошибкам.
Еще бывали ситуации, когда асинхронная задача начинала работу до того, как выполнялась основная транзакция. В этом случае она также пыталась обратиться к данным, которых еще нет в базе.
Мы выделили три основных способа решения.
Отправлять асинхронные задачи к выполнению только после коммита транзакции. Такой подход имеет право на жизнь, но это не всегда возможно по логике приложения, да и часто неудобно в коде. Поэтому мы его откинули.
Ввести idempotency key, то есть какой-то ID транзакции, который мы будем пересылать в ивентах и на основе которого будем проверять, выполнилась ли транзакция вообще и не выполнили ли мы эту операцию раньше (если будем делать retry). Таким образом можем получить идемпонтентные операции. Этот подход эффективен, но достаточно усложняет общую логику. К тому же необходимо хранить, везде передавать и проверять ключи идемпотентности. Мы решили, что на данном этапе это того не стоило.
Создать таблицу в базе для асинхронных задач. Для запуска иметь периодическую джобу, которая бы доставала задачи из базы раз в пару секунд и выполняла их. Здесь мы используем транзакцию основной операции, чтобы «запланировать» асинхронную задачу. То есть, если транзакция завершается успешно, асинхронные задачи сохраняются в базу, откуда будут вычитаны периодической джобой и поставлены на выполнение. После выполнения задача из базы удаляется. Если же транзакция роллбэкается, то асинхронные задачи в базу не попадают и не выполняются. Подход похож на паттерн Saga, только в рамках одного сервиса. Мы использовали именно этот метод.
В таблице с задачами достаточно сохранить «ключ» или название класса, который будет выполнять задачу, и параметры, которые будете туда передавать. Тут необходим механизм, который позволит в рантайме получить класс/функцию, которая будет выполнять задачу по ее имени. Таким механизмом может быть Dependecy Injection.
Вывод. Эта история научила нас тому, что, работая с асинхронными задачами, важно правильно мониторить их выполнение и обрабатывать ошибки.