awesome-everything EN
↑ Обратно к восхождению

Архитектура бэкенда

Исчерпание пула: утечки и почему больший пул не спасёт

Суть Частейший сбой пула — не недоразмер, а утечка, где код берёт соединение и никогда не возвращает. Каждая утечка навсегда сжимает пул, пока не останется ничего, и инстинкт расширить пул лишь оттягивает тот же отказ.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 16 min

Сервис работает часами, затем медленно каждый запрос начинает таймаутить на «unable to acquire connection». Рестарт чинит — на несколько часов, затем возвращается по расписанию. Это не нагрузка; это утечка. Где-то путь кода берёт соединение и на определённой ветке — обычно ветке ошибки — никогда не возвращает. Каждый раз, как та ветка бежит, пул навсегда теряет одно соединение. Пул на 20 переживает 20 утечек и затем мёртв, сколь бы мал ни был трафик. Фикс, к которому все тянутся первым — сделать пул больше — лишь меняет утечку с 3-часового отказа на 6-часовой.

Утечка — это взятие без возврата

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

const conn = await pool.acquire();
const rows = await conn.query(sql);   // бросает здесь
conn.release();                       // недостижимо — утекло

Когда query бросает, исполнение прыгает мимо release(), и то соединение ушло из пула навсегда. Пул не знает, что заёмщик его бросил; с его точки зрения соединение всё ещё «взято, в использовании». Каждый прогон той ветки ошибки убирает ещё одно соединение из обращения. Вот почему утечка выглядит медленной бомбой: трафик нормален, затем за часы доступное число тикает вниз к нулю, и каждый запрос начинает падать — симптом идентичен массовому перегрузу, но причина — несколько строк кода на невезучей ветке.

Фикс структурный, не большие числа: гарантируй освобождение через try/finally (или языковую конструкцию, делающую то же — using, defer, контекст-менеджер, скоупнутую транзакцию фреймворка):

const conn = await pool.acquire();
try {
  return await conn.query(sql);
} finally {
  conn.release();   // бежит на успехе И на броске
}

Почему больший пул не чинит утечку

Рефлекс, когда соединения кончаются — поднять размер пула. Против утечки это хуже, чем бесполезно — оно превращает быстрый, очевидный отказ в медленный, загадочный, и не останавливает кровотечение. Здесь есть глубже, контринтуитивный результат: даже меры устойчивости показывают резко убывающую отдачу против утечек. Исследование влияния утечек нашло, что подъём пула с 5 до 100 соединений — 20× рост — улучшил скорость снижения отказов лишь с 96,8% до 62,8% — суть в том, что бросать соединения в проблему покупает куда меньше, чем намекает рост размера, потому что ровная скорость утечки осушает любой пул; ты лишь поменял, как долго до пустоты. Единственный реальный фикс — перестать течь и обнаруживать утечки рано.

Почему это работает

Почему расширение пула даёт такую плохую отдачу против утечки, когда это естественнейший первый ответ? Потому что утечка — это скорость, не фиксированная цена — каждое исполнение багнутой ветки убирает соединение навсегда, поэтому пул осушается со скоростью, заданной тем, как часто та ветка бежит, не размером пула. Больший пул — просто большее ведро с той же дырой: дольше пустеть, но опустеет. Хуже, большее ведро прячет дыру. С пулом на 5 утечка всплывает за минуты и указывает прямо на свежее изменение; с пулом на 100 она всплывает часами позже, задолго после деплоя, выглядя случайным перегрузом и отправляя тебя искать не там. Так большой пул стоит дважды — он не предотвращает отказ и уничтожает сигнал, что дал бы найти причину. Та же логика к повторам и прочим ручкам устойчивости поверх утечки: они размазывают отказ по времени, не адресуя, что ресурс убегает быстрее, чем возвращается. Дисциплина — ограничить причину, гарантировать возврат, а не раздувать буфер, оттягивающий симптом.

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

Поскольку утечки тихи до катастрофы, защита — наблюдаемость:

  • Порог обнаружения утечки. Пул может предупредить, когда соединение удерживается дольше, чем должен любой легитимный запрос (HikariCP leakDetectionThreshold, напр. 2 с). Соединение, вне дольше этого, почти наверняка утекло или застряло на патологически медленной операции — в любом случае хочешь знать, со стектрейсом того, кто его взял.
  • Четыре датчика пула. Следи за active (в использовании), idle (свободно), total и waiting (потоков в очереди за соединением). Здоровый пул держит idle > 0 большую часть времени. Утечка показывается как active, лезущий вверх и не падающий назад; исчерпание показывается как idle, пришпиленный на 0, и waiting, лезущий вверх. Алертуй на idle около 0 и waiting > 0 устойчиво — они предшествуют отказу.

Ловушка async-границы

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

СимптомActiveIdleWaitingВероятная причина
ЗдоровМеняется> 00Нормальная работа
УтечкаЛезет, не падает→ 0ЛезетВзятие без возврата на каком-то пути
Реальный перегрузНа максимуме0ВысокоПул реально мал для нагрузки
ХомингВысоко~0ЛезетСоединение держится через несвязанный await
Викторина

Сервис таймаутит на взятии соединения после часов работы; рестарт чинит на несколько часов, затем повторяется. Трафик нормален всё время. Вероятнейшая причина?

Викторина

Почему расширение пула — плохой фикс утечки соединения?

Викторина

Почему удержание пулевого соединения через await несвязанного HTTP-вызова опасно, даже когда ничего не утекает?

Расставь шаги по порядку

Упорядочи, как утечка соединения становится полным отказом:

  1. 1 Путь ошибки берёт соединение и пропускает освобождение
  2. 2 Каждый прогон того пути навсегда убирает одно соединение из пула
  3. 3 Active лезет вверх и не возвращается; idle тикает к нулю
  4. 4 Пул пустеет, и каждый запрос таймаутит на взятии соединения
Вспомните перед уходом
  1. 01
    Что такое утечка соединения и как она производит отказ, выглядящий перегрузом?
  2. 02
    Почему увеличение пула — плохой ответ на утечку?
  3. 03
    Как обнаруживать утечки рано, какие метрики важны и что за ловушка async-границы?
Итог

Исчерпание пула обычно идёт от утечки, а не недоразмера: взятие, пропускающее возврат на каком-то пути — почти всегда ветка ошибки, прыгающая мимо release() — навсегда вычитает соединение, поэтому пул тикает к пустоте за часы и каждый запрос падает на взятии, пока трафик выглядит нормально, временно излечимо рестартом. Фикс структурный — try/finally или эквивалентная скоупнутая конструкция, возвращающая соединение на успехе и на броске — и никогда не больший пул, потому что утечка осушает со своей скоростью, поэтому размер лишь меняет график; исследование 5→100 показало, что расширение покупает куда меньше устойчивости, чем ожидалось, и хуже, прячет причину, оттягивая симптом за деплой. Защищайся обнаружением утечки, предупреждающим, когда соединение удерживается дольше пары секунд со стектрейсом, и следи за четырьмя датчиками — active, idle, total, waiting — алертуя на idle около нуля и устойчивый waiting. И не хоменись: удержание соединения через await несвязанной работы исчерпывает пул прямо как утечка. Всё доселе предполагало один пул против одной базы — финальный урок масштабирует наружу, где N инстансов приложения, каждый со своим пулом, сталкиваются против одного max_connections, и мультиплексор соединений вроде PgBouncer становится обязателен.

Связанные уроки
Продолжить восхождение ↑Пулинг на масштабе: много инстансов, одна база и PgBouncer
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.