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

Хорошая новость: соединение можно открыть один раз и переиспользовать много раз. Плохая — управлять переиспользованием нужно аккуратно, иначе получаются классические аварии «too many connections» и «connection pool exhausted». Разберёмся, откуда берётся цена соединения, что такое keep-alive и пулы, и какие таймауты уберегают сервис от зависаний.

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

Три запроса подряд в другой дата-центр, RTT 25 мс Без keep-alive TCP TLS #1 TCP TLS #2 TCP TLS #3 255 мс 225 мс из 255 — только знакомство С keep-alive TCP TLS #1 #2 #3 105 мсна 150 мс быстрее105 мс — внизу уже всё, вверху идёт только второе рукопожатие рукопожатие TCP+TLS — 75 мс запрос и ответ — 10 мс

Рукопожатие TCP+TLS стоит 75 мс — втрое дороже самого запроса. Со свежим соединением на каждый вызов три запроса занимают 255 мс, с keep-alive — 105 мс: цену знакомства платят один раз.

Обязательно

Почему открыть соединение дорого

Возьмём типичный HTTPS-вызов. Прежде чем клиент отправит GET /orders/42, происходит два рукопожатия подряд.

Сначала TCP-рукопожатие — три коротких сообщения (SYN, SYN-ACK, ACK), которыми стороны договариваются «я хочу поговорить — давай — договорились». Это один полный оборот сигнала туда-обратно, то есть один RTT (round-trip time). Если сервер в соседней стойке, RTT — доли миллисекунды. Если в другом дата-центре — уже десятки миллисекунд.

Потом TLS-рукопожатие — стороны предъявляют сертификаты и договариваются о ключах шифрования. Это ещё один-два оборота сигнала. Аналогия: TCP — это дозвониться и услышать «алло», а TLS — убедиться, что на том конце правда тот, за кого себя выдаёт, и договориться о секретном языке. Только после всего этого уходит собственно запрос.

Итог: «пустой» вызов в другой дата-центр может потратить 50–150 мс просто на установку соединения — ещё до того, как сервер начнёт что-то делать. На одном запросе незаметно. На тысяче запросов в секунду, каждый со своим свежим соединением, — это стена. Подробнее про механику самого соединения — в статье про TCP и UDP, а про шифрующий слой — в HTTPS и TLS.

Keep-alive: не класть трубку

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

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

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

Пул соединений: общий ящик открытых линий

Один клиент, который переиспользует одно соединение, — это хорошо, но сервису нужно обслуживать много запросов одновременно. Тогда открытых линий нужно несколько. Их держат в пуле — заранее открытом наборе соединений, из которого запрос берёт свободное, пользуется им и возвращает обратно.

Аналогия — стойка проката велосипедов. Велосипеды (соединения) уже стоят готовые. Пришёл человек — взял, доехал, вернул. Не нужно каждому собирать велосипед с нуля. Если все велосипеды разобрали — новый человек ждёт, пока кто-то вернёт.

запрос за соединением работает сразу свободное есть стоит в очереди все заняты ошибка пула ждал дольше таймаута

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

Пул к базе данных. Самый частый случай. В каждой экосистеме есть свой стандарт: в Java — HikariCP, в Go — пул внутри database/sql, в Node.js — пул драйвера (pg), в Python — SQLAlchemy или пул psycopg. Идея одна: пул держит открытые соединения к PostgreSQL или другой базе, и когда коду нужно выполнить запрос, он берёт готовое соединение из пула. Ключевые настройки везде похожи — вот они на примере Spring Boot и HikariCP:

spring:
  datasource:
    hikari:
      maximum-pool-size: 10        # сколько соединений держим максимум
      connection-timeout: 30000    # сколько ждать свободное, мс
      idle-timeout: 600000         # закрыть простаивающее через 10 мин
      max-lifetime: 1800000        # выбросить и открыть заново через 30 мин

Последняя строка выглядит бессмысленной: зачем выбрасывать живое рабочее соединение? Затем, что оно может быть уже не живым. У базы на той стороне есть свой предел простоя, и межсетевой экран между вами тоже втихую забывает соединения, о которых давно ничего не слышал. В пуле такое соединение продолжает числиться исправным — а первый же запрос по нему падает с «connection reset». Поэтому max-lifetime берут заведомо меньше самого короткого чужого срока: пусть пул закрывает соединения сам и по своей воле, чем однажды наткнётся на закрытое кем-то другим. Как это считают по числам — в статье про два пула.

Пул к соседним сервисам. Когда сервис ходит по HTTP в другой сервис, HTTP-клиент тоже держит пул соединений на каждый хост — и переиспользует их через keep-alive. Идея та же: не открывать TLS-рукопожатие на каждый вызов, а гонять запросы по уже готовым линиям.

Размер пула: золотая середина

Главный вопрос настройки пула — сколько соединений держать. И тут ошибиться легко в обе стороны.

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

Слишком много. Кажется, «дам пул побольше, будет быстрее». Но у самой базы есть жёсткий лимит на число одновременных соединений (в PostgreSQL это max_connections, часто около 100). Каждое соединение — это память и процесс на стороне базы. Если десять инстансов сервиса откроют по 50 соединений, база захлебнётся и начнёт отдавать ошибку «too many connections» — причём всем сразу, а не только виновнику.

Практика контринтуитивна: небольшой пул часто быстрее большого. База с меньшим числом соединений меньше конкурирует за ресурсы и обрабатывает запросы ровнее. Разумная отправная точка для одного инстанса — единицы, максимум пара десятков соединений, а не сотни. Тонкости настройки со стороны PostgreSQL — в разделе про PostgreSQL.

Таймауты: не ждать вечно

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

ожидание из пула 20 мс установка соединения 40 мс чтение ответа 120 мс дедлайн на вызов 180 мс

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

Сервер не отвечает на рукопожатие: за это отвечает connect timeout, сколько ждать установки соединения. Его держат коротким: внутри одного дата-центра соединение поднимается за доли миллисекунды, так что хватает сотен миллисекунд; до чужого сервиса через интернет закладывают секунду-другую. Не установилось за это время — не установится и за десять.

Соединение открыто, а данные не идут: это read timeout (он же socket timeout), сколько ждать очередной порции данных. Тут прячется главное недоразумение: это предел не на весь ответ, а на молчание, каждый пришедший байт обнуляет отсчёт заново. Поэтому ответ, который течёт медленно, но без пауз, по этому таймауту не оборвётся никогда — отсюда и знаменитое «таймауты вроде стоят, а вызов висит».

От такого ответа спасает только дедлайн на операцию, предел на весь вызов целиком: установка соединения, ожидание в очереди пула, повторы и чтение ответа вместе. Это единственный таймаут, который действительно обещает «дольше стольких секунд мы тут не задержимся», и сам собой он не появляется — его задают отдельно. В стандартном HTTP-клиенте Java это HttpRequest.Builder.timeout(Duration), рядом с HttpClient.Builder.connectTimeout(Duration); в gRPC — deadline на вызов.

Пул исчерпан, и запрос стоит в очереди за свободным соединением: сколько там можно простоять, говорит timeout ожидания из пула (в примере выше это connection-timeout), и по истечении мы не висим, а сразу отдаём понятную ошибку. А соединение, которое открыто и никому не нужно, закрывает idle timeout: незачем занимать ресурсы линией, которой никто не пользуется.

Правило простое: у каждого внешнего вызова должны быть выставлены connect и read таймауты. Вызов без таймаута — это мина: однажды удалённая сторона зависнет, и вместе с ней зависнут ваши потоки. Как система в целом переживает такие отказы — в статье про отказоустойчивость.

Утечки соединений

Отдельная беда — утечка соединений. Взяли соединение из пула, поработали и… забыли вернуть. Например, код упал с ошибкой до того, как соединение закрылось, а обработки этого случая нет.

Аналогия: человек взял велосипед с проката и не вернул. Один раз — ерунда. Но если так делает каждый, стойка пустеет, и следующие клиенты стоят перед пустой стойкой. В сервисе это выглядит как медленное отравление: сначала всё хорошо, потом запросы начинают всё дольше ждать соединение, и в итоге пул исчерпан — та самая ошибка «connection pool exhausted». Перезапуск помогает на время, но течь остаётся.

Защита двойная. Первая — писать код так, чтобы соединение возвращалось всегда, что бы ни случилось (в Java это try-with-resources, в Go — defer, в Python — with; на практике при работе через фреймворк соединения обычно закрывает он сам, если не лезть в них руками). Вторая — многие пулы умеют ловить «зависшие» соединения и писать в лог, если соединение держат подозрительно долго, — это первый маячок, что где-то течёт.

Как посчитать размер пула к базе

«Единицы, максимум пара десятков» — это ответ на пул одного экземпляра, и считать надо от того, сколько экземпляров работает одновременно.

Арифметика простая: число экземпляров × размер пула ≤ предел соединений базы − запас. Запас нужен на служебные подключения: миграции, резервное копирование, мониторинг, ручные подключения дежурного, — обычно десяток-полтора.

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

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

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

Внешний пул: PgBouncer

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

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

Цена режима транзакции — всё, что живёт в сессии, перестаёт работать: подготовленные запросы на сервере, SET без LOCAL, временные таблицы, сессионные блокировки, уведомления. Подробный разбор с ограничениями и потолком самого пула — в статье про пул соединений к PostgreSQL.

Как увидеть утечку до аварии

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

Занятые и ожидающие. Число выданных соединений и число потоков, стоящих в очереди за соединением. Первое, приближающееся к размеру пула, — предупреждение; второе, стабильно больше нуля, — уже авария в развитии. Оповещение ставят на ожидающих, а не на занятых.

Отказы по таймауту. Счётчик «не смог получить соединение за отведённое время». Любой рост — повод разбираться сегодня.

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

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

Пул потоков и пул соединений — разные пулы

Их путают постоянно, а кончаются они по-разному и лечатся по-разному.

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

Пул соединений — сколько разговоров с базой (или с чужим сервисом) идёт одновременно. Кончился — поток, взявший запрос, стоит в ожидании соединения и занимает поток, ничего не делая.

Связь между ними и есть источник каскада: медленная база держит соединения, потоки ждут соединений, пул потоков кончается, и сервис перестаёт отвечать даже на запросы, которым база не нужна вовсе — например, на проверку работоспособности. Отсюда два правила. Пул соединений должен быть меньше пула потоков: иначе потоки закончатся раньше, чем соединения, и очередь окажется в самом неудобном месте. И на медленные внешние вызовы ставят отдельный ограничитель (перегородку), чтобы они не съедали общий пул потоков — разбор в статье про два пула.

Где это применяется

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

  • «too many connections» — суммарно инстансы открыли к базе больше соединений, чем она разрешает. Лечится не увеличением лимита базы, а уменьшением пулов.
  • «connection pool exhausted» — свободных соединений в пуле нет. Либо пул мал под нагрузку, либо где-то утечка, либо запросы к базе слишком долгие и не отпускают соединение.
  • Зависания без таймаута — удалённая сторона молчит, а вызов ждёт вечно, копя занятые потоки. Классический способ уронить сервис через один медленный внешний сервис.

Где спотыкаются начинающие:

  • Открывают соединение на каждый запрос. Работает в тестах, разваливается под нагрузкой: всё время уходит на рукопожатия. Пул и keep-alive существуют именно для этого.
  • Раздувают пул «на всякий случай». Больше — не значит быстрее; база с сотнями соединений работает хуже, чем с десятком. Узкое место обычно сама база, а не размер пула.
  • Не ставят таймауты. Вызов без connect/read таймаута однажды зависнет и утянет за собой потоки. Значения по умолчанию у клиентов часто «бесконечность» — их надо задавать явно.
  • Путают виды таймаутов. Connect, read и ожидание из пула — про разные фазы. Настроить один и забыть про остальные — значит закрыть только часть дыр.
  • Забывают возвращать соединение. Утечка не видна сразу и проявляется отравлением спустя часы работы под нагрузкой.
Дополнительно: при первом чтении можно пропустить

Глубже: прокси на выход: HTTP_PROXY, NO_PROXY и доверенные корнирасширенное

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

Договорённость про прокси живёт в переменных окружения: HTTP_PROXY и HTTPS_PROXY с адресом прокси (http://proxy.corp:3128), NO_PROXY со списком имён и подсетей, куда идти напрямую. Их читают curl, pip, npm, Docker и большинство HTTP-клиентов, но не все и не одинаково: JVM по умолчанию переменные игнорирует и берёт свойства -Dhttp.proxyHost, -Dhttps.proxyPort, -Dhttp.nonProxyHosts, Go и Python requests читают переменные, Node читает только с дополнительной настройкой агента. Первая проверка при «из прода не ходит» это env | grep -i proxy внутри контейнера и сравнение с тем, что читает ваш клиент.

NO_PROXY важнее, чем кажется. Забыли в нём внутренний домен, и запросы к соседнему сервису и к базе поехали через прокси, который про них не знает: получаете таймауты «изнутри», причём только у клиентов, которые переменную читают. В NO_PROXY кладут localhost, 127.0.0.1, внутренний домен (.corp.local) и подсети кластера; синтаксис у клиентов различается, звёздочки и маски подсети понимают не все.

Вторая половина проблемы это доверие. Прокси, который проверяет HTTPS-трафик, расшифровывает его, подставляя свой сертификат, выпущенный корпоративным центром сертификации. Браузеру на рабочей машине этот центр подсунули, а вашему приложению нет, и оно получает unable to find valid certification path или self-signed certificate in certificate chain. Отключать проверку нельзя; корпоративный корневой сертификат добавляют в хранилище доверия, у каждой платформы своё: в JVM это cacerts и утилита keytool -importcert, либо свой файл через -Djavax.net.ssl.trustStore; в Node переменная NODE_EXTRA_CA_CERTS с путём к файлу; в Python requests переменная REQUESTS_CA_BUNDLE; в Go и curl системное хранилище, куда сертификат кладут через update-ca-certificates. В контейнере это делает Dockerfile, а не человек по SSH.

Соединение через прокси это ещё одна точка, где живут таймауты и лимиты: у прокси свой предел одновременных соединений и своя пауза, и 502 от прокси выглядит как ответ сервиса. Диагностика: curl -v показывает, к кому реально подключились, CONNECT proxy.corp:3128 в выводе означает, что путь через прокси.

Коротко

  • Открыть соединение дорого: рукопожатие TCP и TLS стоит дороже запроса, поэтому соединения держат живыми и складывают в пул.
  • Пул конечен: размер считают под нагрузку, у каждого взятия таймаут ожидания, соединения возвращают всегда (try-with-resources или аналог).
  • Таймауты на подключение, на чтение и на всю операцию задают явно; умолчание клиента часто бесконечно.
  • Из закрытого контура наружу ходят через прокси: HTTP_PROXY, NO_PROXY с внутренними доменами, у JVM свои свойства; корпоративный корень кладут в хранилище доверия платформы, а не отключают проверку.
  • Размер пула считают от числа экземпляров: экземпляры × пул ≤ предел базы − запас, и по максимальному числу экземпляров, а не обычному.
  • Когда экземпляров десятки, ставят внешний пул в режиме транзакции — ценой всего сессионного (подготовленные запросы, временные таблицы, SET без LOCAL).
  • Утечку видно по трём метрикам: ожидающие в очереди за соединением, отказы по таймауту, обнаружение задержанных соединений со стеком виновника.
  • Пул потоков и пул соединений — разные: пул соединений держат меньше пула потоков, иначе очередь окажется в худшем месте и сервис перестанет отвечать даже на проверки.

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

Соединение — это надстройка над транспортом, поэтому логично сперва укрепить фундамент: TCP и UDP объясняют само рукопожатие и почему оно стоит один RTT, а HTTPS и TLS — откуда берётся вторая, шифрующая часть цены. Дальше стоит посмотреть, как одно соединение переиспользуется под много запросов в разных версиях HTTP. Когда речь про пул к базе, детали настройки со стороны сервера — в разделе про PostgreSQL. А как всё это вместе переживает отказы и распределяет нагрузку — в статьях про отказоустойчивость и балансировщики. Ближе всего к разобранному здесь стоит статья про два пула: там размер пула считают по числам и объясняют, зачем медленные вызовы отделяют перегородкой.