DEV Community

Cover image for HikariCP: el p95 que te miente y cómo leer las señales reales del pool
Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

HikariCP: el p95 que te miente y cómo leer las señales reales del pool

HikariCP: el p95 que te miente y cómo leer las señales reales del pool

Hubo una versión de este análisis que empezaba mal. Miraba el p95 del escenario tiny con delay de 500ms y veía 260.78ms. Comparado con el escenario default que mostraba 2418.16ms, parecía casi cinco veces más rápido. Eso es una trampa clásica, y casi me la como.

El escenario tiny tenía 97.05% de error rate. De 8139 intentos, 7899 fallaban. Los 260ms eran el tiempo promedio de rechazo, no de respuesta útil. No era rápido — estaba fallando rápido. Y la diferencia importa muchísimo cuando estás intentando entender si la configuración de HikariCP sirve o no.

Eso me llevó a armar hikaricp-pool-experiment: un laboratorio reproducible con Java 21, Spring Boot 3.4.5, PostgreSQL 16, HikariCP, Docker Compose y k6 0.51.0. El objetivo no fue simular producción ni documentar un incidente real. Fue construir un entorno donde las señales del pool fueran visibles y medibles, para poder razonar sobre ellas con números en la mano.


El diseño del experimento

La app expone dos endpoints:

  • GET /api/query?delayMs=500: ejecuta una consulta real contra PostgreSQL y retiene la conexión usando pg_sleep durante el tiempo indicado.
  • GET /api/pool: devuelve el estado del pool en tiempo real — active, idle, total, threadsAwaitingConnection y la configuración efectiva.

El delayMs es el mecanismo central del experimento. Una query instantánea puede esconder contención aunque la concurrencia sea alta porque las conexiones se liberan antes de que el siguiente request las necesite. Con pg_sleep(0.5), cada conexión queda ocupada durante medio segundo. Con 50 usuarios virtuales golpeando en paralelo, la presión sobre el pool se vuelve visible rápido.

El script de k6 hace algo que el draft original no tenía bien separado: registra query_duration para todos los intentos y query_success_duration solo para los que devuelven HTTP 200. Sin esa distinción, el p95 agrega rechazos rápidos con queries exitosas lentas y el número resultante no representa ninguna realidad útil.

// load/hikari-pool.js — separación crítica entre intentos totales y exitosos
const ok = check(queryResponse, {
  'query status is 200': (response) => response.status === 200,
});
queryDuration.add(queryResponse.timings.duration);
if (ok) {
  querySuccessDuration.add(queryResponse.timings.duration);
}
queryErrors.add(!ok);
Enter fullscreen mode Exit fullscreen mode

Los escenarios definidos en application.yml son:

Escenario maximumPoolSize connectionTimeout
default 10 (Spring Boot default) 30000ms (HikariCP default)
tiny 2 250ms
pool4 4 1500ms
pool8 8 1500ms
pool16 16 1500ms
pool32 32 1500ms

La matriz se corrió con dos delays — 50ms y 500ms — porque el contraste es importante: una query que libera la conexión rápido y una query que la retiene durante medio segundo no estresan el pool de la misma manera.

Para reproducirlo desde cero:

.\scripts\run-matrix.ps1 -Vus 50 -Duration 60s
Enter fullscreen mode Exit fullscreen mode

O escenario por escenario:

docker compose down -v
.\scripts\run-scenario.ps1 -Scenario tiny -Vus 50 -Duration 60s -DelayMs 500
.\scripts\run-scenario.ps1 -Scenario pool16 -Vus 50 -Duration 60s -DelayMs 500
Enter fullscreen mode Exit fullscreen mode

Limitación importante: todo esto es un single local run del 2026-05-14 en Windows con Docker Desktop/WSL2. Los números sirven para comparar escenarios dentro de la misma máquina. No son un benchmark universal ni reflejan comportamiento en ningún entorno en la nube, Railway o de otro tipo. pg_sleep retiene conexiones de forma artificial para hacer visible la presión — no representa una workload real de producción.


Los resultados completos — y qué leer en ellos

Esta es la tabla que generó summarize-results.ps1 a partir de los JSON de k6:

Escenario Delay Intentos Exitosas Fallidas Error rate Exitosas/s p95 todos p95 exitosas Active máx. Waiting máx.
default 50ms 11772 11772 0 0% 195.38 165.7ms 165.7ms 10 30
default 500ms 1240 1240 0 0% 19.85 2418.16ms 2418.16ms 10 39
tiny 50ms 8289 2325 5964 71.95% 38.53 298.81ms 304.84ms 2 47
tiny 500ms 8139 240 7899 97.05% 3.97 260.78ms 752.51ms 2 47
pool4 50ms 4712 4712 0 0% 77.75 557.55ms 557.55ms 4 43
pool4 500ms 1779 492 1287 72.34% 7.95 1962.83ms 1990.52ms 4 45
pool8 50ms 9253 9253 0 0% 153.4 365.15ms 365.15ms 8 41
pool8 500ms 1653 984 669 40.47% 15.87 1996.36ms 1998.83ms 8 41
pool16 50ms 18155 18155 0 0% 301.83 82.92ms 82.92ms 16 40
pool16 500ms 1948 1947 1 0.05% 31.62 1492.44ms 1492.44ms 16 31
pool32 50ms 18892 18892 0 0% 314.16 70.33ms 70.33ms 32 32
pool32 500ms 3830 3830 0 0% 63.00 784.9ms 784.9ms 32 24

Hay varias cosas que vale la pena leer juntas, no por separado.


La trampa del p95 bajo con error rate alto

El caso tiny con delay 500ms es el más instructivo del experimento. El p95 de todos los intentos es 260.78ms. Si solo mirás ese número, parecería que el pool responde muy rápido. Pero el 97.05% de error rate te dice que casi ninguna query llegó a ejecutarse — HikariCP estaba rechazando requests en connectionTimeout: 250ms porque no había conexiones libres.

La separación entre query_duration y query_success_duration hace visible lo que el número agregado escondía: el p95 de las queries exitosas es 752.51ms — casi tres veces más. Esas pocas queries que sí consiguieron una conexión tardaron casi un segundo, probablemente porque esperaron a que alguna de las dos conexiones del pool se liberara.

Cuando active está pegado al máximo del pool (2/2) y waiting llega a 47, el sistema no está procesando carga — la está rechazando. Los 260ms son el tiempo de fracaso, no de éxito.

Señal que importa: si p95 todos los intentosp95 exitosas y el error rate es alto, el pool está en exhaustion. No estás viendo latencia de queries: estás viendo latencia de rechazo.


Cómo leer las cuatro señales en conjunto

El experimento confirmó que ninguna métrica sola alcanza. Las señales que tiene sentido cruzar son:

1. Error rate + successful queries/s

Estas dos juntas son el primer filtro. Un error rate de 0% con 19.85 exitosas/s (default, delay 500ms) es muy diferente a un error rate de 97% con 3.97 exitosas/s (tiny, delay 500ms). El throughput de exitosas dice cuánto trabajo útil hace el sistema; el error rate dice cuánto trabajo está tirando a la basura.

En pool4 con delay 500ms: 72.34% de error rate con solo 7.95 exitosas/s. Cuatro conexiones con queries de 500ms dan un techo teórico de 8 exitosas/s (4 conexiones × 2 por segundo). Los números coinciden: el pool está al límite y rechaza el resto.

2. active = maximumPoolSize sostenido + waiting > 0

Esta combinación es la señal operativa más directa de que el pool está bajo presión. Cuando maxActiveConnections bate el techo configurado y maxThreadsAwaitingConnection es mayor que cero durante un período sostenido, los threads de la aplicación están esperando una conexión que no está disponible.

Del experimento:

  • tiny delay 500ms: active máx. 2/2, waiting máx. 47. Pool exhausto desde el principio.
  • pool8 delay 500ms: active máx. 8/8, waiting máx. 41, error rate 40.47%. Presión alta pero no total.
  • pool32 delay 500ms: active máx. 32/32, waiting máx. 24, error rate 0%. El pool llega al techo pero absorbe la carga sin rechazar.

En pool32 con delay 500ms, waiting = 24 con error rate 0% significa que los threads esperan pero el connectionTimeout: 1500ms alcanza — las queries encolan y eventualmente consiguen conexión. Es un sistema bajo presión que aún funciona, no uno en crisis.

3. Latencia de intentos vs. latencia de exitosas

Ya mencioné el caso tiny. Pero vale generalizar: cuando hay error rate significativo, el p95 de todos los intentos deja de ser una métrica de performance de la aplicación y pasa a ser una métrica de velocidad de rechazo. La latencia operativa real es la de las queries exitosas.

En pool4 delay 500ms: p95 todos los intentos 1962.83ms, p95 exitosas 1990.52ms. Acá los números son similares porque las queries que sí pasan también esperan mucho — el pool tiene 4 conexiones con queries de 500ms, así que casi todo el tiempo está esperando que alguna se libere.

4. El salto de 50ms a 500ms como revelador de presión

Con delay 50ms, pool8 no tiene un solo error y procesa 153.4 exitosas/s. Con delay 500ms, cae a 40.47% de error rate y 15.87 exitosas/s. El pool no cambió — cambió el tiempo de retención de la conexión. Si cada conexión tarda diez veces más en liberarse, el pool que antes era suficiente ahora no alcanza.

Esta es la variable que más frecuentemente se ignora cuando se calibra un pool: no es solo cuántas conexiones hay, sino cuánto tiempo cada query las retiene. Un pool de 16 conexiones con queries de 50ms es muy diferente a un pool de 16 conexiones con queries de 500ms.


El límite del salto de pool16 a pool32 con delay corto

Hay una observación del experimento que me parece importante para evitar la conclusión fácil de "más conexiones = mejor".

Con delay 50ms:

  • pool16: 301.83 exitosas/s, p95 82.92ms
  • pool32: 314.16 exitosas/s, p95 70.33ms

Doblar el tamaño del pool dio una mejora de apenas ~4% en throughput. El salto de pool8 a pool16 fue mucho mayor (153.4 → 301.83, casi el doble). A partir de cierto punto, el cuello de botella deja de ser el pool y pasa a ser otra cosa — en este caso, probablemente el CPU del Docker Desktop o el propio PostgreSQL bajo carga de 50 VUs.

Esto es consistente con la fórmula que Brettwooldridge menciona en el README de HikariCP: el pool óptimo para throughput de base de datos no es simplemente "el más grande posible". Más allá de cierto umbral, agregar conexiones genera overhead sin beneficio real, y en un entorno con límites de max_connections en PostgreSQL podés quedarte sin slots antes de que el throughput mejore.

La conclusión práctica del experimento no es que 32 sea el número correcto. Es que pool16 con delay 500ms tiene 0.05% de error rate y pool32 tiene 0%, con un throughput 2x mayor. Dependiendo de los tiempos reales de las queries y los límites de la PostgreSQL, el trade-off es diferente en cada caso.


Las métricas que expone el experimento vía Actuator

La app tiene Actuator habilitado con health, info, metrics y prometheus. Durante una corrida podés consultar el estado del pool directamente:

# Estado del pool vía endpoint propio
curl http://localhost:8080/api/pool

# Métricas Micrometer vía Actuator
curl http://localhost:8080/actuator/metrics/hikaricp.connections.active
curl http://localhost:8080/actuator/metrics/hikaricp.connections.pending
curl http://localhost:8080/actuator/metrics/hikaricp.connections.timeout
Enter fullscreen mode Exit fullscreen mode

El endpoint /api/pool usa HikariPoolMXBean directamente y devuelve active, idle, total, threadsAwaitingConnection y la configuración efectiva. Es lo que k6 consulta en paralelo para registrar las métricas hikari_pool_active, hikari_pool_idle, hikari_pool_total y hikari_pool_threads_awaiting_connection.

La métrica hikaricp.connections.timeout de Actuator es la que más me interesa en cualquier entorno real: cuenta las veces que un thread esperó una conexión y se venció el connectionTimeout. Si ese contador es mayor que cero, hay usuarios afectados — no es una advertencia, es un hecho.


La configuración del experimento vs. configuración para un entorno real

El experimento usa valores diseñados para hacer visible la presión en un laboratorio, no valores para copiar en cualquier sistema. El perfil tiny tiene connectionTimeout: 250ms porque 250ms hace que el pool rechace requests rápido y los errores sean inmediatamente visibles. En un sistema real, 250ms es probablemente demasiado agresivo — vas a generar falsos positivos ante cualquier pico breve.

Lo que sí traslada son los principios de lectura:

Sobre connectionTimeout: el valor define la velocidad del fallo, no la velocidad del éxito. Un timeout corto genera errores más rápido y hace que los síntomas sean visibles antes. Un timeout largo acumula threads bloqueados que consumen memoria y pueden saturar el thread pool del servidor web antes de que el error sea obvio. Cuál de los dos querés depende de si tenés circuit breakers y retry logic, y de cuánto tiempo puede esperar un usuario antes de que la experiencia se rompa.

Sobre maximumPoolSize: el número correcto depende del tiempo promedio de retención de las queries, la concurrencia esperada, y los límites de max_connections de la PostgreSQL. No hay una fórmula universal. Lo que el experimento muestra es que con queries de 500ms y 50 VUs, necesitás al menos 16 conexiones para llegar a error rate cercano a cero — y que doblar a 32 da rendimientos marginales decrecientes en el throughput.

Sobre bases de datos gestionadas en la nube: si usás Railway, Supabase, RDS u otro servicio donde no controlás el servidor directamente, hay un parámetro adicional que importa y que el experimento no cubre: maxLifetime. El servidor puede cerrar conexiones inactivas antes del default de 30 minutos de HikariCP, y una conexión que desde el pool "está viva" pero el servidor ya cerró va a generar PSQLException: This connection has been closed en el próximo uso. Configurar maxLifetime por debajo del timeout del servidor es un ajuste necesario en esos entornos — pero no es algo que este laboratorio local con Docker pueda medir.


Mi postura después del experimento

Lo más valioso del ejercicio no fue elegir un número de conexiones. Fue entender que HikariCP no se ajusta mirando una sola métrica.

Si solo mirás el p95 de todos los intentos, podés concluir que un pool en crisis es "rápido". Si solo mirás el error rate, no sabés si el sistema está absorbiendo carga o rechazándola. Si solo mirás active, no sabés si el pool tiene margen o está al límite. Necesitás cruzar los cuatro: error rate, successful queries/s, active vs. máximo configurado, waiting, y latencia de exitosas.

El otro aprendizaje que me quedó: hay dos formas de que un pool falle bajo carga. Una es el timeout largo — threads que esperan 30 segundos y eventualmente explotan el heap. La otra es el timeout corto — rechazos rápidos que generan error rate alto pero dan la ilusión de baja latencia. El laboratorio hizo visible las dos con números reales.

No compro la idea de que hay un maximumPoolSize correcto universal. Lo que hay es un tamaño correcto para la combinación de tiempo de query, concurrencia esperada, y capacidad de la base de datos. Y ese número solo tiene sentido leído junto con el tiempo de retención de conexiones y la tasa de error — no en aislamiento.

El repo tiene todo lo necesario para correrlo de nuevo en la entorno y comparar:

.\scripts\run-matrix.ps1 -Vus 50 -Duration 60s
Enter fullscreen mode Exit fullscreen mode

Si cambiás el delay, la concurrencia o el maximumPoolSize, las señales cambian. Eso es exactamente el punto.

github.com/JuanTorchia/hikaricp-pool-experiment


Referencia:

Top comments (0)