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.
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 consultasSi 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 filasLo 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
preventLazyLoadingactivado 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.
