Pruebas y calidad de software

Las pruebas de rendimiento como disciplina: SLO, benchmarks con JMH y controles en CI

Intermedio10 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 se convierte en una fase al final del proyecto. Esta guía lo trata como parte de la estrategia de pruebas: redactar requisitos no funcionales capaces de hacer fallar un build, medir el código correctamente con JMH, perfilar un test lento en lugar de adivinar y ejecutar controles de regresión en un pipeline donde las mediciones tienen ruido.

Lo que aprenderás

  • Convertir una expectativa de rendimiento vaga en un SLO que un test pueda superar o no
  • Situar los controles de rendimiento en el nivel adecuado, del microbenchmark a la prueba de carga completa
  • Escribir benchmarks JMH que resistan el calentamiento del JIT y la eliminación de código muerto
  • Perfilar un test o un endpoint lento con profilers de muestreo y JFR en lugar de adivinar
  • Automatizar la detección de regresiones de rendimiento en CI pese a las mediciones ruidosas

Los problemas de rendimiento tienen una forma característica: nadie se da cuenta durante meses y, de pronto, una release hace que una página vaya cuatro veces más lenta y la causa queda enterrada entre trescientos commits. Casi nunca es porque al equipo le faltara una herramienta de pruebas de carga. Es porque el rendimiento se trató como una actividad que ocurre cerca del final, en otro entorno, a cargo de otras personas y contra requisitos escritos como adjetivos.

Tratarlo como parte de la estrategia de pruebas cambia eso. Las expectativas de rendimiento se convierten en números capaces de hacer fallar un control, las mediciones se hacen a varios niveles de granularidad y una regresión la detecta el mismo pipeline que la produjo. Esta guía cubre el lado metodológico: objetivos, niveles, benchmarking, profiling y controles en CI. Para el modelado de la carga, los perfiles de rampa y la mecánica de ejecutar una prueba de carga a escala, consulta la guía de metodología de pruebas de carga.

Requisitos capaces de hacer fallar un build

«El sistema debe ser rápido» no se puede probar. Un enunciado que sí se puede probar necesita cuatro partes: qué operación, qué estadístico, qué umbral y en qué condiciones.

El endpoint POST /orders responde en menos de 400 ms en el percentil 95 y en menos de 900 ms en el 99, con una carga sostenida de 150 peticiones por segundo, en dos instancias de 2 vCPU, con una base de datos que contiene 5 millones de pedidos.

Eso es un objetivo de nivel de servicio (SLO), y es refutable. Fíjate en lo que aporta cada parte. Nombrar la operación impide que una media sobre todos los endpoints oculte el que es lento. Elegir un percentil reconoce que los usuarios sufren la cola: una media de 120 ms es perfectamente compatible con que una de cada cincuenta peticiones tarde cuatro segundos. Indicar la carga hace que el número sea reproducible. Indicar el entorno y el volumen de datos evita el clásico bochorno de un sistema que es rápido con diez mil filas y cuadrático con diez millones.

Junto a la latencia hay otras tres magnitudes, y a menudo son las que de verdad se rompen: el throughput (las peticiones atendidas por segundo antes de que la latencia se degrade), el margen de recursos (uso de CPU, memoria y pool de conexiones con la carga objetivo) y la tasa de errores bajo estrés (qué fracción de peticiones falla cuando se empuja el sistema más allá de su zona cómoda, y si se degrada poco a poco o se desploma).

Dónde encajan los controles de rendimiento en la estrategia

El rendimiento no es una sola actividad, sino una familia de ellas, con alcances y costes distintos.

NivelQué mideCosteCuándo se ejecuta
Microbenchmark (JMH)Un método o algoritmo, de nanosegundos a microsegundosde segundos a minutosBajo demanda, o en cada commit para las rutas críticas
Benchmark de componenteUna llamada de servicio de extremo a extremo, en el mismo procesosegundosEn cada commit
Aserción en un test funcionalNúmero de consultas, número de asignaciones, límite de tiempo de relojgratisEn cada build
Prueba de cargaEl sistema desplegado bajo una carga modeladade minutos a horasCada noche, en cada release
Resistencia y estrésEstabilidad durante horas, comportamiento más allá del límitehorasEn cada release
Observación en producciónTráfico real, datos realescontinuoSiempre

La entrada más barata y menos aprovechada de esa tabla es la tercera. Muchas regresiones de rendimiento no tienen nada de sutil: una consulta dentro de un bucle, un recorrido completo de tabla, una comparación O(n²) colada sin querer. Un test funcional puede detectarlas comprobando una propiedad contable en lugar de una duración:

@Test
void loadingAnOrderPageIssuesAFixedNumberOfQueries() {
    QueryCounter counter = QueryCounter.attachTo(dataSource);

    orderPageService.render(customerWith(50).orders());

    assertThat(counter.count())
            .as("N+1 regression: one query per order has crept back in")
            .isLessThanOrEqualTo(3);
}

Contar consultas, asignaciones o llamadas HTTP es determinista, así que la aserción nunca es flaky, a diferencia de un límite de tiempo de reloj, que tarde o temprano fallará en un runner de CI cargado. Cada vez que corrijas un problema N+1, deja un test de recuento para que no pueda volver en silencio.

Microbenchmarks con JMH

Cuando la pregunta es «¿esta rutina de parsing es más rápida que aquella?», necesitas un harness de benchmarking, porque un bucle de cronometraje escrito a mano mide la opinión del compilador JIT sobre tu código, no el código.

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
@Fork(value = 2, jvmArgsAppend = {"-Xms1g", "-Xmx1g"})
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
public class IbanValidationBenchmark {

    @Param({"FR7630006000011234567890189", "DE89370400440532013000"})
    public String iban;

    private IbanValidator regexValidator;
    private IbanValidator arithmeticValidator;

    @Setup(Level.Trial)
    public void setUp() {
        regexValidator = new RegexIbanValidator();
        arithmeticValidator = new ArithmeticIbanValidator();
    }

    @Benchmark
    public boolean regex() {
        return regexValidator.isValid(iban);
    }

    @Benchmark
    public boolean arithmetic() {
        return arithmeticValidator.isValid(iban);
    }

    @Benchmark
    public void consumesExplicitly(Blackhole blackhole) {
        blackhole.consume(arithmeticValidator.normalise(iban));
    }
}

Las anotaciones no sobran: cada una protege frente a una forma concreta en que la medición puede mentir.

@Warmup descarta las iteraciones en las que el método todavía se ejecuta en modo interpretado o se está recompilando. Sin ella, mides el arranque.

@Fork ejecuta el benchmark en JVM nuevas. La contaminación del perfil es real: ejecutar el benchmark A antes que el B en la misma JVM puede dejar al compilador con un punto de llamada polimórfico que hace que B parezca más lento de lo que es. Dos forks además ponen al descubierto la varianza entre ejecuciones que una sola JVM oculta.

Devolver un valor o usar Blackhole evita la eliminación de código muerto. Un benchmark cuyo resultado no se usa puede ser eliminado por completo por el optimizador, y el síntoma clásico es una rutina que parece tardar 0,3 nanosegundos: menos que un solo acceso a memoria, lo cual es una imposibilidad física, no una gran optimización.

@State controla qué se comparte y cuándo se crea. Las entradas no deben ser constantes en tiempo de compilación, o el optimizador plegará todo el cálculo a una constante; @Param las proporciona en tiempo de ejecución y, de paso, recorre toda la matriz de combinaciones.

Ejecútalo desde un módulo aparte (mvn clean verify y luego java -jar target/benchmarks.jar) en lugar de desde un IDE, y lee la salida incluido el margen de error: JMH da un intervalo de confianza, y una mejora más pequeña que ese intervalo no es una mejora.

Incluso un benchmark correcto puede engañar. Mide un método aislado, ya calentado, con la caché caliente y sin contención. En producción, ese método puede ejecutarse una vez por petición con la caché fría mientras cuarenta threads compiten por el ancho de banda de memoria. Una mejora del 40 % en una rutina que supone el 0,2 % del tiempo de la petición no vale absolutamente nada. Mira siempre el perfil antes de optimizar y vuelve a comprobar el número de extremo a extremo después.

Perfilar en lugar de adivinar

Cuando algo va lento, el instinto es revisar el código y formular una teoría. Las teorías sobre rendimiento suelen estar equivocadas, porque el coste está en algún sitio poco vistoso: una llamada de log que formatea una cadena en cada iteración, una expresión regular que se recompila en cada petición, un bloque synchronized que serializa ocho threads.

Los profilers de muestreo interrumpen los threads periódicamente y registran sus pilas. Tienen poca sobrecarga y te dan un flame graph en el que la anchura es tiempo. Son la primera herramienta adecuada para saber «en qué se va el tiempo», y funcionan tanto con un proceso de producción en marcha como con un test.

JDK Flight Recorder viene integrado en la JVM y puede activarse para una sola ejecución de un test:

java -XX:StartFlightRecording=duration=60s,filename=slow-test.jfr,settings=profile \
     -jar target/app.jar

La grabación contiene los puntos de asignación, las pausas del garbage collector, la contención de locks, las excepciones lanzadas, los estados de los threads y la actividad del JIT. Al abrirla ves cosas que un profiler de pilas por sí solo no muestra: que el 30 % del tiempo de reloj es una pausa de GC, o que un lock se mantiene durante milisegundos en una ruta crítica.

El profiling de asignaciones es el que más se omite y el que más a menudo resulta decisivo. El exceso de asignaciones no parece un cuello de botella en un perfil de CPU; se manifiesta como presión sobre el garbage collector y fallos de caché. Si una petición asigna doscientos megabytes, ningún ajuste algorítmico arreglará el síntoma.

Para un test lento se aplican las mismas herramientas, pero antes conviene hacerse una pregunta más sencilla: ¿el test es lento porque el código es lento, o porque arranca un contenedor, construye un contexto de aplicación o hace esperas? En una suite de integración la respuesta es casi siempre lo segundo, y entonces se trata de un problema de la suite, no de rendimiento: consulta la sección sobre gestión de costes de la guía de pruebas de integración.

Memoria, fugas y comportamiento a largo plazo

Algunos fallos solo aparecen con el tiempo. Una caché sin política de expulsión, un thread-local que nunca se limpia, una lista de listeners que no para de crecer, un pool de conexiones con fugas en una ruta de error concreta: ninguno hace fallar un test funcional, y todos tumban un despliegue en producción al cuarto día.

Dos controles automatizables detectan la mayoría:

@Test
void repeatedRenderingDoesNotRetainDocuments() {
    ReferenceQueue<RenderedDocument> queue = new ReferenceQueue<>();
    List<PhantomReference<RenderedDocument>> refs = new ArrayList<>();

    for (int i = 0; i < 200; i++) {
        RenderedDocument doc = renderer.render(sampleInvoice());
        refs.add(new PhantomReference<>(doc, queue));
    }

    System.gc();
    await().atMost(Duration.ofSeconds(5)).until(() -> drain(queue) >= 190);
}

Una prueba de resistencia (soak test), en la que el sistema se mantiene con una carga moderada durante varias horas mientras se registran el uso del heap, el número de threads, los descriptores de fichero y el número de conexiones, detecta el resto. La señal no es el valor absoluto, sino la pendiente: un suelo de heap que sube de forma constante tras cada garbage collection es una fuga, tenga el pico el aspecto que tenga. Dos heap dumps tomados en dos momentos y comparados por histograma de clases suelen señalar al culpable en minutos.

Automatizar la detección de regresiones en CI

La dificultad del rendimiento en un pipeline es que la medición tiene ruido. Los runners compartidos tienen vecinos, las frecuencias de CPU varían, los límites de los contenedores difieren. Un control ingenuo («fallar si tarda más de 400 ms») o salta constantemente o se deja tan holgado que no salta nunca.

Técnicas que lo hacen viable:

Compara, no afirmes valores absolutos. Ejecuta la referencia y la versión candidata en el mismo job, intercaladas, y compáralas. El ruido compartido afecta a ambas, así que la proporción es mucho más estable que cualquiera de los dos números.

Bloquea con un umbral amplio y avisa con uno estrecho. Haz fallar el build solo ante una regresión grande, pongamos un 25 %, y genera un informe no bloqueante para cualquier cosa por encima del 10 %. Así el pipeline sigue siendo usable y la deriva se hace visible.

Sigue la tendencia, no la ejecución. Guarda cada medición junto con su commit y represéntala en una gráfica. El rendimiento rara vez empeora de golpe; se degrada un 2 % cada vez a lo largo de cuarenta commits, y solo una línea de tendencia lo muestra. Un escalón en la gráfica señala el commit que lo causó.

Prefiere indicadores deterministas siempre que puedas. El número de consultas, el número de asignaciones y el tamaño de los objetos no varían con la carga de la máquina, así que pueden controlarse con umbrales ajustados sin tests flaky.

Aísla lo que puedas. Fija las CPU, desactiva el escalado de frecuencia, usa un runner dedicado para los benchmarks. Cuando no sea posible, aumenta el número de repeticiones y usa la mediana en lugar de la media.

performance:
  stage: verify
  script:
    - java -jar target/benchmarks.jar -rf json -rff target/jmh-result.json
    - ./scripts/compare-benchmarks.sh baseline/jmh-result.json target/jmh-result.json --fail-over 25 --warn-over 10
  artifacts:
    paths: ["target/jmh-result.json"]

Sea cual sea el control, registra el resultado aunque pase. El archivo de mediciones es lo que te permite responder «¿cuándo empezó a ir lento?», y esa pregunta se hace mucho más a menudo que «¿este build es lento?», como defiende la guía de integración continua para la duración de los builds en general.

Cuando los números engañan

Una breve lista de formas en que los resultados de rendimiento engañan a la gente; la mayoría ya le ha costado una semana a alguien:

  • Medir una JVM caliente y desplegar una fría. Si las instancias se reinician con frecuencia, el tiempo de arranque y de calentamiento forma parte de la experiencia del usuario.
  • Probar con un conjunto de datos diminuto. Todo es rápido con mil filas. El comportamiento de los índices, los planes de join y las tasas de acierto de la caché cambian con el volumen.
  • Generar la carga desde una sola máquina que se satura primero. Si la CPU del generador está al 100 %, estás midiendo el generador.
  • Ignorar la omisión coordinada. Una herramienta de carga que espera una respuesta antes de enviar la siguiente petición subestima la latencia justo cuando el sistema está en apuros, porque las peticiones que habrían sido lentas nunca se enviaron.
  • Optimizar el 3 % equivocado. Perfila primero, siempre, y confirma después la mejora de extremo a extremo.
  • Una sola ejecución. Un número no es una medición. Repite, informa de la varianza y desconfía de cualquier efecto más pequeño que ella.

Puntos clave

  • Los requisitos de rendimiento solo se vuelven comprobables como SLO: operación concreta, percentil, umbral, carga y entorno.
  • Sitúa controles de rendimiento en varios niveles: recuentos deterministas de consultas y asignaciones en los tests funcionales, JMH para el código crítico y pruebas de carga para el sistema.
  • El calentamiento, los forks y los blackholes de JMH protegen cada uno frente a un engaño de medición concreto; un bucle de cronometraje casero no reproduce ninguno.
  • Perfila antes de optimizar con un profiler de muestreo y JDK Flight Recorder, y fíjate en las asignaciones además de en la CPU.
  • Detecta las fugas con pruebas de resistencia y vigila el suelo del heap tras el GC en lugar del pico.
  • En CI, compara con una referencia en el mismo job, bloquea con margen amplio, avisa con margen estrecho y guarda cada medición para que la tendencia señale el commit que introdujo la regresión.

Fuentes y lecturas recomendadas

Preguntas frecuentes

¿Por qué no basta con cronometrar un bucle con System.nanoTime?
Porque la JVM será más lista que tú. Las primeras mil iteraciones se ejecutan en modo interpretado antes de que el JIT compile el método, así que una fase de calentamiento sin cronometrar es obligatoria. Una vez compilado, el optimizador puede sacar tu llamada del bucle, plegarla a una constante o eliminarla por completo porque el resultado no se usa. El garbage collector y el escalado de frecuencia de la CPU añaden un ruido mayor que la mayoría de los efectos que intentas medir. JMH existe precisamente para encargarse de todo eso.
¿Deben ejecutarse las pruebas de rendimiento en cada commit?
Las pruebas de carga completas, no: necesitan un entorno parecido a producción y tardan demasiado para una pull request. Un pequeño conjunto de benchmarks dirigidos a las rutas críticas puede ejecutarse en cada commit si el runner es lo bastante estable, y una prueba de carga nocturna contra un entorno dedicado detecta el resto. Lo importante es que llegue alguna señal de rendimiento en menos de un día desde el cambio que causó una regresión, mientras el diff sigue siendo pequeño.
¿Qué diferencia hay entre la media y el p99, y cuál debo usar?
La media está dominada por el caso habitual y oculta la cola; el p99 significa que el 1 % de las peticiones fue más lento que ese valor. Los usuarios sufren la cola, y en una página que hace veinte llamadas al backend, el p99 de cada llamada afecta a una gran parte de las cargas de página. Fija los objetivos sobre percentiles, normalmente p95 y p99, más un máximo, y trata la media como una comprobación de coherencia, no como un objetivo.
Mi benchmark dice que el código nuevo es un 15 % más rápido. ¿Me lo creo?
Solo si la diferencia es grande en relación con la varianza entre ejecuciones y puedes reproducirla en ejecuciones repetidas, idealmente en una máquina sin carga o en bare metal. Informa del intervalo de confianza que te da JMH, ejecuta ambas versiones intercaladas en lugar de una tras otra y desconfía de cualquier mejora inferior a un 10 % aproximadamente en un runner de CI compartido. Y confirma que el microbenchmark refleja una ruta que realmente importa en producción.