En las tres lecciones anteriores hemos dado muchos números: 641 peticiones por segundo, luego 3 105, luego 11 840; percentiles 99 de 204, 54 y 11 milisegundos; un retraso del bucle de eventos que pasó de 4 176 ms a 11 ms. Esos números no salieron de la intuición: salieron de medir antes, cambiar una cosa y medir después. Esa práctica, que hemos aplicado sin nombrarla, es el contenido de esta lección.
Aquí no vas a encontrar una lista de trucos, sino un método, un catálogo de instrumentos (pruebas de carga, perfiles de CPU, flamegraphs, instantáneas del montículo) y un catálogo de cuellos de botella típicos de Node con su arreglo, todos anclados a lo que ya sabes del curso.
Contenido
- El método: medir, localizar, arreglar, volver a medir
- Qué se mide: latencia, rendimiento, errores, saturación
- Pruebas de carga con
autocannon - Perfilado de CPU y flamegraphs
- Perfilado de memoria y fugas
- El retraso del bucle de eventos como métrica de salud
- Catálogo de cuellos de botella típicos en Node
- Optimizaciones de la capa HTTP
- La base de datos: el pool de conexiones
- V8 sin caer en el micro-optimizado inútil
- El orden correcto de las palancas
- El método: medir, localizar, arreglar, volver a medir
El ciclo tiene cuatro pasos y ninguno es opcional: medir el sistema completo bajo una carga realista y anotar la línea base; localizar el cuello de botella con un perfil, no con una corazonada; arreglar solo eso, un cambio cada vez; y volver a medir con la misma prueba, comparando con la línea base.
Un cambio por iteración es innegociable. Si tocas tres cosas y el sistema mejora un 30 %, no sabes cuál de las tres funcionó, ni si alguna empeoró las cosas y las otras dos lo compensaron.
La cita de Donald Knuth se repite mucho y casi siempre mutilada. La frase completa es: "deberíamos olvidarnos de las pequeñas eficiencias, digamos el 97 % del tiempo: la optimización prematura es la raíz de todos los males. Sin embargo, no deberíamos dejar pasar nuestras oportunidades en ese 3 % crítico." Los dos extremos son errores: reescribir un bucle para ahorrar microsegundos en código que se ejecuta una vez al día, y también ignorar que tu consulta principal hace un escaneo secuencial sobre una tabla de un millón de filas. La diferencia entre ambos no es filosófica: es que uno lo has medido y el otro no. Corolario práctico: optimizar sin medir es adivinar, y la intuición del programador sobre dónde está el tiempo es notoriamente mala. En Escena Viva cualquiera habría apostado a que el cuello estaba en generar el PDF; el perfil demostró que en el catálogo el 62 % del tiempo se iba en serializar JSON y en una consulta sin índice.
- Qué se mide: latencia, rendimiento, errores, saturación
Cuatro señales, conocidas como las golden signals:
| Señal | Qué es | Cómo se mide en Escena Viva |
|---|---|---|
| Latencia | Tiempo de respuesta | Percentiles por ruta, separando 2xx de 5xx |
| Rendimiento (throughput) | Peticiones atendidas por segundo | autocannon, y contadores en producción |
| Tasa de errores | Proporción de 5xx y de tiempos agotados | Registro estructurado (M6) por codigo de error |
| Saturación | Cuán lleno está el recurso limitante | CPU, memoria, conexiones del pool, retraso del bucle |
Sobre la latencia hay que insistir en algo, y es que la media miente:
| Estadístico | Valor en el catálogo | Qué significa |
|---|---|---|
| Media / p50 | 78 ms / 72 ms | Ningún usuario concreto experimenta la media |
| p95 | 161 ms | 1 de cada 20 peticiones va peor |
| p99 | 204 ms | 1 de cada 100. Con 12 peticiones por página, 1 de cada 8 visitas ve esto |
| p99.9 / máximo | 890 ms | La cola larga: lo que provoca las quejas |
Si diez peticiones tardan 10 ms y una tarda 5 000 ms, la media es 463 ms: un número que no describe ni a las rápidas ni a la lenta. Y la aritmética del p99 asusta cuando se traduce a experiencia: una página que hace 12 llamadas a la API tiene una probabilidad de 1 − 0,99¹² ≈ 11 % de encontrarse al menos una petición del percentil 99. Optimizar la cola, no la media. Estos son los objetivos de servicio de Escena Viva, acordados con el negocio:
| Ruta | Objetivo p99 | Disponibilidad | Justificación |
|---|---|---|---|
GET /api/eventos |
< 150 ms | 99,9 % | Es la primera pantalla; si va lenta, no se vende |
GET /api/eventos/:id/sesiones |
< 200 ms | 99,9 % | Paso previo a la compra |
POST /api/pedidos |
< 800 ms | 99,95 % | Transaccional; se tolera más latencia, no fallos |
POST /api/autenticacion/login |
< 400 ms | 99,9 % | bcrypt con coste 12 tiene un suelo propio |
Ese último caso demuestra que no todo lo lento es un problema: bcrypt con coste 12 tarda ~250 ms a propósito, porque esa lentitud es la defensa contra la fuerza bruta (M8), y optimizarla sería una regresión de seguridad.
- Pruebas de carga con
autocannon
autocannonUna prueba de carga honesta cumple cuatro condiciones. Calentamiento: los primeros segundos no cuentan, porque V8 necesita ejecutar el código varias veces antes de que el compilador optimizador (TurboFan) haga su trabajo y las cachés están frías. Duración suficiente: 30-60 segundos mínimo, ya que diez segundos pueden esconder una fuga o el efecto de una recolección de basura mayor. Concurrencia realista: no 5 000 conexiones si tu pico real son 200. Y escenario realista: no martillear una sola ruta trivial, sino reproducir el recorrido del usuario.
# Calentamiento (10 s que se descartan) y medicion (60 s, 100 conexiones).
npx autocannon -c 50 -d 10 -w 4 http://localhost:3000/api/eventos > /dev/null
npx autocannon -c 100 -d 60 -w 4 --latency http://localhost:3000/api/eventosUn escenario de compra realista se define por programa:
'use strict';
const autocannon = require('autocannon');
// Escenario del estreno del Festival de Jazz: mirar catalogo, abrir la
// ficha del evento, consultar sesiones y, en una fraccion, comprar.
const instancia = autocannon({
url: 'http://localhost:3000',
connections: 100,
duration: 60,
warmup: { connections: 10, duration: 10 },
requests: [
{ method: 'GET', path: '/api/eventos' },
{ method: 'GET', path: '/api/eventos/evt-003' },
{ method: 'GET', path: '/api/eventos/evt-003/sesiones' },
{ method: 'POST', path: '/api/pedidos',
headers: { 'content-type': 'application/json', authorization: 'Bearer <token>' },
body: JSON.stringify({ sesionId: 'ses-003-1', cantidad: 2 }) },
],
});
autocannon.track(instancia, { renderProgressBar: true });
instancia.on('done', (r) => console.log(`p99: ${r.latency.p99} ms, no-2xx: ${r.non2xx}`));Y así se lee la salida:
| Campo | Qué mirar |
|---|---|
Latency p99 |
El número que gobierna tus objetivos de servicio |
Req/Sec |
Rendimiento; compáralo siempre con la línea base |
non2xx |
Si no es 0, la prueba no vale: estás midiendo la velocidad de fallar |
errors / timeouts |
Conexiones rechazadas o agotadas: has superado la capacidad |
Bytes/Sec |
Si te acercas al ancho de banda, el cuello es la red, no tu código |
k6 es la alternativa cuando necesitas escenarios con estado (login, cookies, sesiones encadenadas), umbrales que hagan fallar la prueba en CI y carga distribuida: autocannon es perfecto para el ciclo rápido de "mide, cambia, mide", y k6 para la prueba de regresión de rendimiento en integración continua (M11).
Advertencia importante y no negociable. Una prueba de carga es, técnicamente, un ataque de denegación de servicio. Lánzala solo contra sistemas que sean tuyos y en entornos de prueba: nunca contra producción sin coordinación previa (y ahí, mejor tráfico espejo o carga sintética limitada), y nunca contra una API de terceros, ni la de divisas del M8, ni la pasarela de pago, ni un servicio público. Es ilegal en muchas jurisdicciones y siempre es una falta de respeto profesional.
- Perfilado de CPU y flamegraphs
La prueba de carga te dice que algo es lento; el perfil te dice dónde.
Opción 1: el perfilador integrado de V8. node --prof src/servidor.js genera un isolate-*.log con muestras del contador de programa; se lanza la carga, se para el servidor y node --prof-process isolate-*.log > perfil.txt lo convierte en un informe legible que empieza con el resumen que más importa:
[Summary]: ticks total nonlib name
4821 61.9% 72.4% JavaScript
1901 24.4% GC
502 6.4% Shared libraries
[Bottom up (heavy) profile]:
1204 15.5% JSON.stringify
890 11.4% LazyCompile: *serializarCatalogo src/repositorios/eventos.js:88
731 9.4% LazyCompile: *calcularAforoDisponible src/dominio/sesion.js:41Dos lecturas inmediatas: JSON.stringify se lleva el 15,5 % y la recolección de basura el 24,4 %, lo que sugiere que estamos creando demasiados objetos temporales. El * delante del nombre significa que V8 optimizó esa función; un ~ significaría que la ejecutó sin optimizar, y eso a veces es la pista de un problema de formas ocultas.
Opción 2: el inspector de Chrome DevTools. Con node --inspect src/servidor.js y chrome://inspect tienes perfil de CPU con gráfico de llamas, instantáneas del montículo y comparación entre ellas en una sola interfaz. En un servidor remoto se usa --inspect=0.0.0.0:9229 con un túnel SSH, nunca expuesto a internet: el inspector permite ejecutar código arbitrario en tu proceso.
Opción 3: 0x y los flamegraphs.
npx 0x --output-dir perfiles -- node src/servidor.js
# Se lanza la carga, se para con Ctrl+C y se abre el HTML generado.Cómo se lee un flamegraph, que es donde casi todo el mundo se equivoca:
- El eje horizontal NO es el tiempo, sino la proporción de muestras; las funciones se ordenan alfabéticamente, no cronológicamente.
- La anchura de una caja es el tiempo total en esa función, incluyendo lo que llamó. Es lo único que importa buscar: cajas anchas.
- La altura es la profundidad de la pila. Una torre muy alta y estrecha es solo código con muchas capas: no es un problema.
- Una caja ancha con una meseta plana encima significa que el tiempo se consume en ella misma, no en sus llamadas: ahí está tu función cara.
- El color no significa nada por defecto (salvo en
0x, que resalta las funciones no optimizadas).
En el flamegraph de Escena Viva antes de la caché, una meseta ancha sobre obtenerCatalogo → mapearEventos → JSON.stringify ocupaba casi dos tercios del ancho; tras la lección 10-03 esa meseta desaparece y el ancho lo ocupa el bucle ocioso de libuv, que es exactamente lo que quieres ver.
- Perfilado de memoria y fugas
Una fuga de memoria en Node no se manifiesta como un error, sino como una degradación: el proceso crece, la recolección de basura trabaja cada vez más (y sus pausas son tiempo robado a tu bucle de eventos), y finalmente el proceso muere con JavaScript heap out of memory. Método de diagnóstico con instantáneas del montículo: arranca el proceso y déjalo estabilizarse; toma la instantánea A (DevTools → Memory → Heap snapshot, o v8.writeHeapSnapshot()); aplica carga sostenida durante varios minutos; fuerza una recolección y toma la instantánea B; y en DevTools elige la vista Comparison entre B y A.
La columna decisiva es Delta: cuántos objetos de cada tipo hay de más. Si Array o un constructor tuyo crece de forma monótona instantánea tras instantánea, ahí está la fuga; después, Retainers te dice quién impide que el recolector se los lleve.
'use strict';
// v8.writeHeapSnapshot(ruta) toma la instantanea bajo demanda, tambien en
// produccion (con cuidado: pausa el proceso y genera cientos de MB).
// Y esto es la vigilancia barata y continua del uso de memoria.
function registrarUsoDeMemoria(registrador) {
const uso = process.memoryUsage();
const mb = (bytes) => Math.round(bytes / 1048576);
registrador.info({ rssMb: mb(uso.rss), monticuloUsadoMb: mb(uso.heapUsed),
monticuloTotalMb: mb(uso.heapTotal), externaMb: mb(uso.external) });
}
module.exports = { registrarUsoDeMemoria };Las dos fugas que verás en la vida real, y las dos aparecieron en Escena Viva. La primera, caché sin límite: antes de la lección 10-03, la caché de divisas era un Map en memoria que con pares fijos no crecía, pero que si la clave hubiera incluido el importe (EUR:USD:4500) habría crecido sin fin. Toda caché en memoria necesita un techo con expulsión LRU (lru-cache) o irse a Redis, que caduca sola. La segunda, oyentes no desregistrados: nuestro GestorDeVentas es un EventEmitter (M2), y si cada petición registra un oyente que nadie quita, el emisor acumula referencias a los cierres y con ellos a todo lo que capturan.
'use strict';
// MAL: el oyente sobrevive a la peticion y retiene 'respuesta' para
// siempre, y con ella el socket, las cabeceras y el cuerpo.
const malControlador = (gestor) => (peticion, respuesta) => {
gestor.on('venta-registrada', (venta) => respuesta.write(JSON.stringify(venta)));
};
// BIEN: se registra, y se retira cuando la peticion termina. El evento
// 'close' cubre el final normal y la desconexion del cliente.
const buenControlador = (gestor) => (peticion, respuesta) => {
const alVender = (venta) => respuesta.write(JSON.stringify(venta));
gestor.on('venta-registrada', alVender);
respuesta.on('close', () => gestor.off('venta-registrada', alVender));
};
module.exports = { buenControlador };El aviso MaxListenersExceededWarning: Possible EventEmitter memory leak detected es Node diciéndote literalmente que tienes esta fuga. Nunca lo silencies subiendo setMaxListeners: arregla el desregistro.
- El retraso del bucle de eventos como métrica de salud
Si tuvieras que elegir una sola métrica para saber si un proceso Node está sano, sería esta: la CPU al 90 % puede ser normal, pero un retraso del bucle de 800 ms nunca lo es.
'use strict';
const { monitorEventLoopDelay } = require('node:perf_hooks');
// resolution: cada cuantos ms se toma una muestra. 10 ms es buen punto.
function crearVigilanteDelBucle({ registrador, intervaloMs = 30_000, umbralP99Ms = 100 }) {
const histograma = monitorEventLoopDelay({ resolution: 10 });
histograma.enable();
const ms = (valor) => Number((valor / 1e6).toFixed(2));
const temporizador = setInterval(() => {
const p99 = ms(histograma.percentile(99));
registrador[p99 > umbralP99Ms ? 'warn' : 'info']({
metrica: 'retraso-bucle-eventos', p99Ms: p99,
p50Ms: ms(histograma.percentile(50)), maximoMs: ms(histograma.max),
});
histograma.reset(); // ventana deslizante, no acumulativa
}, intervaloMs);
temporizador.unref();
return { detener: () => { clearInterval(temporizador); histograma.disable(); } };
}
module.exports = { crearVigilanteDelBucle };Cómo interpretar los valores:
| Retraso p99 | Diagnóstico |
|---|---|
| < 10 ms | Sano |
| 10-50 ms | Carga alta pero manejable |
| 50-200 ms | Hay trabajo síncrono: investiga con un perfil de CPU |
| > 200 ms | Algo bloquea el bucle. Es el síntoma de la lección 10-02 |
| > 1 000 ms | El proceso está efectivamente caído aunque responda al ping |
Es también el mejor valor para una comprobación de salud: un proceso con el bucle atascado responde 200 OK a un /salud trivial mientras deja tirados a todos los usuarios, y solo un /salud que mire el retraso del bucle detecta ese estado.
- Catálogo de cuellos de botella típicos en Node
| Cuello de botella | Síntoma | Arreglo | Referencia |
|---|---|---|---|
E/S síncrona en caliente (readFileSync, existsSync por petición) |
Retraso del bucle alto, perfil con fs en la pila |
Versión asíncrona, o leer una vez al arrancar | M3 |
| JSON gigante serializado en cada respuesta | JSON.stringify ancho en el flamegraph, GC alta |
Paginar, seleccionar campos, cachear el JSON ya serializado | 10-03, 10-05 |
| Consulta N+1 | Muchas consultas cortas idénticas por petición | include/JOIN, o DataLoader (10-06) |
M7 |
| Falta de índices | EXPLAIN ANALYZE muestra Seq Scan |
Crear el índice y verificar que se usa | M7 |
await secuencial evitable |
Latencia igual a la suma de las esperas | Promise.all en operaciones independientes |
M2 |
| Logging síncrono | El retraso del bucle sube con el volumen de registro | Registrador asíncrono con buffer (pino), sin console.log en caliente |
M6, M11 |
| ReDoS (retroceso catastrófico) | Una petición concreta congela el proceso | Regex sin ambigüedad anidada, o validación por longitud/zod previa |
M8 |
| Datos cacheables regenerados | CPU y base de datos altas para devolver lo mismo | Cache-aside con TTL | 10-03 |
| Trabajo de CPU en el hilo principal | Retraso del bucle en segundos | Hilos o cola | 10-02 |
Dos merecen desarrollo. El primero, await secuencial donde cabía Promise.all, es el error de rendimiento más común y más barato de arreglar:
'use strict';
// MAL: tres 'await' en serie sobre los tres repositorios.
// Latencia = 40 + 35 + 30 = 105 ms.
//
// BIEN: las tres son independientes. Latencia = max(40, 35, 30) = 40 ms.
async function cargarPantallaDelEvento(eventoId, repos) {
const [evento, sesiones, sala] = await Promise.all([
repos.eventos.obtenerEventoPorId(eventoId),
repos.sesiones.listarPorEvento(eventoId),
repos.salas.obtenerPorEvento(eventoId),
]);
return { evento, sesiones, sala };
}La condición es la independencia: si la segunda consulta necesita el resultado de la primera, el await secuencial es obligatorio. Y para operaciones donde el fallo de una no debe tumbar el resto, usa Promise.allSettled. El segundo es el ReDoS: expresiones regulares con retroceso catastrófico, el único cuello de botella de esta lista que además es una vulnerabilidad de seguridad (OWASP, M8), porque una sola petición de 40 caracteres puede congelar el proceso durante minutos.
'use strict';
// PELIGROSA: (\s*\w+)+ tiene ambiguedad anidada. Con una entrada como
// 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!' el motor prueba un numero exponencial
// de combinaciones antes de rendirse, y el proceso entero se congela.
const REGEX_PELIGROSA = /^(\s*\w+)+$/;
// SEGURA: sin ambiguedad, un solo cuantificador sobre clases disjuntas.
const REGEX_SEGURA = /^[\w\s]+$/;
// Y ademas: limitar la longitud ANTES de aplicar la regex.
const validarTituloDeEvento = (titulo) =>
typeof titulo === 'string' && titulo.length <= 120 && REGEX_SEGURA.test(titulo);
module.exports = { validarTituloDeEvento };La defensa en capas: validar longitud y forma con zod (M6) antes de cualquier regex, evitar cuantificadores anidados y desconfiar de las expresiones copiadas de internet para validar correos o URLs.
- Optimizaciones de la capa HTTP
Antes de tocar tu código, comprueba que la capa de transporte no regala trabajo:
| Palanca | Cómo | Ganancia típica |
|---|---|---|
| Compresión | compression (ya instalado), umbral de 1 KB |
60-80 % menos bytes en JSON |
| ETag + 304 | Ya lo hicimos a mano en el M4; Express lo trae | Respuesta vacía en lecturas repetidas |
Cache-Control |
public, max-age=60 en el catálogo |
Peticiones que no llegan a tu servidor |
keep-alive saliente |
Agente con keepAlive: true en fetch/HTTP |
Ahorra el saludo TCP+TLS en cada llamada |
| Paginación obligatoria | Límite máximo en el servidor | Evita respuestas de megabytes |
La compresión no es gratis: consume CPU. Con respuestas pequeñas (< 1 KB) cuesta más de lo que ahorra, y en un servidor con la CPU saturada puede ser contraproducente; en producción lo habitual es delegarla al proxy inverso (M11). Sobre las conexiones salientes, cada llamada al servicio de divisas del M8 sin keep-alive paga saludo TCP y negociación TLS: unos 40-80 ms de puro protocolo.
'use strict';
const { Agent, setGlobalDispatcher } = require('undici');
// Reutiliza conexiones para todas las llamadas salientes con fetch.
setGlobalDispatcher(new Agent({
connections: 32, // conexiones maximas por origen
keepAliveTimeout: 30_000,
keepAliveMaxTimeout: 120_000,
}));
- La base de datos: el pool de conexiones
Abrir una conexión a PostgreSQL cuesta entre 5 y 50 ms (autenticación, TLS, proceso en el servidor). Por eso Sequelize mantiene un pool: un conjunto de conexiones abiertas que se prestan y devuelven.
'use strict';
const os = require('node:os');
const { Sequelize } = require('sequelize');
const { configuracion } = require('../config/index.js');
// Presupuesto global de PostgreSQL: max_connections (100). Reparto:
// trabajadores de cluster x tamano del pool, mas margen para consumidores
// de cola, migraciones y administracion.
const TRABAJADORES = Math.max(1, os.availableParallelism() - 1); // 7
const MAXIMO_POR_PROCESO = Math.max(2, Math.floor(80 / (TRABAJADORES + 2)));
const sequelize = new Sequelize(configuracion.baseDeDatos.url, {
logging: false,
pool: {
max: MAXIMO_POR_PROCESO, // 8 con 7 trabajadores
min: 2, // conexiones calientes siempre listas
acquire: 10_000, // espera maxima por una conexion libre
idle: 10_000, // cierra las ociosas pasado este tiempo
},
});
module.exports = { sequelize };Cómo se dimensiona, en orden: averigua max_connections del servidor (100 por defecto en PostgreSQL); reserva margen para migraciones, consumidores de cola y administración; divide el resto entre el número de procesos que conectan; y recuerda que más grande no es mejor, porque un pool de 100 contra 4 núcleos de base de datos solo añade contención (hay una fórmula clásica que sugiere núcleos × 2 + husos de disco como punto de partida).
Qué pasa cuando el pool se agota: las peticiones esperan hasta acquire y luego fallan con SequelizeConnectionAcquireTimeoutError, con un síntoma característico y engañoso —latencia altísima sin CPU alta, porque todo el mundo está esperando—. Si lo ves, mira primero si hay consultas lentas reteniendo conexiones o transacciones abiertas que nadie cierra.
- V8 sin caer en el micro-optimizado inútil
V8 optimiza tu código mediante formas ocultas (hidden classes): objetos creados con la misma estructura comparten una descripción interna y el acceso a sus propiedades se compila a un desplazamiento fijo, rapidísimo. Si cambias la forma, V8 crea otra y el acceso pasa a ser polimórfico o megamórfico, mucho más lento.
'use strict';
// MAL: la forma del objeto cambia sobre la marcha (tres formas ocultas).
function crearEntradaMal(codigo, precioCentimos) {
const entrada = {};
entrada.codigo = codigo;
entrada.precioCentimos = precioCentimos;
if (precioCentimos === 0) entrada.esInvitacion = true; // solo a veces
return entrada;
}
// BIEN: una unica forma, siempre los mismos campos y en el mismo orden.
const crearEntrada = (codigo, precioCentimos) => ({
codigo, precioCentimos, esInvitacion: precioCentimos === 0,
});
// 'delete' degrada el objeto a modo diccionario y anula la optimizacion:
// en lugar de 'delete entrada.butaca', se reasigna.
const anularButaca = (entrada) => ({ ...entrada, butaca: null });
module.exports = { crearEntrada, anularButaca };Y sobre memoria: el montículo antiguo tiene un límite (típicamente ~2 GB en 64 bits, o menos en contenedores pequeños) que se ajusta con --max-old-space-size=2048. Dos matices imprescindibles: subirlo no arregla una fuga, solo retrasa la muerte y alarga las pausas de la recolección de basura; y en un contenedor hay que ponerlo por debajo del límite de memoria del contenedor, porque si no el sistema mata el proceso (OOM killer) antes de que V8 se dé cuenta (detalle que retomaremos en el M11).
La advertencia general: estas micro-optimizaciones importan en código que se ejecuta millones de veces. En un controlador HTTP que se ejecuta 3 000 veces por segundo y pasa el 95 % del tiempo esperando a la base de datos, reordenar propiedades no cambia nada. Aplícalas solo si el perfil señala esa función.
- El orden correcto de las palancas
Cuando algo va lento hay un orden por retorno de la inversión:
| Orden | Palanca | Ganancia típica | Coste |
|---|---|---|---|
| 1 | Algoritmo y estructura de datos | 10× a 1000× | Pensar; a veces reescribir |
| 2 | Consulta y esquema (índice, N+1, SELECT de menos columnas) |
10× a 100× | Bajo; migración |
| 3 | Caché | 5× a 50× | Medio: invalidación (10-03) |
| 4 | Concurrencia (cluster, hilos, colas) | 2× a 8× | Medio-alto: estado compartido |
| 5 | Micro-optimización de código | 1,05× a 1,5× | Alto en legibilidad |
| 6 | Hardware | Lineal, con factura mensual | Dinero recurrente |
Se recorre de arriba abajo. Comprar una máquina el doble de grande para tapar una consulta sin índice significa pagar el doble cada mes por un problema que se arreglaba con CREATE INDEX. Y así llegamos al recorrido real de Escena Viva en este módulo: primero concurrencia (10-01, 10-02), luego caché (10-03)... con el matiz honesto de que habría sido más eficiente empezar por el índice y la caché. Lo hicimos en el orden del temario por razones didácticas; en tu proyecto, sigue la tabla. Por último, qué se registra en producción para saber que todo sigue bien:
| Métrica | Umbral de alerta | Por qué |
|---|---|---|
| p99 por ruta | Por encima del objetivo de servicio 5 min | Degradación real percibida |
| Tasa de 5xx | > 0,5 % | Algo se ha roto |
| Retraso p99 del bucle | > 100 ms | Trabajo síncrono colado |
heapUsed |
Crecimiento monótono 30 min | Fuga de memoria |
| Conexiones del pool en uso | > 80 % del máximo | Saturación inminente |
| Ratio de aciertos de caché | < 90 % | Invalidación demasiado agresiva o TTL corto |
| Trabajos en espera / fallidos | Crecimiento sostenido | Consumidores insuficientes o rotos |
Recoger, almacenar y alertar sobre estas métricas es monitorización en producción, materia del módulo 11; aquí hemos aprendido a producirlas y a interpretarlas.
Errores Comunes y Consejos
- Optimizar sin perfil. Es adivinar. En Escena Viva el sospechoso era el PDF y el culpable era el JSON.
- Fijarse en la media. Optimiza el p99; es lo que la gente experimenta y recuerda.
- Medir en desarrollo con datos de juguete. Con 3 eventos todo es rápido; con 30 000 aparece la consulta sin índice.
- Prueba de carga con
non2xxdistinto de cero, o cambiar varias cosas a la vez: en el primer caso mides la velocidad de fallar, en el segundo no sabrás qué funcionó. - Perfilar en modo desarrollo. Sin
NODE_ENV=production, Express no cachea vistas y hay comprobaciones extra: el perfil miente. - Subir
--max-old-space-sizepara tapar una fuga. Retrasa la caída y empeora las pausas de la GC. - Consejo: guarda los resultados de la línea base en el repositorio; comparar contra el mes pasado detecta regresiones que ninguna prueba unitaria ve.
- Consejo: añade una prueba de carga con umbral en CI (
k6conthresholds) y el rendimiento pasa de ser una opinión a un requisito verificable.
Ejercicios
Ejercicio 1: línea base y objetivos
Define una prueba de carga reproducible para GET /api/eventos/evt-003/sesiones con 10 s de calentamiento y 60 s de medición a 100 conexiones. Registra media, p50, p95, p99, req/s y non2xx. Compara con el objetivo de servicio de 200 ms y decide si hay que actuar.
Ejercicio 2: cazar el cuello con un flamegraph
Añade a Escena Viva un endpoint GET /api/informes/ocupacion que calcule la ocupación de las 7 sesiones ordenando el array de entradas con un algoritmo cuadrático deliberado. Perfila con 0x, localiza la caja ancha, sustituye por Array.prototype.sort y vuelve a medir.
Ejercicio 3: diagnosticar una fuga
Introduce una fuga registrando un oyente de venta-registrada en cada petición sin retirarlo. Lanza 30 000 peticiones registrando heapUsed cada 10 s. Toma dos instantáneas del montículo, compáralas y localiza el retenedor. Arregla y verifica.
Soluciones
Ejercicio 1. El escenario define warmup: { connections: 10, duration: 10 } y connections: 100, duration: 60. Resultado típico sin caché: media 96 ms, p50 84 ms, p95 210 ms, p99 340 ms, 1 030 req/s, non2xx: 0. El p99 de 340 ms incumple el objetivo de 200 ms, así que hay que actuar. La lectura fina es más interesante que el veredicto: la media (96 ms) parece cómoda y la mediana también; solo el p99 revela el problema. La causa habitual aquí es que las sesiones se cargan con una consulta por sesión (N+1): el arreglo es un include (M7) más caché (10-03), y tras aplicarlos el p99 baja a ~35 ms.
Ejercicio 2. En el flamegraph aparece una meseta ancha y plana sobre ordenarPorOcupacion, ocupando ~70 % del ancho, con muy poca profundidad encima: la señal inequívoca de que el tiempo se consume en esa función y no en sus llamadas. Con 7 sesiones el algoritmo cuadrático es irrelevante; el ejercicio revela su coste al hacerlo sobre las 1 811 entradas vendidas (~3,3 millones de comparaciones por petición). Sustituir por entradas.sort((a, b) => b.ocupacion - a.ocupacion) (O(n log n)) reduce el tiempo de la ruta de ~420 ms a ~6 ms. Es la palanca número 1 de la tabla —el algoritmo— y ninguna cantidad de cluster, hilos o caché habría dado un factor 70.
Ejercicio 3. heapUsed crece de forma monótona: 42 MB, 78 MB, 121 MB, 168 MB... sin estabilizarse nunca, ni siquiera tras una recolección forzada. Alrededor de la petición 10 000 aparece MaxListenersExceededWarning. En la comparación de instantáneas, el Delta muestra decenas de miles de objetos Closure y ServerResponse de más; los Retainers apuntan a GestorDeVentas._events['venta-registrada'], un array que crece sin fin. Cada cierre retiene respuesta, y con ella el socket y las cabeceras: por eso una fuga de "un oyente" cuesta kilobytes por petición. El arreglo es respuesta.on('close', () => gestor.off('venta-registrada', alVender)), y tras aplicarlo heapUsed se estabiliza en una sierra entre 45 y 70 MB: subida por asignación, bajada por recolección. Esa forma de sierra estable es exactamente el aspecto de un proceso sano.
Conclusión
Ya no optimizamos por intuición. Tenemos un método —medir, localizar, arreglar una cosa, volver a medir— y las cuatro señales que hay que vigilar, sabiendo que la media miente y que el p99 es lo que la gente experimenta. Sabemos diseñar una prueba de carga honesta con autocannon (calentamiento, duración, concurrencia y escenario realistas) y por qué jamás se lanza contra un sistema ajeno. Sabemos sacar un perfil de CPU con --prof, con el inspector o con 0x, y leer un flamegraph buscando cajas anchas y no torres altas. Sabemos cazar una fuga comparando instantáneas del montículo y mirando los retenedores. Y tenemos el retraso del bucle de eventos como la métrica de salud número uno. Además, hemos recorrido el catálogo de cuellos de botella de Node —E/S síncrona, JSON gigante, N+1, falta de índices, await secuencial, logging síncrono, ReDoS, datos cacheables— con su arreglo, hemos afinado la capa HTTP y el pool de conexiones, y hemos puesto las micro-optimizaciones de V8 en su sitio: al final de la lista, y solo si el perfil las señala. Sobre todo, tenemos el orden de las palancas: algoritmo, consulta, caché, concurrencia, hardware.
Escena Viva ya es rápida y está medida, así que lo que le queda pendiente no es rendimiento sino diseño. Su API creció por acumulación: rutas con verbos, códigos de estado usados a ojo, sin paginación por cursor, sin versionado, sin política de deprecación, y con una duda que dejamos abierta en el módulo 4 y que nunca resolvimos: qué pasa cuando un POST /api/pedidos se envía dos veces porque el usuario perdió la cobertura en el estreno. En la lección siguiente, Construcción de APIs RESTful, pasamos de "funciona" a "está bien diseñado".
Curso de Node.js: De Principiante a Avanzado
Módulo 1: Introducción a Node.js
- ¿Qué es Node.js?
- Instalación y Configuración del Entorno
- Tu Primer Programa en Node.js
- El REPL de Node.js
- JavaScript Moderno para Node.js
- El Proyecto del Curso: la Plataforma Escena Viva
Módulo 2: Conceptos Básicos
- Arquitectura de Node.js
- El Bucle de Eventos (Event Loop)
- Callbacks y Programación Asíncrona
- Promesas y async/await
- Eventos y EventEmitter
- Módulos CommonJS y require()
- Módulos ES e Interoperabilidad
Módulo 3: Sistema de Archivos y E/S
- Lectura y Escritura de Archivos
- El Módulo fs a Fondo
- Rutas Multiplataforma con el Módulo path
- Trabajando con Streams
- Streams de Transformación y pipeline
- Buffers y Datos Binarios
Módulo 4: HTTP y Servidores Web
- Creando un Servidor HTTP Simple
- Manejo de Solicitudes y Respuestas
- Enrutamiento Manual
- Sirviendo Archivos Estáticos
- Recibiendo Datos: Cuerpos de Petición y JSON
- Consumiendo APIs Externas desde Node.js
Módulo 5: NPM y Gestión de Paquetes
- Introducción a NPM y package.json
- Instalación y Uso de Paquetes
- Versionado Semántico y package-lock
- Scripts de npm y Automatización del Proyecto
- Creación y Publicación de Paquetes
- Seguridad y Mantenimiento de Dependencias
Módulo 6: Framework Express.js
- Introducción a Express.js
- Configuración de una Aplicación Express
- Enrutamiento en Express
- Middleware
- Middleware de Terceros Esenciales
- Validación de Datos de Entrada
- Manejo de Errores
Módulo 7: Bases de Datos y ORMs
- Introducción a las Bases de Datos
- Usando MongoDB con Mongoose
- Operaciones CRUD
- Relaciones, Poblado y Consultas Avanzadas
- Usando Bases de Datos SQL con Sequelize
- Migraciones, Transacciones y Datos de Prueba
Módulo 8: Autenticación y Autorización
- Introducción a la Autenticación
- Registro de Usuarios y Hash de Contraseñas
- Sesiones y Cookies con Passport.js
- Autenticación con JWT
- Control de Acceso Basado en Roles
- Buenas Prácticas de Seguridad en APIs
Módulo 9: Pruebas y Depuración
- Introducción a las Pruebas
- Pruebas Unitarias con Mocha y Chai
- Dobles de Prueba con Sinon
- Pruebas de Integración
- Cobertura y Automatización de las Pruebas
- Depuración de Aplicaciones Node.js
Módulo 10: Temas Avanzados
- El Módulo Cluster
- Hilos de Trabajo (Worker Threads)
- Caché y Colas de Trabajo con Redis
- Optimización del Rendimiento
- Construcción de APIs RESTful
- GraphQL con Node.js
Módulo 11: Despliegue y DevOps
- Configuración y Variables de Entorno
- Registro y Monitorización en Producción
- Usando PM2 para la Gestión de Procesos
- Empaquetado con Docker
- Desplegando en Heroku y Otras PaaS
- Integración y Despliegue Continuos
