Грабли на пути к keep-alive

в 11:19, , рубрики: .net, http, keep-alive, nginx, высоконагруженные проекты, разработка .net

Увеличение активности обмена данными между микросервисами зачастую является проблемой в архитектуре современных IT решений. Выжать максимум и выжить любой ценой — серьёзный вызов для любой разработки. Поэтому поиск оптимальных решений — это не прекращающийся процесс. В статье кратко изложены проблемы, которые могут возникнуть при высоконагруженном использовании http запросов и пути их обхода.

Эта история начинается с ошибки. Как-то мы проводили нагрузочное тестирование, основным элементом которого было выполнение большого количества коротких http запросов. Клиент, написаный под netcore 2.2, начиная с какого-то момента, выдавал System.Net.Sockets.SocketException: Address already in use. Достаточно быстро выяснилось, что на клиенте не успевали освобождаться порты, и в какой-то момент система получала отказ в открытии нового. Теперь, если перейти к коду, проблема была в использовании старого подхода с классом HttpWebRequest и конструкции:

var request = WebRequest.CreateHttp(uri);
using(var resp = request.GetResponse()){ … }

Казалось бы, мы высвобождаем ресурс, и порт должен быть освобожден своевременно. Однако netstat сигнализировал о быстром росте количества портов в состоянии TIME_WAIT. Это состояние означает ожидание закрытия соединения (и возможно получение потерянных данных). Как следствие порт может находится в нем 1-2 минуты. Данная проблема рассмотрена довольно подробно во многих статьях (Проблемы с очередью TIME_WAIT, История о TIME_WAIT). Все же это означает, что dotnet «честно» пытается закрыть соединение, а дальнейшее происходит уже по вине настроек таймаута в системе.

Почему так происходит и как с этим бороться

Не буду рассказывать про keep-alive. Об этом можно почитать самостоятельно. Целью статьи является попытка обойти грабли, заботливо разложенные на пути разработчика. Согласно msdn, свойство KeepAlive класса HttpWebRequest по умолчанию равно true. То есть все это время HttpWebRequest «обманывал» сервер, предлагая ему поддержать соединение, после чего сам же его разрывал. Если быть точнее, HttpWebRequest с настройками по умолчанию не отправлял заголовок «Connection: keep-alive», просто этот режим подразумевается в стандарте HTTP/1.1. Первое, что следовало попробовать, это принудительно отключить KeepAlive. Если установить HttpWebRequest.KeepAlive = false, то в запросе появляется заголовок «Connection: close». Надо признать, что на тестовом стенде это полностью решило проблему. В качестве сервера был настроен nginx со статической страницей.

Тестировался следующий код:

while (true)
{
  var request = WebRequest.CreateHttp(uri);
  request.KeepAlive = false;
  var resp = await request.GetResponseAsync();
  using (var sr = new StreamReader(resp.GetResponseStream()))
  {
    var content = sr.ReadToEnd();
  }
}

Однако при попытке запустится на серверном железе, при больших нагрузках (свыше 1000 запросов в секунду) этот код вновь начал выдавать те же ошибки. Только теперь порты находились в состоянии CLOSE_WAIT, LAST_ACK. Это пред-финальные состояния закрытия соединения, когда клиент ждет подтверждение от инициатора закрытия. Такое поведение сигнализирует о том, что клиент начинает «захлебываться» вновь открываемыми соединениями.

Закрывать нельзя, переиспользовать

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

Другой вопрос заключается в том, как убедится, что соединения переиспользуются? Существование одного keep-alive соединения регулируется двумя основными параметрами на сервере nginx:

  • keepalive_timeout – время жизни (в среднем 15с)
  • keepalive_requests – максимальное количество запросов в одном соединении (по умолчанию 100)

Если просматривать соединения в netstat или wireshark, то при больших нагрузках открытые порты на клиенте также будут стремительно меняться. Только выставив keepalive_requests в большие значения (> 1000) можно увидеть, что все работает как надо.

Вывод

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

И напоследок немного тестовых сравнений производительности:

  • RunHttpClient – использует класс HttpClient режиме "Connection: keep-alive"
  • RunHttpClientClosed – использует класс HttpClient режиме "Connection: closed"
  • RunWebRequestClosed — использует класс HttpWebRequest режиме "Connection: closed"

Сервер nginx настроен с параметрами:

  • keepalive_timeout 60s;
  • keepalive_requests 100000;

Method N Theads Mean
RunHttpClient 1000 1 963.3 ms
RunWebRequestClosed 1000 1 3,857.4 ms
RunHttpClientClosed 1000 1 1,612.4 ms
RunHttpClient 10000 1 9,573.9 ms
RunWebRequestClosed 10000 1 37,947.4 ms
RunHttpClientClosed 10000 1 16,112.9 ms

Автор: skywarer

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js