1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

# теория · шаг 4 из 7

Соединение: где кончается один запрос

Коротко

HTTP ничего не знает про сеть - он живёт поверх соединения, которое кто-то установил. Установка стоит времени, поэтому соединение переиспользуют, и отсюда растёт половина настроек nginx.

  • Каждый запрос - это круг по сети. Установка соединения - ещё один круг, TLS - от одного до двух сверх того.
  • Соединение живёт дольше запроса: по умолчанию nginx держит его открытым 75 секунд после ответа.
  • Чтобы переиспользовать соединение, обе стороны должны знать, где кончается ответ: либо Content-Length, либо Transfer-Encoding: chunked.
  • Бэкенд, ответивший без длины и закрывший соединение, не ломает клиенту keep-alivekeep-aliveДоговорённость не закрывать соединение после ответа, чтобы следующий запрос не платил за установку заново. nginx держит его открытым 75 секунд по умолчанию.: nginx перекодирует ответ в chunkedchunkedСпособ передать ответ, размер которого выяснится только по ходу: каждый кусок начинается со своей длины, а кусок нулевой длины означает конец. Второй способ - заранее известный Content-Length..

Если счёт кругов и разница Content-Length с chunked знакомы - листай до «Теперь сам».

Сначала ответь сам

Стенд с искусственной задержкой: 50 миллисекунд в одну сторону. Запрашиваем файл в 300 байт по обычному HTTP. Сколько займёт?

↳  выводcurl -w 'connect=%{time_connect} total=%{time_total}'
connect=0.051  total=0.102

Сто две миллисекунды на триста байт. Данные тут ни при чём: время ушло на два круга. Первый - установить TCP-соединениеTCP-соединениеУстановленный канал между клиентом и сервером, по которому байты приходят в том же порядке, в каком отправлены. HTTP живёт поверх него: сначала соединение, потом уже запросы. Установка стоит одного круга по сети., второй - спросить и получить ответ. Файл мог быть в десять раз меньше, разницы бы не было.

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

На что это похоже

Звонок по телефону. Сначала набираешь номер и ждёшь, пока на том конце снимут трубку и скажут «алло» - это круг, и вопроса в нём ещё не было. Потом задаёшь вопрос и ждёшь ответ - второй круг.

Если вопросов три, у тебя два варианта. Позвонить трижды - трижды набирать и трижды слушать «алло». Или задать все три в одном разговоре, не вешая трубку. И если разговор дорогой, второй вариант выигрывает не немного, а на треть.

Именно это и делает keep-alive. А трубку, которую держат «на всякий случай», рано или поздно кладут: сидеть с открытой линией без дела тоже стоит денег.

Механизм: из чего складывается время

HTTP TCP запрос 102 мс HTTPS, TLS 1.3 TCP TLS запрос 163 мс HTTPS, TLS 1.2 TCP TLS TLS запрос 210 мс Второй запрос в том же соединении - один круг, 50 мс. Три файла тремя соединениями: 304 мс. Три файла одним: 204 мс. Замер на стенде с задержкой 50 мс в одну сторону.
Каждый прямоугольник - один круг по сети. Разница между второй и третьей строкой - это ровно то, ради чего переходят на TLS 1.3.

Правило

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

Разбор: где кончается ответ

Чтобы отправить второй запрос в то же соединение, клиент обязан понять, где кончился первый ответ. Способов ровно два.

Длина известна заранее - сервер ставит Content-Length, клиент отсчитывает столько байт и знает, что дальше начнётся следующий ответ. Так отдаётся файл с диска: его размер известен.

Длина заранее неизвестна - тогда Transfer-Encoding: chunked: тело идёт кусками, у каждого в начале написан его размер, а кусок нулевой длины означает конец. Так отдаётся всё, что генерируется на лету.

Есть и третий способ, оставшийся от старых времён: не говорить ничего и просто закрыть соединение. Он работает, но убивает переиспользование - соединения-то больше нет. Проверено на стенде: бэкенд ответил без длины и закрыл связь, а nginx клиенту отдал так:

↳  выводcurl -sD- к проксируемому ответу без Content-Length
HTTP/1.1 200 OK
Transfer-Encoding: chunked
Connection: keep-alive

nginx перекодировал ответ в chunked и сохранил соединение с клиентом. Он знает, где ответ кончился (бэкенд закрыл связь), и умеет пересказать это клиенту на нормальном языке. Плата за старый бэкенд ложится на nginx, а не на посетителя.

Разбор: где наивное правило подводит

Соединение не бессмертно. nginx держит его открытым keepalive_timeout секунд после ответа. Умолчание - 75, и замер это подтверждает: простаивающее соединение закрылось через 75,1 секунды. Клиент, который решит переиспользовать его на 80-й секунде, получит обрыв и должен уметь переспросить.

keep-alive у клиента и у бэкенда - разные настройки. Соединение «браузер - nginx» держится само; соединение «nginx - бэкенд» до недавнего времени открывалось заново на каждый запрос, и это была самая частая причина «nginx медленный». Начиная с 1.29.7 keepalive к бэкенду включён по умолчанию, и строка proxy_http_version 1.1; из старых руководств больше не нужна. Подробно - в разделе про upstream.

HTTP/1.1 отвечает строго по очереди. Даже в одном соединении второй запрос ждёт, пока закончится первый: очередь одна. Отсюда трюк с несколькими соединениями на домен в браузерах и отсюда же смысл HTTP/2Разбирается в разделе 10, глава «HTTP/2 сегодня»: Что HTTP/2 меняет на самом деле, где в одном соединении едут десятки независимых потоков. Это десятый раздел курса.

Что ломается без этого

Типовая беда - «оптимизировали картинки, а быстрее не стало». Если страница тянет сорок мелких файлов, время съедают не байты, а круги: на мобильной связи с задержкой 60 мс сорок запросов в одну очередь - это секунды ожидания, сколько файлы ни ужимай.

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

Зачем это в работе

Понять, куда ушло время, помогает разбивка того же curl:

$  команда
curl -o /dev/null -s -w 'dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ответ=%{time_starttransfer} итого=%{time_total}\n' https://shop.local/

Числа читаются как лесенка: каждое следующее включает предыдущее. Большой разрыв между tcp и tls - дорогое рукопожатиеРукопожатиеУ TCP это три коротких сообщения, у TLS - обмен параметрами шифрования. Пока рукопожатие не закончилось, ни один байт запроса не отправлен.; между tls и ответ - думает бэкенд; между ответ и итого - тело едет по медленному каналу.

Теперь сам

Мобильное приложение жалуется: первый экран открывается за две секунды, хотя данных там на 40 килобайт и бэкенд отвечает за 30 миллисекунд. Приложение делает двенадцать запросов к API. Что происходит и куда смотреть?

Считай круги, а не байты. Если приложение открывает соединение на каждый запрос, это двенадцать установок плюс двенадцать TLS-рукопожатий, и при задержке в 60 мс получается больше двух секунд одного ожидания. Смотреть надо $upstream_response_time (он подтвердит, что бэкенд быстрый) и число соединений с одного клиента, а чинить - на стороне приложения: переиспользовать соединение и по возможности сложить двенадцать запросов в меньшее число.

Главное

HTTP живёт поверх соединения, и время уходит на круги: TCP - один, TLS 1.3 - ещё один, TLS 1.2 - два, сам запрос - последний. Соединение переживает запрос и закрывается через keepalive_timeout, по умолчанию 75 секунд. Переиспользовать его можно, только если понятно, где кончился ответ: Content-Length для известной длины, chunked для генерируемой на лету. Бэкенд без длины nginx перекодирует в chunked и соединение с клиентом сохранит. В HTTP/1.1 очередь в соединении одна - это и есть причина появления HTTP/2.

Комментарии

Пока нет комментариев. Будь первым!

Оставить комментарий

Комментарий появится после проверки. Email не публикуется. Войти, чтобы не вводить имя каждый раз.