Кажется, что «отправить запрос в базу» или «сходить в соседний сервис» — это одно мгновенное действие. На самом деле перед тем, как улетит первый байт полезных данных, между двумя машинами происходит целый ритуал знакомства: они устанавливают соединение, а если канал защищённый — ещё и договариваются о шифровании. Ритуал этот стоит времени, и если делать его на каждый запрос, сервис начинает необъяснимо тормозить под нагрузкой.
Хорошая новость: соединение можно открыть один раз и переиспользовать много раз. Плохая — управлять переиспользованием нужно аккуратно, иначе получаются классические аварии «too many connections» и «connection pool exhausted». Разберёмся, откуда берётся цена соединения, что такое keep-alive и пулы, и какие таймауты уберегают сервис от зависаний.
Почему открыть соединение дорого
Возьмём типичный 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 или другой базе, и когда коду нужно выполнить запрос, он берёт готовое соединение из пула. Ключевые настройки везде похожи (пример):
pool:
maximum-pool-size: 10 # сколько соединений держим максимум
connection-timeout: 30000 # сколько ждать свободное, мс
idle-timeout: 600000 # закрыть простаивающее через 10 мин
max-lifetime: 1800000 # обновить соединение через 30 мин
Пул к соседним сервисам. Когда сервис ходит по HTTP в другой сервис, HTTP-клиент тоже держит пул соединений на каждый хост — и переиспользует их через keep-alive. Идея та же: не открывать TLS-рукопожатие на каждый вызов, а гонять запросы по уже готовым линиям.
Размер пула: золотая середина
Главный вопрос настройки пула — сколько соединений держать. И тут ошибиться легко в обе стороны.
Слишком мало. Все соединения заняты, новые запросы встают в очередь и ждут, пока освободится хоть одно. Пользователь видит задержки на ровном месте, хотя база при этом простаивает. Симптом — запросы «висят» именно в ожидании соединения, а не в работе.
Слишком много. Кажется, «дам пул побольше, будет быстрее». Но у самой базы есть жёсткий лимит на число одновременных соединений (в PostgreSQL это max_connections, часто около 100). Каждое соединение — это память и процесс на стороне базы. Если десять инстансов сервиса откроют по 50 соединений, база захлебнётся и начнёт отдавать ошибку «too many connections» — причём всем сразу, а не только виновнику.
Практика контринтуитивна: небольшой пул часто быстрее большого. База с меньшим числом соединений меньше конкурирует за ресурсы и обрабатывает запросы ровнее. Разумная отправная точка для одного инстанса — единицы, максимум пара десятков соединений, а не сотни. Тонкости настройки со стороны PostgreSQL — в разделе про PostgreSQL.
Таймауты: не ждать вечно
Соединение может не установиться, сервер может задуматься, сеть может тихо проглотить пакет. Без таймаутов поток будет ждать ответа бесконечно — а такие зависшие потоки копятся и в какой-то момент кладут весь сервис. Поэтому у сетевых вызовов настраивают несколько разных таймаутов, и путать их не стоит:
- Connect timeout — сколько ждать установки соединения. Сервер не отвечает на рукопожатие? Через N секунд сдаёмся. Обычно небольшой — единицы секунд.
- Read timeout (он же socket timeout) — сколько ждать ответа по уже открытому соединению. Соединились, отправили запрос, а данных в ответ нет — через N секунд обрываем.
- Timeout ожидания из пула — сколько запрос готов простоять в очереди за свободным соединением (в примере выше это
connection-timeout). Пул исчерпан и не освобождается? Не висим вечно, а сразу отдаём понятную ошибку. - Idle timeout — сколько держать простаивающее соединение открытым, прежде чем закрыть. Незачем занимать ресурсы линией, которой никто не пользуется.
Правило простое: у каждого внешнего вызова должны быть выставлены connect и read таймауты. Вызов без таймаута — это мина: однажды удалённая сторона зависнет, и вместе с ней зависнут ваши потоки. Как система в целом переживает такие отказы — в статье про отказоустойчивость.
Утечки соединений
Отдельная беда — утечка соединений. Взяли соединение из пула, поработали и… забыли вернуть. Например, код упал с ошибкой до того, как соединение закрылось, а обработки этого случая нет.
Аналогия: человек взял велосипед с проката и не вернул. Один раз — ерунда. Но если так делает каждый, стойка пустеет, и следующие клиенты стоят перед пустой стойкой. В сервисе это выглядит как медленное отравление: сначала всё хорошо, потом запросы начинают всё дольше ждать соединение, и в итоге пул исчерпан — та самая ошибка «connection pool exhausted». Перезапуск помогает на время, но течь остаётся.
Защита двойная. Первая — писать код так, чтобы соединение возвращалось всегда, что бы ни случилось (в Java это try-with-resources, в Go — defer, в Python — with; на практике при работе через фреймворк соединения обычно закрывает он сам, если не лезть в них руками). Вторая — многие пулы умеют ловить «зависшие» соединения и писать в лог, если соединение держат подозрительно долго, — это первый маячок, что где-то течёт.
Где это применяется
Соединения, пулы и таймауты — это тот слой, который редко замечают, пока он работает, и который первым всплывает при разборе странных замедлений и аварий под нагрузкой. Три самых частых симптома, за которыми стоит именно эта тема:
- «too many connections» — суммарно инстансы открыли к базе больше соединений, чем она разрешает. Лечится не увеличением лимита базы, а уменьшением пулов.
- «connection pool exhausted» — свободных соединений в пуле нет. Либо пул мал под нагрузку, либо где-то утечка, либо запросы к базе слишком долгие и не отпускают соединение.
- Зависания без таймаута — удалённая сторона молчит, а вызов ждёт вечно, копя занятые потоки. Классический способ уронить сервис через один медленный внешний сервис.
Где спотыкаются начинающие:
- Открывают соединение на каждый запрос. Работает в тестах, разваливается под нагрузкой: всё время уходит на рукопожатия. Пул и keep-alive существуют именно для этого.
- Раздувают пул «на всякий случай». Больше — не значит быстрее; база с сотнями соединений работает хуже, чем с десятком. Узкое место обычно сама база, а не размер пула.
- Не ставят таймауты. Вызов без connect/read таймаута однажды зависнет и утянет за собой потоки. Значения по умолчанию у клиентов часто «бесконечность» — их надо задавать явно.
- Путают виды таймаутов. Connect, read и ожидание из пула — про разные фазы. Настроить один и забыть про остальные — значит закрыть только часть дыр.
- Забывают возвращать соединение. Утечка не видна сразу и проявляется отравлением спустя часы работы под нагрузкой.
Что учить дальше
Соединение — это надстройка над транспортом, поэтому логично сперва укрепить фундамент: TCP и UDP объясняют само рукопожатие и почему оно стоит один RTT, а HTTPS и TLS — откуда берётся вторая, шифрующая часть цены. Дальше стоит посмотреть, как одно соединение переиспользуется под много запросов в разных версиях HTTP. Когда речь про пул к базе, детали настройки со стороны сервера — в разделе про PostgreSQL. А как всё это вместе переживает отказы и распределяет нагрузку — в статьях про отказоустойчивость и балансировщики.