HikariCP в проде: три раза, когда пул соединений уронил сервис, и почему maximumPoolSize тут был ни при чём

В прошлой статье про 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 # включаем сразу, не дожидаясь беды
И три правила, которые в сумме важнее любого конфига:
Пул мал не потому, что цифра маленькая, а потому, что соединения долго не возвращаются. Сначала профилируйте запросы (3000 RPS у нас закрывались десятком соединений), потом трогайте
maximumPoolSize.Внутри транзакции — только работа с БД. Любой сетевой или дисковый I/O выносите наружу или в outbox.
maxLifetimeсинхронизируйте с самым коротким таймаутом инфраструктуры. Firewall, NAT, балансировщик,idle_session_timeout— что короче, то и потолок.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.