Saltar al contenido
Computer Science Fundamentals
Código fuente
← Inicio

Rendimiento

100 preguntas

Correctas
0
Incorrectas
0
Sin responder
100
Dificultad
Número de preguntas

100 preguntas en este filtro

Resumen teórico

Imagina dos panaderías. En la primera, cada pan está listo en 2 minutos, pero el sábado por la mañana la fila sale por la puerta y algunas personas esperan una hora. En la segunda, el pan tarda 3 minutos, pero nadie espera más de 5. ¿Cuál panadería es la "más rápida"? La respuesta honesta es: depende de lo que midas. El rendimiento (performance) es el estudio de qué tan rápido es un sistema informático, cuánto trabajo puede hacer y a dónde se va su tiempo.

Toda app que usas depende de esto. Una página que tarda 3 segundos en abrir hace que la gente se vaya. Una tienda que se congela durante una gran oferta pierde dinero. Un programa que es rápido en tu portátil puede arrastrarse cuando mil personas lo usan a la vez. Quien se ocupa del rendimiento no adivina. Mide primero, encuentra la parte realmente lenta, arregla solo esa parte y vuelve a medir.

En este resumen aprenderás: las palabras que se usan para hablar de velocidad (latencia, throughput, percentiles), cómo hacer una prueba justa (benchmarking), cómo descubrir qué está lento (profiling y flame graphs), cómo la memoria y la caché del computador cambian la velocidad, cómo funciona de verdad un servidor JavaScript, por qué las bases de datos se vuelven lentas, los tipos de pruebas de carga y cómo escribirlas con la herramienta k6, y las matemáticas que dicen cuántos servidores necesitas. Al final estarás listo para el quiz.

Panorama general: medir, encontrar, arreglar, medir

Arreglar el rendimiento es como ser médico. Un médico no receta antes de revisar qué anda mal. De la misma forma, un buen ingeniero sigue un ciclo: mide cómo se comporta el sistema, encuentra la parte que más tiempo desperdicia, cambia solo esa parte y vuelve a medir para ver si de verdad mejoró.

  medir --> encontrar la --> cambiar una --> medir
    ^      parte lenta       cosa          de nuevo
    |                                        |
    +----------------------------------------+
El ciclo del rendimiento. Nunca te saltes el primer ni el último paso.

Cuatro capas de la misma historia

CapaPregunta de ejemploDónde la verás más abajo
La CPU y su memoria¿Por qué recorrer una matriz por filas es más rápido que por columnas?Caché de la CPU y localidad de memoria
El runtime del programa¿Por qué una solicitud lenta congela un servidor Node.js?Rendimiento del runtime
La base de datos¿Por qué una página de lista ejecuta 21 consultas?Rendimiento de bases de datos
El servicio completo¿Cuántos servidores necesitamos el día de la oferta?Pruebas de carga, k6 y planificación de capacidad

En todas las capas necesitas dos habilidades. Primero, buenos números: las medidas correctas, tomadas de forma justa (benchmarking). Segundo, saber a dónde se va el tiempo (profiling). El resto de este resumen construye esas habilidades paso a paso.

Mira el video: Performance Matters, Emery Berger (Strange Loop)↑ Volver al índice

Latencia, throughput y percentiles

Volvamos a la panadería. Importan dos cosas distintas. Una es cuánto tiempo espera una persona desde que entra hasta que sale con su pan. La otra es cuántas personas atiende la panadería por hora. Los computadores tienen las mismas dos medidas.

Latencia y throughput son independientes: un sistema puede hacer muchas solicitudes por segundo y aun así dejar algunas muy lentas.
PalabraSignificadoPanaderíaUnidad habitual
Tiempo de una solicitudTiempo de espera de una personamilisegundos (ms)
Trabajo hecho por tiempoPersonas atendidas por horasolicitudes por segundo (req/s)
Tasa de errorFracción de solicitudes que fallaronPersonas que se fueron sin panfallos divididos por el total

El tiempo que ve el cliente es el tiempo de respuesta. En este resumen usamos la convención: tiempo de respuesta = tiempo de espera en la cola + tiempo de servicio (el tiempo que el servidor realmente trabaja en la solicitud). Cuando el servidor está ocupado, el tiempo de servicio casi no cambia. Lo que crece es la espera.

Por qué el promedio miente

Imagina 10 solicitudes: nueve tardan 10 ms y una tarda 910 ms. El promedio (sumar todo y dividir entre 10) es 100 ms. ¡Pero ninguna solicitud tardó 100 ms! La mediana (el valor del medio tras ordenar) es 10 ms y describe mucho mejor la solicitud típica. Unos pocos valores enormes empujan el promedio hacia arriba, y las latencias casi siempre tienen unos pocos valores enormes.

Percentiles

Un percentil responde: ¿por debajo de qué valor queda el p% de las medidas? El p50 es la mediana. El p95 es el valor que 95 de cada 100 solicitudes superan, así que 1 solicitud de cada 20 es más lenta que el p95. El p99 deja 1 de cada 100 más lenta. El extremo lento de la distribución se llama cola, y los percentiles altos la describen. Esto es la latencia de cola (tail latency).

Para calcular un percentil con el método del rango más cercano (nearest-rank): ordena los valores y toma el valor en la posición ceil(p / 100 × n), contando desde 1. Ejemplo con 10 valores ordenados:

valores: 5 6 7 8 9 10 11 12 13 50     (n = 10)
p50 -> posición ceil(0,50 x 10) = 5  -> 9
p90 -> posición ceil(0,90 x 10) = 9  -> 13
p99 -> posición ceil(0,99 x 10) = 10 -> 50
Otras herramientas interpolan entre vecinos y pueden dar un número ligeramente distinto, así que siempre di qué convención usas.

Throughput a partir de los números

El throughput promedio de una prueba es el total de solicitudes completadas dividido entre la duración. En k6, http_reqs cuenta solicitudes HTTP, mientras que iterations cuenta ejecuciones completas del script, y una iteración puede hacer varias solicitudes. Por ejemplo, 18.000 solicitudes en 150 segundos son 120 req/s. No mezcles los dos conteos.

En un modelo cerrado sin pausa, cada uno de los U usuarios envía una solicitud a la vez, así que el throughput es aproximadamente U / tiempo de respuesta. Con 20 usuarios y respuestas de 100 ms: 20 / 0,1 = 200 req/s. Si el servidor se vuelve más lento, los usuarios también envían menos.

La cola crece cuando hay muchas llamadas

Si una llamada tiene un 1% de probabilidad de ser lenta (su p99), una página que hace muchas llamadas es lenta con mucha más frecuencia, porque solo se salva si todas las llamadas son rápidas. La probabilidad de al menos una llamada lenta es 1 - 0,99^n.

Probabilidad de que una página tenga al menos una llamada entre el 1% más lento
1 llamada
1 %
10 llamadas
9.6 %
50 llamadas
39.5 %
100 llamadas
63.4 %

Dos trampas

  • Los fallos son rápidos. Una solicitud que falla con un error muchas veces responde en 5 ms. Si los errores suben de 0% a 60%, la mediana puede bajar y parecer una mejora. Lee la latencia y la tasa de error juntas y mide las respuestas exitosas por separado.
  • Omisión coordinada (coordinated omission). Un generador de carga que espera la respuesta lenta antes de enviar la siguiente solicitud deja de enviar justo en el momento lento, así que nunca registra lo malo que fue. Los percentiles salen demasiado optimistas. La cura es un modelo abierto: las solicitudes salen a una tasa fija, hagan lo que hagan las respuestas.
Mira el video: How NOT to Measure Latency, Gil Tene↑ Volver al índice

Metodología de benchmark

Un benchmark es una carrera justa entre programas. Si un corredor sale con mochila y el otro sin ella, la carrera no prueba nada. Hacer benchmarks es el arte de volver justa la carrera, para que la diferencia en los números venga realmente del código.

Warm-up, repeticiones y ruido

  • Warm-up (calentamiento). Un programa recién iniciado no está en su estado normal. Los archivos no están en la caché del sistema, las cachés de la CPU están frías y los runtimes con aún no han optimizado. Por eso un runner ejecuta el trabajo algunas veces y descarta esos tiempos. Si lo que importa es el tiempo de arranque, mídelo por separado y dilo.
  • Repite. Una ejecución prueba poco. Otro programa en la misma máquina (una videollamada, un antivirus) puede retrasar una ejecución. Esto se llama vecino ruidoso (noisy neighbour). Ejecuta varias veces en una máquina lo más ociosa posible.
  • Reporta la dispersión, no solo la mejor ejecución. Di el promedio y la y, si puedes, el mínimo y el máximo.

La desviación estándar muestral divide entre n - 1 (no entre n), porque el promedio se estimó con las mismas ejecuciones. Ejemplo con ejecuciones de 4, 6 y 8 ms: promedio = 6; las distancias al promedio son 2, 0 y 2, así que los cuadrados son 4, 0 y 4, que suman 8; divide entre 3 - 1 = 2 y da 4; la raíz cuadrada es 2 ms.

Cómo leer un resultado: si A tiene promedio 102 ms y B tiene promedio 99 ms, pero la desviación estándar de ambos es de unos 6 ms, entonces la diferencia de 3 ms es menor que el ruido. Repetir la prueba podría invertir el orden. Reporta un empate, o mide más, en una máquina más tranquila.

Speed-up y la ley de Amdahl

El speed-up es el tiempo antiguo dividido entre el tiempo nuevo, para el mismo trabajo. Pasar de 800 ms a 200 ms es un speed-up de 4. Cuidado: reducir el tiempo un 50% es un speed-up de 2, y reducirlo un 90% es un speed-up de 10. Di qué medida usas.

Acelerar una parte tiene un límite. Si una parte ocupa la fracción p del tiempo y la haces s veces más rápida, el programa completo se vuelve 1 / ((1 - p) + p / s) veces más rápido. Esta es la ley de Amdahl. Ejemplo: un trabajo de 10 s tiene 5 s de cálculo. Hacer el cálculo 5 veces más rápido da 1 / (0,5 + 0,1) = 1,67, no 5. Incluso un cálculo infinitamente rápido dejaría los otros 5 s, así que el techo es 2.

Una comparación justa

  • Cambia una cosa a la vez. Para comparar dos runtimes, usa la misma carga de trabajo, el mismo tamaño de datos, la misma concurrencia y la misma máquina.
  • Mide el proceso completo (desde fuera) para saber cuánto espera el usuario. Incluye cargar el runtime y leer la entrada, un costo casi fijo que domina con entradas pequeñas. Mide solo la sección (dentro del programa) para saber el costo del algoritmo. Ambos son útiles si dices cuál muestras.
  • Haz que cada variante imprima un checksum de su resultado. Si una variante es mucho más rápida pero su checksum es distinto, no es una optimización, es un bug.
  • Consume el resultado. Un compilador optimizador puede borrar código cuyo resultado nadie usa, así que un microbenchmark puede imprimir "0 ms" porque midió un bucle vacío. Acumula los resultados en un checksum e imprímelo al final.

Resultados reproducibles

Un número publicado solo vale si otra persona puede repetirlo. Registra siempre la máquina (CPU, memoria, sistema), las versiones exactas de compiladores y runtimes y el comando completo con su entrada y opciones. La herramienta hyperfine mide comandos de terminal por ti, con warm-up, repeticiones y estadísticas (promedio, desviación estándar, mínimo, máximo), y exporta JSON o Markdown. Mide el proceso completo, así que el arranque está incluido.

↑ Volver al índice

Profiling y flame graphs

Sabes que el programa está lento, pero ¿dónde? Un profiler es una herramienta que observa un programa y te dice a dónde se va el tiempo. Es como una cámara de seguridad en una cocina: después puedes ver qué paso consume más minutos. Haz profiling primero, optimiza después.

Muestreo e instrumentación

Una función llamada 50 millones de veces parece mucho más cara con un profiler de instrumentación. Por eso el muestreo es la opción habitual en sistemas bajo carga real.
TipoCómo funcionaBuenoMalo
Profiler por muestreo (sampling)Espía el programa muchas veces por segundo y anota la Costo bajo y estable, incluso con millones de llamadasUna estimación estadística, sin conteo exacto de llamadas
Profiler por instrumentaciónAgrega código a cada función para contar y medir cada llamadaConteos y tiempos exactosEl costo crece con el número de llamadas y distorsiona funciones diminutas llamadas muchas veces

Qué perfil para qué problema

  • Perfil de CPU: ¿dónde gasta su tiempo el procesador?
  • Perfil de heap: ¿quién asigna memoria y qué objetos siguen vivos? Úsalo cuando la memoria crece durante horas (una fuga) mientras la CPU está baja.
  • Tiempo de reloj (wall-clock) vs tiempo de CPU: wall-clock es el tiempo real entre el inicio y el fin. El tiempo de CPU es solo el tiempo en que el procesador ejecutó tu programa (user en tu código, sys en el kernel). La diferencia es el tiempo off-CPU: esperar disco, red, una base de datos o un lock.
$ time ./script
real 5.0 s    # el reloj de la pared
user 0.3 s    # CPU ejecutando tu código
sys  0.1 s    # CPU ejecutando el kernel por ti
Un programa de un solo hilo con real muy por encima de user + sys pasó casi todo el tiempo esperando, no calculando. Un perfil de CPU no ayuda aquí.

Cómo leer un flame graph

Un flame graph dibuja las pilas de llamadas muestreadas. Cada caja es una función. Una caja está encima de la función que la llamó, así que la altura es la profundidad de la pila. El ancho de una caja es la fracción de muestras en que esa función estaba en la pila, así que ancho significa "mucho tiempo". Las pilas idénticas se fusionan y las cajas se ordenan por nombre.

                [ escape  ]
  [ parse ]     [ render .............. ][ send ]
  [ handleRequest ............................ ]
  [ main ..................................... ]
   (la raíz siempre tiene 100% de ancho)
Lectura: handleRequest llamó a parse, render y send. render es ancho, escape está encima de render.

El tiempo total (total time) de una función incluye todo lo que ella llama. El tiempo propio (self time) es solo su código: el total menos la suma de sus hijos directos. Si renderHtml tiene un total de 600 ms y sus hijos tardan 450 + 100 ms, su tiempo propio es 50 ms. La CPU realmente trabaja donde el tiempo propio es alto, que en un flame graph es el borde libre en la parte de arriba de una caja. Busca la meseta más ancha arriba, no la caja más ancha (la raíz siempre es la más ancha) ni la torre más alta (eso es solo una pila profunda).

↑ Volver al índice

Rendimiento del runtime: event loop, garbage collection, cluster

Node.js y Bun ejecutan el JavaScript de tu servidor en un solo hilo. Un hilo (thread) es como un cocinero. ¿Cómo atiende un cocinero a miles de clientes? Porque la mayor parte del tiempo no está cocinando, solo esperando.

El event loop

El event loop es una cola de tareas listas (callbacks) que ejecuta una a la vez. Cuando tu código pide una respuesta a la base de datos, la espera se entrega al sistema operativo y el bucle sigue con otras solicitudes. Esto es trabajo I/O-bound: el tiempo se va esperando, no calculando. Un hilo mantiene miles de estas solicitudes en marcha, y el límite pasa a ser la base de datos o el pool de conexiones.

I/O-bound:  [inicia query][ otras solicitudes corren aquí ][respuesta]
                          (el event loop está libre)

CPU-bound:  [ calculando 200 ms, nadie más corre ]
            /ping espera detrás
El trabajo CPU-bound retiene el único hilo de JavaScript de principio a fin.

El trabajo CPU-bound es lo contrario: el hilo está ocupado calculando, así que ningún otro callback corre, ningún temporizador se dispara, ningún resultado de I/O se lee. Si dos solicitudes de 300 ms de CPU síncrona llegan juntas, seguidas de un /ping diminuto, el ping se responde solo después de unos 600 ms. Un endpoint lento arruina la latencia de todo el servidor.

Usar más núcleos

HerramientaQué haceMemoriaBueno para
worker_threadsHilos extra dentro del mismo proceso, cada uno con su propio event loopPuede compartir memoria binariaSacar un cálculo pesado del hilo principal
cluster / pm2 start app.js -i maxVarias copias del servidor completo, normalmente una por núcleo, todas escuchando en el mismo puertoCada proceso tiene su propia memoria, nada se comparteAtender más solicitudes, aislar fallos

El cluster multiplica la capacidad de CPU, así que ayuda cuando la CPU es el cuello de botella. Si una solicitud necesita 25 ms de CPU, un proceso atiende como máximo 1 / 0,025 = 40 req/s. Cuatro workers en cuatro núcleos dan hasta 160 req/s (algo menos en la vida real). Pero para trabajo I/O-bound (5 ms de CPU y 95 ms esperando), un proceso está libre la mayor parte del tiempo, así que más workers no cambian nada. Antes de añadir workers, calcula cuánto de un núcleo usa realmente el proceso: tasa de solicitudes × tiempo de CPU por solicitud.

Garbage collection

Los programas crean objetos y los olvidan. El garbage collector (GC) encuentra los objetos que nadie usa y libera su memoria. V8, el motor de Node.js, es generacional, basado en la observación de que la mayoría de los objetos mueren jóvenes. Los objetos nuevos van a un área pequeña, la generación joven, que se limpia a menudo con recolecciones menores (minor) rápidas. Los supervivientes pasan a la generación vieja, que se limpia rara vez con recolecciones mayores (major), más lentas porque el heap es mucho más grande.

Gran parte del trabajo del GC es incremental o corre en hilos auxiliares, pero algunas fases son stop-the-world: JavaScript se detiene por completo. Las recolecciones mayores sobre un heap viejo grande pueden pausar por mucho tiempo. Como son raras, casi no cambian el promedio ni la mediana, y aparecen como picos periódicos en el p99. No lo confundas con una fuga: tras una fuga, la memoria en uso después de cada recolección sigue creciendo. Si la memoria vuelve al mismo nivel después de cada recolección, no es una fuga.

↑ Volver al índice

Caché de la CPU y localidad de memoria

Big O cuenta pasos, como si cada paso costara lo mismo. Pero leer de la memoria no es igual de rápido en todas partes. Piensa en tu escritorio, tu estantería y la biblioteca de la ciudad. El lápiz sobre el escritorio tarda un segundo. Un libro en la estantería tarda un minuto. Un libro de la biblioteca tarda una tarde.

La jerarquía de memoria

registros < L1 < L2 < L3 < RAM < disco
más pequeña, más rápida, más cara  -->
               <-- más grande, más lenta, más barata
L1, L2 y L3 son cachés dentro del procesador o junto a él.

La memoria rápida es pequeña y cara, la memoria grande es barata y lenta. Por eso el computador mantiene los datos en uso ahora en los niveles pequeños y rápidos. En números redondos, la L1 responde en cerca de 1 nanosegundo y la memoria principal (RAM) en cerca de 100, unas cien veces más lenta. Un programa es rápido cuando la mayoría de sus lecturas las atienden los niveles de arriba. Una lectura que encuentra su dato en la caché es un hit, y una que debe ir más lejos es un miss.

Líneas de caché y localidad

La caché no copia bytes sueltos. Copia bloques llamados líneas de caché (cache lines), normalmente de 64 bytes. Si lees un byte que falta, el procesador carga la línea completa de 64 bytes que lo contiene. Los programas tienen dos costumbres, llamadas juntas localidad:

  • Localidad temporal: usas la misma dirección otra vez poco después (un contador de bucle).
  • Localidad espacial: usas direcciones vecinas una tras otra (los elementos de un arreglo).

Cuenta los misses de un recorrido secuencial: es el número de líneas tocadas, no el número de elementos. Un double tiene 8 bytes, así que una línea guarda 8 de ellos. Recorrer 4.096 doubles lee 4096 × 8 / 64 = 512 líneas: 1 miss por cada 8 elementos, y los siguientes 7 son hits.

Orden por filas (row-major)

C, C++ y Rust guardan una matriz fila tras fila (row-major). El elemento (i, j) está en el índice i × n + j. Avanzar a lo largo de una fila usa vecinos. Bajar por una columna salta una fila completa en cada paso.

/* A: bucle interno en j, sigue el orden de la memoria */
for (i = 0; i < N; i++)
    for (j = 0; j < N; j++) sum += a[i][j];

/* B: bucle interno en i, salta N valores en cada paso */
for (j = 0; j < N; j++)
    for (i = 0; i < N; i++) sum += a[i][j];
Ambos son O(N²). Cuando la matriz es mayor que la caché, A es varias veces más rápido.

La misma idea explica por qué sumar 1 millón de números en un arreglo le gana a una lista enlazada con nodos dispersos en la memoria. Ambos son O(n), pero la lista puede pagar un miss por elemento.

Multiplicación de matrices: intercambio de bucles y blocking

Multiplicar dos matrices n × n tiene tres bucles anidados y n³ multiplicaciones, sea cual sea el orden de los bucles. En el orden i, j, k, el bucle interno baja por una columna de b, el peor recorrido. El intercambio de bucles (loop interchange) a i, k, j hace que cada acceso interno sea secuencial o fijo. Mismo resultado, mismo Big O, velocidad muy distinta.

El blocking (o tiling) va más allá: hace el trabajo en pequeños bloques B × B que caben en la caché y reutiliza cada pedazo cargado muchas veces antes de seguir. Tres bloques de doubles usan 3 × B² × 8 bytes. Para una L1 de 32 KiB (32.768 bytes): B = 32 necesita 24.576 bytes y cabe, mientras que B = 64 necesita 98.304 y no cabe. El tamaño del bloque sale de la capacidad de la caché, y el blocking no cambia el número de operaciones.

↑ Volver al índice

Rendimiento de bases de datos: índices, N+1 y pool de conexiones

Muchas apps lentas lo son por culpa de la base de datos. Una tabla con 10 millones de filas es como una biblioteca con 10 millones de libros. Encontrar un libro recorriendo todos los estantes toma una eternidad. El bibliotecario necesita un catálogo.

Índices

Un índice es ese catálogo. Un índice B-tree guarda los valores de una columna ordenados en un árbol balanceado, así que encontrar un valor cuesta O(log n) en vez de O(n). Cada nodo guarda cientos de claves, por lo que el árbol es poco profundo incluso en tablas enormes. Sin un índice útil, PostgreSQL hace un recorrido secuencial (sequential scan): lee todas las filas y descarta las que no coinciden.

EXPLAIN muestra el plan que eligió la base de datos, sin ejecutar la consulta. Cada nodo muestra un costo estimado (unidades abstractas), las filas estimadas y el ancho de fila en bytes. Un Seq Scan con un Filter que devuelve pocas filas de una tabla enorme es la señal clásica de un índice que falta.

EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
-- Seq Scan on orders (cost=0.00..23864.00 rows=95 width=64)
--   Filter: (customer_id = 42)
CREATE INDEX idx_orders_customer ON orders (customer_id);
  • Los índices cuestan escrituras. Cada INSERT, y cada UPDATE o DELETE que toca columnas indexadas, debe actualizar cada índice, y cada índice usa disco y memoria. Crea índices para consultas que existen y elimina los que nadie usa.
  • Selectividad. Es la fracción de filas que deja pasar un filtro. Un índice vale la pena cuando esa fracción es pequeña. En una columna con tres valores donde 'delivered' es el 95% de las filas, el planificador prefiere el recorrido secuencial, porque millones de lecturas aleatorias son peores que una lectura directa.
  • Índices compuestos y el prefijo izquierdo (leftmost prefix). Un índice en (tenant_id, status, created_at) está ordenado por la primera columna, luego por la segunda dentro de cada valor de la primera, como una guía telefónica ordenada por apellido y luego por nombre. Atiende bien los filtros que fijan un prefijo empezando por la izquierda. Un filtro que se salta la primera columna encuentra sus filas dispersas por el índice y es el peor atendido.
  • **SELECT *** trae todas las columnas, incluso las que la app tira. Las columnas anchas (texto largo, JSON) cuestan lecturas de disco, memoria y red. Pide solo las columnas que necesitas.

El problema N+1

El código trae una lista con 1 consulta y luego hace más consultas por cada uno de los N elementos. Cada consulta es rápida, pero cada una paga un viaje de ida y vuelta a la base de datos. Una lista de 10 posts que carga el autor de cada post en un bucle ejecuta 1 + 10 = 11 consultas, y 100 posts ejecutarían 101. La solución es un número fijo de consultas: un JOIN, un filtro WHERE id IN (...) con todos los ids, o el eager loading del ORM.

Paginación

Con OFFSET 100000, la base de datos debe producir 100.000 filas y tirarlas antes de la página, así que las páginas más profundas cuestan más. La paginación por clave (keyset) recuerda el último valor visto: WHERE id > 100000 ORDER BY id LIMIT 20. El índice va directo a ese punto, y el costo es el mismo en cada página. La contrapartida: solo puedes ir a la página siguiente o anterior, y el orden necesita columnas indexadas sin empates.

Pool de conexiones

Abrir una conexión con la base de datos cuesta varios viajes de ida y vuelta por la red y, en PostgreSQL, un proceso nuevo en el servidor. Eso puede costar más que la consulta misma. Un pool de conexiones abre un número limitado de conexiones, las mantiene abiertas y presta una a cada solicitud, que la devuelve al terminar.

La capacidad de un pool es el número de conexiones dividido entre el tiempo que cada solicitud retiene una. Con 20 conexiones y 40 ms cada una: 20 / 0,04 = 500 req/s. Por encima de esa tasa las solicitudes se acumulan y la latencia crece sin límite, aunque cada consulta siga tardando lo mismo. En una prueba de carga esto aparece como una rodilla (knee): latencia estable hasta un punto y luego una subida empinada mientras el throughput deja de crecer. Acortar el tiempo que se retiene una conexión aumenta la capacidad tanto como agrandar el pool.

El límite max_connections de PostgreSQL pertenece a todo el servidor, pero cada instancia de la aplicación tiene su propio pool. Lo que debe caber es la suma: tamaño del pool × instancias + reserva. Con max_connections = 100, una reserva de 10 y 6 instancias: (100 - 10) / 6 = 15, así que como máximo 15 por instancia. Un pool enorme no es la respuesta: demasiadas conexiones activas pelean por CPU, memoria y disco en la base de datos.

↑ Volver al índice

Pruebas de carga, estrés, pico y resistencia

Una prueba de carga envía muchos usuarios falsos a un servicio y observa cómo se comporta. Es como un simulacro de incendio: quieres encontrar los problemas antes del incendio de verdad. Preguntas distintas piden formas distintas de tráfico, por eso existen distintos tipos de prueba. Se diferencian en dos cosas: cuánta carga y por cuánto tiempo.

TipoForma de la cargaPregunta que responde
Smoke (humo)Carga mínima, muy corta¿Funcionan el script, el entorno y la recolección de métricas? También da una línea base
Carga (load, carga promedio)El tráfico normal esperado, mantenido un tiempo¿Cumple el servicio sus metas con la carga habitual?
Estrés (stress)Sube gradualmente por encima de lo normal y se mantiene¿Qué pasa por encima del tráfico esperado? ¿Dónde está el punto de saturación?
Pico (spike)Muy alta, muy corta, casi sin rampa¿Sobrevive a una ráfaga repentina y se recupera?
Resistencia (soak, endurance)Carga promedio durante horas¿Qué se degrada lentamente con el tiempo, como una fuga de memoria?
Punto de ruptura (breakpoint)Crece lenta y continuamente hasta que falla un criterio¿Cuál es el límite? El valor en la falla es la respuesta
usuarios
  |            pico                    estrés
  |             /\                       ____
  |   carga    /  \          carga       /
  |  _____    /    \       _____      /
  | /     \__/      \_____/     \____/
  +----------------------------------> tiempo
   (soak = la meseta de 'carga' durando horas)
Formas aproximadas. La altura de la meseta distingue carga de estrés, la longitud distingue carga de soak, la pendiente de la rampa distingue estrés de pico.

Qué revela cada uno

  • Estrés y la rodilla. Mientras sobra capacidad, más usuarios dan más throughput con latencia casi igual. En el punto de saturación se agota algún recurso (CPU, conexiones del pool, hilos): el throughput se aplana y los usuarios extra solo alargan la cola, así que la latencia se dispara. En una tabla: 100 usuarios dan 800 req/s a 30 ms, 150 usuarios dan 1200 req/s a 32 ms, pero 200 usuarios dan solo 1250 req/s a 190 ms. El codo está en unos 150 usuarios.
  • Pico. Observa tres momentos: antes, durante y después. Sobrevivir a la ráfaga no basta. Si el p95 sigue diez veces por encima de lo normal durante 15 minutos después de que baja la carga, hay colas sin límite, timeouts largos, tormentas de reintentos o pools lentos para liberar recursos. Una tienda que abre una oferta a una hora exacta necesita una prueba de pico.
  • Soak. ¿La memoria crece al principio y luego queda plana? Eso es warm-up (cachés llenándose), no una fuga. Una fuga es un crecimiento que continúa con carga constante. Por eso una prueba de soak dura horas: en pocos minutos las dos curvas se ven iguales.
  • Punto de ruptura. A diferencia del estrés, no tiene una meseta planeada. Normalmente se usa un executor de tasa de llegada, para que la presión siga subiendo incluso cuando las respuestas se vuelven lentas.

Modelos abierto y cerrado, otra vez

En un modelo cerrado el número de usuarios es fijo y cada uno espera su respuesta. Cuando el servidor se vuelve lento, el generador se vuelve lento con él, lo que alivia al servidor en el peor momento. En un modelo abierto las llegadas siguen una tasa fija, como personas reales que no saben que el sitio está lento. Por eso, para pruebas de estrés y de pico, un executor de tasa de llegada es la mejor opción.

↑ Volver al índice

k6: usuarios virtuales, etapas, thresholds y checks

es la herramienta de esta área. Un script de k6 es un archivo JavaScript que dice qué hace un usuario falso y cuántos usuarios falsos hay. Piensa en un teatro de títeres: escribes la obra una vez y k6 mueve muchos títeres al mismo tiempo.

Usuarios virtuales e iteraciones

Un usuario virtual (VU) es un ejecutor independiente que corre la función por defecto de tu script en un bucle. Cada pasada completa por la función es una iteración. Una iteración puede contener varias solicitudes, checks y pausas, así que k6 cuenta iteraciones y solicitudes en métricas distintas.

import http from 'k6/http';
import { sleep } from 'k6';

export const options = { vus: 10, duration: '60s' };

export default function () {
  http.get('http://localhost:3000/products');
  sleep(1); // una pausa, en segundos, como una persona pensando
}

Una iteración dura lo que duran sus solicitudes más el sleep. Aquí, si la solicitud tarda 500 ms, una iteración dura 1,5 s, así que el número de iteraciones es VUs × duración / tiempo de la iteración, unas 10 × 60 / 1,5 = 400.

Etapas y executors

La opción stages dibuja una rampa: cada etapa tiene una duration y un target, y k6 mueve el número de VUs en línea recta desde donde está hasta el objetivo. Una etapa que repite el objetivo anterior es una meseta, y una última etapa con objetivo 0 es la rampa de bajada.

export const options = {
  stages: [
    { duration: '1m', target: 20 }, // subida
    { duration: '3m', target: 20 }, // meseta
    { duration: '1m', target: 0 },  // bajada
  ],
};
Con etapas de ramping-vus 1m hasta 20, 2m hasta 80, 1m hasta 0: a los 2 minutos llevas 1 minuto dentro de la segunda etapa, a mitad de camino entre 20 y 80, es decir, 50 VUs.
ExecutorModeloQué controlaNota
constant-vus, ramping-vusCerradoNúmero de VUsUna nueva iteración empieza solo cuando termina la anterior, así que tasa = VUs / tiempo de la iteración
constant-arrival-rate, ramping-arrival-rateAbiertoIteraciones iniciadas por unidad de tiempoNecesita suficientes VUs: aproximadamente tasa × duración de la iteración. Si se acaban, las iteraciones se descartan y se cuentan en dropped_iterations

Ejemplo: 50 VUs de constant-vus sin sleep. Con 100 ms por solicitud envían 50 / 0,1 = 500 req/s. Si el servidor se ralentiza a 500 ms, envían solo 100 req/s: la prueba alivia la presión justo cuando el sistema sufre. Un constant-arrival-rate de 30 por segundo con una respuesta de 1 s necesita unos 30 VUs. Si solo tiene 20, k6 descarta el resto.

Thresholds y checks

Un threshold es una regla de aprobado o fallo sobre una métrica: una agregación (avg, p(95), rate, count), un operador y un límite. http_req_duration: ['p(95)<500'] significa que el p95 de todas las duraciones de solicitud debe ser menor que 500 ms. Cuando se supera cualquier threshold, k6 termina con código de salida 99, así que un pipeline de CI ve un fallo. Por defecto k6 sigue corriendo. La forma larga { threshold: 'rate<0.05', abortOnFail: true, delayAbortEval: '30s' } detiene la prueba antes, y delayAbortEval espera primero, para que unos pocos fallos iniciales no aborten la prueba antes de que existan suficientes muestras.

Un check verifica algo sobre una respuesta, como "el cuerpo tiene elementos". No detiene la prueba ni la hace fallar: solo registra la tasa de aciertos en la métrica checks. Para que un contenido incorrecto haga fallar la prueba, añade un threshold sobre los checks, por ejemplo checks: ['rate>0.99'].

Las cuatro fases y los tipos de métrica

FaseCuándo correUso
Init (nivel superior)Una vez por VUImports, lectura de archivos, definir options
setup()Una vez, antes de la cargaCrear datos compartidos, como una cuenta de prueba. Su valor de retorno debe ser serializable a JSON
función por defectoRepetida por todos los VUsLa carga en sí
teardown()Una vez, al finalLimpieza
  • Counter: suma valores (cuánto).
  • Gauge: guarda el último valor (cuánto ahora).
  • Rate: la proporción de valores verdaderos (con qué frecuencia). http_req_failed y checks son Rates.
  • Trend: estadísticas como promedio y percentiles (qué distribución). Úsalo para el tiempo de un flujo de login.

Otras piezas útiles: __ENV guarda variables de entorno (k6 run -e NOMBRE=valor), así que const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000' hace que el caso seguro y local sea el predeterminado. Y handleSummary(data) recibe todas las métricas al final y devuelve un objeto cuyas claves son destinos (un archivo, stdout) y cuyos valores son el contenido, que es como se generan informes JSON o HTML. Reemplaza el resumen por defecto, así que incluye una clave stdout si quieres ambos.

↑ Volver al índice

Planificación de capacidad y colas

La planificación de capacidad responde: ¿cuántos servidores necesitamos? Para responder usamos unas pocas leyes simples de la teoría de colas, que son las matemáticas de esperar en una fila. Imagina una sola taquilla de cine.

Dos leyes que siempre se cumplen

La ley de utilización: la fracción de tiempo que un servidor está ocupado es tasa de llegada × tiempo medio de servicio (con las mismas unidades de tiempo). Una taquilla que recibe 10 clientes por segundo y tarda 50 ms en cada uno está ocupada 10 × 0,05 = 0,5, o sea el 50% del tiempo. Es el primer cálculo de la planificación de capacidad: cuánto ya está comprometido.

La ley de Little: en un sistema estable, L = λ × W, donde L es el número promedio de elementos dentro, λ (lambda) la tasa de llegada y W el tiempo promedio que cada elemento pasa dentro (espera más servicio). No depende de cómo se distribuyan las llegadas ni de cuántos servidores haya. Si llegan 100 solicitudes por segundo y cada una pasa 0,05 s en el sistema, en promedio 100 × 0,05 = 5 solicitudes están dentro. Convierte entre concurrencia, throughput y latencia.

Por qué la latencia explota cerca del 100%

Las llegadas nunca están espaciadas por igual. Cuando dos clientes llegan juntos, uno debe esperar. La cola se vacía durante el tiempo ocioso. Con utilización baja hay mucho tiempo ocioso y una ráfaga se absorbe rápido. Con utilización alta, la cola de una ráfaga no está vacía cuando llega la siguiente. Para un modelo simple, la cola M/M/1 (un servidor, llegadas aleatorias de Poisson, tiempos de servicio exponenciales), el tiempo medio de respuesta es:

R = S / (1 - U)

S = tiempo medio de servicio, U = utilización (entre 0 y 1)
ejemplo: S = 20 ms, 40 req/s -> U = 0,8 -> R = 20 / 0,2 = 100 ms
Tiempo de respuesta como múltiplo del tiempo de servicio, cola M/M/1
U = 50%
2 veces S
U = 70%
3.33 veces S
U = 80%
5 veces S
U = 90%
10 veces S
U = 95%
20 veces S
U = 99%
100 veces S

Mira la forma: casi plana al principio, luego muy empinada. Pasar del 50% al 90% de utilización (un tráfico que ni siquiera se duplicó) multiplica la latencia por 5. La fórmula depende del modelo, pero la forma es una buena intuición para sistemas reales. Por eso dejamos holgura (headroom): capacidad de sobra.

Dimensionar a partir de una prueba de carga

  1. Mide la capacidad de una instancia dentro de la meta de latencia, por ejemplo 250 req/s.
  2. Aplica la regla de holgura: si cada instancia puede correr como máximo al 70%, la capacidad utilizable es 175 req/s.
  3. Divide el pico esperado entre ella y redondea hacia arriba. Para un pico de 1.000 req/s: 1000 / 175 = 5,7, así que 6 instancias.

Esto supone que la carga se reparte por igual y que la escala es lineal. Compruébalo: una base de datos compartida puede saturarse primero.

El mismo método dimensiona un pool de conexiones. El número promedio de conexiones ocupadas es tasa de llegada × tiempo de retención. Con 100 req/s reteniendo una conexión 30 ms: 3 ocupadas en promedio. Para mantener como máximo el 60% ocupadas, divide entre 0,6: 5 conexiones. Un pool dimensionado exactamente para el promedio estaría 100% ocupado, y la cola crecería mucho antes de eso.

La ley de Amdahl para escalar

Si una fracción s del trabajo no se puede paralelizar, el speed-up con N procesadores es 1 / (s + (1 - s) / N). Con un 10% serial, nunca supera 10, sin importar cuántos procesadores añadas.

Speed-up cuando el 10% del trabajo es serial (Amdahl)
1 núcleo
1 veces
2 núcleos
1.82 veces
4 núcleos
3.08 veces
8 núcleos
4.71 veces
16 núcleos
6.4 veces
Infinito
10 veces

En un cluster de procesos, la parte serial suele ser un recurso compartido: la base de datos o un lock.

↑ Volver al índice

Juntando todo

Ahora tienes una caja de herramientas. Así se conectan las piezas cuando un servicio está lento, paso a paso.

  1. Define rápido: elige una meta con un percentil, por ejemplo "95% de las solicitudes en menos de 300 ms".
  2. Produce carga con una prueba del tipo correcto (carga, estrés, pico, soak) en un modelo abierto, contra un objetivo local.
  3. Lee el resultado con percentiles, throughput y tasa de error juntos, y busca la rodilla.
  4. Encuentra el cuello de botella: CPU alta pide un perfil de CPU y un flame graph. CPU baja y latencia alta significa espera: base de datos, pool, red.
  5. Arregla una cosa, vuelve a medir con justicia (warm-up, repeticiones, misma máquina, checksum) y reporta la dispersión.
  6. Dimensiona el servicio con utilización, ley de Little y holgura.
SíntomaCausa probableHerramienta o idea
p99 alto, p50 normalPocas solicitudes lentas: pausa del GC, lock, colaPercentiles, logs del GC
Un endpoint lento congela todas las solicitudes (Node.js)CPU síncrona en el hilo únicoFlame graph, worker threads, cluster
Latencia plana y luego una subida repentina al aumentar la cargaUn recurso saturado (pool, CPU)Prueba de estrés, ley de utilización
Latencia alta pero CPU bajaEspera: base de datos, pool, redWall-clock vs tiempo de CPU, tracing
Una página de lista hace decenas de consultasN+1JOIN o IN, eager loading
Mismo Big O, velocidad muy distintaCache misses, mal orden de memoriaIntercambio de bucles, blocking
La memoria crece sin finFugaPerfil de heap, prueba de soak

Errores comunes

  • Optimizar sin hacer profiling.
  • Mirar solo el promedio, o promediar percentiles de servidores distintos.
  • Comparar dos programas que hacen trabajos distintos, o nunca revisar el checksum.
  • Medir código que el compilador eliminó, o incluir el arranque sin querer.
  • Una prueba de modelo cerrado que esconde el problema que debería encontrar (omisión coordinada).
  • Apuntar una prueba de carga a un sistema que no es tuyo.
  • Añadir workers o un pool mayor sin comprobar dónde está el cuello de botella.
↑ Volver al índice