API REST con picos de latencia por queries N+1 y GC en PHP — cómo diagnosticar y corregir

Por · Publicado el · 8 min de lectura

Tablero de instrumentos del coche con velocímetro y tacómetro iluminados en rojo
Metáfora visual para picos de latencia y monitorización de rendimiento Foto: Erik Mclean / Pexels

API REST con picos de latencia por queries N+1 y GC en PHP: diagnóstico y correcciones. Si tus endpoints muestran picos intermitentes en p95/p99, esta guía explica cómo reconocer los síntomas, probar el N+1, medir el impacto del GC y aplicar correcciones rápidas en el código, en la base de datos y en PHP‑FPM — siempre validando con métricas y tracing.

Síntomas típicos: cómo reconocer los picos en producción

Los picos aparecen en momentos aleatorios, sin un aumento proporcional del tráfico. El p95/p99 sube mientras la media casi no cambia. Endpoints de listados e informes suelen verse más afectados.

En los logs aparecen decenas o cientos de SELECT similares en una sola petición. En PHP‑FPM, algunos workers se vuelven mucho más lentos y son reciclados por uso de memoria.

La base de datos muestra ráfagas cortas de QPS con patrones repetidos. La CPU de la aplicación no se satura, pero hay pausas en el tiempo de respuesta — típico de consultas N+1 combinadas con pausas del GC.

  • p95/p99 en forma de serrucho e impredecible
  • Muchas consultas similares por petición
  • Workers de PHP‑FPM “hinchándose” y siendo reciclados
  • Listados y relaciones más afectados

Por qué N+1 y el GC generan latencia intermitente

El N+1 ocurre cuando el código obtiene una colección y, por cada elemento, dispara otra consulta. Con lazy loading en ORMs, esto puede pasar desapercibido y multiplicar el I/O.

El GC del PHP corre dentro de la misma petición. Objetos y arrays grandes que forman ciclos de referencia desencadenan el recolector. En ciertos momentos el recolector pausa la ejecución para barrer estructuras.

Al combinarse, los bucles generan muchos objetos, cada iteración hace I/O y, en algún punto, entra el GC. La petición se alarga y aparece un pico en p95/p99.

Diagnóstico observable: logs, correlación, tracing y slowlog

Haz las peticiones trazables de extremo a extremo. Usa logs estructurados con correlation ID, contadores de consultas y tiempos de serialización. Registra esta telemetría para comparar antes/después.

Implementa tracing en las rutas críticas para descomponer el tiempo en spans de aplicación, SQL y serialización. Esto ayuda a identificar el momento del N+1 y las pausas del GC.

Activa el slowlog de PHP‑FPM para capturar stacks por encima de un umbral y revisar cuellos de botella en el código. En la base de datos, recoge EXPLAIN de las consultas más repetidas.

  • Logs estructurados y correlación de IDs
  • Tracing con spans para app, BD y serialización
  • Slowlog de PHP‑FPM con request_slowlog_timeout
  • Muestras de EXPLAIN de consultas “calientes”

Cómo detectar el N+1 de forma inequívoca

Cuenta las consultas por petición y agrúpalas por fingerprint del SQL. Si la misma consulta se ejecuta cientos de veces en un endpoint, es N+1.

En el ORM, busca lazy loading dentro de bucles. En frameworks como Laravel, habilita eager loading en las relaciones afectadas y comprueba la reducción de consultas.

Valida con tracing: en lugar de muchos spans cortos de base de datos, deberías ver pocas consultas bien planificadas (y con índices adecuados).

  • Métrica: queries por request por endpoint
  • Fingerprints de SQL repetidos en la misma request
  • Eager loading/joins en relaciones usadas en bucles
  • EXPLAIN para planes e índices adecuados

Midiendo y mitigando el impacto del GC en PHP

Monitorea memoria por petición e identifica puntos con crecimiento abrupto. Objetos temporales grandes y arrays anidados suelen disparar el GC.

Reduce ciclos: evita referencias cruzadas innecesarias, libera estructuras grandes temprano (unset) y considera generators para procesar streams sin materializar todo.

En bloques concretos y medidos, desactivar y reactivar el GC puede reducir pausas. Para profundizar en la estabilidad de memoria del runtime, revisa guías sobre fugas de memoria en PHP‑FPM.

  • Liberar buffers grandes de forma temprana
  • Evitar crear ciclos de referencia
  • Usar generators/yield para pipelines grandes
  • Reciclar workers con pm.max_requests

Correcciones prácticas y quick wins: del código a la base de datos

Ataca el N+1 con reescrituras dirigidas: reemplaza bucles con consultas por joins y eager loading. En APIs muy usadas, cachea resultados calientes con TTL corto e invalidación por clave de negocio.

Optimiza consultas reincidentes con índices y reduciendo columnas cargadas. En el serializador, evita mapear objetos gigantes si no son necesarios. Valida cada cambio monitorizando p95/p99 y el conteo de consultas.

Mantén un ciclo de cambios pequeños y medibles, comprobando que cada optimización reduce la carga de I/O y mejora latencias.

Causa Síntoma observable Corrección rápida Cómo validar
N+1 en relación 1:N Cientos de SELECT iguales por request Eager loading/joins; buscar IDs en lote Consultas por request disminuyen; p95 se estabiliza
Campos innecesarios Payload grande y serialización lenta Seleccionar solo las columnas usadas (SELECT proyectado) Tiempo de serialización disminuye; respuesta más pequeña
GC en arrays masivos Pausas en bucles; alto uso de memoria Procesar en chunks/generators; liberar buffers Memoria por request disminuye; las pausas desaparecen
Worker inestable Latencia aumenta con el tiempo Ajustar pm.max_requests; revisar memory_limit Workers se reciclan antes de “hincharse”; p99 baja

Ajustes en PHP‑FPM para previsibilidad de latencia

Dimensiona pm.max_children según CPU y la capacidad del banco, evitando saturar conexiones. Define request_terminate_timeout y slowlog para capturar bloqueos.

Usa pm.max_requests para reciclar procesos tras N peticiones, reduciendo fragmentación y efectos acumulativos de memoria. Monitoriza la tasa de reciclaje para evitar thrash.

Expone el estado del FPM (pm.status_path) y crea gráficos de cola, tiempos medios, p95/p99 y memoria. Cruza estas métricas con las del BD para detectar backpressure.

  • pm=dynamic o static según el patrón de carga
  • request_slowlog_timeout y slowlog configurados
  • pm.max_requests calibrado al perfil de memoria
  • pm.status_path expuesto y monitorizado

Validación y monitorización continua tras las correcciones

Define SLOs de latencia por endpoint y vigila histogramas (p50/p95/p99). Guarda dashboards y alertas en el repositorio para trazabilidad.

Haz despliegues graduales y compara antes/después con tracing y logs. Si hace falta, usa feature flags para alternar versiones de consultas o serializers con seguridad.

Revisa periódicamente los endpoints con mayor coste por petición y reevalúa índices cuando cambien los patrones de acceso.

  • SLOs por ruta y alertas por p99 sostenido
  • Comparación A/B en ventanas temporales
  • Revisión periódica de endpoints más costosos

Preguntas frecuentes

¿Cómo diferenciar N+1 de falta de índice en la base de datos?

En el N+1, la misma consulta aparece decenas de veces por petición. Si falta un índice, hay pocas consultas pero lentas. Cuenta queries por request, agrupa por fingerprint y ejecuta EXPLAIN para comprobar full scan o un plan inadecuado.

¿Desactivar el garbage collector de PHP soluciona los picos?

Puede reducir pausas en blocs puntuales y medidos, pero no es una solución general. El GC recoge ciclos; desactivarlo globalmente puede incrementar la memoria y provocar fallos. Es preferible optimizar la asignación y reciclar workers con pm.max_requests.

¿pm.max_requests ayuda a la estabilidad de latencia?

Sí. Reciclar procesos tras N peticiones reduce la fragmentación y los efectos acumulativos de memoria. Evita reciclajes excesivos. Monitoriza p95/p99 y la memoria por worker para calibrar el valor.

¿Cómo demostrar que corregí el N+1?

Antes/después deben mostrar reducción en el número de consultas por petición y estabilización del p95/p99. El tracing mostrará menos spans de base de datos. Los logs estructurados deben registrar el contador de queries por ruta con una tendencia sostenida a la baja.

¿Cachear el JSON completo resuelve el problema?

El cache puede mitigar picos, pero no reemplaza la corrección. Usa TTL corto e invalidación por clave de negocio. Corrige el N+1 en origen (eager loading, joins) y mantiene el cache como optimización complementaria.

¿Querés estabilizar la latencia de tu API REST?

Si enfrentás picos por queries N+1 y pausas del GC en PHP, podemos analizar tus endpoints críticos, medir causas y proponer correcciones prácticas.

Hablemos

Ver también