← назад к разделу

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

А устроены они по-разному, и разница ровно одна. Из неё следует и разный размер пула, и то, почему HTTP умеет мультиплексирование, а база нет, и главная авария на стыке — когда один пул утаскивает за собой другой.

Если про сам смысл пула ещё не читал — начни с соединений и keep-alive, там про то, почему открывать соединение дорого. Здесь — про то, чем два пула отличаются.

Общее: платим один раз, пользуемся много

Перед тем как улетит первый полезный байт, две машины проводят ритуал знакомства: устанавливают TCP-соединение, а если канал защищённый — ещё и договариваются о шифровании. Это несколько путешествий по сети туда-обратно. На локальной машине незаметно, между дата-центрами — десятки миллисекунд.

Пул решает эту проблему одинаково в обоих случаях: держим открытые линии и раздаём тем, кому надо. На этом сходство заканчивается.

Разница одна: у соединения с базой есть память

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

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

Соединение с БД — занято всей транзакцией BEGIN … SELECT … UPDATE … COMMIT — всё это время линия ничья больше HTTP-соединение — свободно сразу после ответа запрос — ответ — свободна; за то же время четыре разных вызова

Соединение с базой занято всей транзакцией целиком. HTTP-соединение освобождается после каждого ответа — за то же время оно обслуживает четыре разных вызова.

Из этого следует всё остальное.

Почему пул к базе маленький, а HTTP — большой

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

Пул к соседнему сервису может быть в сотни соединений. За его адресом обычно балансировщик и много машин, и лишнее соединение почти ничего не стоит. Ограничение здесь не жёсткое, как max_connections у базы, а вопрос вежливости к соседу и настроек клиента.

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

Мультиплексирование: почему у HTTP пул может выродиться

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

В HTTP/2 всё иначе: в одном TCP-соединении одновременно едут десятки запросов, вперемешку, каждый своим потоком. Пул при этом почти вырождается — одного-двух соединений на хост хватает на всё. Если видишь, что клиент открывает к соседу сотню соединений, а тот умеет HTTP/2, — скорее всего, версия не согласовалась и вы работаете по старому протоколу.

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

Пул к базе один, HTTP-пулов много

Ещё одно различие, на котором спотыкаются при настройке. Пул к базе один: одна база — один набор соединений.

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

Общая грабля: клиент должен сдаваться раньше сервера

Сервер на той стороне сам закрывает соединения, которые долго простаивают. То же делает файрвол между вами, часто молча. Если клиент об этом не знает и считает соединение живым, он отправит запрос в уже закрытую трубу и получит внезапный обрыв — обычно под нагрузкой и обычно необъяснимый.

Сервер закрывает простой через 60 с закрыл Клиент держит 90 с — дольше сервера запрос ушёл в закрытую трубу — обрыв Правило одно и для базы, и для HTTP: на клиенте — меньше, чем на сервере

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

Лечится одинаково для обоих пулов: время жизни соединения на клиенте выставляют заведомо меньше, чем на сервере и на файрволе. У базы это maxLifetime в пуле, у HTTP — время простоя в пуле клиента. Правило одно, а забывают про него обычно на стороне HTTP: про базу все читали, про соседний сервис — нет.

Авария на стыке: сетевой вызов внутри транзакции

Теперь то, ради чего стоит держать оба пула в голове одновременно.

Открыли транзакцию, что-то записали, а посреди неё сходили по HTTP во внешний сервис — за курсом валют, за проверкой лимита, за чем угодно. Выглядит безобидно. На деле вы держите сразу оба ресурса: HTTP-соединение занято пока сосед думает, а соединение с базой — всё это время плюс время самой транзакции.

Транзакциядержит соединение Внешний сервисдумает 5 секунд ответ когда-нибудь Пул соединений к базе занято занято занято занято …все десять держат внешний вызов Очередь на соединение встало всё, даже несвязанное

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

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

Отсюда правило, которое стоит помнить дословно: сетевых вызовов внутри транзакции не бывает. Сходи к соседу до транзакции или после неё; если результат нужен обязательно и в одной операции — сохрани намерение в базу, закрой транзакцию, а вызов сделай отдельным шагом.

Тот же механизм срабатывает мягче в других местах: долгий расчёт внутри транзакции, ожидание блокировки, запись большого файла. Всё это держит соединение и уменьшает пропускную способность пула ровно во столько раз, во сколько удлинилась транзакция.

Как это выглядит в мониторинге

Симптом один и тот же с обеих сторон, а причина разная — поэтому смотреть надо оба пула сразу.

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

Полезная привычка: выводить в метрики не только «сколько соединений занято», но и время ожидания в очереди за соединением. Занятость сама по себе не тревожна — тревожно, когда за соединением стоят.

Коротко

  • Пулы существуют по одной причине: установка соединения стоит нескольких путешествий по сети, и платить за неё на каждый запрос нельзя.
  • Разница одна и она в состоянии: соединение с базой stateful и занято всей транзакцией, HTTP-соединение освобождается сразу после ответа.
  • Отсюда размеры: к базе десяток-другой (там процессы, ядра и диски), к соседу могут быть сотни.
  • HTTP/2 мультиплексирует запросы в одном соединении, и пул почти вырождается; у базы так не бывает никогда.
  • HTTP-пул разбит по адресатам, и настоящее ограничение — лимит на один адрес, а не общий.
  • Общее правило таймаутов: время жизни соединения на клиенте меньше, чем на сервере и файрволе, иначе получишь запрос в закрытую трубу.
  • Главная авария на стыке: сетевой вызов внутри транзакции держит оба пула и роняет весь сервис, а выглядит как «база легла».
  • В мониторинге смотреть не занятость пула, а время ожидания в очереди за соединением.

Что почитать дальше