CNN TürkİSTANBUL'DA HAVALAR HAFTA SONU NASIL? 12 - 13 Eylül İstanbul'da yağmur var mı? Sıcaklıklar kaç derece olacak? MGM uyardı!PunchDeath toll in Ondo suspected herbal poisoning incident rises to 29וואלהצעיר כבן 20 נפצע בינוני באירוע אלימות ברהטThe Jerusalem PostSyrian private airport plans spark outcry over mass graves, land rightsESPN DeportesAtlante no encontró respuesta y Pachuca escapó con la goleadaBollywood HungamaTriptii Dimri joins Peep Beauty as investor and co-creator한겨레김민석 “민주당, 대통합의 길 갈 것…지분 나누기 없어, 합당·연대 원칙은 경쟁력”MintTaking AI too far? New Mexico Supreme Court fines lawyer $5,000 for presenting ‘fake testimony’, ChatGPT-generated briefSözcü'Erkek doktor' krizi: Hastane birbirine girdiUOLFacebook e TikTok são a nova fronteira do tráfico de espécies silvestresDaily MailFour In A Bed clip branded 'absolute cinema' by fans after hotel owner overpays his rivals in brutal twist to stop cheaters from winningCBS NewsIran-backed Houthis tighten grip on key oil shipping lane
The Daily Newsstand · Free, Always
Saturday, September 12, 2026

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

Translate

В прошлой статье про 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 — что короче, то и потолок.

View the original on Хабр

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.