Оптимизация производительности .NET WEB приложения от Ангуляра до MS SQL

Современные .NET приложения обычно состоят из нескольких уровней: клиентская логика, веб-фреймворк, бизнес-логика, технология ORM и база данных. Например, конкретный стек может включать в себя Vue.js, ASP.NET WebAPI, C#, Entity Framework, MSSQL либо React.js, ASP.NET Core, NHibernate, C#, PostgreSQL. Если приложение во время своей работы подтормаживает, то проблемы с производительностью могут быть скрыты на одном конкретном уровне или одновременно на нескольких.

Представим, загрузка страницы с большим количеством отчетов занимает 5 секунд. Подобная задержка может быть вызвана таким набором просчетов: на таблице в базе MS SQL отсутствует необходимый индекс; Entity Framework загружает в память все записи из таблицы базы данных с отчетами, так как используется интерфейс IEnumerable; бизнес-логика, выполняя дополнительные преобразования данных, бездумно аллоцирует тонны объектов (например, в следствие активной модификации строк или частых resize’ов List’ов), а мегабайтные .js и .css файлы отсылаются клиенту не минифицированными. Исправление только одного из перечисленных пунктов не будет достаточным для достижения максимального быстродействия приложения в целом. Диагностика проблем с производительностью, их устранение и профилактика требуют комплексной работы.

На изображении ниже показаны основные способы борьбы с проседанием производительности на каждом из слоев приложения. Большинство изложенных рекомендаций не относятся к конкретной технологии или ее версии.

arch-min

CLIENT

Уменьшение количества запросов на сервер. Если для отображения некоторой страницы выполняется большое количество запросов на сервер и общее время загрузки долгое, то можно предпринять несколько действий. Во-первых, ответы отдельных запросов могут быть кешированы. Во-вторых, список выполняемых запросов в пределах страницы может быть пересмотрен. Некоторые запросы могут быть уже не актуальны, т. е. клиент выполняет запрос, но не обрабатывает ответ. Также запросы могут дублироваться. Такие сюрпризы нередко можно встретить в больших системах, которые разрабатываются большой распределенной командой и часто меняются требования от заказчика, в результате чего в коде остается мусор. В-третьих, страница не всегда должна ожидать завершения выполнения всех без исключения запросов для того, чтобы пользователь мог начать с ней работу. Достаточно дождаться выполнения запросов, которые возвращают основной контент или подготавливают ключевой функционал, а остальное подгрузить асинхронно.

Для диагностики вышеизложенных проблем обычно достаточно Network вкладки в Chrome DevTools, в которой можно изучать количество выполненных запросов, их время, содержимое, объем и тд.

Уменьшение количества обращений к DOM. JavaScript и DOM являются двумя отдельными технологиями. JavaScript работает с DOM через API, что влечет за собой дополнительные накладные расходы. Модификации объектов DOM-модели влекут за собой выполнение тяжелой браузерной операции Reflow, время выполнения которой растет с увеличением количества тегов на странице, уровня вложенности тегов и количества CSS-стилей.

Если появляется необходимость работать с DOM напрямую, в обход JS-фреймворка, то ссылки на DOM-объекты могут быть закешированы и использованы повторно. Также большое количество обновлений DOM-объекта можно выполнить в «отсоединенном» режиме (в копии реального DOM-объекта в оперативной памяти, изменения которого не влияют на UI), после чего все внесенные изменения единожды применяются к UI.

Диагностировать проблему частой манипуляции DOM можно при помощи профайлера, который находится в Chrome DevTools во вкладке Performance.

Хорошие знания JS-фреймворка. Cинхронизация JS-моделей и DOM является основной обязанностью JS-фреймворков, которую они выполняют достаточно быстро. Однако по мере увеличения объема данных, скорость синхронизации DOM и JS-модели при внесении изменений в последнюю может замедлится до неприемлемой. Для исправления такой проблемы, поверхностных знаний JS-фреймворка на уровне «как прибиндить модель к UI» уже недостаточно.

В документации каждого JS-фреймворка можно найти способы оптимизировать его производительность. Например, в React.js для избежания ненужного рендеринга используется shouldComponentUpdate(), в Knockout.js метод valueHasMutated() позволяет избежать проблем с быстродействием при частой модификации модели привязанной к UI и тд.

Переход на протокол HTTP/2. Простой переход с протокола HTTP/1.1 на HTTP/2.0 может увеличить скорость работы сайта, так как новая версия протокола поддерживает возможность совершать несколько запросов на сервер в рамках одного TCP соединения, использует механизм сжатия для HTTP-заголовков, приоритизирует запросы, доставляя клиенту более важный контент быстрее.

HTTP/2 поддерживается большинством современных браузеров, не требуется никаких настроек со стороны клиента. Со стороны веб-серверов IIS или Kestrel конфигурирование выполняется вручную. В Network вкладке в Chrome DevTools можно проверить, по какому протоколу общаются клиент и сервер.

Асинхронная загрузка JS-файлов. Браузер рендерит страницу, читая HTML-код. Если браузер встречает тег script, рендеринг страницы приостанавливается до момента полной загрузки JS-файла со всеми его возможными зависимостями и выполнениями запросов на сервер. Атрибут async в теге script заставит браузер не приостанавливать рендеринг страницы, она будет быстрее показана пользователю намного быстрее.

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

WEB Framework — ASP.NET

Оптимизация тяжелых серверных ответов. Серверные ответы по сотни килобайт требуют заметного времени на загрузку браузером. Такие ответы могут быть сжаты стандартным gzip’ом, что может быть достигнуто добавлением аттрибута над декларацией GET-запроса. На стороне клиента никакого кода писать не требуется, так как браузеры сами умеют выполнять распаковку сжатых данных.

Диагностировать деградацию перформанса по причине тяжелых ответов в Chrome можно в Network вкладке в DevTools. При клике на конкретные веб-запрос появится информация о его размере и о времени, которое браузер потратил на загрузку ответа (свойство Content Download).

Использования OutputCache и механизма ETag. Если сервер возвращает тяжелые и редко изменяющиеся данные, то он может попросить браузер их закешировать, путем установки специальных HTTP-заголовков. Для кеширования статического контента обычно применяются аттрибуты OutputCache или ResponseCache с указанием времени, в течение которого браузер будет брать данные из своего кеша. Если нужно закешировать контент с возможностью немедленного обновления кеша при изменении данных на сервере, применяют механизм ETag.

Асинхронный вызов блокирующих операций. Синхронный вызов блокирующих операций, таких как обращение к стороннему сервису, чтение/запись данных c жесткого диска или базы данных, ведет к блокированию потока, который инициировал такую операцию. Поток будет ожидать завершения блокирующей операции и не сможет заниматься другой работой, такой как обработка входящих запросов на веб-сервер. В результате можно получить проблему, при которой сервер не сможет выделить новый поток на обработку входящего запроса, так как все имеющиеся в распоряжении потоки будут ожидать завершения синхронно вызванных ими блокирующих операций. Когда поток вызывает блокирующую операцию асинхронно, то он возвращается в пул потоков, в результате чего сервер может использовать минимальное число потоков для обработки входящих запросов.

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

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

Минификация файлов скриптов и стилей. Минификация — это процесс оптимизации при котором уменьшается размер .js и .css файлов путем удаления лишних пробелов, отступов, комментариев, в результате чего увеличивается время загрузки страницы. JS-библиотеки обычно поставляются с минифицированными версиями файлов. При необходимости минифицировать собственные файлы можно использовать какой-нибудь javascript-minifier. Другая техника оптимизации Bundling позволяет объединять несколько .js или .css файлов в один. Bundling и Minification включают для продакшена, но для тестовой среды минифицированные файлы не используются, чтобы не затруднять процесс отладки.

Оптимизация изображений. Уменьшения размера изображений снижает время загрузки страницы. Изображения могут быть оптимизированы вручную при помощи большого количества онлайн-сервисов или расширений как например Visual Studio Image Optimizer. Также изображения могут быть оптимизированы автоматически при запуске билда или развертывании аппликации с использованием gulp-imagemin, Azure Image Optimizer и других.

Business Logic — С#/.NET

Выбор наиболее оптимальной структуры данных. Каждая структура данных (Stack, List, Dictionary, HashSet и другие) оптимизирована под конкретные операции с ней и должна выбираться программистом исходя из условий решаемой задачи. Например, обычный массив отлично подойдет для хранения объектов в определенном порядке, но расширение массива будет происходить медленно, так как его нужно будет постоянно пересоздавать и поиск элемента будет самым медленным — линейным. HashSet подойдет для быстрого поиска объекта, но в пределах коллекции они должны быть уникальными. Если на выбранной структуре данных часто используются операции для которых она не оптимизирована и в ней хранятся десятки тысяч объектов, имеем проблемы с перформансом.

Засорение памяти ненужными объектами или долгое время выполнения фрагмента кода являются симптомами неправильного выбора структуры данных, соответственно для диагностики данной проблемы подойдут memory и CPU профайлеры, такие, как Visual Studio Memory Profiler, JetBrains dotTrace, ANTS Memory Profiler.

Контроль количества аллокаций. Лишняя аллокация имеет два недостатка: сам процесс аллокации занимает некоторое время и появляется дополнительная работа для сборщика мусора. Существует много возможностей получить аллокацию неявно, среди них механизм упаковки, работа с LINQ, работа с неизменяемыми объектами, замыкания, переполнение коллекций и тд. Для контроля объемов занимаемой оперативной памяти можно использовать профайлеры из предыдущего пункта. В статическом анализе кода на предмет неявных аллокаций полезны Heap Allocations Viewer или Roslyn Clr Heap Allocation Analyzer.

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

Сборщик мусора имеет различные события (GCStart, GCEnd, GCAllocationTick и тд), на которые можно подписаться для сбора информации о времени и причинах запуска GC, объеме освобожденной памяти и тд. Также детальную статистику по работе GC и объему используемой памяти предоставляют performance counter’ы (Time in GC, Total committed Bytes, Large Object Heap size и тд), которые можно получить при помощи легковесной программы Performance Monitor. Для более продвинутого анализа памяти можно использовать Windows Debugger (WinDbg). Для изучения деталей работы GC и устройства памяти в .NET есть прекрасная книга Under the Hood of .NET Memory Management.

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

Кеширование часто используемых данных. Речь идет не о кешировании данных из базы с использованием Cache-Aside шаблона либо в статических свойствах, а о максимальном повторном использовании вычисляемых данных. Когда-то я исследовал проблему с производительностью при генерации репортов. Помимо ряда ненужных обращений к базе данных, профайлер указал, что 20% времени тратилось на работу с типом DateTime. Оказалось, что код генерации репортов постоянно обращался в циклах к свойству DateTime.Now. Вынесение его за пределы циклов улучшило скорость генерации репортов на те же 20%.

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

ORM — EntityFramework

Использование IQueyrable вместо IEnumerable. Оба интерфейса позволяют отфильтровать коллекцию данных по переданному предикату. Однако IEnumerable выполняет фильтрацию на стороне .NET, предварительно выгружая все записи из базы. IQueyrable фильтрует данные на стороне SQL-сервера, возвращая только то количество записей, которое реально необходимо.

Отладка и оптимизация LINQ запросов. Программист пишет LINQ-запрос, ORM трансформирует его в SQL и передает SQL-серверу на выполнение. Не всегда сгенерированные запросы являются самыми оптимальными. Необходимо контролировать все, что ORM отправляет SQL-серверу и при необходимости переписывать исходный LINQ с целью улучшить SQL-код. Если переписывание LINQ не дает результата, от него можно отказаться в пользу написания SQL-запроса в C# коде, либо использовать хранимые процедуры.

Начиная с Entity Framework 6.0, SQL-запросы можно легко отслеживать при помощи свойства context.Database.Log. Также можно пользоваться SQL-профайлером, Entity Framework профайлером и простым вызовом IQueryable.ToString().

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

  • используется IEnumerable вместо IQueyrable
  • за оператором Where не следует вызов Select с перечислением необходимых колонок
  • отсутствует lazy loading, вследствие чего, вместо загрузки одного необходимого объекта с парой свойств, может загружаться огромный агрегат

Использование методов AsNoTracking, CompileQuery. Использование метода AsNoTracking в Entity Framework помогает в улучшении перформанса в случаях, когда из базы извлекается большая коллекция объектов только для чтения. Entity Framework не кеширует такие объекты в DbContext’е, перестает отслеживать изменения в них изменения. В Entity Framework Core появилась возможность заставить весь DbContext работать в read-only режиме, установив свойство QueryTrackingBehavior.

Методы CompileQuery и CompileQueryAsync помогают улучшить производительность в случаях, когда некоторый LINQ-запрос вызывается многократно. Вместо постоянной компиляции LINQ-запроса в SQL перед своим выполнением, он компилируется единожды в пределах жизненного цикла веб-запроса либо целого приложения. Более того, скомпилированный запрос не нуждается в перекомпиляции при изменении входящих параметров.

Избегание проблемы N+1. Для хорошего перформанса, количество запросов к SQL-серверу должно быть минимально, но проблема N+1 наоборот способствует их увеличению. Например, имеется коллекция объектов List<Country>, класс Country содержит в себе свойство List<City>, для которого включена ленивая загрузка. При итерировании List<Country> и обращении к свойству List<City> в цикле, получим по одному запросу в базу на каждой итерации.

Отследить проблему N+1 можно через SQL-профайлер. Обычно в нем будут один за другим идти одинаковые SQL-запросы, с отличающимися параметрами. В Entity Framework Проблема решается предварительной загрузкой данных методом Include (Eager Loading).

Database — MS SQL

Создание и поддержка индексов. Индексы создаются для одной или нескольких колонок таблицы, по которым наиболее часто происходит выборка данных. Скорость выполнения запроса, например, SELECT * FROM Info i WHERE i.SomeID = 10, на таблице с сотней тысяч записей будет отличаться в разы в зависимости от наличия или отсутствия индекса для колонки SomeID. Однако, наличие большого количества индексов на таблице замедлит производительность, так как индексы необходимо перестраивать при обновлении данных в таблице + требуется дополнительное место в памяти для их хранения.

Понять используются ли индексы запросами поможет анализ результатов SQL Server Query Execution Plan’а. Статистику по использованию индексов дает MS SQL функция sys.dm_db_index_operational_stats.

Извлечение минимально необходимого набора данных. Для минимизации извлекаемых данных нужно следить за количеством указанных столбцов в операторе SELECT, использовать SET NOCOUNT ON в хранимых процедурах, использовать ‘SELECT 1 …’ вместо ‘SELECT * …’ для проверки наличия или отсутствия записей в таблице, использовать наиболее «узкие» предикаты в WHERE (которые не возвращают лишние строки, только необходимые).

Использование наименьшего типа данных для колонок. Используемая SQL-сервером память может быть значительно сэкономлена, если при проектировании таблиц выбирать наименьшие типы данных для колонок. Перед выбором типа данных для колонки недостаточно понять, будет ли в ней храниться строка или число. Нужно подумать о диапазоне допустимых значений. Например, для хранения возраста человека нет смысла использовать bigint или int. Подойдет tinyint с диапазоном значений от 0 до 255. При оптимизации размера таблиц, помимо экономии памяти, увеличится скорость работы JOIN’ов и вырастет скорость сканирования индексов.

Снижение уровня изоляции. В следствии одновременного обращения несколькими транзакциями к таблицам MS SQL могут возникать блокировки, которые снижают ожидаемое время выполнения запросов. В первую очередь проблемы блокировок решаются оптимизацией SELECT’ов, что помогает блокировать меньшее количество данных и на меньшее время. В других случаях можно рассматривать понижение уровней изоляций транзакций вплоть до READ UNCOMMITTED либо использование уровня SNAPSHOT ISOLATION, который решает проблемы одновременного доступа не блокированием, а версионностью. Также можно использовать опцию WITH (NOLOCK), которая работает аналогично уровню READ UNCOMMITTED, но применяется не на уровне транзакции, а для отдельного SELECT’а .

Диагностировать проблемы блокировок в MS SQL можно при помощи утилиты Activity monitor.

Денормализация редко изменяемых данных. Нормализованные данные оптимизированы для их быстрого и удобного обновления, денормализованные — для чтения. Цена нормализации — плохая скорость чтения по причине частых JOIN’ов и вызовах агрегатных функций, денормализации — высокая сложность обновления данных, поддержка их в согласованном состоянии.

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

Почитать дальше

15+ Experts Share Their Web Performance Advice for 2018
Performance difference between HTTP2 and HTTP1.1
Performance in ASP.NET Core
Параллелизм против многопоточности против асинхронного программирования: разъяснение
Know Thy Complexities!
Learning .NET memory management
Карта знаний .NET Web программиста
How To: Optimize SQL Queries (Tips and Techniques)
Denormalization: When, Why, and How

Оптимизация производительности .NET WEB приложения от Ангуляра до MS SQL: Один комментарий

  1. Отличная стаття, Олександр! Я бы еще добавил что регулярное обновление фреймворков как для клиента так и для сервера в большинстве случаев приносит прирост производительности, например переход на Roslyn компилятор может серьезно увеличить производительность всей системыю

    Нравится

Добавить комментарий для Олександр Отменить ответ

Заполните поля или щелкните по значку, чтобы оставить свой комментарий:

Логотип WordPress.com

Для комментария используется ваша учётная запись WordPress.com. Выход /  Изменить )

Google photo

Для комментария используется ваша учётная запись Google. Выход /  Изменить )

Фотография Twitter

Для комментария используется ваша учётная запись Twitter. Выход /  Изменить )

Фотография Facebook

Для комментария используется ваша учётная запись Facebook. Выход /  Изменить )

Connecting to %s