Saltar a contenido

Performance

De un vistazo

Materia: Capa 3 · Sobrevivir en producción · Tiempo de lectura: ~32 min · Requisitos previos: Capa 0 (complejidad, memoria/caché), Bases de datos, Networking.

En una frase: vas a entender que optimizar es un proceso empírico, no un acto de adivinación —que tu intuición sobre "dónde está lento" casi siempre miente, que en una aplicación web el tiempo se va en esperar I/O y no en calcular, y que la única forma honesta de saber si algo mejoró es medirlo con percentiles.

Por qué esto importa

Todo el mundo sabe hacer que un programa vaya "más rápido": reescribes ese bucle que "seguro que es el problema", cambias el array por algo más listo, metes un array_map en vez del foreach… y la página sigue tardando 2 segundos. Has optimizado durante una tarde y no se nota nada.

Esto pasa porque optimizar por intuición es como buscar las llaves debajo de la farola: miras donde hay luz (el código que entiendes bien), no donde están las llaves (el SELECT que se ejecuta 400 veces sin que lo veas). Las preguntas que esta lección te enseña a responder son:

  • ¿Por qué mi endpoint tarda 1,8 s si "no hace casi nada", solo pinta una lista?
  • ¿Por qué el servidor va perfecto en mi máquina y se cae en producción con 200 usuarios?
  • ¿De verdad merece la pena reescribir esta función en C, o el 95% del tiempo se va esperando a la base de datos?
  • ¿Por qué la media de respuesta es 120 ms pero hay usuarios furiosos porque "va lentísimo"?
  • ¿Meter una caché aquí ayuda… o me acabo de comprar el problema de invalidarla?

Todas tienen la misma raíz: no sabes dónde se va el tiempo, y sin ese dato optimizar es tirar esfuerzo a ciegas. La diferencia entre un programador junior y uno senior en esto no es que el senior "sepa trucos": es que el senior mide antes de tocar nada. Ese es el salto de esta lección.

Intuición primero: el médico que no ausculta

Imagina un médico que, en cuanto entras por la puerta, y sin mirarte, te receta una operación de rodilla porque "la mayoría de la gente de tu edad tiene la rodilla mal". Da igual que hayas venido por un dolor de cabeza. Es un curandero, no un médico.

Optimizar sin medir es exactamente eso. La secuencia correcta es la de cualquier diagnóstico serio:

graph LR
  A["1 · MEDIR<br/>¿cuánto tarda<br/>de verdad?"] --> B["2 · LOCALIZAR<br/>¿dónde se va<br/>ese tiempo?<br/>(profiling)"]
  B --> C["3 · ARREGLAR<br/>solo el cuello<br/>de botella real"]
  C --> D["4 · MEDIR OTRA VEZ<br/>¿mejoró?<br/>¿cuánto?"]
  D -->|"si no basta"| A

Fíjate en dos cosas. La primera: medir aparece dos veces, al principio y al final. Al principio para saber dónde atacar; al final para comprobar que lo que hiciste sirvió de algo (muchísimas "optimizaciones" no cambian nada, o empeoran). La segunda: arreglar es el paso más pequeño. La mayor parte del trabajo de rendimiento no es escribir código listo, es encontrar el sitio donde ese código listo importa.

La regla de oro, que vas a oír repetida hasta el aburrimiento y aun así incumplirás: mide, no adivines. Tu intuición sobre qué es lento está construida sobre el código que ves, y lo lento casi nunca es lo que ves.

El detalle

1. La regla de oro: la intuición miente (y por qué)

Hay un patrón que se repite en casi todas las aplicaciones: la Ley de Amdahl llevada a lo cotidiano. Si el 90% del tiempo de tu petición se va en la base de datos y el 10% en tu código PHP, entonces optimizar tu PHP hasta el infinito —hacerlo instantáneo, coste cero— solo te quita ese 10%. Pasas de 1000 ms a 900 ms. Has trabajado un montón para una mejora que el usuario ni nota.

En cambio, tocar el 90% (la parte lenta) aunque sea para hacerlo la mitad de rápido te lleva de 1000 ms a 550 ms. La conclusión es incómoda pero inevitable:

El único sitio donde optimizar importa es el cuello de botella. Todo lo demás es esfuerzo desperdiciado, por muy satisfactorio que sea.

Y aquí está la trampa: no sabes cuál es el cuello de botella hasta que mides. Tu cerebro está sesgado hacia el código que escribiste con esfuerzo (ese algoritmo que te costó una tarde) y ciego al código que "no cuenta" (un ->getCategoria() dentro de un foreach que dispara una consulta cada vuelta). El cuello de botella real está, con una regularidad deprimente, en el sitio que juraste que estaba bien.

La cita que todos malinterpretan

Donald Knuth dijo "la optimización prematura es la raíz de todos los males" —pero 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. Aun así, no debemos dejar pasar nuestras oportunidades en ese 3% crítico." No es una excusa para escribir código descuidado. Es una orden de priorizar: no micro-optimices el 97% que no importa, y ataca con todo el 3% que sí. Y para saber cuál es el 3%… mides.

2. Profiling: encontrar el cuello de botella real

Un profiler es una herramienta que ejecuta tu programa y te dice en qué se fue el tiempo, función por función, consulta por consulta. Es el estetoscopio. Sin él, estás adivinando; con él, tienes datos.

Hay dos familias:

  • Profiling por muestreo (sampling): cada pocos milisegundos, el profiler "hace una foto" de qué está ejecutando el programa en ese instante. Con miles de fotos, las funciones que aparecen más veces son las que consumen más tiempo. Barato, poco intrusivo, ideal para producción. Es como observar una fábrica a ratos y anotar quién está siempre ocupado.
  • Profiling por instrumentación: el profiler mete cronómetros en la entrada y salida de cada función. Datos exactísimos, pero ralentiza el programa (a veces mucho) y puede distorsionar la propia medida. Mejor en desarrollo.

En el mundo PHP/Symfony esto se concreta en herramientas que ya usas o deberías:

  • El Symfony Profiler (la barra de debug) te da, para cada petición, el tiempo total, cuántas consultas se lanzaron y cuánto tardó cada una, la memoria consumida y el desglose por eventos. Es lo primero que miras.
  • Blackfire o Xdebug (--profile) generan un call graph con el tiempo de cada función.
  • Para SQL, EXPLAIN te dice si una consulta usa índices o hace un full table scan (esto es materia de Bases de datos, pero es donde acabarás mirando el 80% de las veces).

La forma canónica de leer un profiler es el flame graph (gráfico de llamas): cada barra es una función, su anchura es el tiempo que consumió, y se apilan según quién llamó a quién. Buscas las barras anchas: ahí está tu tiempo.

graph TB
  H["handleRequest() · 1000 ms"]
  H --> C["renderVista() · 120 ms"]
  H --> D["cargarPedidos() · 850 ms"]
  D --> Q["consultas SQL · 840 ms<br/>(400 queries!) ← AQUÍ está el tiempo"]
  D --> M["mapear a objetos · 10 ms"]

En ese diagrama, alguien que optimiza por intuición se pondría a mejorar renderVista() o mapear a objetos porque son "su código". El profiler grita que el problema son 400 consultas en cargarPedidos(). Ahí es donde se gana o se pierde la partida.

Regla práctica

Antes de optimizar cualquier endpoint, mira cuántas consultas SQL lanza. Si son más de 10-15 para pintar una página normal, casi seguro tienes un N+1 (lo vemos en la sección 5) y ese es tu cuello de botella, no el algoritmo que estabas mirando.

3. Dónde se va el tiempo: casi siempre es I/O, no CPU

El tiempo de un programa se va en cuatro sitios: CPU (calcular), I/O de disco (leer/escribir ficheros, base de datos local), red (hablar con otra máquina: base de datos remota, API, caché) y memoria (que en realidad es un problema de CPU esperando, como veremos).

La intuición del principiante es que el programa está "calculando". La realidad de una aplicación web típica es que está esperando:

graph LR
  subgraph Req["Una petición web típica · 1000 ms"]
    direction LR
    CPU["CPU: tu código PHP<br/>~50 ms"]
    DB["Esperando la BD<br/>~600 ms"]
    API["Esperando una API externa<br/>~300 ms"]
    REST["Resto (red, serializar)<br/>~50 ms"]
  end

El 95% de ese tiempo el procesador está de brazos cruzados, esperando a que vuelva un dato de otro sitio. Optimizar la CPU aquí es como cambiar el motor de un coche que está parado en un atasco: el problema no es la potencia, es que no te dejan avanzar.

Esto tiene una consecuencia enorme y contraintuitiva: reescribir tu código en un lenguaje más rápido casi nunca ayuda en una app web. Si tu PHP tarda 50 ms y el resto son 950 ms de espera, migrar a Go/Rust para que ese código tarde 5 ms te ahorra 45 ms de 1000. Irrelevante. El tiempo está en la espera de I/O, y esa espera es idéntica en cualquier lenguaje.

¿Cuándo es CPU el cuello de botella? Cuando de verdad calculas: procesamiento de imágenes/vídeo, criptografía, machine learning, simulaciones, compresión, cálculo numérico pesado. En un CRUD que mueve datos entre la base de datos y el navegador, no. Y la mayoría del software del mundo es un CRUD que mueve datos.

4. La jerarquía de latencias: los números que hay que conocer

Para entender por qué "esperar I/O" domina, necesitas interiorizar cuánto cuesta ir a buscar un dato según dónde esté. Esto conecta directo con la caché de Capa 0: allí viste que ir a RAM cuesta ~100 veces más que un registro. Ahora extendemos la escala hasta el disco y la red, y el abismo se vuelve absurdo.

Los famosos "números que todo programador debería conocer" (Jeff Dean), redondeados:

Operación Latencia real Escalada a "tiempo humano" (×1000M)
1 ciclo de CPU / registro ~0,3 ns 1 segundo
Acceso a caché L1 ~1 ns 3 segundos
Acceso a caché L2 ~4 ns 12 segundos
Acceso a RAM ~100 ns ~5 minutos
Leer 1 MB de SSD ~50 µs ~1,5 días
Ida y vuelta en la misma red local ~500 µs ~2 semanas
Leer de un disco duro (HDD) ~5 ms ~2 meses
Ida y vuelta entre continentes ~150 ms ~5 años

Lee la tercera columna: es la misma tabla, pero como si un ciclo de CPU durara 1 segundo. Ir a la RAM son 5 minutos. Ir a la base de datos por red son semanas. Llamar a una API en otro continente son cinco años. Esa es, a escala humana, la diferencia entre calcular y esperar I/O.

graph LR
  A["Registro / L1<br/>~1 ns"] --> B["RAM<br/>~100 ns"]
  B --> C["SSD<br/>~50 µs"]
  C --> D["Red local<br/>~500 µs"]
  D --> E["Otro continente<br/>~150 ms"]

De aquí salen dos leyes prácticas que gobiernan casi todo el rendimiento de sistemas:

  1. Cuanto más lejos está el dato, brutalmente más caro es traerlo. El orden es: registro → caché → RAM → disco → red local → red lejana, y cada salto es de 10× a 1000×.
  2. La estrategia universal de rendimiento es acercar los datos. Todo —cachés de CPU, cachés de aplicación, CDN, réplicas de lectura geográficas— es la misma idea: traer el dato más cerca de quien lo usa para no pagar el viaje largo cada vez. Cuando entiendas esto, la sección de caching deja de ser una lista de herramientas y se convierte en una sola idea aplicada en varios niveles.

El coste oculto: la latencia no se acumula, se multiplica

Una consulta a la BD son ~1 ms. Parece nada. Pero si tu endpoint lanza 400 consultas (el N+1 de la sección siguiente), son 400 ms solo de ida y vuelta, aunque cada consulta sea trivial. El problema rara vez es una operación lenta; es una operación rápida repetida miles de veces. Repetir barato mil veces es caro.

5. El problema N+1 y el batching

Este es, con diferencia, el problema de rendimiento número uno en aplicaciones con ORM (Doctrine, Eloquent, Hibernate…). Y es tan común precisamente porque el código que lo causa parece perfectamente inocente.

Quieres listar 100 pedidos con el nombre de su cliente:

$pedidos = $repo->findAll();          // 1 consulta: SELECT * FROM pedidos
foreach ($pedidos as $pedido) {
    echo $pedido->getCliente()->getNombre();  // ← ¡una consulta CADA vuelta!
}

Ese ->getCliente() parece un simple acceso a una propiedad. Pero como Doctrine carga las relaciones de forma perezosa (lazy loading), la primera vez que tocas el cliente de cada pedido dispara un SELECT * FROM clientes WHERE id = ?. Con 100 pedidos, son 1 consulta inicial + 100 consultas = 101 idas y vuelta a la base de datos. De ahí el nombre: N+1.

graph TB
  Q0["1 · SELECT * FROM pedidos<br/>→ 100 pedidos"]
  Q0 --> L["foreach (100 vueltas)"]
  L --> Q1["SELECT ... cliente WHERE id=1"]
  L --> Q2["SELECT ... cliente WHERE id=2"]
  L --> Q3["... 98 consultas más ..."]
  L --> Q4["SELECT ... cliente WHERE id=100"]
  Q1 --> R["101 consultas · ~400 ms<br/>para pintar UNA lista"]
  Q2 --> R
  Q3 --> R
  Q4 --> R

La solución es el batching (agrupar): en vez de mil viajes pequeños, haz un viaje grande. Traes todo lo que vas a necesitar de una vez, con un JOIN o un WHERE id IN (...):

// ✅ Eager loading / JOIN: 1 sola consulta trae pedidos Y clientes
$pedidos = $repo->createQueryBuilder('p')
    ->addSelect('c')
    ->join('p.cliente', 'c')
    ->getQuery()->getResult();

foreach ($pedidos as $pedido) {
    echo $pedido->getCliente()->getNombre();  // ya está en memoria: 0 consultas nuevas
}
// → 1 consulta en vez de 101

El principio general trasciende el ORM y es una de las ideas más rentables de todo el rendimiento de sistemas:

Cuando una operación tiene coste fijo de ida y vuelta (una consulta, una llamada de red, una escritura a disco), agrupar N operaciones en una sola casi siempre gana, porque pagas el viaje una vez en lugar de N. Batch requests, bulk inserts, pipeline en Redis, IN (...) en SQL: la misma idea con distinto nombre.

Un INSERT de 1000 filas de una vez es dramáticamente más rápido que 1000 INSERT sueltos, no porque escribir sea más rápido, sino porque pagas una negociación con la base de datos en vez de mil.

6. Caching: acercar los datos (y por qué invalidar es infierno)

Cachear es guardar el resultado de algo caro (una consulta pesada, un cálculo, una llamada a una API) en un sitio rápido y cercano para no volver a pagarlo. Es la aplicación directa de la ley de la sección 4: acerca el dato. Hay niveles, y suelen combinarse:

graph LR
  U["Usuario"] --> CDN["CDN<br/>(archivos estáticos,<br/>cerca del usuario)"]
  CDN --> APP["Caché de aplicación<br/>(Redis / APCu:<br/>resultados, sesiones)"]
  APP --> DB["Caché de la BD<br/>(buffer pool, plan cache)"]
  DB --> DISK["Disco<br/>(la fuente lenta<br/>de la verdad)"]
  • CDN (Content Delivery Network): copias de tus archivos estáticos (imágenes, CSS, JS) en servidores repartidos por el mundo, físicamente cerca del usuario. Un usuario en Tokio recibe la imagen de un servidor en Tokio, no de tu servidor en Madrid. Ataca la latencia de red lejana de la tabla (esos "5 años").
  • Caché de aplicación (Redis, Memcached, APCu): guardas en RAM el resultado de operaciones caras. La consulta que tardaba 600 ms, la primera vez la pagas y guardas el resultado; las siguientes lo lees de RAM en <1 ms. Es donde más control tienes y donde más se gana.
  • Caché de la base de datos: la propia BD cachea en RAM las páginas de datos más usadas (el buffer pool) y los planes de ejecución. Por eso la misma consulta va lenta la primera vez y rápida después. No la gestionas tú, pero conviene saber que existe.

El beneficio es tan grande que la tentación es cachearlo todo. Y aquí llega la factura:

Los dos problemas difíciles de la informática

Hay una broma famosa: "solo hay dos problemas difíciles en informática: la invalidación de caché, nombrar las cosas, y los errores de contador en uno". La broma es que el chiste tiene un error de contador (dice dos y enumera tres) y nombra mal el segundo. Los tres problemas están dentro del chiste. Pero el primero es el serio.

Invalidar la caché —decidir cuándo el dato guardado ya no vale y hay que refrescarlo— es genuinamente difícil porque introduce el problema de la coherencia: la caché dice una cosa y la fuente de la verdad dice otra. Cacheas el precio de un producto; el precio cambia en la base de datos; tu caché sigue sirviendo el viejo. Ahora tienes usuarios viendo precios distintos, o peor, comprando al precio equivocado.

Las estrategias (TTL —caduca a los N segundos—, invalidación por evento —borro la caché cuando cambia el dato—, write-through —escribo a la vez en caché y BD) son todas compromisos entre frescura y velocidad, y ninguna es gratis. La regla sensata:

No caches por defecto. Cachea cuando has medido que algo es caro y se pide mucho, y cuando puedes tolerar que el dato esté un poco desactualizado. Una caché mal invalidada no es una optimización: es un bug de datos servido muy rápido.

7. Concurrencia: throughput frente a latencia

Cuando llegan 1000 usuarios a la vez, entra en juego la concurrencia, y aquí hay que distinguir dos métricas que la gente confunde constantemente:

  • Latencia: cuánto tarda una petición en completarse. "Esta página carga en 200 ms." Es la experiencia de un usuario.
  • Throughput (rendimiento/caudal): cuántas peticiones por segundo puede atender el sistema en total. "El servidor aguanta 5000 req/s." Es la capacidad del sistema.

No son lo mismo y mejorar una puede empeorar la otra. La analogía: un cajero de supermercado tarda 2 minutos por cliente (latencia). Si pones 10 cajas, cada cliente sigue tardando 2 minutos —la latencia no cambia—, pero despachas 5 veces más gente por hora (throughput). Y si sobrecargas las cajas con más clientes de los que pueden llevar, las colas crecen y la latencia de cada uno empeora aunque el throughput esté al máximo.

graph LR
  subgraph Lat["Latencia · 1 usuario"]
    U1["Usuario"] --> T1["tarda 200 ms"]
  end
  subgraph Thr["Throughput · el sistema"]
    S["Servidor"] --> C["atiende<br/>5000 req/s"]
  end

La clave, que conecta con la sección 3: como en una app web el procesador está casi siempre esperando I/O, puedes atender muchas peticiones a la vez con la misma CPU. Mientras la petición A espera a la base de datos, la CPU atiende a la B, la C, la D… Por eso la concurrencia dispara el throughput en cargas de I/O: no estás calculando más rápido, estás aprovechando los huecos de espera. (Los modelos concretos —procesos, hilos, async/event loop— son materia propia; lo que importa aquí es entender qué métrica estás optimizando y por qué.)

Un aviso importante: añadir concurrencia no arregla una operación lenta. Si tu consulta tarda 5 s, con 10 usuarios en paralelo cada uno seguirá esperando 5 s (o más, si compiten por el mismo recurso). La concurrencia mejora el throughput del sistema, no la latencia de la operación. Primero arreglas la operación; luego escalas.

8. Benchmarks honestos: percentiles, no medias

Has hecho un cambio. ¿Mejoró? Aquí la mayoría de la gente se miente sola mirando la media, y la media es, para latencias, casi siempre engañosa.

El problema: las latencias tienen una distribución con cola larga. La mayoría de peticiones son rápidas, pero unas pocas son lentísimas (un GC que salta, una consulta que no pilló la caché, un pico de red). La media esconde esa cola:

Imagina 100 peticiones: 99 tardan 50 ms y 1 tarda 5000 ms. La media es ~100 ms, y suena bien. Pero hay un usuario cada 100 esperando 5 segundos, y en una página con 20 recursos por carga, ese caso raro lo pisa casi todo el mundo. La media te ha mentido.

Por eso se miden percentiles:

  • p50 (mediana): la mitad de las peticiones van por debajo de este valor. Es la experiencia "típica".
  • p95: el 95% van por debajo; el 5% peor va por encima. Empieza a capturar la cola.
  • p99: solo el 1% peor va por encima. Es el que refleja a los usuarios que sufren.
graph LR
  P50["p50: 50 ms<br/>(el usuario típico)"] --> P95["p95: 80 ms<br/>(empeora)"]
  P95 --> P99["p99: 5000 ms<br/>(los que sufren y se van)"]

En sistemas grandes el p99 es lo que importa, porque una sola página hace muchas peticiones y basta con que una caiga en la cola para arruinar la experiencia entera. Si tu página carga 20 recursos, la probabilidad de que al menos uno caiga en tu p95 es enorme; el "caso raro" deja de ser raro a escala. Amazon y Google gobiernan sus SLA por p99/p99.9, no por la media, precisamente por esto.

Y para que el benchmark sea honesto, no solo mirado con la métrica correcta:

  • Calienta antes de medir (warm-up): las primeras ejecuciones son lentas (cachés frías, JIT sin calentar, conexiones sin abrir). Descártalas o el número miente.
  • Mide en un entorno parecido a producción. Tu portátil con la BD en local no tiene la latencia de red real. Un benchmark en condiciones irreales da conclusiones irreales.
  • Repite y mira la varianza, no una sola pasada. Una medición aislada puede haber pillado justo un pico o un valle.
  • Cambia una cosa cada vez. Si tocas tres cosas y mejora, no sabes cuál fue —y a lo mejor una ayudó y otra empeoró y se compensaron.

Cómo se ve en la práctica

Un caso clásico de Symfony: un endpoint que lista las últimas 50 facturas con su cliente y sus líneas. En local va "bien"; en producción tarda 2,3 s y el equipo lleva una semana "optimizando el algoritmo de ordenación".

Abres el Symfony Profiler y ves lo que el algoritmo de ordenación jamás te habría hecho sospechar:

Tiempo total:   2312 ms
Consultas SQL:  312        ← la señal de alarma
Memoria:        48 MB

312 consultas para 50 facturas. Es un N+1 de manual: por cada factura, una consulta para su cliente y otra para sus líneas. El "algoritmo de ordenación" que llevaban una semana puliendo consume, según el profiler, 4 ms. Lo estaban puliendo porque era su código; el problema estaba en los ->getCliente() y ->getLineas() que no parecían consultas.

// ❌ 1 + 50 + 50 + ... = 312 consultas
$facturas = $repo->findBy([], ['fecha' => 'DESC'], 50);
foreach ($facturas as $f) {
    $f->getCliente()->getNombre();   // consulta lazy
    foreach ($f->getLineas() as $l) { /* ... */ }  // más consultas lazy
}

// ✅ eager loading: 1 consulta (o 2-3), no 312
$facturas = $repo->createQueryBuilder('f')
    ->addSelect('c', 'l')
    ->join('f.cliente', 'c')
    ->leftJoin('f.lineas', 'l')
    ->orderBy('f.fecha', 'DESC')->setMaxResults(50)
    ->getQuery()->getResult();

Resultado medido (no adivinado): de 2312 ms a 180 ms. Y luego, sobre esos 180 ms, mides otra vez: si la consulta sigue pesando, un índice sobre fecha (materia de Bases de datos); si el endpoint se pide mucho y los datos cambian poco, una caché en Redis con TTL corto. Medir, arreglar el cuello real, medir de nuevo. Nunca al revés.

Lo que sacrificas / errores comunes

  • Optimizar sin medir. El pecado original. Trabajas duro en el sitio equivocado y no se nota. Si no has abierto un profiler, no estás optimizando, estás adivinando.
  • Optimizar demasiado pronto. Retorcer código por rendimiento antes de que exista un problema medido te deja código más complejo, más difícil de mantener y con más bugs, a cambio de una mejora que nadie pidió. La claridad primero; la velocidad, cuando los datos la exijan.
  • Mirar la media en vez de los percentiles. La media te dice que todo va bien mientras el p99 quema a uno de cada cien usuarios. Para latencias, media = mentira cómoda.
  • Cachear para tapar un problema de fondo. Meter una caché sobre una consulta N+1 esconde el problema y te regala el infierno de la invalidación. Arregla la consulta; cachea lo que ya está bien y sigue siendo caro.
  • Creer que un lenguaje más rápido arregla una app web. Si el 95% es espera de I/O, reescribir en Rust te ahorra una miseria. El cuello está en la BD y la red, no en tu bucle.
  • Confundir throughput con latencia. Añadir servidores/concurrencia sube el caudal, no acelera la operación lenta. Primero arreglas la operación; luego escalas para servir a más gente.
  • Benchmarks deshonestos: medir en frío, en tu portátil, una sola vez, cambiando cinco cosas a la vez. Un número mal medido es peor que ninguno, porque te da falsa confianza.

Resumen

  1. Mide antes de optimizar. Tu intuición sobre qué es lento casi siempre miente, porque estás sesgado hacia el código que ves. El profiler es el estetoscopio; sin él, eres un curandero.
  2. El tiempo importa solo en el cuello de botella. Optimizar cualquier otra cosa (Amdahl) es esfuerzo desperdiciado. Y no sabes cuál es el cuello hasta que mides.
  3. En una app web, el tiempo se va esperando I/O (base de datos, red, disco), no calculando. La jerarquía de latencias explica por qué: cada salto hacia datos más lejanos es de 10× a 1000×, y la estrategia universal es acercar el dato (cachés, CDN, batching).
  4. El N+1 es el enemigo número uno en apps con ORM: una operación barata repetida cientos de veces. Se arregla con batching (traer todo de un viaje). Y cachear ayuda muchísimo, pero invalidar la caché es genuinamente difícil.
  5. Mide con percentiles, no con la media. El p95/p99 revela la cola que la media esconde, y a escala esa cola la sufre casi todo el mundo. Performance es un proceso empírico: medir, arreglar, medir.

Ejercicios socráticos

No busques la respuesta fuera. Razónala con el modelo de esta lección. Si te atascas, la pista está en el texto.

  1. Un endpoint tarda 800 ms. El profiler dice: 40 ms de PHP, 750 ms en 250 consultas SQL, 10 ms de red. Tu compañero propone reescribir la lógica PHP en un microservicio Go. Con la Ley de Amdahl en la mano, ¿cuánto podría mejorar eso en el mejor de los casos? ¿Qué atacarías tú y por qué?
  2. Cacheas el saldo de la cuenta de un usuario con un TTL de 5 minutos para ahorrarte la consulta. Explica un escenario concreto en el que esto produzca un bug de datos que un usuario note. ¿Qué compromiso estás aceptando al poner ese TTL?
  3. La media de latencia de tu API es 90 ms y estás contento. Un usuario se queja de que "va lentísimo, a veces tarda 4 segundos". ¿Cómo es posible que ambas cosas sean ciertas a la vez? ¿Qué métrica pedirías para verlo?
  4. Tienes una consulta que tarda 3 s. Añades 20 servidores web en paralelo. Un usuario que lanza esa consulta, ¿la ve más rápida? Distingue qué métrica has mejorado y cuál no, y por qué la concurrencia no arregla esto.
  5. Mira la tabla de latencias: leer de RAM (~100 ns) frente a una ida y vuelta entre continentes (~150 ms). ¿Cuántas veces más caro es lo segundo? Con ese número, argumenta por qué una CDN cambia radicalmente la experiencia de un usuario lejano, aunque tu servidor sea igual de rápido.

Repaso espaciado

Pasa estas a Anki (repasos.md). Formato: pregunta que obligue a razonar, no a reconocer.

  • [ ] ¿Por qué "reescribir el código en un lenguaje más rápido" casi nunca acelera una aplicación web típica? (Responde en términos de dónde se va el tiempo.)
  • [ ] ¿Qué es el problema N+1, por qué el código que lo causa parece inocente, y cómo lo arregla el batching?
  • [ ] Distingue latencia de throughput. ¿Por qué añadir concurrencia sube una y no la otra? ¿Por qué en apps de I/O la concurrencia es tan efectiva?
  • [ ] ¿Por qué se miden percentiles (p95/p99) y no la media para latencia? Da un ejemplo numérico donde la media engañe.
  • [ ] Ordena de más rápido a más lento: RAM, SSD, registro de CPU, red entre continentes, disco duro. ¿Cuál es la estrategia general que se deriva de esa jerarquía?

Para seguir tirando del hilo

  • Systems Performance (Brendan Gregg) — la biblia del rendimiento de sistemas; el creador de los flame graphs explica cómo medir de verdad.
  • "Latency Numbers Every Programmer Should Know" (Jeff Dean / Peter Norvig) — busca la versión interactiva que escala los números al año presente; interiorizar esa tabla cambia cómo diseñas.
  • Designing Data-Intensive Applications (Martin Kleppmann), cap. sobre percentiles y colas de latencia — por qué el p99 gobierna los sistemas grandes.
  • Experimento: coge un endpoint real de un proyecto tuyo, abre el Symfony Profiler y cuenta las consultas. Si hay más de 15 para una lista, tienes un N+1. Arréglalo con join/addSelect y mide el antes y el después. Ver el 2312 ms → 180 ms con tus ojos asienta esto más que releerlo.