Контекст

У приложения небольшой JDBC-пул, но hikaricp_connections_pending периодически достигает 400–800.

В списке долгих scheduled-задач одновременно видны несколько методов.

Возникает естественный вопрос: кто именно потребляет соединения — HTTP, scheduled-задачи, Kafka или другая фоновая работа?

Одна метрика HikariCP на него не отвечает. Она показывает состояние пула, но не добавляет к connection имя Java-класса.

В ней также нет имени метода или бизнес-операции.

Для атрибуции нужны несколько источников: HikariCP, workload-метрики, OpenTelemetry и состояние PostgreSQL в одном временном окне.

долгая транзакция или медленный SQL
  → active достигает max
  → HTTP, scheduled и Kafka операции переходят в pending
  → virtual threads позволяют накопить сотни ожидающих задач
  → растут acquire time и connection timeout
  → клиенты и фоновые задачи получают ошибки

Что означает pending в сотни операций

hikaricp_connections_pending=800 не означает, что PostgreSQL открыл 800 сессий. Это число потоков или задач внутри JVM, ожидающих выдачи connection из пула.

Если max=10 и active=10, все десять соединений уже выданы.

Новая операция становится pending и ждёт до освобождения connection или до connection-timeout.

Колебание очереди с 400 до 800 и обратно может быть пилообразным эффектом.

Новые задачи приходят, старые массово завершаются по timeout, затем цикл повторяется.

Virtual threads делают ожидание дешёвым, но не увеличивают пропускную способность базы.

Очередь может вырасти до сотен задач раньше, чем JVM исчерпает обычные platform threads.

Каждая ожидающая задача всё равно удерживает контекст запроса и прикладные объекты.

Большая очередь увеличивает память, задержки и риск каскадного отказа.

Три роли в расследовании

Важно разделять держателя, ожидающего и саму базу данных.

holder   — получил connection и ещё не вернул его
waiter   — запросил connection, но pool уже заполнен
database — выполняет SQL, ждёт lock или оставляет session idle in transaction

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

Например, OutboxRelay может попасть в topk, пока ждёт connection для ShedLock или начала транзакции.

Его бизнес-логика в этот момент могла ещё не запуститься.

И наоборот, LargeBatchProjection может получить connection сразу и удерживать его на протяжении большого транзакционного batch.

Сначала разделяем pod и pool

Prometheus должен опрашивать каждый pod отдельно.

Scrape через балансируемый Service может попадать в разные экземпляры и скрывать локальное насыщение.

Начать стоит со списка пулов и их размера:

max by (pod, pool) (
  hikaricp_connections_max{namespace="$namespace"}
)

Количество занятых соединений:

max by (pod, pool) (
  hikaricp_connections_active{namespace="$namespace"}
)

Процент заполнения:

100 *
max by (pod, pool) (
  hikaricp_connections_active{namespace="$namespace"}
)
/
clamp_min(
  max by (pod, pool) (
    hikaricp_connections_max{namespace="$namespace"}
  ),
  1
)

Текущая очередь:

max by (pod, pool) (
  hikaricp_connections_pending{namespace="$namespace"}
)

Таймауты за пять минут:

sum by (pod, pool) (
  increase(
    hikaricp_connections_timeout_total{namespace="$namespace"}[5m]
  )
)

Grafana может визуально складывать серии.

Значение на stacked-графике нельзя считать очередью одного pod, пока запрос не сгруппирован по pod и pool.

Сумма по всем pod полезна для оценки масштаба инцидента.

Она не помогает найти конкретный экземпляр, на котором началось насыщение.

Отличаем получение connection от его удержания

Время получения connection показывает acquire:

max by (pod, pool) (
  max_over_time(
    hikaricp_connections_acquire_seconds_max{
      namespace="$namespace"
    }[5m]
  )
)

Время удержания выданного connection показывает usage:

max by (pod, pool) (
  max_over_time(
    hikaricp_connections_usage_seconds_max{
      namespace="$namespace"
    }[5m]
  )
)

Большой acquire означает, что операция долго ждала свободное соединение.

Большой usage означает, что уже выданное соединение долго не возвращалось в пул.

Обе метрики фиксируются после завершения соответствующей операции.

Прямо сейчас зависшего держателя быстрее покажут trace и pg_stat_activity.

Что показывает topk scheduled-задач

Самые долгие завершившиеся scheduled-запуски за последние пять минут:

topk(
  10,
  max by (pod, code_namespace, code_function) (
    max_over_time(
      tasks_scheduled_execution_seconds_max{
        namespace="$namespace"
      }[5m]
    )
  )
)

Значение 40 означает, что один завершившийся запуск указанного метода выполнялся до 40 секунд. Это не 40 запусков и не 40 соединений.

Метрика duration появляется после завершения метода. Запуск, который всё ещё висит, может пока не попасть в результат.

Средняя длительность завершившихся запусков:

sum by (pod, code_namespace, code_function) (
  increase(
    tasks_scheduled_execution_seconds_sum{
      namespace="$namespace"
    }[5m]
  )
)
/
clamp_min(
  sum by (pod, code_namespace, code_function) (
    increase(
      tasks_scheduled_execution_seconds_count{
        namespace="$namespace"
      }[5m]
    )
  ),
  1
)

Количество завершившихся запусков:

sum by (pod, code_namespace, code_function, outcome) (
  increase(
    tasks_scheduled_execution_seconds_count{
      namespace="$namespace"
    }[5m]
  )
)

Ошибки scheduled-задач:

sum by (pod, code_namespace, code_function, exception) (
  increase(
    tasks_scheduled_execution_seconds_count{
      namespace="$namespace",
      outcome!="SUCCESS"
    }[5m]
  )
)

Задача длительностью около connection-timeout с outcome="ERROR" часто является ожидающей жертвой.

Особенно если одновременно растут acquire и timeout_total.

Совпадение длительности успешной задачи с usage_seconds_max указывает на вероятного держателя connection.

Это сильная корреляция, но ещё не окончательное доказательство.

Проверяем HTTP и Kafka

Scheduled-задачи используют не единственный путь к базе. Параллельно общий pool могут занимать HTTP-запросы и обработчики сообщений.

Наиболее частые HTTP-маршруты:

topk(
  20,
  sum by (pod, uri, method) (
    rate(
      http_server_requests_seconds_count{
        namespace="$namespace"
      }[5m]
    )
  )
)

Этот запрос показывает интенсивность, но не время удержания connection. Для доказательства нужен JDBC span внутри соответствующего HTTP trace.

Названия Kafka-метрик зависят от версии Spring Kafka и включённой instrumentation. Сначала безопаснее посмотреть доступные серии:

{
  namespace="$namespace",
  __name__=~"spring_kafka_listener.*"
}

После этого listener-метрику нужно группировать по pod, listener name, topic, result и exception, используя labels, которые реально присутствуют в Prometheus.

Почему несколько scheduled-задач видны одновременно

Допустим, в topk одновременно появились LargeBatchProjection, HistoryProjection и OutboxRelay.

Это не означает, что каждая задача удерживала connection. Возможен такой сценарий:

LargeBatchProjection держит connection в длинной транзакции
  → HistoryProjection получает ещё один connection и тоже работает долго
  → свободные соединения заканчиваются
  → OutboxRelay ждёт connection для ShedLock
  → каждый новый запуск увеличивает pending

При fixed-rate запуске новые попытки могут появляться быстрее, чем освобождаются соединения.

Virtual threads упрощают такое накопление. Даже пропускаемая ShedLock задача сначала обращается к хранилищу блокировок.

Поэтому сообщение Unexpected error occurred in scheduled task часто указывает на место проявления дефицита, а не на его источник.

Атрибуция через OpenTelemetry

Лучший способ связать Java consumer с SQL — найти длинный JDBC span и подняться к его родительскому span.

Практический порядок:

  1. Выбрать pod и минуту, где active=max и растёт pending.
  2. Отфильтровать traces по имени сервиса и pod.
  3. Найти длинные database/JDBC spans.
  4. Подняться к родителю: HTTP, scheduled или Kafka.
  5. Сравнить trace duration с usage_seconds_max.

В зависимости от версии semantic conventions PostgreSQL может обозначаться атрибутом db.system="postgresql" или db.system.name="postgresql".

Полезны также db.operation, db.operation.name, db.query.summary, имя scheduled-метода, HTTP route и Kafka destination.

Sampling может скрыть нужный trace.

Перед выводами следует проверить sampler и убедиться, что проблемная операция могла попасть в tracing backend.

Что смотреть в PostgreSQL

Снимок нужно делать во время роста pending, желательно до первых connection timeout.

SELECT pid,
       application_name,
       client_addr,
       state,
       now() - xact_start  AS transaction_age,
       now() - query_start AS query_age,
       wait_event_type,
       wait_event,
       pg_blocking_pids(pid) AS blockers,
       left(query, 500) AS query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY xact_start NULLS LAST,
         query_start NULLS LAST;

Долгий active с большим query_age указывает на медленный SQL.

wait_event_type='Lock' означает ожидание блокировки, а blockers показывает удерживающие её процессы.

idle in transaction означает, что транзакция остаётся открытой, хотя SQL сейчас не выполняется.

Connection может удерживаться Java-кодом, внешним вызовом или обработкой большого batch.

Если application_name одинаков для всех экземпляров, PostgreSQL не позволит точно связать session с pod.

Полезно передавать уникальный ApplicationName, содержащий безопасный идентификатор pod.

Временный leak detection

Когда tracing недоступен, HikariCP может временно записывать stack trace места, где был выдан долго удерживаемый connection.

spring:
  datasource:
    hikari:
      leak-detection-threshold: 30000

Порог 30000 означает 30 секунд. Если connection не вернулся вовремя, HikariCP запишет stack trace его получения.

Leak detection не закрывает connection и не доказывает настоящую утечку. Легальная длинная транзакция также создаст warning.

Настройку следует включать временно, контролировать объём логов и отключать после сбора достаточного числа примеров.

Матрица интерпретации

НаблюдениеВероятная интерпретация
Task duration ≈ usage duration, SUCCESSЗадача, вероятно, удерживала connection
Task duration ≈ connection timeout, ERRORЗадача, вероятно, ждала connection
Растут acquire и timeout, usage невеликДефицит создан другими держателями
Долгий active SQLМедленный запрос или большой объём работы
PostgreSQL ждёт LockConnection занят заблокированным SQL
Долгий idle in transactionПриложение удерживает connection между запросами
active=max, pending растётУстойчивое насыщение пула
После рестарта очередь быстро возвращаетсяРестарт очистил симптом, но не причину

Как связать насыщение с HTTP 502 или 504

Общий JDBC-пул связывает фоновую работу и клиентские запросы.

Hikari active = max
  → HTTP-запрос ждёт connection
  → приложение не отвечает вовремя
  → gateway завершает upstream-запрос
  → клиент получает 502 или 504

Прямой CannotCreateTransactionException обычно приводит к HTTP 500. Коды 502 и 504 создаёт gateway при timeout, reset или недоступности upstream.

Для доказательства нужны ingress-поля upstream_status, request_time, upstream_response_time, адрес upstream, имя pod и общий trace либо request ID.

Пошаговый сценарий расследования

  1. Найти pod и pool, где active=max, idle=0 и растёт pending.
  2. Проверить рост timeout_total, а также acquire_seconds_max и usage_seconds_max.
  3. Наложить scheduled, HTTP и Kafka workload-метрики на то же временное окно.
  4. Разделить задачи с SUCCESS и ERROR; сравнить их длительность с acquire и usage.
  5. Найти длинный JDBC span и его родительский HTTP, scheduled или Kafka span.
  6. Снять pg_stat_activity и проверить query age, transaction age, locks и idle in transaction.
  7. Если trace не даёт ответа, временно включить Hikari leak detection.
  8. Подтвердить одну причинную цепочку по времени, а не отдельное совпадение графиков.

Полное доказательство должно содержать одно временное окно:

ingress 5xx или рост latency
  + Hikari active=max и pending>0 на конкретном pod
  + конкретный workload или trace
  + соответствующий SQL, lock или idle transaction в PostgreSQL

Чего не стоит делать первым

Не следует сразу увеличивать maximum-pool-size.

Это может временно уменьшить очередь, но перенесёт конкуренцию в PostgreSQL и увеличит число одновременных запросов.

Рестарт pod также очищает очередь лишь временно. Если причина осталась, pending быстро вернётся.

Сначала нужно определить, что ограничивает систему.

Причиной может быть длинная транзакция, медленный SQL, lock, fan-out запросов или наложение фоновых запусков.

Выводы

  1. pending=800 означает 800 ожидающих операций внутри JVM, а не 800 соединений PostgreSQL.
  2. HikariCP показывает состояние пула, но не имя Java consumer.
  3. Большой usage указывает на держателя, большой acquire — на ожидающего.
  4. Scheduled-задача с timeout и ошибкой может быть жертвой уже заполненного пула.
  5. Virtual threads позволяют очереди быстро расти, но не добавляют соединений и мощности базе.
  6. Надёжная атрибуция требует Prometheus, traces и PostgreSQL в одном временном окне.
  7. Leak detection полезен как временный источник acquisition stack trace, но не является доказательством утечки.
  8. Размер пула следует менять только после измерения SQL latency, транзакций и допустимой нагрузки на PostgreSQL.