Перейти к содержанию

Что отслеживать в пуле соединений SQLAlchemy

Первым обычно рисуют число занятых соединений, хотя оно мало говорит о качестве обслуживания. Я запустил восемь worker с пулом из четырёх, затем замедлил БД и посмотрел метрики. До замедления было 4/4, после — 4/4, весь инцидент без изменений. Важное оказалось в трёх других рядах.

Числа получены в эксперименте статьи с PostgreSQL 17 в контейнере. Версии: sqlalchemy-foundation-kit 0.4.0, SQLAlchemy 2.0.52, asyncpg 0.31.0, Python 3.13.

Запуск

Восемь worker в цикле, pool_size=2, max_overflow=2, pool_timeout=1.0, запрос на пятьдесят миллисекунд. Через шесть секунд он начинает занимать секунду.

   0.5 s  in_use=4/4  checkouts/s=  72  wait=    57 ms  held=    53 ms  timeouts_total=0  failed=0
   2.0 s  in_use=4/4  checkouts/s=  78  wait=    52 ms  held=    52 ms  timeouts_total=0  failed=0
   3.0 s  in_use=4/4  checkouts/s=  78  wait=    53 ms  held=    52 ms  timeouts_total=0  failed=0
  ... the database slows down: queries take 1.0 s instead of 0.05 s
   4.0 s  in_use=4/4  checkouts/s=  16  wait=    55 ms  held=    54 ms  timeouts_total=0  failed=0
   5.0 s  in_use=4/4  checkouts/s=  12  wait=   669 ms  held=  1002 ms  timeouts_total=2  failed=2
   6.0 s  in_use=4/4  checkouts/s=   8  wait=   979 ms  held=  1003 ms  timeouts_total=2  failed=2
   7.0 s  in_use=4/4  checkouts/s=  12  wait=   669 ms  held=  1002 ms  timeouts_total=4  failed=4
   8.0 s  in_use=4/4  checkouts/s=   8  wait=   979 ms  held=  1003 ms  timeouts_total=4  failed=4

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

Полная загрузка не равна инциденту

in_use=4/4 и в первой, и в последней строке. Восемь worker постоянно занимают четыре соединения по устройству нагрузки. Полное использование может быть нормальным состоянием. Метрика, максимальная в здоровой фазе, не отличает от неё нездоровую.

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

Зато размер полезен для планирования ёмкости БД. Возможное число соединений всех реплик, включая overflow и других клиентов, должно укладываться в max_connections. Это расчёт мощности, а не самостоятельная тревога. При масштабировании он легко нарушается: двадцать pod с двадцатью соединениями — уже четыреста.

Ожидание становится задержкой запроса

При инциденте изменилось время получения соединения: примерно с 53 до 979 миллисекунд.

Это очередь перед пулом и чистая добавленная задержка. Работа ещё не идёт, клиент ждёт разрешения начать. Для пользователя она выглядит так же, как медленная БД. Секунда ожидания добавляется ещё до первого запроса.

Это и ранний сигнал. Очередь растёт до таймаутов: чтобы отказать, запрос должен прождать весь pool_timeout. В измерении ожидание уже велико, пока счётчик отказов только начинает расти. Следите за ожиданием до отказов, выбирая порог из пользовательского бюджета задержки.

Нужен процентиль, не только среднее. В здоровой фазе среднее 53 мс, потому что ждали все. При большом запасе среднее почти нулевое, но p99 может вырасти, когда один медленный запрос удержит соединение. Именно хвост отражает пострадавшие запросы.

ИДЕЯ В СХЕМЕОжидание и удержание показывают разные проблемы
---
config:
  theme: default
  look: classic
  sequence:
    useMaxWidth: false
    wrap: true
    width: 140
    actorMargin: 36
    mirrorActors: false
---
sequenceDiagram
    accTitle: Ожидание и удержание показывают разные проблемы
    accDescr: Ожидание checkout добавляет задержку до получения соединения. Время удержания идёт от выдачи до возврата; долгие удержания занимают ёмкость пула и заставляют другие запросы ждать или завершаться по таймауту.
    participant R as Запрос
    participant P as Пул соединений
    participant D as PostgreSQL
    R->>P: Запросить соединение
    Note over R,P: Ожидание checkout
    alt Соединение доступно
      P-->>R: Выдать соединение
      Note over R,D: Время удержания до возврата
      R->>D: SQL / транзакция
      D-->>R: Результат
      R->>P: Вернуть соединение
    else Таймаут пула наступил раньше
      P-->>R: TimeoutError
    end

Ожидание checkout добавляет задержку до получения соединения. Время удержания идёт от выдачи до возврата; долгие удержания занимают ёмкость пула и заставляют другие запросы ждать или завершаться по таймауту.

Время удержания объясняет причину

Вторая гистограмма — время от получения соединения до возврата. Здесь оно выросло с 52 мс до секунды вслед за запросом. Отдельные ожидание и удержание позволяют читать причину:

  • Растут удержание и ожидание: БД или запросы замедлились, пул передаёт задержку дальше. Исследуйте БД и работу внутри транзакций.
  • Удержание ровное, ожидание растёт: увеличилась конкуренция либо уменьшился пул. Проверьте трафик и настройки.
  • Удержание растёт, ожидание ровное: соединения держат дольше, но конкуренции пока нет. Это ранний сигнал долгой транзакции, N+1 или HTTP-вызова внутри session.

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

Тонкая ошибка — назвать время удержания «checkout duration» и принять за ожидание. Они могут меняться вместе, но измеряют разные интервалы; поэтому здесь отдельные ряды.

Таймауты расходуют бюджет ошибок

timeouts_total считает запросы, прождавшие весь срок без соединения. В эксперименте он точно совпал с отказами приложения: два к двум, четыре к четырём. Это прямая потеря обслуживания, посчитанная у пула.

Ненулевой рост требует реакции согласно SLO сервиса. Счётчик отделяет исчерпание пула от отказа самой БД принимать соединения — у них разные причины и исправления.

Пропускная способность и ёмкость

Частота получений упала примерно с семидесяти пяти до менее пятнадцати в секунду на показанных интервалах. В устойчивом режиме четыре соединения с секундным удержанием дают около четырёх операций в секунду; дополнительная конкуренция приложения это не изменит. Остальные worker ждут.

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

Шесть показателей на графике

postgres_db_pool_size                          gauge      настроенный размер пула
postgres_db_pool_checked_out                   gauge      соединения, используемые сейчас
postgres_db_pool_overflow                      gauge      используемые соединения сверх pool_size
postgres_db_connection_checkout_wait_seconds   histogram  время ожидания соединения
postgres_db_connection_held_duration_seconds   histogram  время последующего удержания соединения
postgres_db_connection_timeouts_total          counter    запросы, так и не получившие соединение

Две основные тревоги: p99 ожидания выше бюджета задержки и рост таймаутов. Совместная панель гистограмм помогает отличить увеличение конкуренции от удлинения работы. Gauge состояния полезны для планирования мощности.

Если между приложением и PostgreSQL есть PgBouncer, SHOW POOLS показывает очередь уровнем ниже: cl_waiting и maxwait. Сравнение помогает локализовать задержку; подробнее в статье о PgBouncer.

Инструменты

Метрики предоставляет sqlalchemy-foundation-kit, инструментируя создаваемый пул и публикуя показатели через протокол с Prometheus-реализацией в extra. Ожидание измеряется обёрткой connect пула: событие SQLAlchemy checkout срабатывает уже после получения соединения и не знает длительность очереди.

Число занятых соединений не изменилось. Изменилось всё остальное.