Módulo 3: Métricas — latencia, throughput y errores

3. p50/p90/p95/p99 con `statistics.quantiles`

Descripción

En la lección 2 quedó claro por qué miramos percentiles en vez del promedio. Esta lección es la mecánica: qué son exactamente p50, p90, p95 y p99, cómo se lee cada uno en una frase que cualquiera entiende, y cómo se calculan de verdad a partir de una lista de latencias medidas usando statistics.quantiles de la biblioteca estándar de Python. Al terminar, un percentil dejará de ser una palabra de blog para convertirse en algo que sabes producir con cinco líneas de código y explicar sin titubear.

Un percentil es una idea simple disfrazada de término técnico. El p95 de una lista de latencias es el valor por debajo del cual cae el 95% de las mediciones; dicho en humano, "el 95% de los usuarios vio esto o algo mejor, y el 5% peor vio esto o algo peor". El p50 es la mediana (el usuario justo en el medio); el p99 es la cola extrema (1 de cada 100). Calcularlos es ordenar las latencias y encontrar el valor en la posición que corresponde —o, mejor, dejar que statistics.quantiles haga la interpolación por ti—. Vamos a hacerlo primero sobre una lista mínima de diez latencias, donde puedes seguir la cuenta a mano y ver cómo un solo valor de la cola dispara el promedio pero apenas mueve la mediana; y luego sobre una corrida real contra Reservo, con miles de peticiones.

Conexión con el módulo: la lección 2 te dio la intuición; esta te da la herramienta de cálculo; la lección 7 la usa a fondo para el clímax del promedio-vs-p95. El helper q(data, p) que construyes aquí —el que envuelve statistics.quantiles— es el mismo que aparece en el generador de carga de todo el módulo y en el mini-proyecto de la lección 8. En k6 no calcularás percentiles a mano: la herramienta te da directamente las columnas p(90) y p(95) en su resumen (lección 6). Pero calcularlos tú una vez, con tus manos, es lo que hace que esas columnas de k6 dejen de ser magia.

El examen y la nota de corte

Piensa en un examen que hicieron mil estudiantes. El profesor quiere resumir "cómo le fue al grupo". Podría dar el promedio de las notas, pero eso esconde mucho: un promedio de 7 puede ser "casi todos sacaron 7" o "la mitad sacó 10 y la mitad sacó 4". Así que en su lugar usa percentiles. Ordena las mil notas de menor a mayor y pregunta: ¿qué nota deja al 50% de los estudiantes por debajo? Esa es la nota de la mediana (p50). ¿Y al 95%? Esa es el p95: solo el 5% de los estudiantes sacó más que esa nota. Los percentiles convierten una montaña de números en preguntas concretas sobre posiciones: "el estudiante en el puesto 950 de 1000, ¿qué sacó?".

Con latencias es idéntico, solo que "más alto" es peor (más lento). Ordenas todas las latencias medidas de menor a mayor. El p50 es la latencia en la posición central: la mitad de las peticiones fue igual o más rápida, la mitad igual o más lenta —el usuario típico—. El p95 es la latencia en la posición 95%: el 95% de las peticiones fue igual o más rápida, y solo el 5% peor fue más lenta —el usuario que empieza a sufrir—. El p99 es la posición 99%: la cola extrema, 1 de cada 100. Fíjate en la asimetría deliberada: no elegimos p50 y luego p60, p70, p80... saltamos a p90, p95, p99 porque lo que nos importa es la cola —los usuarios lentos—, y la cola vive en los percentiles altos. El p50 nos dice cómo va el típico; los percentiles altos nos dicen cuán mal la pasa el desafortunado.

El pXX es el valor por debajo del cual cae el XX% de las mediciones. En latencias: "el XX% de los usuarios vio esto o mejor; el (100−XX)% peor, esto o peor". p50 = el típico; p95 = el que sufre (1 de cada 20); p99 = la cola extrema (1 de cada 100).

statistics.quantiles: cómo se calcula de verdad

Python trae en la biblioteca estándar todo lo que necesitas: statistics.quantiles. La función parte tus datos en n grupos de igual tamaño y devuelve los n − 1 puntos de corte entre ellos. Si pides n=100 (percentiles), devuelve 99 valores: el primero es el p1, el segundo el p2, …, el último (el número 99) es el p99. Para sacar un percentil concreto, indexas: el pXX está en la posición XX − 1 de la lista devuelta (porque las listas empiezan en 0).

import statistics

# statistics.quantiles(data, n=100) devuelve 99 puntos de corte: [p1, p2, ..., p99]
def q(data, p):
    """Percentil p (1-99) de data, con el método inclusivo."""
    cortes = statistics.quantiles(data, n=100, method="inclusive")
    return cortes[p - 1]   # p50 -> cortes[49], p95 -> cortes[94], p99 -> cortes[98]

Un detalle que conviene fijar: statistics.quantiles tiene dos métodos, exclusive (el de por defecto) e inclusive, y dan valores ligeramente distintos en los extremos. El método exclusive asume que tus datos son una muestra de una población mayor y puede extrapolar más allá del mínimo y el máximo observados; el inclusive trata tus datos como la población completa y nunca sale del rango medido. Para latencias de una prueba de carga —donde tus mediciones son todas las peticiones que hiciste, no una muestra de un universo teórico— el método inclusive es el más natural, y es el que usamos en toda la guía. La diferencia es pequeña con muchos datos, pero conviene ser explícito: siempre pasamos method="inclusive".

Ejemplo trabajado: diez latencias que puedes seguir a mano

Antes de miles de peticiones, hagámoslo con diez, para que veas cada número. Imagina que mediste diez latencias (en ms): nueve rápidas, entre 8 y 14 ms, y una de la cola, de 250 ms —un pico, como los que produce una base de datos que a veces se atasca—.

import statistics

data = [8, 9, 10, 10, 11, 12, 12, 13, 14, 250]  # 9 rápidas + 1 de la cola

def q(data, p):
    return statistics.quantiles(data, n=100, method="inclusive")[p - 1]

print("promedio (fmean):", round(statistics.fmean(data), 1))
print("mediana  (p50)  :", statistics.median(data))
print("p90:", round(q(data, 90), 1))
print("p95:", round(q(data, 95), 1))
print("p99:", round(q(data, 99), 1))
print("max:", max(data))

Qué esperar. El único valor grande (250) va a arrastrar el promedio muy por encima de las nueve latencias rápidas, mientras que la mediana se queda tranquila en el centro. Esta es la salida real:

promedio (fmean): 34.9
mediana  (p50)  : 11.5
p90: 37.6
p95: 143.8
p99: 228.8
max: 250

Detente en el contraste. Nueve de las diez peticiones tardaron 14 ms o menos. El usuario típico (p50) vivió 11.5 ms. Y sin embargo el promedio es 34.9 ms: casi el triple de lo que vivió la mayoría, empujado él solo por el 250. Si reportaras "la API tarda de media 34.9 ms", estarías describiendo un tiempo que ninguna de las diez peticiones tuvo —ni las rápidas (≤14) ni la lenta (250)—. El promedio cayó en el vacío. La mediana (11.5), en cambio, describe fielmente a las nueve peticiones rápidas, y el p95 (143.8) y el p99 (228.8) capturan que existe una cola fea. Con estos cinco números —p50, p90, p95, p99, max— cuentas la historia completa; con el promedio solo, la falseas.

Fíjate también en cómo suben los percentiles al acercarse a la cola: p90 = 37.6, pero p95 = 143.8 y p99 = 228.8. Ese salto brusco entre p90 y p95 es la firma de una cola larga: cuando los percentiles altos se disparan mientras los bajos están juntos, sabes que hay una minoría de peticiones muchísimo más lentas que el grueso. Es exactamente lo que pasa en producción, y lo que /quote_slow reproduce.

Los dos métodos, de cerca

Para que veas la diferencia entre inclusive y exclusive (y por qué elegimos el primero), aquí están los cuartiles de esos mismos diez datos con ambos métodos:

statistics.quantiles(data, n=4)                     # exclusive (por defecto)
# -> [9.8, 11.5, 13.2]
statistics.quantiles(data, n=4, method="inclusive") # inclusive
# -> [10.0, 11.5, 12.8]

Los dos coinciden en la mediana (11.5), pero difieren en los cuartiles de los extremos (Q1 y Q3): exclusive los empuja un poco más afuera (9.8 y 13.2) porque asume que hay más población fuera de la muestra; inclusive se queda más adentro (10.0 y 12.8). Con diez datos la diferencia se nota; con miles se difumina. Para una prueba de carga usamos inclusive porque nuestras mediciones son la población completa de esa corrida, no una muestra de algo mayor.

De diez a miles: los percentiles reales de Reservo

Ahora lo mismo, pero sobre una corrida de verdad. El generador golpea /quote_slow con 2000 peticiones y 50 clientes concurrentes, guarda las 2000 latencias, y calcula los percentiles con el mismo helper q. Esta es la salida real:

avg (promedio)      28.64
median (p50)        12.18
p90                 38.59
p95                182.81
p99                240.31
max                257.33

Reconoces la firma: p50 y p90 relativamente juntos (12 y 39 ms), y luego el salto a p95 = 182.81 y p99 = 240.31 —la cola larga del endpoint lento, que declaramos en la lección 1—. Con 2000 datos en vez de 10, los percentiles son estables y confiables, pero la lectura es idéntica a la del ejemplo mínimo: la mitad de los usuarios vio ≤12 ms, y 1 de cada 20 vio ≥183 ms. El promedio (28.64) sigue cayendo en tierra de nadie. La única diferencia entre el ejemplo de diez latencias y esta corrida de 2000 es la escala; la técnica —statistics.quantiles(data, n=100), indexar [p−1]— es exactamente la misma.

Errores comunes

Indexar mal el resultado de quantiles. Qué pasa: alguien hace statistics.quantiles(data, n=100)[95] esperando el p95 y obtiene el p96. Por qué pasa: la lista devuelta tiene 99 elementos (p1…p99) e indexa desde 0, así que el pXX está en la posición XX − 1, no XX. Cómo detectarlo: si tu "p95" no coincide con lo que esperas, revisa el índice; [95] es el p96, [94] es el p95. Cómo corregirlo: usa un helper como q(data, p) que reste 1 por ti (cortes[p - 1]), y así no vuelves a equivocarte.

Olvidar el método y comparar percentiles calculados de formas distintas. Qué pasa: una corrida usa exclusive (el default) y otra inclusive, y al compararlas parece que la latencia cambió cuando solo cambió el método. Por qué pasa: statistics.quantiles usa exclusive por defecto, y es fácil olvidarlo en una de las dos. Cómo detectarlo: diferencias pequeñas y sistemáticas en los extremos entre dos corridas "iguales". Cómo corregirlo: fija el método explícitamente en todas partes (method="inclusive" en esta guía) y no lo mezcles.

Calcular percentiles sobre pocos datos y confiar en ellos. Qué pasa: alguien mide 8 peticiones y reporta un p99 "de 228 ms". Con 8 datos, el p99 es casi el máximo y no significa nada estable. Por qué pasa: se olvida que un percentil alto necesita muchos datos para ser confiable —el p99 pide al menos cientos de mediciones para tener sentido—. Cómo detectarlo: si tu p95 o p99 cambia drásticamente entre corridas, probablemente tienes pocos datos. Cómo corregirlo: mide suficientes peticiones (miles para un p99 estable) y desconfía de los percentiles altos calculados sobre puñados de mediciones.

Ejercicios

Ejercicio 1 — Lee los percentiles en voz alta. Para la corrida de /quote_slow (p50 = 12.18 ms, p95 = 182.81 ms, p99 = 240.31 ms), traduce cada percentil a una frase que un gerente no técnico entienda. (a) el p50, (b) el p95, (c) el p99.

Ver solución
  • (a) p50 = 12.18 ms: "La mitad de los usuarios recibió su cotización en 12 milisegundos o menos." (El usuario típico.)
  • (b) p95 = 182.81 ms: "El 95% de los usuarios la recibió en 183 milisegundos o menos; el 5% peor —1 de cada 20— esperó más que eso." (El que empieza a sufrir.)
  • (c) p99 = 240.31 ms: "El 99% la recibió en 240 milisegundos o menos; el 1% peor —1 de cada 100— esperó más." (La cola extrema.)

La plantilla: "el XX% vio pXX o mejor; el (100−XX)% peor, eso o peor". Nota que ninguna frase menciona el promedio: para describir la experiencia se usan percentiles.

Ejercicio 2 — Calcula un percentil a mano y con quantiles. Tienes estas siete latencias ya ordenadas (ms): [10, 12, 14, 15, 18, 22, 400]. (a) ¿Cuál es la mediana (p50) a ojo? (b) ¿Qué le pasa al promedio por culpa del 400? (c) Escribe la llamada a statistics.quantiles que te daría el p90.

Ver solución
  • (a) Con 7 datos ordenados, la mediana es el del medio (posición 4 de 7): 15 ms. Seis de las siete latencias están entre 10 y 22 ms.
  • (b) El 400 dispara el promedio: (10+12+14+15+18+22+400)/7 ≈ 70.1 ms. Ese "70 ms de media" es más del cuádruple de la mediana (15) y mayor que seis de las siete mediciones. El promedio, otra vez, describe a un usuario inexistente.
  • (c) statistics.quantiles([10, 12, 14, 15, 18, 22, 400], n=100, method="inclusive")[89] — recuerda que el p90 está en la posición 90 − 1 = 89.

Ejercicio 3 — La firma de la cola. Para cada juego de percentiles, di si la distribución tiene cola larga (hay que preocuparse por la cola) o es compacta (el promedio serviría). (a) p50 = 5, p90 = 8, p95 = 9, p99 = 15. (b) p50 = 12, p90 = 39, p95 = 183, p99 = 240. (c) p50 = 100, p90 = 102, p95 = 103, p99 = 108.

Ver solución
  • (a) Compacta. Los percentiles suben suave y juntos (5 → 8 → 9 → 15); no hay salto brusco. Es el /quote sano de la lección 2. El promedio la describiría bien, aunque igual conviene reportar el p95.
  • (b) Cola larga. Salto brusco entre p90 (39) y p95 (183): casi 5 veces. Es la firma de una minoría de peticiones muchísimo más lentas —el /quote_slow—. Hay que mirar p95/p99, nunca el promedio.
  • (c) Compacta (pero lenta). Los percentiles están pegadísimos (100 → 108): distribución plana, sin cola. Es consistentemente lenta, no a veces lenta. El promedio no engañaría sobre la forma, aunque 100 ms de p50 quizás sea inaceptable de por sí.

La regla: busca el salto entre percentiles. Si p90 → p95 → p99 se disparan, hay cola; si suben juntos, es compacta.

Resumen y siguiente paso

En esta lección convertiste "percentil" de palabra en herramienta. Un pXX es el valor por debajo del cual cae el XX% de las mediciones: p50 = el usuario típico, p95 = el que sufre (1 de cada 20), p99 = la cola extrema (1 de cada 100). Los calculaste de verdad con statistics.quantiles(data, n=100, method="inclusive"), que devuelve 99 puntos de corte de los que el pXX está en la posición XX − 1. Y viste la trampa del promedio con números que puedes seguir a mano: diez latencias donde nueve fueron ≤14 ms y una fue 250, y el promedio salió 34.9 —un tiempo que ninguna petición tuvo—, mientras la mediana (11.5) describía fielmente a la mayoría.

Aprendiste a reconocer la firma de una cola larga: cuando los percentiles bajos están juntos pero p95 y p99 se disparan (como en /quote_slow: p90 = 39, p95 = 183). Y fijaste dos detalles prácticos: indexar [p − 1] y usar siempre method="inclusive" en una prueba de carga. Antes de avanzar deberías poder: leer un p95 en una frase para no técnicos; escribir la llamada a quantiles para cualquier percentil; y distinguir una distribución con cola de una compacta mirando el salto entre percentiles.

Lo que sigue es el segundo instrumento del tablero. En la lección 4 pasamos de la latencia al throughput: qué es el RPS (peticiones por segundo), cómo lo reporta k6 (http_reqs, iterations), y su relación —a veces contraintuitiva— con los VUs y el think time. Verás con un barrido real por qué subir usuarios virtuales sube el throughput solo hasta que el sistema satura, y después lo único que sube es la latencia.

Recursos