В прошлой статье про PostgreSQL я разбирал три инцидента на стороне базы — блокировки, bloat и ночные пересчёты. В комментариях несколько человек написали примерно одно и то же: «база базой, а покажи, что творилось на стороне приложения — пул-то небось тоже горел».

Горел. Ещё как.

Это продолжение того же разбора, но на слой выше — про HikariCP, дефолтный пул соединений в Spring Boot. Тот самый, который «просто работает», пока не перестаёт. Проекты те же, что и в статье про миграцию и в статье про PostgreSQL: банковская система после переезда с Oracle (терабайт, 70+ таблиц, 500–3000 RPS на чтение и 50–300 на запись в пике) и enterprise-платформа на Java/Spring, где PostgreSQL жил под Hibernate. Разные команды, разные нагрузки, но сюжет один: сервис встаёт, в логах Connection is not available, и первое, что делает любой человек, — лезет крутить maximumPoolSize. И почти всегда делает хуже.

Это не туториал «как настроить HikariCP за 10 строк». Таких на Хабре хватает, и половина из них сводится к «поставьте пул побольше». Здесь — список мест, где у нас всё ломалось, в порядке от «это все знают, но всё равно наступают» до «поняли только в проде под нагрузкой».

Если вы сейчас живёте на HikariCP — читайте как чеклист. Если уже прошли через это — сверьте, сколько совпало.

Дисклеймер: проекты под NDA, названия, точные объёмы и часть деталей изменены. Порядок величин, конфиги и сами инциденты — настоящие.

Инцидент 1. Увеличили пул — стало хуже

Начну с самого контринтуитивного, потому что именно на нём ломается интуиция большинства.

Симптом. Банковская система, будний день, пик нагрузки. Сервис начинает отдавать таймауты. В логах:

HikariPool-1 - Connection is not available, request timed out after 30000ms.

Тридцать секунд поток честно ждал свободное соединение, не дождался и умер. Значит, соединений не хватает — логика железная. Дефолтный maximumPoolSize у Hikari равен 10, инстансов приложения у нас было шесть. Мы подняли пул с 10 до 50 на инстанс.

Стало хуже. Latency выросло, throughput просел, а на стороне PostgreSQL мы увидели под три сотни соединений (шесть инстансов по полсотни), из которых реально что-то делали единицы, а остальные висели в active, конкурируя за одни и те же страницы, буферы и блокировки. База, которая до этого спокойно держала 3000 RPS чтения, начала задыхаться.

Что происходило на самом деле. Пул соединений — это не водопроводная труба, где «шире труба — больше воды». Каждое активное соединение к PostgreSQL — это отдельный backend-процесс на сервере БД, который борется за CPU, за shared_buffers, за блокировки. Когда у вас на БД 16 ядер, а вы присылаете туда 300 одновременных запросов, эти 300 не выполняются параллельно — они выполняются пачками по числу ядер, а остальные стоят в очереди уже внутри базы, где вы их не видите и не контролируете.

А теперь посчитаем честно, сколько соединений нам вообще было нужно. Наши 3000 RPS на чтение звучат как «дайте много соединений». Но средний запрос после починки bloat и планов укладывался в ~2 мс. Одно соединение при 2 мс на запрос отдаёт около 500 запросов в секунду. Значит на 3000 RPS чтения нужно шесть-семь непрерывно занятых соединений, плюс запас на пики записи (50–300 RPS) и на разброс времени ответа. Не триста. Даже не пятьдесят.

У самих авторов HikariCP на эту тему есть отдельная wiki-страница «About Pool Sizing» с формулой-ориентиром:

connections = (core_count * 2) + effective_spindle_count

Для нашего сервера БД на 16 ядер с SSD это выходит где-то 20–35 соединений на весь кластер приложения, а не 300. В их же бенчмарке 10 000 фронтовых пользователей прекрасно обслуживались пулом на десяток соединений — потому что реальное время работы запроса измеряется миллисекундами.

Как чинили. Мы сделали ровно противоположное первому инстинкту: уменьшили пул. С 50 обратно до 20 на инстанс… нет, даже это оказалось много — остановились на 15. Плюс починили пару медленных запросов, которые держали соединение дольше нужного (снова привет EXPLAIN из прошлой статьи). Таймауты ушли, latency вернулось к прежним ~300 мс на дашборде, БД перестала задыхаться.

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

Инцидент 2. Соединение ушло из пула и не вернулось

Этот инцидент — прямой родственник второго инцидента из статьи про PostgreSQL. Там один и тот же корень — тяжёлый внешний вызов внутри @Transactional — вылез как очередь блокировок на таблице loans и idle in transaction на сорок минут. Здесь тот же класс бага показал себя иначе: пул просто опустел. Одна ошибка — два разных симптома в зависимости от того, откуда смотришь.

Симптом. Enterprise-платформа, модуль генерации документов. Тот же Connection is not available, но плавающий: днём всё нормально, а в отчётные часы сервис деградирует, потом сам «отпускает». Рестарт помогает на несколько часов. Классическая картина утечки.

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

spring:
  datasource:
    hikari:
      leak-detection-threshold: 20000   # 20 секунд

После этого в логах поехали стектрейсы: «Apparent connection leak detected». Стектрейс указывал в метод, который отвечал за конвертацию документов — ту самую, про которую у меня есть отдельная статья:

@Transactional
public void buildReport(Long reportId) {
    Report report = repository.findById(reportId);
    // ... а вот тут — рендер через LibreOffice в headless-режиме
    byte[] pdf = converterService.docxToPdf(report.getTemplate()); // до 30–40 секунд
    report.setRenderedPdf(pdf);
    repository.save(report);
}

Видите проблему? Метод помечен @Transactional. Значит, соединение из пула забирается в начале метода и держится до его конца — до коммита транзакции. А в середине у нас синхронный вызов конвертера: LibreOffice в headless-режиме, который на тяжёлом шаблоне в плохие дни рендерил по 30–40 секунд. Всё это время соединение к PostgreSQL просто занято и ничего не делает — ждёт, пока LibreOffice закончит.

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

Как чинили. Ровно как в PG-статье — разнесли работу с БД и тяжёлый I/O по разным транзакциям, а где архитектурно нельзя, ушли в outbox:

public void buildReport(Long reportId) {
    Report report = readReport(reportId);              // короткая транзакция: прочитали
    byte[] pdf = converterService.docxToPdf(report.getTemplate()); // рендер — БЕЗ транзакции и без соединения
    saveRenderedPdf(reportId, pdf);                    // короткая транзакция: записали
}

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

ALTER ROLE app_backend SET idle_in_transaction_session_timeout = '5min';

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

Вывод. Внутри @Transactional не должно быть ничего, что ждёт сеть, диск, внешний процесс или человека. Никаких HTTP-вызовов, никакого рендера, никакой обработки файлов «пока держим транзакцию». Соединение из пула — самый дорогой ресурс в этой цепочке. А leak-detection-threshold включайте сразу, не дожидаясь инцидента: он ничего не стоит и однажды сэкономит вам ночь.

Инцидент 3. Пул полон, соединения живые, но каждое N-е падает

Этот был самый неприятный, потому что все метрики показывали, что всё в порядке. И случился он там, где сеть охраняют серьёзнее всего, — на банковском контуре.

Симптом. Раз в несколько минут случайный запрос падал с сетевой ошибкой:

org.postgresql.util.PSQLException: An I/O error occurred while sending to the backend.
Caused by: java.net.SocketException: Connection reset

При этом пул был полон, свободные соединения были, база жива, сеть в целом жива. Просто некоторые соединения из пула оказывались… мёртвыми. Hikari выдавал их приложению, приложение пыталось на них что-то отправить — и получало Connection reset. Особенно много таких падений было по ночам, когда трафик в этой гео падал и соединения подолгу простаивали.

Что происходило. Между приложением и PostgreSQL на банковском контуре стоял firewall, который тихо убивал TCP-соединения, простоявшие без трафика дольше 15 минут. Не присылал RST, не закрывал вежливо — просто молча выбрасывал соединение из своей таблицы. Со стороны приложения соединение продолжало числиться живым.

А теперь смотрим на дефолты HikariCP:

  • maxLifetime = 1 800 000 мс = 30 минут

  • idleTimeout = 600 000 мс = 10 минут

  • keepaliveTime = 0 (по умолчанию выключен)

То есть Hikari считал соединение валидным до 30 минут, а firewall убивал его на 15-й. В окне между 15 и 30 минутами пул был набит соединениями, которых на той стороне уже не существовало.

Как чинили. Правило простое: maxLifetime должен быть заметно меньше самого короткого таймаута на пути к базе — будь то firewall, NAT, балансировщик или idle_session_timeout на стороне PostgreSQL. Плюс включили keepalive, чтобы Hikari сам пинговал простаивающие соединения и не давал сети их протухнуть:

spring:
  datasource:
    hikari:
      max-lifetime: 600000       # 10 минут — меньше, чем 15-минутный лимит firewall
      keepalive-time: 120000     # раз в 2 минуты «трогаем» простаивающие соединения
      idle-timeout: 300000
      connection-timeout: 10000  # не ждать соединение 30с молча — падать за 10 и видеть это в метриках

connection-timeout мы заодно снизили с дефолтных 30 секунд до 10: тридцатисекундное молчаливое ожидание маскировало проблему, а десятисекундное падение сразу подсвечивало её в метриках и алертах.

Вывод. HikariCP не умеет читать мысли вашей сетевой инфраструктуры. Если где-то между приложением и БД есть устройство, которое рвёт idle-соединения по таймауту, — вы обязаны рассказать об этом пулу через maxLifetime и keepaliveTime. Иначе пул будет уверенно раздавать соединения, которых уже нет.

Бонус: HikariCP + PgBouncer в transaction pooling

Отдельная короткая грабля, на которую наступают при попытке «а давайте поставим PgBouncer перед базой», чтобы не плодить соединения (см. инцидент 1). Если PgBouncer работает в режиме transaction pooling, серверные prepared statements ломаются: соединение к реальной БД между транзакциями меняется, а подготовленный statement привязан к конкретному backend’у. В итоге — плавающие prepared statement "S_1" does not exist.

Лечится либо переводом PgBouncer в session mode (но тогда теряется половина смысла пулера), либо отключением серверных prepared statements на стороне драйвера:

jdbc:postgresql://.../db?prepareThreshold=0

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

Что мы в итоге поставили в дефолтный конфиг

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

spring:
  datasource:
    hikari:
      maximum-pool-size: 15          # считать от ядер БД и времени запроса, а не «на глаз побольше»
      minimum-idle: 15               # держим пул ровным, без просадок на прогрев
      connection-timeout: 10000      # падать быстро и заметно
      max-lifetime: 600000           # меньше самого короткого таймаута на пути к БД
      keepalive-time: 120000         # не давать соединениям протухать в простое
      idle-timeout: 300000
      leak-detection-threshold: 20000 # включаем сразу, не дожидаясь беды

И три правила, которые в сумме важнее любого конфига:

  1. Пул мал не потому, что цифра маленькая, а потому, что соединения долго не возвращаются. Сначала профилируйте запросы (3000 RPS у нас закрывались десятком соединений), потом трогайте maximumPoolSize.

  2. Внутри транзакции — только работа с БД. Любой сетевой или дисковый I/O выносите наружу или в outbox.

  3. maxLifetime синхронизируйте с самым коротким таймаутом инфраструктуры. Firewall, NAT, балансировщик, idle_session_timeout — что короче, то и потолок.