В типичном сервисе живут два пула соединений: один к базе, другой к соседним сервисам по HTTP. Снаружи они выглядят одинаково — набор открытых линий, которые переиспользуются вместо того, чтобы устанавливаться заново. Настраивают их часто одинаково: копируют цифры из одного места в другое и удивляются результату.
А устроены они по-разному, и разница ровно одна. Из неё следует и разный размер пула, и то, почему HTTP умеет мультиплексирование, а база нет, и главная авария на стыке — когда один пул утаскивает за собой другой.
Если про сам смысл пула вы ещё не читали — начните с соединений и keep-alive, там про то, почему открывать соединение дорого. Здесь — про то, чем два пула отличаются.
Общее: платим один раз, пользуемся много
Перед тем как улетит первый полезный байт, две машины проводят ритуал знакомства: устанавливают TCP-соединение, а если канал защищённый — ещё и договариваются о шифровании. Это несколько путешествий по сети туда-обратно. На локальной машине незаметно, между дата-центрами — десятки миллисекунд.
Пул решает эту проблему одинаково в обоих случаях: держим открытые линии и раздаём тем, кому надо. На этом сходство заканчивается.
Разница одна: у соединения с базой есть память
Соединение с базой — с состоянием. У него есть сессия: открытая транзакция, временные таблицы, подготовленные запросы, установленный search_path. Пока идёт транзакция, соединение принадлежит ей целиком, и отдать его в середине кому-то другому нельзя — второй увидит чужую незакоммиченную работу.
HTTP-соединение памяти не имеет. Отправил запрос, получил ответ — линия свободна, и следующий запрос может быть каким угодно. Соединение занято миллисекунды, а не всю транзакцию.
Соединение с базой занято всей транзакцией целиком. HTTP-соединение освобождается после каждого ответа — за то же время оно обслуживает четыре разных вызова.
Из этого следует всё остальное.
Почему пул к базе маленький, а HTTP — большой
Пул к базе — десяток-другой соединений. Не из жадности: на той стороне каждое соединение это отдельный процесс со своей памятью, а реальный параллелизм упирается в число ядер и в диски. Тысяча соединений не сделает базу быстрее — она сделает её медленнее, потому что процессор будет занят переключением между ними, а не работой. Подробности и формула — в пуле соединений PostgreSQL.
Пул к соседнему сервису может быть в сотни соединений. За его адресом обычно балансировщик и много машин, и лишнее соединение почти ничего не стоит. Ограничение здесь не жёсткое, как max_connections у базы, а вопрос вежливости к соседу и настроек клиента.
Отсюда практическое следствие: цифры из одного пула в другой не переносятся. Двадцать соединений к базе — нормально, двадцать к нагруженному соседу — узкое место.
Мультиплексирование: почему у HTTP пул может выродиться
В HTTP/1.1 по одному соединению в каждый момент едет один запрос: отправил, дождался ответа, освободил. Именно поэтому клиенты держат пул — чтобы делать несколько вызовов одновременно.
В HTTP/2 всё иначе: в одном TCP-соединении одновременно едут десятки запросов, вперемешку, каждый своим потоком. Пул при этом сильно ужимается — вместо сотни соединений хватает единиц. Если видишь, что клиент открывает к соседу сотню соединений, а тот умеет HTTP/2, — скорее всего, версия не согласовалась и вы работаете по старому протоколу.
А вот ужимать до одного соединения не надо, и это отдельная грабля. У HTTP/2 есть свой потолок: сервер объявляет, сколько потоков разрешает держать в одном соединении одновременно (SETTINGS_MAX_CONCURRENT_STREAMS, обычно около сотни), плюс на всё соединение действует общее окно управления потоком. Упёрлись в потолок — запросы встают в очередь ровно так же, как вставали бы в HTTP/1.1, только теперь незаметно. И второе: одно соединение — это разговор с одной машиной за балансировщиком. Сколько бы узлов за адресом ни стояло, весь ваш трафик идёт в один, и балансировка не работает вовсе. Поэтому разумная настройка — не «одно соединение», а небольшой пул на адрес: и потолок потоков не упрётся, и нагрузка размажется.
С базой так нельзя никогда. Транзакция занимает линию целиком, чересполосицы не бывает, и никакой протокол этого не изменит: это свойство не транспорта, а модели данных.
Пул к базе один, HTTP-пулов много
Ещё одно различие, на котором спотыкаются при настройке. Пул к базе один на адрес: одна база — один набор соединений. Правда, «одна база» сегодня редко означает одну машину: как только рядом с основной появилась реплика для чтения, пула уже два, и свой max_connections каждая из них считает отдельно. Арифметику лимита это меняет — считать надо не «инстансы × размер пула», а по каждому адресу, куда ходят соединения, отдельно.
HTTP-пул — это не один ящик, а по ящику на каждого адресата: соединения к платёжному шлюзу и к каталогу невзаимозаменяемы, и лимит у каждого свой. В библиотеках это обычно два числа: сколько соединений всего и сколько на один адрес. Второе и есть настоящее ограничение. Общий лимит в двести при лимите на адрес в пять означает, что к нужному сервису у вас пять соединений, сколько бы ни было написано в первой цифре.
Общий лимит в двести ничего не решает: к каждому адресату клиент открывает не больше пяти линий, и настоящее ограничение это вторая цифра, а не первая.
Арифметика на пальцах
Ради чего всё считается: получить число, которое можно поставить в конфигурацию.
Берём предел соединений базы — пусть сто. Вычитаем запас на служебные подключения (миграции, резервное копирование, мониторинг, дежурный) — пятнадцать, остаётся восемьдесят пять. Делим на максимальное число экземпляров сервиса — не на обычное, а на то, до которого доедет автомасштабирование; пусть десять. Получаем восемь соединений на экземпляр.
Дальше проверяем результат двумя вопросами. Хватает ли восьми? Считаем по правилу «одновременных запросов к базе столько, сколько ядер у базы плюс запас на ожидание диска» — пул больше этого числа не ускорит, а создаст очередь внутри базы. И не берёт ли одна операция два соединения одновременно (вложенная транзакция в отдельном соединении)? Если берёт, минимум считают иначе, иначе пул встанет намертво — формула и объяснение в статье про пул к PostgreSQL.
Для HTTP-пула арифметика другая, потому что ограничения другие: считают от числа одновременных вызовов к этому получателю (сколько запросов в секунду умножить на время ответа) плюс запас. Десять запросов в секунду при ответе за 200 мс — это два одновременных вызова; пул в двадцать соединений здесь не ускорение, а просто запас. И у каждого получателя свой пул, чтобы один медленный партнёр не съел соединения остальных.
Входящие долгие соединения тратят то же самое
Симметричный случай, о котором забывают: авария бывает не только от исходящих вызовов. Долгие входящие соединения — поток событий, веб-сокеты, длинное ожидание ответа — занимают поток на всё время жизни соединения (в классической модели «поток на запрос») и место в очереди приёма.
Картина отказа выглядит так же: тысяча открытых потоков событий — тысяча занятых потоков, и обычные запросы начинают ждать, хотя ни база, ни партнёры не при чём. Лечится это тремя способами: неблокирующей моделью (поток не занят, пока данных нет), отдельным пулом или отдельным экземпляром сервиса под долгие соединения, и пределом на число таких соединений на экземпляр. Разбор устройства и цены — в статье про WebSocket и поток событий.
Общая грабля: клиент должен сдаваться раньше сервера
Сервер на той стороне сам закрывает соединения, которые долго простаивают. То же делает файрвол между вами, часто молча. Если клиент об этом не знает и считает соединение живым, он отправит запрос в уже закрытую трубу и получит внезапный обрыв — обычно под нагрузкой и обычно необъяснимый.
Сервер закрыл простаивающее соединение первым, а клиент считал его живым и отправил запрос. Поэтому время жизни на клиенте делают заведомо меньше серверного.
Лечится одинаково для обоих пулов: время жизни соединения на клиенте выставляют заведомо меньше, чем на сервере и на файрволе. У базы это maxLifetime в пуле, у HTTP — время простоя в пуле клиента. Правило одно, а забывают про него обычно на стороне HTTP: про базу все читали, про соседний сервис — нет.
Второй предохранитель — перегородка (bulkhead): ограничитель одновременных вызовов к каждому партнёру, заметно меньший, чем пул к базе. Тогда медленный партнёр может занять только свои N потоков, а не все соединения с базой. Третий — circuit breaker: после серии таймаутов вызовы к партнёру перестают выполняться вовсе и сразу возвращают ошибку, пока проверочный запрос не покажет, что партнёр ожил, — подробнее в статье про надёжность.
Авария на стыке: сетевой вызов внутри транзакции
Теперь то, ради чего стоит держать оба пула в голове одновременно.
Открыли транзакцию, что-то записали, а посреди неё сходили по HTTP во внешний сервис — за курсом валют, за проверкой лимита, за чем угодно. Выглядит безобидно. На деле вы держите сразу оба ресурса: HTTP-соединение занято пока сосед думает, а соединение с базой — всё это время плюс время самой транзакции.
Пока сосед думает, соединение с базой удерживается. Свободные кончаются, остальные запросы встают в очередь — и сервис встаёт целиком, хотя база почти простаивает.
Сосед затупил на пять секунд. Каждый входящий запрос забирает соединение к базе и держит его пять секунд вместо десяти миллисекунд. Пул из десяти соединений выдерживает два запроса в секунду вместо тысячи. Дальше очередь, таймауты ожидания из пула — и встают все операции сервиса, включая те, которые с этим соседом вообще не связаны. Снаружи это выглядит как «база легла», хотя база в порядке и почти простаивает.
Отсюда правило, которое стоит помнить дословно: сетевых вызовов внутри транзакции не бывает. Сходите к соседу до транзакции или после неё; если результат нужен обязательно и в одной операции — сохрани намерение в базу, закрой транзакцию, а вызов сделай отдельным шагом.
Тот же механизм срабатывает мягче в других местах: долгий расчёт внутри транзакции, ожидание блокировки, запись большого файла. Всё это держит соединение и уменьшает пропускную способность пула ровно во столько раз, во сколько удлинилась транзакция.
Глубже: перегородка и предохранитель: где ставитьрасширенное
Оба механизма упоминают вместе, а решают они разные задачи.
Перегородка (ограничитель) отвечает на вопрос «сколько одновременных вызовов к этому получателю разрешено». Ставят её на партнёра, а не на весь исходящий трафик: медленный платёжный шлюз должен упереться в свой предел и не занимать потоки, которые нужны остальным. Величину берут из той же арифметики — число одновременных вызовов плюс небольшой запас; превышение означает быстрый отказ, а не ожидание.
Второе место для перегородки — входящие долгие запросы: выгрузки, отчёты, потоковые ответы. Их держат в отдельном пуле потоков, иначе десять выгрузок занимают весь сервис.
Предохранитель отвечает на другой вопрос: «стоит ли вообще пытаться». Он считает долю ошибок и таймаутов за окно и, когда она переходит порог, на время перестаёт отправлять запросы — отдаёт ошибку сразу. Ставят его на метод или на группу вызовов к одному получателю: один сломанный метод чужого API не должен отключать остальные. Величины: окно в несколько десятков запросов, порог по доле ошибок (обычно половина), пауза перед пробной попыткой — секунды.
Порядок применения: сначала таймауты (без них остальное не работает), потом перегородка, потом предохранитель. И к обоим — метрика: сколько раз сработали. Механизм, о срабатывании которого никто не узнаёт, невозможно настроить.
Глубже: как отличить исчерпание пула от медленной базы за минутурасширенное
Обе аварии выглядят одинаково: время ответа выросло, запросы падают по таймауту. Различить их можно по трём числам, и стоит заранее знать, где они лежат.
Время ожидания соединения (в HikariCP это hikaricp.connections.acquire, у pgxpool — AcquireDuration и EmptyAcquireCount из Stat(), у остальных драйверов и клиентов — аналогичная метрика). Растёт — виноват пул: запросы стоят в очереди до обращения к базе. Не растёт — пул в порядке.
Время самого запроса к базе (метрики репозитория или клиента, время в pg_stat_statements). Растёт при спокойном ожидании соединения — виновата база или запрос.
Число занятых и ожидающих (hikaricp.connections.active, .pending). Занятых столько же, сколько размер пула, ожидающие больше нуля — пул кончился. Занятых мало, а время запроса большое — база медленная, пул не при чём.
Порядок разбора за минуту такой: посмотреть ожидающих (есть — пул), потом время ожидания против времени запроса (что из двух выросло), потом уже причину внутри выбранной половины. Со стороны базы то же подтверждает число активных соединений: пул кончился — соединений столько, сколько разрешено, и все в работе; база медленная — соединения есть, но заняты долгими запросами.
Глубже: как это выглядит в мониторингерасширенное
Симптом один и тот же с обеих сторон, а причина разная — поэтому смотреть надо оба пула сразу.
Если растёт время ожидания соединения из пула к базе, а сама база не загружена и запросы в ней быстрые, — почти наверняка соединения кто-то держит, и первый подозреваемый это внешний вызов внутри транзакции. Если растёт ожидание в HTTP-пуле, а сосед отвечает быстро, — скорее всего, мал лимит на адрес или соединения не возвращаются в пул из-за незакрытых ответов.
Полезная привычка: выводить в метрики не только «сколько соединений занято», но и время ожидания в очереди за соединением. Занятость сама по себе не тревожна — тревожно, когда за соединением стоят.
Коротко
- Пулы существуют по одной причине: установка соединения стоит нескольких путешествий по сети, и платить за неё на каждый запрос нельзя.
- Разница одна и она в состоянии: соединение с базой stateful и занято всей транзакцией, HTTP-соединение освобождается сразу после ответа.
- Отсюда размеры: к базе десяток-другой, и считают их как
(предел базы − запас) ÷ максимум экземпляров; к соседу — от числа одновременных вызовов к нему, и у каждого получателя свой пул. - HTTP/2 мультиплексирует запросы в одном соединении, и пул почти вырождается; у базы так не бывает никогда.
- Настоящее ограничение — лимит на один адрес, а не общий; долгие входящие соединения (поток событий, веб-сокеты) занимают те же потоки, и им нужен отдельный пул или предел.
- Общее правило таймаутов: время жизни соединения на клиенте меньше, чем на сервере и файрволе, иначе получишь запрос в закрытую трубу.
- Главная авария на стыке: сетевой вызов внутри транзакции держит оба пула и роняет весь сервис, а выглядит как «база легла».
- В мониторинге смотрят не занятость пула, а время ожидания в очереди за соединением: оно и отличает исчерпание пула (ожидающие больше нуля) от медленной базы (растёт время самого запроса).
- Перегородку ставят на партнёра и на долгие входящие запросы, предохранитель — на метод или группу вызовов; порядок: таймауты, перегородка, предохранитель, и к каждому метрика срабатываний.
Что почитать дальше
- Соединения: keep-alive, пулы и таймауты — почему открыть соединение дорого и что такое keep-alive.
- Connection pooling в PostgreSQL — размер пула, PgBouncer, режимы работы и реплики.
- Версии HTTP — откуда взялось мультиплексирование и что оно меняет.
- Надёжность: таймауты и повторы — что делать, когда сосед всё-таки затупил.