Google предоставляет такую статистику посещаемости веб-страниц:

53% посетителей мобильных сайтов покидают страницу, если она загружается более трёх секунд.
https://www.thinkwithgoogle.com/marketing-resources/data-measurement/mobile-page-speed-new-industry-benchmarks/

Это значит, что очень важно сделать ваш сайт максимально быстрым и отзывчивым. Между тем, недавно мы полностью переписали API для сохранённых элементов, чтобы улучшить доступность функционала пользователям и повысить производительность, используя новые технологии, такие как .NET Core 2.

Вступление

Итак, API был переписан и функции работали, как мы ожидали. Продукт был одобрен, проведены интеграция и тесты производительности. Всё выглядело прекрасно! Клиенты API переключились на новые конечные точки. 

У мобильных приложений была первая работающая реализация. Как только аномалии были устранены, мы выкатили клиентам обновления как бета-версии. Трафик пошёл на API.

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

Ухудшение было минимальным  —  они отразились только на пользователях бета-версии. Но что делать с релизом для всех клиентов? После командного мозгового штурма появилось несколько идей.

Блокирование потоков

Часто узким местом оказывается пул потоков. Он может быть занят, исчерпан или недоступен для обработки новых запросов. У нас были симптомы истощения пула, так что с него мы и начали решать проблемы. Насколько мы знали, наше приложение следовало лучшим асинхронным практикам. Но кодовая база не маленькая, и мы могли что-то упустить, поэтому использовали библиотеку Ben.BlockingDetectorbenaadams/Ben.BlockingDetector
Blocking Detection for ASP.NET Core Blocking calls can lead to ThreadPool starvation. Ouputs a warning to the log when…github.com

Чтобы добавить её в API, нужно:

  • Установить через NuGet:
Install-Package Ben.BlockingDetector -Version 0.0.3
  • Вызвать лишь один метод в IApplicationBuilder:
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
      {
         // Другие конфигурации
         app.UseBlockingDetection();
         // Другие конфигурации
      }

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

Blocking method has been invoked and blocked, this can lead to threadpool starvation at ….

Блокирующий метод был вызван и заблокирован. Это может привести к истощению пула потоков в ….

Мы нашли много мест, где асинхронный вызов был лучшей альтернативой. Основной причиной задержек оказалось соединение с Redis.

Соединение с Redis

Мы использовали кэширование Redis через пакет клиента StackExchange. Соединение было таким:

ConnectionMultiplexer.Connect(Connection)

Мы думаем, что здесь и заключается самая большая проблема. Если соединение быстрое и успешное  —  всё замечательно, но если есть проблема и задержка, например, в 30 секунд, то поток блокируется. Поэтому мы заменили этот код на асинхронный:

ConnectionMultiplexer.ConnectAsync(Connection)

Мы не видели этого в тестах производительности потому, что при тестировании у нас не было проблем с соединением.

Управление большим объёмом данных Redis

Также в Redis мы работали с коллекциями и несколькими записями одновременно. Раньше мы объединяли запросы так:

var batch = database.CreateBatch();
foreach (var record in records)
{
  await AddSetToBatch(record, batch);
}
batch.Execute();

Ещё один виновник блокировок  —  batch.Execute(). Мы переключились на немедленное выполнение и ожидание всех этапов как задач:

var setRedisCacheTasks = records.Select(record => database.StringSetAsync(
   record.Key,
   record.Value,
   record.TimeToKeepInCache, 
   when: When.Always, 
   flags: CommandFlags.FireAndForget)
);
await Task.WhenAll(setRedisCacheTasks);

И теперь, что важно, создаём записи прямо в объекте базы данных, не создавая пакет запросов. Мы также не используем foreach, а значит, не ожидаем завершения работы с каждой записью отдельно. Мы запускаем все задачи сразу параллельно и ожидаем завершения работы с ними.

Логирование

Наконец, мы использовали библиотеку Serilog. И она, как говорят, блокирует потоки. Мы писали логи в два места. Одним из них была консоль. Мы не смотрим на консоль в продакшне: у нас есть инструмент логирования поудобнее. Кроме того, как выяснилось, консольный вывод работает медленно. 

Итак, консольный вывод:

  • Медленный.
  • Мы не читаем его.
  • Он потенциально блокирует поток.

Поэтому отключаем консоль:

var config = new LoggerConfiguration();
if (isDevelopmentEnvironment)
{
  config.WriteTo.Console();
}

Теперь наличием консольного вывода в проекте управляет метод isDevelopmentEnvironment, устанавливаемый переменной окружения ASPNETCORE_ENVIRONMENT. Он false в продакшне. Есть и неконтролируемые причины блокировок, например, сериализация JSON.

Избыточные задачи

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

Сейчас мы используем Polly  —  популярную библиотеку, помогающую легко контролировать задачи, такие как тайм-ауты или повтор неудачных тестов. Она имеет и другие мощные функции. Вот так мы обрабатываем вызовы зависимостей:

  • Тайм-аут после 30 секунд.
  • Трёхкратный повтор, когда запрос не удался, включая неудачу из-за тайм-аута.
  • Если четвёртая попытка неудачна, бросаем исключение и обрабатываем, указывая ошибку в ответе API.

Мы не должны видеть запросы, отнимающие больше 30 секунд, верно? Но в мониторинге мы видели запросы более 10 минут! Почему? Мы сотворили хаос: запросы были медленными из-за использования прокси middleman. После некоторого тестирования и отладки с такой конфигурацией мы определяли тайм-аут и возвращали ошибку из-за сбоя вызова в течение продолжительности тайм-аута в конфигурации. И упускали из виду поведение Polly. Из документации:

Polly не рискует состоянием приложения и не прерывает потоки в одностороннем порядке.

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

Polly о соединениях:

Ожидание без тайм-аута  —  плохая стратегия. Она приводит к блокировке потоков или соединений и сама по себе является причиной дальнейших сбоев.

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

  1. При выполнении запроса мы должны использовать перегруженный метод, принимающий CancellationToken.
  2. При определении политики тайм-аутов устанавливаем TimeoutStrategy в Optimistic. Предполагается, что CancellationToken прервётся, выбросив исключение, в соответствии со стандартной семантикой отмены.
// var timeSpan = TimeSpan.FromSeconds(30)
return Policy.TimeoutAsync<HttpResponseMessage>(timeSpan, TimeoutStrategy.Optimistic);

Мы изменили код получения ответа:

var response = await _pollyPolicy.ExecuteAsync(async () => await _httpClient.GetAsync(url));

Теперь он такой:

var response = await _policyWrap.ExecuteAsync(async token => 
       await _httpClient.GetAsync(url, token), 
    CancellationToken.None);

CancellationToken.None может быть вашим CancellationToken.

Вот, что происходит:

  • Мы передаем CancellationToken или CancellationToken.None политике Polly, и она передает нам еще один CancellationToken в лямбде. Это важно, так как это  —  токен, который мы должны передать в исполняемый код.
  • Polly создает свой собственный CancellationTokenSource, связывая его с вашим CancellationToken, а вам возвращает новый.
  • Если вы отмените свой токен, то и запрос будет отменён: эти токены связаны.

Когда тайм-аут истекает, Polly отменяет токен с помощью собственного CancellationTokenSource. Запрос также будет отменён.

После релиза

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

Итоги

Обнаружение блокировок включено только для тестового окружения. Мы можем быть уверены, что в проекте не будет блокирующего потоки кода, если мы будем отслеживать блокировки и исправлять код. Асинхронные вызовы должны использоваться везде, где это возможно, чтобы избежать истощения пула.

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

Эти методы  —  гарантия производительности, а также того, что мы не столкнёмся с описанными проблемами в будущем.

Читайте также:


Перевод статьи Tom Longhurst: Maximising .NET Core API performance

Предыдущая статьяФункции-генераторы в JavaScript для оптимизации памяти
Следующая статьяКак я встраивал ресурсы в Go