Módulo 7 · Pruebas

Lección 36 — Pruebas de carga (k6)

Encontrar el cuello de botella antes que la cola del concierto.

Publicada
En esta lección
  1. Objetivos
  2. 1. Qué pregunta (y qué no) una prueba de carga
  3. 2. El entorno de prueba: honesto y aislado
  4. 3. Encontrar el cuello: capa por capa
  5. 4. El pico y la descarga: lo que la curva revela
  6. 5. Del número al plan
  7. Autoevaluación

Stack: k6 + Django/DRF · Proyecto: TicketFlow Estado: Publicada — cierre del módulo de pruebas Prerrequisito: Lección 35 — Pruebas E2E y de contrato


Objetivos

  1. Escribir un escenario k6 del pre-venta de TicketFlow: rampa de usuarios, thresholds de p95/p99 y comprobaciones de corrección bajo presión.
  2. Encontrar el cuello de botella con la disciplina del perfilado (37 lo profundiza): medir capa por capa, no adivinar.
  3. Decidir la capacidad honesta: qué N de concurrentes soporta el sistema y con qué hardware — el número que el negocio necesita.

1. Qué pregunta (y qué no) una prueba de carga

La pregunta correcta: ¿cuántos usuarios concurrentes soporta el checkout sin romper los SLOs (46)? — no "¿cuánto aguanta?" (sin objetivo, sin criterio). Los criterios se definen ANTES: p95 de POST /reservations < 400 ms, p99 < 900 ms, tasa de error < 0.5%, cero dobles-vendedores (la corrección no se negocia bajo presión). Y el escenario REALISTA no es "GET /events 1000 veces" (el listado cacheado aguanta milagros, 12): es la mezcla del negocio — 70% browse (listados), 20% detalle, 9% reservas, 1% pagos — con el pico de las 10:00 del viernes de pre-venta.

javascript
// load/checkout.js — el escenario de pre-venta
import http from "k6/http";
import { check, sleep } from "k6";
import { Trend } from "k6/metrics";

const reservaDur = new Trend("reserva_duration");

export const options = {
  stages: [
    { duration: "2m", target: 200 },   // rampa: el tráfico crece, no salta
    { duration: "5m", target: 200 },   // meseta: el estado estacionario que mides
    { duration: "1m", target: 400 },   // pico de stress: ¿dónde rompe?
    { duration: "1m", target: 0 },     // descarga: ¿se recupera?
  ],
  thresholds: {
    "http_req_duration{scenario:default}": ["p(95)<400"],
    "reserva_duration": ["p(99)<900"],
    "http_req_failed": ["rate<0.005"],
    "checks": ["rate>0.999"],
  },
};

export default function () {
  const evento = http.get(`${BASE}/api/v1/events/01H...`).json();
  check(evento, { "detalle ok": (r) => r.status === 200 });
  sleep(Math.random() * 2 + 0.5);                      // think time: humanos, no máquinas
  const res = http.post(`${BASE}/api/v1/reservations`,
    JSON.stringify({ event: evento.id, seats: ["A1"] }),
    { headers: { "Content-Type": "application/json", Authorization: `Bearer ${TOKEN}` } });
  reservaDur.add(res.timings.duration);
  check(res, { "reserva 201 o 409": (r) => [201, 409].includes(r.status) });   // 409 es ÉXITO del sistema
}

Dos detalles que separan un buen script de un script ingenuo: el think time (los humanos piensan entre acciones; sin sleep, mides tu servidor contra una horda de robots sin piedad) y el check que acepta 409 como éxito (bajo contención, el conflicto es el sistema funcionando: la trampa es contarlo como error y "arreglar" lo que no está roto).

2. El entorno de prueba: honesto y aislado

La carga se prueba contra un entorno con la MISMA topología de prod (app + PG + Redis + worker, 40) pero aislado (staging pequeño o una réplica en tu máquina para los primeros números). Dos mentiras clásicas: probar contra manage.py runserver (un solo proceso síncrono: mides el servidor de desarrollo, no a gunicorn) y probar SIN datos (1M de asientos hace que el índice (09) y el planificador se comporten distinto que con 100 filas). El setup honesto: gunicorn con los workers de prod (la fórmula 2×CPU+1 como punto de partida), seed de datos realista (50k eventos, 2M de asientos, 100k usuarios), y el EXPLAIN de las queries del pico ya validado (09).

El aislamiento: nunca contra prod (la 22/47: un test de carga mal lanzado ES un incidente DoS), y los tokens de k6 son usuarios de test (el rate limiter de la 23 te va a throttlear si reutilizas credenciales reales — y ese es otro finding).

3. Encontrar el cuello: capa por capa

El número malo llega ("p95 2 s"), y el adivino dice "más servidores" (la 55). La disciplina: medir cada capa en la MISMA corrida: (1) el p95 por endpoint (k6 lo separa con tags); (2) el lado del servidor: gunicorn logs de latencia, pg_stat_statements para las queries top, el pool de conexiones (39: ¿el p95 coincide con "esperando conexión"?), el uso de CPU por contenedor (¿un core al 100% es Python o Postgres?). El resultado típico de TicketFlow:

p95 global 2.1s  →  desglose:
  browse (GET /events)        180 ms   ✓ (cacheado, 12)
  detalle (GET /events/:id)   210 ms   ✓
  reserva (POST /reservations) 2.1 s   ✗ el cuello
       └─ pg_stat_statements: UPDATE seats SET status WHERE id IN (...) 1.8 s
          └─ el UPDATE masivo del checkout contiende por el mismo evento

El cuello casi nunca es "el framework es lento": es una query (09), un lock (10), el pool (39) o la pasarela remota (32: el p95 del checkout incluye 1.2 s de la pasarela sandbox — la carga local no puede arreglar la latencia ajena; se separa la latencia propia de la remota en la medición).

4. El pico y la descarga: lo que la curva revela

Las fases de la curva cuentan la historia: en la rampa (2m→200), el p95 sube suave y se estabiliza (capacidad suficiente); en la meseta, el número honesto (el SLO se cumple o no); en el pico de stress (400), la pregunta es ¿degradación elegante o colapso? — los errores 429/409 crecen (bien: el sistema rechaza con gracia) o los timeouts en cadena y el OOM (mal: el sistema se cae entero). Y la descarga (target 0): ¿la latencia vuelve a la línea base en 30 s? Si no: hay acumulación (cola de tareas sin drenar (29), conexiones zombis, caché entera invalidada (38) — el "después del pico quedó cojo" es un finding tan valioso como el pico).

bash
k6 run load/checkout.js --out json=results.json   # o influxdb/grafana (46)
k6 run load/checkout.js --vus 100 --duration 5m   # el humo rápido pre-meseta

La corrida fija (meseta 200 por 5 m) es la que produce el número de capacidad; las rampas exploratorias son diagnóstico. Cada corrida se registra con su commit (44: el IaC del entorno de carga) y su resultado en el repo (load/results/2027-03-01.md) — la capacidad es un dato con fecha, no una leyenda.

5. Del número al plan

El output del ejercicio: "TicketFlow soporta 200 concurrentes de mezcla realista (≈ 12k reservas/min) con p95 380 ms en 2 vCPU/4GB; a 400 degrada con 409s elegante (sin corrupción); recupera línea base en 40 s". Ese número alimenta: el autoscaling (43/55), la cola de preventa (el "entrar por turnos" de los festivales reales: limitar la admisión al checkout ANTES de que el lock contienda, 23), y el plan de capacidad del negocio. Y la lista de mejoras priorizada por impacto medido: (1) el UPDATE de asientos por lotes con CASE único en vez de N updates (09), (2) partición de reservas por evento (55), (3) read replicas para el browse (55). Cada mejora se re-mide con la MISMA corrida k6: antes/después con números, no con opinión.


Autoevaluación

  1. ¿Qué SLOs defines ANTES de lanzar k6 para POST /reservations y por qué el 409 cuenta como éxito del sistema?
  2. ¿Por qué el think time y el seed de datos realista son obligatorios y qué mide cada mentira (runserver, sin datos)?
  3. El cuello del ejemplo es el UPDATE de asientos: ¿cómo lo localizaste con las herramientas del servidor y qué NO es el cuello?
  4. ¿Qué revela cada fase de la curva (rampa/meseta/pico/descarga) y qué finding escondido tiene la fase de descarga?
  5. ¿Cómo se convierte "200 concurrentes" en decisiones de producto e infraestructura?

Continúa con los ejercicios. Las solutions.md solo tras intentarlo.