Ir al contenido

Consultas N+1 en Laravel: encuéntrelas antes que producción

El N+1 es el fallo de rendimiento más común en Laravel y el más fácil de reintroducir. Dónde se esconde, cómo hacer que rompa una prueba en vez de una página, y cuándo la carga anticipada sobra.

6 min de lectura

El N+1 es el primer fallo de rendimiento del que todo el mundo oye hablar en Laravel y el que seguimos encontrando más a menudo en producción. No porque los equipos no sepan lo que es, sino porque saber lo que es no impide que vuelva.

Qué es en realidad

Una consulta para traer una colección, y después una más por cada elemento de esa colección:

$orders = Order::latest()->take(50)->get();   // 1 consulta
 
foreach ($orders as $order) {
    echo $order->customer->name;              // 50 consultas
}

Cincuenta y una consultas donde bastaban dos. Contra una base de datos local con cuarenta filas no cuesta nada. En producción es el endpoint del que todo el mundo se queja.

El arreglo que todos conocen:

$orders = Order::with('customer')->latest()->take(50)->get();  // 2 consultas

Si el artículo se parase aquí sería el mismo artículo que todos los demás. La parte interesante es por qué esto sigue pasando en bases de código donde todos los desarrolladores ya saben lo anterior.

Dónde se esconde de verdad

En un accesor. Este es el que engaña, porque el sitio de la llamada parece formateo de cadenas y no acceso a base de datos:

public function getDisplayNameAttribute(): string
{
    return $this->customer->name . ' (' . $this->customer->country->code . ')';
}

Nada en el sitio de la llamada dice "consulta". Peor aún: los accesores se ejecutan a menudo durante la serialización, así que un endpoint de API que parece cargar una relación emite dos por fila.

En un parcial de Blade reutilizado en un sitio nuevo. El parcial se escribió para una página de detalle, donde cargar una relación es una consulta. Alguien lo incluye dentro de un bucle en una página de listado. El diff que causó la regresión no contiene ni una sola consulta.

Detrás de un condicional. La relación se carga por anticipado en el controlador que se perfiló. Un segundo controlador devuelve el mismo recurso sin el with(), porque lo escribió alguien que no sabía que el recurso tocaba una relación.

Dentro de una policy. La autorización se ejecuta por elemento. Una policy que lee $user->team->settings es una consulta por cada elemento de la colección que está autorizando.

En un trabajo, por registro. Los workers de cola hacen invisibles los N+1: nadie está esperando la página, así que el único síntoma es una cola que se vacía más despacio de lo que debería y una base de datos con una carga que nadie sabe atribuir.

Haga que falle a gritos, en una línea

Este es el cambio más valioso del artículo:

// AppServiceProvider::boot()
Model::preventLazyLoading(! app()->isProduction());

La carga perezosa lanza ahora una LazyLoadingViolationException en desarrollo y en pruebas. Un N+1 deja de ser algo que se nota en una gráfica y pasa a ser algo que falla en CI, en el pull request que lo introdujo, con una traza que apunta a la línea.

La mayoría de equipos lo activan en todas partes menos en producción, porque una violación que se les pasó es mejor como página lenta que como error 500. Es un valor por defecto razonable y merece la pena revisarlo cuando el código esté limpio: una excepción en producción es como se encuentra el camino de código que sus pruebas no cubren.

Y después, que siga arreglado

La prevención caza el fallo mientras el desarrollador todavía lo tiene en la mano. La protección contra regresiones lo caza después. Quiere las dos, y la segunda son tres líneas:

it('lista pedidos sin un N+1', function () {
    Order::factory()->count(20)->create();
 
    DB::enableQueryLog();
    $this->get('/orders')->assertOk();
 
    expect(DB::getQueryLog())->toHaveCount(4);
});

Una prueba que afirma un número y no un rango, sobre los endpoints que llevan tráfico. Cuando alguien quita una carga anticipada, la compilación falla con un recuento que pasó de cuatro a veinticuatro, y la causa está en el diff que esa persona tiene delante.

La objeción a esto es que el número es frágil. Esa es justamente la función: usted quiere que le avisen cuando el número de consultas cambie, y actualizar la aserción es el momento en el que decide si el cambio era intencionado.

Cuándo la carga anticipada es la respuesta equivocada

La carga anticipada sustituye N consultas por una. A veces el número correcto es cero.

Solo necesita un recuento. $post->comments->count() carga todos los comentarios para contarlos. withCount('comments') le pide un número a la base de datos:

$posts = Post::withCount('comments')->get();
// $post->comments_count, sin hidratar filas

Lo mismo vale para sumas, máximos y existencia. withSum, withMax y whereHas mantienen el trabajo en SQL.

Solo necesita el último. Cargar todo el historial de pedidos de un cliente para enseñar su pedido más reciente es una relación con una restricción, no una carga anticipada completa.

Está paginando algo enorme. Un with() sobre una relación con miles de filas por padre convierte un problema en un problema de memoria. Trocéelo, o reestructure la consulta para que filtre la base de datos.

Los datos se pueden desnormalizar. Una columna contador mantenida en la escritura no es poco elegante: es la respuesta correcta cuando un valor se lee mil veces por cada escritura y hay que ordenar por él.

Una lista corta

  • preventLazyLoading activado fuera de producción.
  • Aserciones sobre el número de consultas en los endpoints con más tráfico.
  • Audite sus accesores: cualquiera que toque una relación es un generador de N+1 disfrazado.
  • Revise policies y recursos de API, no solo controladores.
  • Antes de añadir un with(), pregúntese si necesita las filas o solo un número.

Nada de esto es difícil. Es la diferencia entre una aplicación que es rápida porque alguien la perfiló el trimestre pasado y una que es rápida porque no puede dejar de serlo en silencio.

Si las consultas ya están en producción y nadie sabe cuáles le están costando dinero, ese es el trabajo que hacemos

  • y empieza por medir, no por una lista de buenas prácticas.

Preguntas relacionadas

¿La carga anticipada siempre arregla un N+1?
Sustituye muchas consultas por dos, que suele ser lo correcto. Es el arreglo equivocado cuando solo necesita un agregado - cargar diez mil filas relacionadas para contarlas es una consulta en lugar de diez mil, y sigue siendo mucho más trabajo que pedirle un número a la base de datos.
¿Es seguro activar preventLazyLoading?
En desarrollo y en pruebas, sí, y es la línea de mayor valor de todo este artículo. En producción lanza una excepción en un camino de código que se le pasó, así que la mayoría de equipos lo activan en todas partes menos en producción: eso caza el fallo en CI y deja producción degradada en lugar de rota.
¿Por qué el N+1 no aparece en desarrollo?
Porque cien consultas de más contra una base de datos local con cuarenta filas cuestan unos milisegundos. El fallo no depende de la latencia sino de los datos, y los datos de desarrollo son el único sitio donde los datos son pequeños.
¿Puede encontrarlas un paquete por nosotros?
Los paquetes de detección ayudan y funcionan en tiempo de ejecución, lo que significa que solo ven los caminos de código que usted ejercita. Son una buena red de seguridad y un mal sustituto de una aserción sobre el número de consultas en los endpoints que importan.

← Volver a todos los artículos

Llamar+1 848 272 7583WhatsApp+90 850 308 5436Correoinfo@codefacture.comPágina de contacto