Rendimiento Eloquent: 4 ejemplos de problemas de consulta N+1


Victor Arana Flores

11 Aug 2022

El rendimiento de Eloquent suele ser la principal razón de la lentitud de los proyectos Laravel. Una gran parte de ello es el llamado "Problema de las Consultas N+1". En este artículo, mostraré algunos ejemplos diferentes de lo que hay que tener en cuenta, incluyendo los casos en los que el problema está "escondido" en lugares inesperados del código.

Qué es el problema de la consulta N+1

En resumen, es cuando el código de Laravel ejecuta demasiadas consultas a la base de datos. Ocurre porque Eloquent permite a los desarrolladores escribir una sintaxis legible con modelos, sin profundizar en la "magia" que ocurre bajo el capó.

Esto no es sólo un problema de Eloquent, o incluso de Laravel: es bien conocido en la industria del desarrollo. ¿Por qué se llama "N+1"? Porque, en el caso de Eloquent, consulta UNA fila de la base de datos, y luego realiza una consulta más por cada registro relacionado. Por lo tanto, N consultas, más el propio registro, suman N+1.

Para solucionarlo, tenemos que consultar los registros relacionados por adelantado, y Eloquent nos permite hacerlo fácilmente, con la llamada carga ansiosa. Pero antes de llegar a las soluciones, vamos a discutir los problemas. Les mostraré 4 casos diferentes.


Caso 1. Consulta "normal" N+1.

Este puede ser tomado directamente de la documentación oficial de Laravel:

// app/Models/Book.php:
class Book extends Model
{
    public function author()
    {
        return $this->belongsTo(Author::class);
    }
}
 
// Entonces, en algún Controlador:
$books = Book::all();
 
foreach ($books as $book) {
    echo $book->author->name;
}

¿Qué pasa aquí? $book->author realizará una consulta de base de datos adicional por cada libro, para así poder obtener su autor.

He creado un pequeño proyecto de demostración para simular esto y he sembrado 20 libros falsos con sus autores. Mira el número de consultas.

Como puede ver, para 20 libros, hay 21 consultas, exactamente N+1, donde N = 20.

Y sí, has acertado: si tienes 100 libros en la lista, tendrás 101 consultas a la BD. Un rendimiento horrible, aunque el código parecía "inocente", cierto.

La solución es cargar la relación por adelantado, inmediatamente en el Controlador, con la carga ansiosa que mencioné antes:

// En lugar de:
$books = Book::all();
 
// Deberías hacerlo así:
$books = Book::with('author')->get();

El resultado es mucho mejor: sólo 2 consultas:

Cuando usas la carga ansiosa, Eloquent obtiene todos los registros en el array y lanza una consulta a la tabla de la BD relacionada, pasando esos IDs desde ese array. Y entonces, cada vez que llames a $book->author, carga el resultado desde la variable que ya está en memoria, sin necesidad de volver a consultar la base de datos.

Ahora, espera, ¿te preguntas qué es esta herramienta para mostrar las consultas?

Utilice siempre la barra de depuración. Y sembrar datos falsos.

Esta barra inferior es un paquete Laravel Debugbar. Todo lo que necesitas hacer para usarlo es instalarlo:

composer require barryvdh/laravel-debugbar --dev

Y ya está, se mostrará la barra inferior en todas las páginas. Sólo tienes que habilitar la depuración con la variable .env APP_DEBUG=true, que es un valor por defecto para entornos locales.

Aviso de seguridad: asegúrese de que cuando su proyecto se ponga en marcha, tenga APP_DEBUG=false en ese servidor, de lo contrario los usuarios regulares de su sitio web verán la barra de depuración y sus consultas a la base de datos, lo cual es un gran problema de seguridad.

Por supuesto, te aconsejo que uses Laravel Debugbar en todos tus proyectos. Pero esta herramienta por sí misma no mostrará los problemas obvios hasta que tengas más datos en las páginas. Así que usar Debugbar es sólo una parte del consejo.

Además, también recomiendo tener clases sembradoras que generen algunos datos falsos. Preferiblemente, muchos datos, para que veas cómo se comporta tu proyecto "en la vida real" si lo imaginas creciendo con éxito en los futuros meses o años.

Utiliza Factory y genera más de 10.000 registros para libros/autores y otros modelos:

class BookSeeder extends Seeder
{
    public function run()
    {
        Book::factory(10000)->create();
    }
}

A continuación, navegue por el sitio web y observe lo que le muestra Debugbar.

También hay otras alternativas a Laravel Debugbar:

  • Laravel Telescope Toolbar
  • Clockwork

Caso 2. Dos símbolos importantes.

Digamos que tienes la misma relación hasMany entre autores y libros, y necesitas listar los autores con el número de libros de cada uno de ellos.

El código del controlador podría ser

public function index()
{
    $authors = Author::with('books')->get();
 
    return view('authors.index', compact('authors'));
}

Y luego, en el archivo Blade, haces un bucle foreach para la tabla:

@foreach($authors as $author)
    <tr>
        <td>{{ $author->name }}</td>
        <td>{{ $author->books()->count() }}</td>
    </tr>
@endforeach

Parece legítimo, ¿verdad? Y funciona. Pero mira los datos de la barra de depuración de abajo.

Pero espera, dirás que estamos usando la carga ansiosa, Author::with('books'), así que ¿por qué se producen tantas consultas?

Porque, en Blade, $author->books()->count() no carga realmente esa relación de la memoria.

  • $author->books() significa el MÉTODO de relación.
  • $author->books significa que los datos están cargados en la memoria

Así, el método de relación consultaría la base de datos para cada autor. Pero si se cargan los datos, sin símbolos (), utilizará con éxito los datos cargados con antelación:

Por lo tanto, tenga cuidado con lo que está utilizando exactamente: el método de relación o los datos.

Observe que en este ejemplo en particular hay una solución aún mejor. Si sólo necesitas los datos agregados calculados de la relación, sin los modelos completos, entonces debes cargar sólo los agregados, como withCount:

// Controlador:
$authors = Author::withCount('books')->get();
 
// Blade:
{{ $author->books_count }}

Como resultado, sólo habrá UNA consulta a la base de datos, ni siquiera dos consultas. Y además la memoria no se "contaminará" con datos de relaciones, por lo que también se ahorra algo de RAM.


Caso 3. Relación "oculta" en el Accessor.

Tomemos un ejemplo similar: una lista de autores, con la columna de si el autor está activo: "Sí" o "No". Esa actividad se define por si el autor tiene al menos un libro, y se calcula como un accesor dentro del modelo Autor.

El código del controlador podría ser:

public function index()
{
    $authors = Author::all();
 
    return view('authors.index', compact('authors'));
}

Archivo blade

@foreach($authors as $author)
    <tr>
        <td>{{ $author->name }}</td>
        <td>{{ $author->is_active ? 'Yes' : 'No' }}</td>
    </tr>
@endforeach

Ese "is_active" está definido en el modelo de Eloquent:

use Illuminate\Database\Eloquent\Casts\Attribute;
 
class Author extends Model
{
    public function isActive(): Attribute
    {
        return Attribute::make(
            get: fn () => $this->books->count() > 0,
        );
    }
}

Aviso: esta es una nueva sintaxis de los accessors de Laravel, adoptada en Laravel 9. También puedes usar la sintaxis "antigua" de definir el método getIsActiveAttribute(), también funcionará en la última versión de Laravel.

Así, tenemos la lista de autores cargada, y de nuevo, mira lo que muestra Debugbar:

Sí, podemos solucionarlo cargando ansiosamente los libros en el Controlador. Pero en este caso, mi consejo general es evitar el uso de las relaciones en los accesores. Porque un accessor se suele utilizar cuando se muestran los datos, y en el futuro, otra persona puede utilizar este accessor en algún otro archivo de Blade, y usted no tendrá el control de la apariencia de ese Controlador.

En otras palabras, se supone que el Accessor es un método reutilizable para dar formato a los datos, por lo que usted no tiene el control de cuándo/cómo será reutilizado. En tu caso actual, puedes evitar la consulta N+1, pero en el futuro, alguien más puede no pensar en ello.


Caso 4. Cuidado con los paquetes.

Laravel tiene un gran ecosistema de paquetes, pero a veces es peligroso usar sus características "a ciegas". Puedes encontrarte con consultas N+1 inesperadas si no tienes cuidado.

Permítanme mostrarles un ejemplo con un paquete muy popular spatie/laravel-medialibrary. No me malinterpretes: el paquete en sí es impresionante y no quiero mostrarlo como un defecto del paquete, sino como un ejemplo de lo importante que es depurar lo que ocurre bajo el capó.

El paquete Laravel-medialibrary utiliza relaciones polimórficas entre la tabla de la BD "media" y tu modelo. En nuestro caso, serán los libros los que se listarán con sus portadas.

Modelo Book:

use Spatie\MediaLibrary\HasMedia;
use Spatie\MediaLibrary\InteractsWithMedia;
 
class Book extends Model implements HasMedia
{
    use HasFactory, InteractsWithMedia;
 
    // ...
}

Código del controlador:

public function index()
{
    $books = Book::all();
 
    return view('books.index', compact('books'));
}

Código Blade:

@foreach($books as $book)
    <tr>
        <td>
            {{ $book->title }}
        </td>
        <td>
            <img src="{{ $book->getFirstMediaUrl() }}" />
        </td>
    </tr>
@endforeach

Ese método getFirstMediaUrl() viene de la documentación oficial del paquete.

Ahora, si cargamos la página y miramos la barra de depuración...

20 libros, 21 consultas a la base de datos. Precisamente N+1 de nuevo.

Entonces, ¿el paquete hace un mal trabajo en el rendimiento? Bueno, no, porque la documentación oficial dice cómo recuperar archivos multimedia para un objeto modelo específico, para un libro, pero no para la lista. La parte de la lista tienes que descubrirla por ti mismo.

Si escarbamos un poco más, en el trait InteractsWithMedia del paquete, encontramos esta relación que se autoincluye en todos los modelos:

public function media(): MorphMany
{
    return $this->morphMany(config('media-library.media_model'), 'model');
}

Por lo tanto, si queremos que todos los archivos multimedia se carguen ansiosamente con los libros, tenemos que añadir with() a nuestro Controlador:

// Instead of:
$books = Book::all();
 
// You should do:
$books = Book::with('media')->get();

Este es el resultado visual, sólo 2 consultas.

Una vez más, este es el ejemplo no para mostrar este paquete como uno malo, pero con el consejo de que usted necesita para comprobar las consultas DB en todo momento, ya sea que vienen de su código o un paquete externo.


La solución incorporada contra la consulta N+1

Ahora, después de haber cubierto los 4 ejemplos, te daré el último consejo: desde Laravel 8.43, ¡el framework tiene un detector de consultas N+1 incorporado!

Además de la barra de depuración de Laravel para la inspección, puede añadir un código para la prevención de este problema.

Tienes que añadir dos líneas de código a app/Providers/AppServiceProvider.php:

use Illuminate\Database\Eloquent\Model;
 
class AppServiceProvider extends ServiceProvider
{
    public function boot()
    {
        Model::preventLazyLoading(! app()->isProduction());
    }
}

Ahora, si lanza cualquier página que contenga un problema de consulta N+1, verá una página de error, algo así:

Esto le mostrará el código "peligroso" exacto que puede querer corregir y optimizar.

Tenga en cuenta que este código debe ser ejecutado sólo en su máquina local o en los servidores de prueba/establecimiento, los usuarios en vivo en los servidores de producción no deben ver este mensaje, porque eso sería un problema de seguridad. Por eso necesitas añadir una condición como ! app()->isProduction(), que significa que tu valor APP_ENV en el archivo .env no es "production".

¡Te deseo que tengas un gran rendimiento de velocidad en tus proyectos!

Artículo traducido del blog de Laravel New


1 comentarios

Inicia sesión para comentar

Comentarios:

  • Johnnie

    Johnnie hace 3 meses

    No consigo resolver este problema. Tengo la tabla usuarios la cual esta relacionada con status mediante status_id. Y quiero obtener el ultimo estado de ese usuario. El usuario puede tener muchos estados. Hasta ahora lo he hecho recorriendolo con un foreach pero se demora mucho. Necesitaria que fuera por Eloquet o alguna manera más optima.


                $usuariosToBeAlta = [];
                $usuarios = Ussers::all();
                foreach($usuarios as $user){
                        if(
                            ($user->getStatus() == Alta
                        )else{
                            $usuarioToBeAlta[] = $user;
                        }
                }
     

        public function getStatus()
        {
            $lastStatus = Status::where('usuario_id', '=', $this->id)
                ->orderBy('created_at', 'desc')->first();
            if(!empty($lastStatus))
                return $lastStatus->status;
        }