Ejercicio 1 — La línea base
- El output modelo (500 reservas, un evento):
1204512 function calls (1120004 primitive) in 2.411 seconds
ncalls tottime percall cumtime filename:lineno(function)
500 0.812 0.0016 1.988 services.py:88(reservar)
6000 0.641 0.0001 0.912 money.py:31(__add__)
12000 0.233 0.0000 0.244 {built-in method builtins.str}
1500 0.121 0.0001 0.301 uuid.py:278(__str__)
...- La hipótesis del output: el 82% del tiempo (1.988/2.411) está en
reservar, y DENTRO el 46% (0.912) enMoney.__add__— el recálculo del total con Decimals por cada operación del checkout. NO era "la BD" ni "el serializer": la intuición falló, el perfil no.
- La mejora mínima: acumular en
Decimalinterno durante el checkout y construir elMoneyUNA vez al final (osum(amounts, Decimal(0))→ un soloMoney). Antes/después: 2.411 → 1.512 s;Money.__add__del top 3 (0.912 → 0.091). El mismo test, el mismo criterio: la medida es la que declara la victoria, no el diff.
Ejercicio 2 — El N+1
- La vista con el disfraz: 40 reservas → 83 queries (1 de reservas + 40 de tarifas + 40 de seats + 2 de extras). El toolbar lo muestra con la columna de tiempo: 412 ms en BD para un listado que debería costar 2 queries y 40 ms.
- El fix:
qs = (Reservation.objects.filter(user=user)
.select_related("event__tariff") # FK anidada: un JOIN
.prefetch_related("seats")) # reverse FK: 1 query por lote
# 3 queries totales, 44 msEl índice de calidad: queries/recurso no debe crecer con las filas — es O(1) en queries o hay un N+1.
.iterator()solo cambia el TRANSPORTE (cursor por lotes, útil para 2M filas, la 31): las queries siguen siendo N porque cadareserva.seats.all()dispara su SELECT.prefetch_relatedmaterializa el lote UNA vez y el related manager lee del cache (reserva.seats.all()NO re-querya si hay prefetch). La regla: iterator para memoria, prefetch para queries — no se sustituyen.
Ejercicio 3 — El micro-perfil
- Los números:
str(uuid)21.4 µs,f"{uuid}"21.9 µs,uuid.hex0.9 µs por iteración. ¿Importa? En un endpoint con 1 conversión: no (0.02 ms). En un CSV de 100k filas con 3 por fila: 6 s de diferencia. La lección doble: "no optimices esto" Y "si el flujo es masivo,.hexes gratis". El dato decide, no la superstición.
- El line_profiler en
calcular_total:
Line # Hits Time % Time Content
12 500 118400 71.2% tariff = Tariff.objects.get(event=self.event) ← la query
14 500 21400 12.9% total = sum(...)Es el N+1 de nuevo (71% en la query), no la CPU de los Decimals (12.9%): el micro-perfil te evita optimizar el Decimal cuando el costo es la BD. Las herramientas en cascada: cProfile localiza la función, line_profiler la línea, EXPLAIN (09) la query.
- El snapshot:
def confirmar(self):
self.total_amount = self.total() # FROZEN: el precio del momento de compra (08)
self.save(update_fields=["total_amount", "status"])El test de la 08 (cambiar la tarifa después de confirmar NO cambia el total de la reserva) sigue en verde: la denormalización es legal porque es un snapshot del hecho económico, no un cache de un dato vivo — la distinción exacta que separa esto de la caché de la 38.
Ejercicio 4 — py-spy
- Los dumps de 3 workers durante la meseta: worker-1 en
recv(esperando request: normal), worker-2 enpg execute(el UPDATE: coincide con la 36), worker-3 enjson.dumpsdel serializer de listado (hallazgo nuevo: la serialización del listado de 400 eventos por request — candidato a paginación más agresiva, 39, o caché de la respuesta completa, 38).
- El flamegraph: la pila ancha domina
UPDATE seats...(55%) y detráspg executeanida el pool wait (el hallazgo del pool del 36 confirmado visualmente). Lo nuevo del SVG: eljson.dumpsdel listado es el 12% de la CPU total — invisible en cProfile de unitaria (los datos eran 5 eventos; con 400 reales, la serialización pesa). Moraleja: el perfil de PROD con datos REALES cuenta cosas que la unitaria no puede.
- El on-call con el dump: "worker-3 está en
time.sleepinvocado porenviar_recordatoriolínea 88 desde hace 118 s; la tarea es la de recordatorios del 31, no bloquea la cola (prefetch=1) ni la BD (sin transacción activa en pg_stat_activity). Espera programada, no bug: NO reiniciar" — el dump convierte "algo va lento" en una decisión informada.
Ejercicio 5 — El informe
- La estructura del informe (extracto):
# Perfil del checkout — 2027-09
Línea base (k6, meseta 200): p99 1.4s; desglose: BD 55%, app 22%, pasarela 18%.
Hallazgos: [cProfile] Money.__add__ 46% de reservar(); [silk] N+1 en mis-reservas (83q);
[py-spy] UPDATE masivo (coincide con k6/36); pasarela = latencia remota.
Mejoras: bulk UPDATE (-700ms p99), snapshot total_amount (-14ms), prefetch (-368ms listado).
NO arregladas: str(uuid) (0.02ms/req), json listado (12% CPU: la paginación 39 lo aborda).
Re-medición: p99 640ms. Próximo: paginación del listado.Las NO-mejoras documentadas valen tanto como las arregladas: protegen del "refactor de rendimiento" sin criterio la próxima vez.
- La plantilla del PR:
Mejora: [qué]
Before: [p95/p99/queries — herramienta y fecha]
After: [mismo criterio]
Costo: [complejidad añadida, invalidación, riesgo]
Revert: [cómo se deshace]Cinco líneas que convierten cada optimización en un dato versionado (y en el caso de "no mejoró": el PR no se mergea — el caché que no mejora se rechaza, la 38).
Resumen del profesor
- El ciclo medir→hipótesis→medir con la MISMA herramienta y criterio; una sola medición es una opinión.
- cProfile localiza la función, line_profiler la línea, EXPLAIN la query; en prod solo sampling (py-spy) — el flamegraph de prod revela lo que la unitaria no ve.
- Toda mejora lleva before/after medido en el PR; las NO-mejoras se documentan igual.