Google предоставляет такую статистику посещаемости веб-страниц:
Это значит, что очень важно сделать ваш сайт максимально быстрым и отзывчивым. Между тем, недавно мы полностью переписали API для сохранённых элементов, чтобы улучшить доступность функционала пользователям и повысить производительность, используя новые технологии, такие как .NET Core 2.
Вступление
Итак, API был переписан и функции работали, как мы ожидали. Продукт был одобрен, проведены интеграция и тесты производительности. Всё выглядело прекрасно! Клиенты API переключились на новые конечные точки.
У мобильных приложений была первая работающая реализация. Как только аномалии были устранены, мы выкатили клиентам обновления как бета-версии. Трафик пошёл на API.
О, это страшное клеймо новизны. Мы протестировали, что могли, но все мы знаем, как по-разному ведут себя конфигурации окружения. И это был наш первый значительный трафик, первая большая нагрузка. Через некоторое время мы получили тревожные сигналы. В логах — ошибки и скачки продолжительности ответа на вызовы зависимостей. Затем всё успокаивается, производительность возвращается к нормальной. И снова — случайные потери и возвраты к норме без ясной связи с чем-либо.
Ухудшение было минимальным — они отразились только на пользователях бета-версии. Но что делать с релизом для всех клиентов? После командного мозгового штурма появилось несколько идей.
Блокирование потоков
Часто узким местом оказывается пул потоков. Он может быть занят, исчерпан или недоступен для обработки новых запросов. У нас были симптомы истощения пула, так что с него мы и начали решать проблемы. Насколько мы знали, наше приложение следовало лучшим асинхронным практикам. Но кодовая база не маленькая, и мы могли что-то упустить, поэтому использовали библиотеку Ben.BlockingDetector. benaadams/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(); // Другие конфигурации }
Используя эту библиотеку в тестовом окружении, мы запустили регрессионные тесты. И вот, что увидели в логах:
Блокирующий метод был вызван и заблокирован. Это может привести к истощению пула потоков в ….
Мы нашли много мест, где асинхронный вызов был лучшей альтернативой. Основной причиной задержек оказалось соединение с 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 имела дело с тайм-аутом запроса и возобновлением работы приложения, запрос всё еще находился в потоке где-то в фоне и работал до тех пор, пока сам не получал ответ. Такой вызов зависимости больше не привязан к входящему запросу к API. Это означает, что если он успешно завершится, то фактически не вернется к инициировавшему его запросу. Поэтому он тратит ресурсы соединения впустую.
Polly о соединениях:
Если время вызова зависимости истекло, мы должны предварительно отменить его, чтобы предотвратить фоновое выполнение и бесполезную трату ресурсов. Изменения оказались неожиданно простыми:
- При выполнении запроса мы должны использовать перегруженный метод, принимающий CancellationToken.
- При определении политики тайм-аутов устанавливаем 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 и не приводят к бесполезному расходованию ресурсов. Мы больше не наблюдаем всплесков в логах благодаря тому, что не делаем блокирующие вызовы, а значит, у нас в пуле всегда есть доступные потоки. Мы больше не получаем сигналы тревоги каждый день. Производительность определённо улучшилась и мониторинг наших приложений подтверждает это.
Итоги
Обнаружение блокировок включено только для тестового окружения. Мы можем быть уверены, что в проекте не будет блокирующего потоки кода, если мы будем отслеживать блокировки и исправлять код. Асинхронные вызовы должны использоваться везде, где это возможно, чтобы избежать истощения пула.
О вызовах зависимостей мы теперь знаем, что должны обеспечить поддержку отмены с реализацией тайм-аутов и отменяющих запрос токенов, где это возможно.
Эти методы — гарантия производительности, а также того, что мы не столкнёмся с описанными проблемами в будущем.
Специально для сайта ITWORLD.UZ. Новость взята с сайта NOP::Nuances of programming