Устранение узких мест производительности в приложениях .NET 6

Учебное пособие по ASP.NET Core Изучение

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

Мы создали эту статью в партнерстве с Site24×7. Спасибо за поддержку партнеров, которые делают SitePoint возможным.

В этом примере я исследую узкие места производительности в приложении.NET 6. Основное внимание будет уделено проблеме с производительностью, которую я лично видел в продакшене. Цель состоит в том, чтобы вы могли воспроизвести проблему в вашей локальной среде разработки и решить ее.

Не стесняйтесь загружать пример кода с GitHub или следуйте инструкциям. Решение имеет два API, невообразимо названные First.Apiи Second.Api. Первый API вызывает второй API для получения данных о погоде. Это распространенный вариант использования, поскольку API-интерфейсы могут вызывать другие API-интерфейсы, поэтому источники данных остаются несвязанными и могут масштабироваться по отдельности.

Во-первых, убедитесь, что на вашем компьютере установлен пакет SDK для.NET 6. Затем откройте терминал или окно консоли:

> dotnet new webapi --name First.Api --use-minimal-apis --no-https --no-openapi
> dotnet new webapi --name Second.Api --use-minimal-apis --no-https --no-openapi

Вышеупомянутое может находиться в папке решения, например performance-bottleneck-net6. Это создает два веб-проекта с минимальными API, без HTTPS, без чванства или Open API. Инструмент формирует структуру папок, поэтому, если вам нужна помощь в настройке этих двух новых проектов, посмотрите пример кода.

Файл решения может находиться в папке решения. Это позволяет открыть все решение через IDE, например Rider или Visual Studio:

dotnet new sln --name Performance.Bottleneck.Net6
dotnet sln add First.Api\First.Api.csproj
dotnet sln add Second.Api\Second.Api.csproj

Затем обязательно установите номера портов для каждого веб-проекта. В примере кода я установил для них значение 5060 для первого API и 5176 для второго. Конкретный номер не имеет значения, но я буду использовать его для ссылки на API в примере кода. Поэтому убедитесь, что вы либо изменили номера портов, либо сохранили то, что генерирует скаффолд, и оставайтесь согласованными.

Приложение-нарушитель

Откройте Program.csфайл во втором API и поместите код, который отвечает данными о погоде:

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var summaries = new[]
{
 "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};

app.MapGet("/weatherForecast", async () =>
{
 await Task.Delay(10);
 return Enumerable
   .Range(0, 1000)
   .Select(index =>
     new WeatherForecast
     (
       DateTime.Now.AddDays(index),
       Random.Shared.Next(-20, 55),
       summaries[Random.Shared.Next(summaries.Length)]
     )
   )
   .ToArray()[..5];
});

app.Run();

public record WeatherForecast(
 DateTime Date,
 int TemperatureC,
 string? Summary)
{
 public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Функция минимальных API в.NET 6 помогает сделать код небольшим и лаконичным. Это будет перебирать тысячу записей и задерживает задачу для имитации асинхронной обработки данных. В реальном проекте этот код может обращаться к распределенному кешу или базе данных, что является операцией, связанной с вводом-выводом.

Теперь перейдите к Program.csфайлу в первом API и напишите код, использующий эти данные о погоде. Вы можете просто скопировать и вставить это и заменить все, что сгенерирует скаффолд:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton(_ => new HttpClient(
 new SocketsHttpHandler
 {
   PooledConnectionLifetime = TimeSpan.FromMinutes(5)
 })
{
 BaseAddress = new Uri("http://localhost:5176")
});

var app = builder.Build();

app.MapGet("/", async (HttpClient client) =>
{
 var result = new List<List<WeatherForecast>?>();

 for (var i = 0; i < 100; i++)
 {
   result.Add(
     await client.GetFromJsonAsync<List<WeatherForecast>>(
       "/weatherForecast"));
 }

 return result[Random.Shared.Next(0, 100)];
});

app.Run();

public record WeatherForecast(
 DateTime Date,
 int TemperatureC,
 string? Summary)
{
 public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
}

Вводится HttpClientкак синглтон, потому что это делает клиент масштабируемым. В.NET новый клиент создает сокеты в базовой операционной системе, поэтому хорошим методом является повторное использование этих соединений путем повторного использования класса. Здесь HTTP-клиент устанавливает время жизни пула соединений. Это позволяет клиенту висеть на сокетах столько времени, сколько необходимо.

Базовый адрес просто сообщает клиенту, куда идти, поэтому убедитесь, что он указывает на правильный номер порта, установленный во втором API.

Когда приходит запрос, код повторяется сто раз, а затем обращается ко второму API. Это нужно для имитации, например, ряда записей, необходимых для вызовов других API. Итерации жестко закодированы, но в реальном проекте это может быть список пользователей, который может неограниченно расти по мере роста бизнеса.

Теперь сосредоточьте свое внимание на зацикливании, потому что это имеет значение в теории производительности. В алгоритмическом анализе один цикл имеет линейную сложность Big-O или O (n). Но второй API также зацикливается, что увеличивает сложность алгоритма до квадратичной или O (n ^ 2) сложности. Кроме того, зацикливание проходит через границу ввода-вывода для загрузки, что снижает производительность.

Это имеет мультипликативный эффект, потому что для каждой итерации в первом API второй API выполняет тысячу циклов. Есть 100 * 1000 итераций. Помните, что эти списки не связаны, что означает, что производительность будет экспоненциально снижаться по мере роста наборов данных.

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

CURL и NBomber

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

Во-первых, вызовите каждый API отдельно, например, с помощью CURL, чтобы почувствовать задержку:

> curl -i -o /dev/null -s -w %{time_total} http://localhost:5060
> curl -i -o /dev/null -s -w %{time_total} http://localhost:5176

Номер порта 5060 принадлежит первому API, а 5176 — второму. Убедитесь, что это правильные порты на вашем компьютере.

Второй API отвечает за доли секунды, что достаточно хорошо и, скорее всего, не виновато. Но ответ первого API занимает почти две секунды. Это неприемлемо, потому что веб-серверы могут прерывать запросы, которые занимают так много времени. Кроме того, задержка в две секунды слишком медленна с точки зрения клиента, потому что это разрушительная задержка.

Затем такой инструмент, как NBomber, поможет протестировать проблемный API.

Вернитесь в консоль и внутри корневой папки создайте тестовый проект:

dotnet new console -n NBomber.Tests
cd NBomber.Tests
dotnet add package NBomber
dotnet add package NBomber.Http
cd ..
dotnet sln add NBomber.Tests\NBomber.Tests.csproj

В Program.csфайл пропишите бенчмарки:

using NBomber.Contracts;
using NBomber.CSharp;
using NBomber.Plugins.Http.CSharp;

var step = Step.Create(
 "fetch_first_api",
 clientFactory: HttpClientFactory.Create(),
 execute: async context =>
 {
   var request = Http
     .CreateRequest("GET", "http://localhost:5060/")
     .WithHeader("Accept", "application/json");
   var response = await Http.Send(request, context);

   return response.StatusCode == 200
     ? Response.Ok(
       statusCode: response.StatusCode,
       sizeBytes: response.SizeBytes)
     : Response.Fail();
 });

var scenario = ScenarioBuilder
 .CreateScenario("first_http", step)
 .WithWarmUpDuration(TimeSpan.FromSeconds(5))
 .WithLoadSimulations(
   Simulation.InjectPerSec(rate: 1, during: TimeSpan.FromSeconds(5)),
   Simulation.InjectPerSec(rate: 2, during: TimeSpan.FromSeconds(10)),
   Simulation.InjectPerSec(rate: 3, during: TimeSpan.FromSeconds(15))
 );

NBomberRunner
.RegisterScenarios(scenario)
.Run();

NBomber рассылает только спам API со скоростью один запрос в секунду. Затем с интервалами два раза в секунду в течение следующих десяти секунд. Наконец, три раза в секунду в течение следующих 15 секунд. Это предотвращает перегрузку локальной машины разработчика слишком большим количеством запросов. NBomber также использует сетевые сокеты, поэтому действуйте осторожно, когда и целевой API, и инструмент тестирования работают на одном компьютере.

Шаг теста отслеживает код ответа и помещает его в возвращаемое значение. Это отслеживает сбои API. В.NET, когда сервер Kestrel получает слишком много запросов, он отклоняет те из них, в ответ на которые получен отказ.

Теперь просмотрите результаты и проверьте задержки, одновременные запросы и пропускную способность.

Теперь просмотрите результаты и проверьте задержки

Задержки P95 показывают 1,5 секунды, это то, что испытает большинство клиентов. Пропускная способность остается низкой, поскольку инструмент был откалиброван таким образом, чтобы обрабатывать не более трех запросов в секунду. На локальной машине разработки трудно понять параллелизм, потому что те же ресурсы, на которых работает инструмент эталонного тестирования, также необходимы для обслуживания запросов.

dotTrace анализ

Затем выберите инструмент, который может выполнять алгоритмический анализ, например dotTrace. Это поможет дополнительно определить, где может быть проблема с производительностью.

Чтобы провести анализ, запустите dotTrace и сделайте снимок после того, как NBomber заспамит API как можно сильнее. Цель состоит в том, чтобы смоделировать большую нагрузку, чтобы определить, откуда исходит медлительность. Уже реализованные тесты достаточно хороши, поэтому убедитесь, что вы используете dotTrace вместе с NBomber.

Согласно этому анализу, примерно 85%

Согласно этому анализу, примерно 85% времени тратится на GetFromJsonAsyncразговор. Поиск в инструменте показывает, что это исходит от HTTP-клиента. Это коррелирует с теорией производительности, поскольку показывает, что проблема может заключаться в асинхронном цикле со сложностью O(n^2).

Тестовый инструмент, работающий локально, поможет выявить узкие места. Следующим шагом является использование инструмента мониторинга, который может отслеживать запросы в реальной производственной среде.

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

Мониторинг сайта 24×7

Такой инструмент, как Site24×7, может помочь в решении проблем с производительностью.

Для этого приложения вы хотите сосредоточиться на задержках P95 в двух API. Это волновой эффект, потому что API являются частью серии взаимосвязанных сервисов в распределенной архитектуре. Когда у одного API начинаются проблемы с производительностью, другие нижестоящие API также могут испытывать проблемы.

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

Наконец, при развертывании оптимизаций и улучшений важно отслеживать зависимости версий. С каждой итерацией вы должны знать, какая версия лучше или хуже по производительности.

Необходим надлежащий инструмент мониторинга, потому что проблемы не всегда легко обнаружить в локальной среде разработки. Предположения, сделанные на местном уровне, могут быть неприменимы в производственной среде, поскольку у ваших клиентов может быть другое мнение. Начните 30-дневную бесплатную пробную версию Site24×7.

Более производительное решение

Имея на данный момент арсенал инструментов, пришло время изучить лучший подход.

CURL сказал, что у первого API есть проблемы с производительностью. Это означает, что любые улучшения, внесенные во второй API, незначительны. Несмотря на то, что здесь есть волновой эффект, сокращение нескольких миллисекунд от второго API не будет иметь большого значения.

NBomber подтвердил эту историю, показав, что P95 в первом API шли почти две секунды. Затем dotTrace выделил асинхронный цикл, потому что именно на него алгоритм тратил большую часть своего времени. Инструмент мониторинга, такой как Site24×7, предоставил бы вспомогательную информацию, показав задержки P95, масштабируемость с течением времени и версии. Вероятно, конкретная версия, которая представила вложенный цикл, имела бы пиковые задержки.

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

Одно ограничение в.NET заключается в том, что каждый раз, когда вы видите ожидание, логика блокируется и отправляет только один запрос за раз. Это останавливает итерацию и ждет, пока второй API вернет ответ. Это печальная новость для спектакля.

Один наивный подход состоит в том, чтобы просто разорвать цикл, отправив все HTTP-запросы одновременно:

app.MapGet("/", async (HttpClient client) =>
 (await Task.WhenAll( // blocks only once
   Enumerable
     .Range(0, 100)
     .Select(_ =>
       client.GetFromJsonAsync<List<WeatherForecast>>( // Oof
         "/weatherForecast")
     )
   )
 )
 .ToArray()[Random.Shared.Next(0, 100)]);

Это уничтожит ожидание внутри цикла и заблокирует только один раз. Посылает Task.WhenAllвсе параллельно, что разбивает цикл.

Такой подход может сработать, но он рискует спамить второй API слишком большим количеством запросов одновременно. Веб-сервер может отклонять запросы, так как считает, что это может быть DoS-атака. Гораздо более устойчивый подход — сократить количество итераций, отправляя только несколько за раз:

var sem = new SemaphoreSlim(10); // 10 at a time

app.MapGet("/", async (HttpClient client) =>
 (await Task.WhenAll(
   Enumerable
     .Range(0, 100)
     .Select(async _ =>
     {
       try
       {
         await sem.WaitAsync(); // throttle requests
         return await client.GetFromJsonAsync<List<WeatherForecast>>(
           "/weatherForecast");
       }
       finally
       {
         sem.Release();
       }
     })
   )
 )
 .ToArray()[Random.Shared.Next(0, 100)]);

Это работает так же, как вышибала в клубе. Максимальная вместимость десять. Когда запросы поступают в пул, одновременно могут поступать только десять. Это также позволяет выполнять одновременные запросы, поэтому, если один запрос выходит из пула, другой может немедленно войти, не дожидаясь десяти запросов.

Это снижает сложность алгоритма в десять раз и снимает нагрузку со всех сумасшедших циклов.

Имея этот код, запустите NBomber и проверьте результаты.

Имея этот код, запустите NBomber и

Задержки P95 теперь составляют треть от того, что было раньше. Полсекундный ответ гораздо более разумен, чем все, что занимает больше секунды. Конечно, вы можете продолжить и оптимизировать это, но я думаю, что ваши клиенты будут очень довольны этим.

Заключение

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

Оцените статью
bestprogrammer.ru
Добавить комментарий