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 usandopg_sleepdurante el tiempo indicado.GET /api/pool: devuelve el estado del pool en tiempo real —active,idle,total,threadsAwaitingConnectiony 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);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 60sO 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 500Limitació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_sleepretiene 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 intentos ≪ p95 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:
tinydelay 500ms: active máx. 2/2, waiting máx. 47. Pool exhausto desde el principio.pool8delay 500ms: active máx. 8/8, waiting máx. 41, error rate 40.47%. Presión alta pero no total.pool32delay 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.92mspool32: 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.timeoutEl 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 60sSi 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:
- HikariCP GitHub — Configuration: https://github.com/brettwooldridge/HikariCP#gear-configuration-knobs-baby
Artículos Relacionados
Spring Security con Spring Boot Actuator: así quedó el modelo de autorización después del incidente
Cerrar los endpoints de Actuator no alcanza. Después del incidente, reconstruí el modelo de autorización desde cero: SecurityFilterChain explícito, health groups separados, roles para /metrics y /env, y validación real con curl. Esto es lo que quedó en pie.
pnpm workspaces en monorepo con Next.js 16: lo que el benchmark no midió y casi me rompe el CI
El benchmark de install time que publiqué antes no capturó el verdadero costo de pnpm workspaces en CI: cache invalidation silenciosa, hoisting de dependencias que rompe en App Router, y un edge case específico que puede tirar tu pipeline en Railway. Acá está lo que faltó medir.
Spring Boot Actuator en producción: los endpoints que dejé abiertos sin darme cuenta y cómo los cerré
Después de publicar el análisis de Jakarta EE vs Spring Boot, revisé los defaults de Actuator en un backend propio y encontré endpoints sensibles abiertos que nunca configuré conscientemente. Acá está el checklist de hardening que armé después.
Comentarios (0)
¿Qué pensás de esto?
Dejá tu comentario en 10 segundos.
Usamos tu login solo para mostrar tu nombre y avatar. Nada de spam.