
Введение
Сегодня мне немного грустно, поэтому, чтобы подбодрить себя, расскажу вам историю, самой, наверно, смехотворной задачи по оптимизации, которую мне поручали. Не знаю, извлечёте ли вы из неё что-то полезное, но, по крайней мере, кого-то она развеселит…
Вылет в конечной точке списка пользователей
Это была задача, которая однажды появилась на моей доске Jira, когда я работал в компании, занимающейся организацией совещаний. Она не показалась мне чем-то особенным, в этой кодовой базе у нас уже были вылеты. Кстати, об этой кодовой базе: работа с ней была сущим ужасом: бэкенд REST API на C#; в этом нет ничего такого, на самом деле, я люблю C#, если только это не монолит из нескольких тысяч файлов для пяти инстансов баз данных, созданный двумя аутсорсинговыми компаниями, которые могут общаться друг с другом только на ломанном английском и не способны договориться о стандартном способе создания совещаний. Моя основная компания (которая по понятным причинам останется анонимной), очевидно, считала технологии только способом выполнения задачи, она не стремилась совершить революцию в отрасли. Ей просто нужна была работающая платформа со всеми фичами, которые CEO придумывал на ходу; из-за этого весь отдел разработки ПО считали чем-то вроде фабрики, способной производить всё больше и больше. Если где-то возникало узкое место, то руководство не считало это «техническим долгом»: просто какой-то разработчик трудится недостаточно быстро; любые опасения о всё большей неустойчивости этой вавилонской башни (так мы называли наш продукт) мгновенно сбрасывали со счетов.
Исследование вылета
Итак... это был не вылет, и разобраться в этом было довольно просто: продуктовый отдел и отдел QA думали, что это вылет, потому что при нажатии на кнопку обновления списка пользователей фронтенд ждал ответа бэкенда с подтверждением, что вс�� элементы были успешно созданы/обновлены, но ответ так и не поступал. Поэтому они предположили, что сервер вылетал; на самом деле, он просто продолжал выполнение… Именно так: бэкенду требовалось куча времени для загрузки этой информации в базу данных. Но почему?
Паттерн «репозиторий»
Кодовые базы на C# обычно выполняют отправку запросов к базе данных SQL, реализуя Repository , содержащий функциональный способ описания операций запросов. Например, чаще всего в нём можно найти следующие функции:
// CREATE await this.m_userRepository.InsertAsync(userToInsert); // READ User user = await this.m_userRepository.FirstOrDefaultAsync(); user.name = "New name"; // UPDATE await this.m_userRepository.UpdateAsync(user); // DELETE await this.m_userRepository.DeleteAsync(userToDelete);
Это операции для одной записи, то есть операция задействует только одного пользователя (в данном случае); разумеется, есть способы задействовать одновременно множество записей, чтобы бэкенд запросов, используемый фреймворком EF core, сгенерировал «оптимизированный» запрос, нацеленный на все записи в указанной таблице/диапазоне. Это не так сложно реализовать (в буквальном смысле две-три строки кода), но ПО КАКОЙ-ТО ПРИЧИНЕ эта конкретная компания решила использовать мета-фреймворк под названием ABP.
Мета-фреймворк ABP

Это ни в коем случае не наезд на компанию ABP или на технологию, уверен, она отлично выполняет чьи-то задачи… Но не в этом случае: на ABP было завязано всё приложение, но использовался он всего для двух вещей:
Создание экземпляров Singleton (инъецирование зависимостей).
Реализации паттерна «репозиторий».
И по сей день я не уверен, что эти функции — самые сильные стороны ABP; мне кажется перебором проектировать весь стек на основе технологии, используемой не в полную свою силу, потому что эта компания осталась на удивительно старой версии ABP. В этой версии отсутствовали операции с множественными записями, в частности, методы InsertMany и UpdateMany, а код был слишком хрупким, чтобы апгрейдиться до новых версий, в которых эти функции появились.
Хотфикс на два года
Вероятно, вы задаётесь вопросом: «Если функции InsertMany не было, то как же они выполняли групповые операции?» Превосходный вопрос! Вот обходное решение из кодовой базы в продакшене, которое построчно я воссоздал по памяти:
List<User> usersToInsert = CreateUsersFromSomewhere(); foreach(var user in usersToInsert) { await this.m_userRepository.InsertAsync(user); }
ПРОШУ ЛЮБИТЬ И ЖАЛОВАТЬ, КАЧЕСТВЕННЫЙ КОД УРОВНЯ ПРОДАКШЕНА!!!
Если вы сразу же не увидели проблему в этом коде, то вам стоит освежить свои знания в async/await; но не беспокойтесь, я всё объясню. В C# ключевое слово await приказывает коду ждать, пока асинхронная функция подаст сигнал о своём завершении. Это полезно, потому что гарантирует, что всё будет находиться в нужном корректном состоянии при выполнении операций, на которые у внешней системы может уйти неизвестное количество времени (например, операций с сетью и базами данных, выходящих за пределы структуры управления среды исполнения C#). В случае вставки в базу данных это подразумевает полный путь от приложения до базы данных и обратно, а также время обработки в базе данных для вставки пользователя в таблицу.
И мы должны тратить это время ДЛЯ КАЖДОГО ПОЛЬЗОВАТЕЛЯ В СПИСКЕ. Поначалу веб-пользователи могут не заметить существенных замедлений, пока размер коллекции не превышает сотен элементов, а запрос требует одну-две секунды (не поймите меня неправильно, я тоже считаю, что это невероятно медленно для выполняемой задачи), но когда размер коллекции начинает измеряться тысячами, это становится ОЧЕНЬ заметно, и в результате операции завершаются по таймауту. Настал момент, когда конечной точке требовалось больше пяти минут в среде продакшена.
Стоит учесть, что когда я пришёл в компанию, этот продукт работал уже чуть больше двух лет, то есть именно так они кодировали каждую групповую операцию не меньше двух лет; при этом ни одна живая душа не подумала найти альтернативу этому обходному решению, убивающему производительность.
Реальное исправление
Итак, как я и обещал, это исправление действительно оказалось простым и дурацким: достаточно было всего лишь самостоятельно реализовать «репозиторий» при помощи инструментов, которые уже есть в EF core… Кто бы мог подумать, что создание легковесного API поверх уже имеющегося фреймворка будет хорошей идеей. Вот пример: функция InsertManyAsync, которую я реализовал (воссоздана по памяти):
public Task InsertManyAsync(ICollection<T> entities) { context.Set<T>().AddRange(entities); return context.SaveChangesAsync(); }
Как я и говорил: две строки кода, и проблема решена. После этого мне пришлось запланировать совещание со всей командой, чтобы она посмотрела на новую реализацию и помогла мне избавиться от всех ручных групповых операций, заменив их на эту реализацию.
После внедрения реализации полное время выполнения конечной точки списка пользователей снизилось с пяти с лишним минут до всего 300 мс. Да, это большое улучшение по сравнению с исходной реализацией, все члены команды это понимали, а пара моих коллег похвалила меня за мои методики оптимизации и знания. Но я думаю, что вы со мной согласитесь в том, что дело было не в том, что я сделал нечто особо умное, а в том, что я удалил нечто крайне глупое.
Вывод (?)
Не думаю, что с технической точки зрения здесь можно научиться чему-то полезному. Возможно, уроки из статьи может извлечь только руководство: если команду разработчиков постоянно заставляют выпускать новые фичи, и она не имеет возможности обернуться назад, то это обязательно отразится на продукте. Некоторые из моих коллег думают, что я какой-то оптимизатор-экстремист, но на самом деле я всего лишь выступаю за разумные меры, не позволяющие техническому долгу вредить конечным пользователям. Именно они всегда становятся жертвами плохого ПО, и я считаю, что мы обязаны поступать с ними по справедливости.
