Arquitectura de software

Rendimiento de aplicaciones: latencia, percentiles, profiling y la JVM

Avanzado12 min de lecturaActualizado

Esta página se tradujo del inglés con ayuda de IA y se cotejó con el original. Leer la versión en inglés

El trabajo de rendimiento fracasa cuando empieza por opiniones sobre qué es lento. Esta guía presenta las métricas que describen un sistema con honestidad —latencia frente a throughput, percentiles, utilización y colas—, recorre los puntos donde un servicio JVM pierde tiempo de verdad y termina con un ciclo de optimización que produce evidencias en lugar de folclore.

Lo que aprenderás

  • Distinguir latencia, throughput y concurrencia, y relacionarlas mediante la ley de Little
  • Leer distribuciones de percentiles y explicar por qué las medias ocultan los problemas que ve el usuario
  • Hacer profiling de un servicio en ejecución para averiguar adónde van realmente el tiempo y las asignaciones de memoria
  • Diagnosticar las causas habituales de lentitud: consultas N+1, agotamiento del pool, presión sobre el GC y contención de locks
  • Aplicar caché y límites de concurrencia sin introducir problemas de corrección ni de estabilidad

Casi todas las investigaciones de rendimiento que salen mal empiezan igual: alguien dice que la aplicación va lenta, otra persona tiene una teoría y se va una semana optimizando algo que nunca estuvo en la ruta crítica. La disciplina que lo evita no tiene nada de glamuroso. Mide lo que experimentan los usuarios, localiza con evidencias adónde va el tiempo, cambia una sola cosa, vuelve a medir y conserva o revierte el cambio según el número, no según la teoría.

Esta guía trata de ese ciclo y de los puntos donde un servicio JVM típico pierde tiempo de verdad. Se centra en el diagnóstico y la optimización de un sistema que puedes observar; cómo generar la carga que saca a la luz los problemas antes de que lo haga producción se explica en metodología de pruebas de carga.

Las tres magnitudes y cómo se relacionan

La latencia es lo que tarda una operación. El throughput es cuántas se completan por unidad de tiempo. La concurrencia es cuántas hay en curso a la vez. No son independientes, y la ley de Little las relaciona de forma exacta:

L = λ × W

L = average number of requests in the system (concurrency)
λ = average arrival rate (throughput)
W = average time in the system (latency)

La ley se cumple en cualquier sistema estable, sea cual sea su funcionamiento interno, lo que la convierte en una comprobación de coherencia notablemente buena. Un servicio que atiende 500 peticiones por segundo con una latencia media de 200 ms tiene, de media, 100 peticiones en curso: un pool de 50 threads es un techo infranqueable y la cola que tiene delante es donde se va el tiempo. Dale la vuelta y te sirve para dimensionar: para atender 2000 peticiones por segundo a 50 ms necesitas 100 huecos concurrentes, y si cada uno ocupa una conexión a la base de datos, ya sabes algo importante sobre el pool.

La segunda relación que conviene interiorizar es lo que ocurre cuando sube la utilización. Para un sistema sencillo, la teoría de colas da un tiempo de espera proporcional a ρ / (1 − ρ), donde ρ es la utilización. Al 50 % de utilización, la cola añade más o menos lo mismo que el propio tiempo de servicio; al 90 %, nueve veces más; al 95 %, diecinueve. Por eso un recurso al 80 % parece sano en un dashboard y se comporta fatal, y por eso los planes de capacidad que apuntan a una utilización alta producen sistemas que se caen ante el menor pico de tráfico.

La consecuencia práctica: encuentra el cuello de botella, el recurso más cercano a la saturación, porque nada más importa hasta que se desplace. Optimizar cualquier otra cosa no cambia nada medible y, cuando lo resuelvas, el cuello de botella se trasladará a otro sitio, que es el resultado normal y esperado, no un fracaso.

Mide percentiles, nunca medias

Los tiempos de respuesta tienen una distribución asimétrica con una cola larga, así que unas estadísticas resumen elegidas sin cuidado describen un sistema que nadie usa.

EstadísticoQué te dice
MediaCasi nada; oculta por completo las colas y la bimodalidad
Mediana (p50)La experiencia típica
p90 / p95Donde los usuarios empiezan a notarlo
p99La cola que tus usuarios más activos sufren constantemente
p99.9Donde aparecen los fallos de infraestructura y las pausas del GC
MáximoUn único dato; solo sirve como alarma

Un usuario cuya página lanza veinte llamadas al backend se topa con tu p95 con una probabilidad cercana a dos tercios. La latencia de cola, por tanto, no es un caso límite: en las páginas compuestas es el caso habitual, y por eso «la latencia de cola manda» es la frase más útil del trabajo de rendimiento.

Los percentiles deben calcularse a partir de un histograma, no de una muestra de «peticiones lentas», y sobre una ventana explícita: un p99 de 24 horas esconde una hora de sufrimiento. Agrega sumando los buckets del histograma de todas las instancias; nunca promedies cuantiles precalculados. Y cuidado con la coordinated omission, el error de medición en el que un cliente que espera una respuesta antes de enviar la siguiente deja de muestrear justo cuando el sistema está más lento, lo que hace que los percentiles reportados sean mucho mejores que la realidad.

Por último, vincula los números a algo con sentido: un objetivo de nivel de servicio (SLO) expresado en términos del usuario, como «el 99 % de las peticiones de checkout terminan en menos de 800 ms, medido durante 28 días». Así el rendimiento deja de ser una opinión y se convierte en un presupuesto que puedes gastar de forma deliberada.

Averiguar adónde va el tiempo

Con un objetivo definido, la pregunta pasa a ser dónde están los milisegundos.

Empieza por el borde y avanza hacia dentro. Una traza distribuida de una petición lenta muestra qué salto consumió el tiempo: la aplicación, la base de datos, un servicio aguas abajo o la red entre ellos. Sin trazas, se pierden horas en el servicio equivocado. La instrumentación necesaria se describe en Actuator y observabilidad.

Después, haz profiling del proceso. Dos tipos de profiler responden a preguntas distintas y necesitas ambos:

  • Un profiler de CPU muestrea las pilas de llamadas para mostrar dónde se gasta el tiempo de cómputo. Los profilers de muestreo modernos recorren la pila de forma asíncrona, lo que evita el sesgo de safepoint que hacía que las herramientas antiguas señalaran métodos totalmente equivocados. Lee el resultado como un flame graph: el ancho es el tiempo total, así que busca mesetas anchas, no torres altas.
  • Un profiler de asignaciones muestra qué puntos de llamada crean objetos. Las asignaciones impulsan la recolección de basura, y el tiempo de GC se atribuye a menudo erróneamente al código que se estuviera ejecutando en ese momento.

Java Flight Recorder es la opción pragmática por defecto en la JVM: viene integrado, su sobrecarga ronda el 1 % con el perfil por defecto y se puede activar en un proceso de producción durante una ventana acotada.

# Attach to a running service and record for two minutes.
jcmd <pid> JFR.start name=diag settings=profile duration=120s filename=/tmp/diag.jfr

# Cheap first look while you wait: what are the threads doing?
jcmd <pid> Thread.print | grep -A 5 'java.lang.Thread.State: BLOCKED'

# GC behaviour at a glance.
jstat -gcutil <pid> 1000 30

Lee thread dumps para lo que los profilers no ven. Tres dumps con diez segundos de separación dicen mucho más que uno solo. Threads que aparecen siempre BLOCKED en el mismo monitor indican contención de locks; threads en WAITING sobre el getConnection de un pool indican que el pool está agotado; threads en Socket.read significan que estás esperando a otro y que el problema no está aquí.

Dónde suele estar el tiempo

En un servicio JVM tras otro se repite el mismo puñado de causas.

Acceso a la base de datos

Casi siempre es el primer sitio donde mirar y, por lo general, la mayor mejora individual.

Consultas N+1. Un ORM carga 200 pedidos y luego lanza una consulta por pedido para obtener sus líneas. En una prueba con tres filas es invisible; en producción son 201 viajes de ida y vuelta. La solución es un join fetch, un entity graph o una segunda consulta agrupada, y la forma de detectarlo es contar las sentencias por petición en los tests, no cruzar los dedos. La mecánica en JPA se explica en Spring Data JPA.

Índices ausentes o inutilizables. Lee el plan de ejecución en lugar de adivinar. Un escaneo secuencial sobre una tabla grande en una ruta OLTP es un defecto, igual que un índice que no puede usarse porque el predicado envuelve la columna en una función.

EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.total
FROM   orders o
WHERE  o.customer_id = 4711
  AND  o.created_at >= now() - interval '90 days'
ORDER  BY o.created_at DESC
LIMIT  50;
-- Look for: Seq Scan on a large table, rows removed by filter, an estimate
-- far from the actual count, and sorts spilling to disk.

Over-fetching. Seleccionar todas las columnas de todas las filas para mostrar cuatro campos, o paginar por offset muy adentro de un resultado grande. Proyecta solo lo que necesitas.

Agotamiento del pool. Los threads que esperan una conexión aparecen como latencia en la aplicación mientras la base de datos parece ociosa. Vigila el tiempo de espera del pool como una métrica de primera clase y recuerda que un pool más grande suele empeorar las cosas, no mejorarlas.

Transacciones abiertas durante llamadas remotas. Un método @Transactional que llama a una API HTTP retiene una conexión a la base de datos y sus locks mientras dura esa llamada. Haz el trabajo remoto fuera de la transacción.

Memoria y recolección de basura

El heap de la JVM tiene generaciones porque la mayoría de los objetos mueren jóvenes. Las recolecciones de la generación joven son baratas y frecuentes; el trabajo sobre la generación antigua es lo que provoca pausas apreciables.

Diagnostica antes de ajustar. Activa los logs del GC y fíjate en tres cosas: el porcentaje del tiempo real que se pasa en GC (por encima de un 5 % aproximadamente, merece investigarse), la distribución de los tiempos de pausa frente a tu objetivo de latencia y si el heap vivo tras cada recolección de la generación antigua tiende a crecer, que es la firma de una fuga.

-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20M
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/

La mayoría de los problemas de GC son problemas de asignación. Un servicio que asigna 2 GB por segundo para construir cadenas y colecciones intermedias recolectará sin parar sea cual sea el recolector, y reducir las asignaciones lo resuelve para siempre. Cuando la elección del recolector es de verdad el problema: G1 es una buena opción por defecto que equilibra throughput y tiempo de pausa; ZGC y Shenandoah apuntan a pausas por debajo del milisegundo a costa de algo de throughput y una huella mayor, y compensan cuando la latencia p99.9 está dominada por las pausas. Un heap generoso reduce la frecuencia del GC, pero alarga cada recolección y aumenta la competencia con la caché de páginas; y en un contenedor, fija siempre el heap de forma explícita o verifica que la JVM detecta el contenedor, porque un límite que la JVM no ve se convierte en un OOM kill sin heap dump.

Concurrencia y contención

La contención de locks aparece como una latencia que crece con la concurrencia mientras la CPU se mantiene plana. Busca bloques synchronized alrededor de cachés o contadores compartidos, instancias únicas compartidas de objetos costosos y logging configurado de forma síncrona hacia un appender lento. Las soluciones, por orden de preferencia: eliminar lo compartido, reducir la sección crítica, sustituir el lock por una estructura de datos concurrente o una variable atómica, o particionar los datos para que los threads compitan por franjas distintas.

El fallo opuesto es la concurrencia sin límites: un executor con una cola ilimitada acepta trabajo indefinidamente, de modo que una ráfaga se convierte en un problema de memoria y cada petición encolada acaba agotando su timeout de todos modos. Limita todas las colas y rechaza pronto.

Serialización, red y el trabajo que no hacía falta

El exceso de idas y vueltas en las llamadas remotas, la serialización JSON de payloads enormes, la configuración de compresión y los handshakes TLS en conexiones que deberían haberse reutilizado desde un pool aparecen en los perfiles más a menudo de lo que se piensa. También el trabajo redundante: calcular el mismo valor cinco veces por petición, hacer logging en debug dentro de un bucle crítico o construir un grafo de objetos que quien llama descarta.

Caché, con cuidado

La caché es la optimización con mayor efecto palanca y la que tiene los peores modos de fallo, porque convierte un problema de rendimiento en un problema de corrección.

Capa a capa: el navegador y la CDN se encargan de los recursos estáticos y de las respuestas GET cacheables mediante los validadores que se explican en diseño de API REST; una caché distribuida como Redis guarda resultados calculados compartidos; una caché local en el propio proceso guarda datos calientes, pequeños y sobre todo de lectura con la menor latencia posible; y el buffer pool de la propia base de datos cachea páginas por debajo de todo lo anterior.

Las decisiones que determinan si ayuda:

  • ¿Qué grado de desactualización es aceptable? Si la respuesta es «ninguno», no quieres una caché, quieres una consulta más rápida.
  • Estrategia de expiración. La expiración por tiempo es sencilla y predecible; la invalidación explícita al escribir es precisa y fácil de hacer mal; un TTL corto combinado con invalidación es un compromiso robusto.
  • Tamaño acotado con una política de desalojo sensata, porque una caché local sin límite es una fuga de memoria con un nombre simpático.
  • Protección contra la estampida. Cuando expira una clave popular, cientos de peticiones fallan a la vez y todas recalculan. Usa un lock por clave o sirve contenido desactualizado mientras un único llamante lo refresca.
  • Mide la tasa de aciertos. Una caché por debajo de aproximadamente el 80 % de aciertos a menudo añade latencia y complejidad a cambio de muy poco, y debes saberlo en lugar de suponerlo.

La abstracción de caché de Spring y sus proveedores se explican en caché.

El ciclo de optimización

  1. Definir el objetivo

    Elige una operación visible para el usuario y un número: «p95 de GET /orders por debajo de 300 ms a 400 peticiones por segundo». Sin esto no sabrás cuándo parar.
  2. Reproducir con una carga representativa

    Una carga que refleje la combinación de peticiones, el volumen de datos y la concurrencia de producción. Optimizar contra una base de datos vacía no te enseña nada.
  3. Medir y localizar

    Traza para encontrar el salto lento, haz profiling para encontrar el código lento e identifica el único recurso que actúa como cuello de botella.
  4. Formular una hipótesis con un efecto previsto

    «Agrupar estas 40 consultas en una debería ahorrar unos 120 ms». Una predicción que resulta errónea es información; un cambio sin predicción es una apuesta.
  5. Cambiar una sola cosa

    Un cambio por medición, o no sabrás cuál importó, y alguno de ellos puede haber empeorado las cosas.
  6. Volver a medir y decidir

    Compara con la línea base sobre la misma carga. Conserva el cambio si movió el número, reviértelo si no, y deja constancia de ambos resultados.
  7. Repetir hasta cumplir el objetivo y parar

    El cuello de botella se habrá desplazado. Continúa solo mientras no se cumpla el objetivo; optimizar más allá de un objetivo cumplido es coste sin beneficio.

Dos hábitos hacen que esto perdure. Mantén el benchmark en el pipeline para que una regresión la detecte el build y no un cliente. Y apunta lo que probaste y lo que pasó, fracasos incluidos: si no, la próxima persona que mire este sistema los repetirá todos.

Ideas clave

  • Latencia, throughput y concurrencia están ligados por la ley de Little, y los efectos de cola hacen que un recurso por encima del 80 % de utilización ya esté en apuros.
  • Reporta percentiles a partir de histogramas combinados sobre una ventana explícita, nunca medias ni cuantiles promediados; en las páginas con varias llamadas, la cola es la experiencia habitual.
  • Traza primero para encontrar el salto lento, luego haz profiling de CPU y de asignaciones, y lee varios thread dumps para detectar contención y agotamiento del pool; el acceso a la base de datos —consultas N+1, índices ausentes, over-fetching, pools de conexiones agotados— es el cuello de botella más común y el más barato de corregir.
  • La mayoría de los problemas de GC son problemas de asignación; diagnostica con los logs del GC y reduce la basura antes de cambiar de recolector, y fija siempre el heap de forma explícita en contenedores.
  • Añade caché solo después de medir, con una tolerancia de desactualización explícita, un tamaño acotado, protección contra la estampida y una tasa de aciertos monitorizada.
  • Optimiza en ciclo: define el objetivo, reproduce, localiza, predice, cambia una sola cosa, mide y para cuando se cumpla el objetivo.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Por qué engaña el tiempo de respuesta medio?
Porque los tiempos de respuesta no se distribuyen de forma simétrica. Un servicio con una media de 40 ms puede servir perfectamente una de cada veinte peticiones en más de un segundo, y esas peticiones lentas se concentran en los usuarios que hacen lo más valioso: las cestas más grandes, las carteras más voluminosas. La media también oculta por completo un comportamiento bimodal: aciertos de caché a 5 ms y fallos a 900 ms dan una media que ninguna petición llega a experimentar.
¿Puedo promediar los percentiles de varios servidores?
No. La media del p99 de cada nodo no es el p99 del servicio completo y puede estar muy equivocada. Exporta los buckets del histograma de cada instancia, súmalos de forma centralizada y calcula el percentil a partir del histograma combinado. La mayoría de los sistemas de métricas lo admiten; si el tuyo publica cuantiles precalculados por instancia, no se pueden agregar de forma significativa.
¿Qué tamaño debe tener un pool de conexiones a la base de datos?
Mucho menor de lo que suele suponerse. Las conexiones que superan lo que la base de datos puede ejecutar en paralelo simplemente esperan en cola, lo que añade latencia sin aumentar el throughput, y un pool grande permite que la aplicación desborde la base de datos. Parte de la capacidad paralela de la base de datos —aproximadamente el número de núcleos más el número efectivo de discos—, dimensiona el pool en torno a esa cifra y compruébalo aumentándolo: verás que el throughput no mejora mientras la latencia empeora.
¿Cuándo debo añadir una caché?
Cuando hayas medido que el mismo resultado costoso se calcula una y otra vez y puedas decir qué grado de desactualización es aceptable. Una caché es un riesgo para la corrección y una dependencia operativa, así que debe responder a una medición, no a una corazonada. Corregir una consulta N+1 o añadir un índice a menudo elimina por completo la necesidad de la caché, sin coste alguno en consistencia.