Webrium
Подписаться

Пул соединений: то их слишком много, то ни одного

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

Схема фильтрации: из нескольких соединений в пуле выбирается одно рабочееПул соединенийРабочее соединение

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

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

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

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

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

Соединение со стороны базы

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

Общий предел числа подключений задаётся параметром сервера max_connections; своё значение смотрят командой SHOW max_connections;. Меняется предел только перезапуском сервера: перечитывание настроек новое значение не подхватывает.

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

SHOW max_connections;
SHOW superuser_reserved_connections;
SHOW reserved_connections;  -- ответ с ошибкой о нераспознанном параметре означает, что в сборке его нет

Какие резервные параметры есть в вашей сборке и что в них стоит, показывает SELECT name, setting FROM pg_settings WHERE name LIKE '%reserved%';

Число соединений от приложения считается как верхняя граница, а не как факт. Формула такая:

(постоянный размер пула + допустимое переполнение) ×
× число рабочих процессов ×
× число экземпляров +
+ всё, что ходит в базу мимо пула

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

Множитель «число рабочих процессов» верен только тогда, когда пул создаётся после разветвления процесса. Если пул заведён до разветвления, несколько процессов начинают писать в одно соединение, и отказ выглядит как порча данных на проводе, а не как нехватка соединений.

Почему соединение умирает в паузе

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

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

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

Зависание на запросе, отправленном в уже мёртвое соединение, ограничивают не проверки живости, а предельное время неподтверждённой передачи данных (TCP user timeout).

Что делать с мёртвыми соединениями

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

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

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

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

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

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

Пул пуст, а база не загружена

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

Состояния сессии в системном представлении активности pg_stat_activity означают для пула разное:

  • «активна» — соединение работает;
  • «простаивает» — вернулось в пул или висит;
  • «простаивает в транзакции» — не вернулось и держит блокировки;
  • «простаивает в транзакции (прервана)» — приложение получило ошибку и не сделало откат.

Последнее состояние указывает на дефект обработки ошибок в коде. Оба состояния с незакрытой транзакцией держат блокировки и не отдают соединение пулу.

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

Повтор после обрыва опасен не отказом, а неизвестностью. Если соединение умерло после того, как запрос ушёл, приложение не знает, применилась ли запись. Нужен ключ идемпотентности или проверка результата перед повтором. Сломанное соединение перед повтором надо пометить негодным и вернуть в пул как негодное.

Внешний пулер

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

У него есть и свои тайм-ауты простоя клиента и простоя серверного соединения, то есть ещё одно место, где соединение умирает в паузе.

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

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

Тупик: поднимаем предел подключений

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

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

Третий удар — простаивающее соединение не бесплатно. Под него заведён процесс со своей памятью и занято место в общих структурах сервера.

Позиция редакции

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

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

Позиция неверна и в среде с короткоживущими процессами. Там считать надо не размер пула, а частоту подключений.

С чем это связано

Метка над заголовком говорит, зачем туда идти: продолжить тему, перейти к практике или увидеть возражение.

Поле выбора между скоростью и корректностью транзакций, разделённое границейСкоростьКорректность
Углубление

Уровни изоляции: что выбрать и чем за это платят

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

Время съедает один шаг плана выполнения, а не запрос целиком: искать надо его, а не вешать индекс наугадшаги планадорогой шаг
Практика

Медленный запрос в PostgreSQL: с чего начинать

Как диагностировать медленный запрос в PostgreSQL без лишних индексов: пошагово, с планами и статистикой.

Три показателя нагрузки: два в норме, один зашкаливает из-за N+1 запросовN+1 запрос
Усиление

ORM или запросы руками: где проходит граница

ORM покажет, где пул соединений превращается в проблему N+1 — и как переписать запрос, чтобы база отвечала за одну операцию, а не за сто.

Схема разделения: слева логи сервиса, справа зона запрета доступа, элементы пересекают границуЛоги сервисаДоступ запрещён
Практика

Логи на сервере: почему в них «ничего нет»

Как найти логи сервиса, если они не пишутся или недоступны — от прав доступа до ротации и контейнеров.

Письмо по вторникам

Один разбор недели и короткий список того, что изменилось. Без дайджестов на сорок ссылок.

Начните вводить — материалы появятся здесь.

↑↓ выбрать · Enter открыть · Esc закрыть