Eager loading y lazy loading

El eager loading y lazy loading son dos técnicas que tenemos disponibles para recuperar datos relacionados al trabajar con modelos Eloquent. Y hay que conocerlas al detalle para emplear técnicas que más se ajuste a nuestras necesidades; no hay una técnica mejor que la otra, ambas se emplean para optimizar el rendimiento de la aplicación al reducir la cantidad de consultas a la base de datos necesarias para obtener datos relacionados. Vamos a conocerlas en detalle.

Lazy Loading (Carga perezosa)

También conocido como “carga bajo demanda” o “carga perezosa”; este es el comportamiento predeterminado en Eloquent por defecto que se emplea al emplear las relaciones foráneas.

El funcionamiento de esta técnica consiste en que al momento de obtener una colección de datos relacionados (entiéndase un listado de registros provenientes de una relación, por ejemplo, el listado de publicaciones dado la categoría) Eloquent solo recupera los datos de la base de datos en el momento en que los solicitas. Es decir, que para cada acceso a un registro relacionado, se ejecuta una consulta separada a la base de datos. Esto usualmente trae consigo el famoso problema de tipo N+1, en donde se ejecutan N+1 consultas a la base de datos en una misma tarea.

Ya en nuestro módulo de dashboard tenemos este problema, por una parte, tenemos las consulta principal:

app\Http\Controllers\Dashboard\PostController.php

public function index()
{
    if(!auth()->user()->hasPermissionTo('editor.post.index')){
        return abort(403);
    }

    $posts = Post::paginate(10);
    return view('dashboard/post/index', compact('posts'));
}

Y desde la vista, referenciamos la categoría, por defecto, Laravel emplea la técnica de lazy loading para obtener los datos relacionados por lo tanto, cada vez que se realice una consulta, se va a realizar una consulta adicional, desde el listado, estamos obteniendo la categoría y con esto una consulta adicional por cada post en la página:

resources\views\dashboard\post\index.blade.php

@foreach ($posts as $p)
     ****
     <td>
        {{ $p->category->title }}
***

Lo cual, es el problema del N+1, en donde N en nuestro ejemplo es el tamaño de la página, unos 10 que representan las categorías obtenidas desde el post y el 1 es la consulta principal para obtener los datos paginados.

Por suerte, Laravel en versiones modernas permite detectar este problema muy fácilmente mediante la siguiente configuración:

app\Providers\AppServiceProvider.php

<?php

namespace App\Providers;

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

Con el AppServiceProvider podemos cargar clases esenciales de nuestro proyecto para integrarlos en la aplicación.

Así que, si ahora intentamos acceder a la página anterior, veremos un error por pantalla como el siguiente:

Attempted to lazy load [category] on model [App\Models\Post] but lazy loading is disabled.

El sistema de detección del problema N+1 en Laravel no es perfecto, ya que si solamente tuviéramos una paginación de 1 nivel, no ocurriría la excepción anterior.

Con truco adicional, podemos ver las consultas realizadas para resolver una petición del cliente:

routes/web.php

DB::listen(function ($query){
    echo $query->sql;
  //  Log::info($query->sql, ['bindings' => $query->bindings, 'time' => $query->time]);
});

También podemos emplear la extensión de debugbar, pero esto lo veremos en el siguiente capítulo, si habilitas el script anterior, verás que ocurren más de 15 consultas, una de ellas para la sesión del usuario autenticado, permisos y roles la de los post y 10 para las categorías si tienes una paginación de 10 niveles, Esto es estupendo para detectar el problema pero con el inconveniente de que nuestra página de detalle para la categoría ya no funciona, para corregirla, vamos a introducir el siguiente tema.

Vamos a crear otro ejemplo, vamos a emplear la relación de posts que tenemos en la categoría:

app\Models\Category.php

class Category extends Model
{
   ***
    function posts() {
        return $this->hasMany(Post::class);
    }
}

Si desde la vista obtenemos la relación:

resources\views\dashboard\category\index.blade.php

@foreach ($categories as $c)
    ***
        <td>
            {{ $c->posts }}

Veremos la excepción anterior, así que, obtenemos los posts junto con las categorías:

app\Http\Controllers\Dashboard\CategoryController.php

$categories = Category::with('posts')->paginate(10);

El problema de este esquema es que va a traer todo los posts asociados a una categoría, y un post es una relación algo pesada, ya que contiene la columna de content con todo el contenido HTML, y si esto sumamos las 10 categorías en el listado el problema se multiplica.

Existen varias formas en las cuales podemos especificar las columnas que queremos obtener de la relación secundaria:

$posts = Post::with('category:id,title')->paginate(10);
$posts = Post::with(['category' => function($query){
   // $query->where('id',1);
   $query->select('id','title');
}])->paginate(10);

Aunque estos esquemas no son soportados por la relación de posts de las categorías:

$categories = Category::with('posts:id,title')->paginate(10);
$categories = Category::with(['posts' => function($query){
    // $query->where('id',1);
    $query->select('id','title');
}])->paginate(10);

De momento, no podemos solucionar el problema y con esto la excepción ya que para ello necesitamos o cambiar la petición para que emplee los JOINs, o presentar el siguiente tema que es el de Eager Loading que veremos a continuación.

Eager Loading (Carga ansiosa)

Con este proceso podemos realizar todas las operaciones en una sola consulta, si nos vamos al ejemplo anterior, que tenemos N+1 consultas a la base de datos, solamente realizaremos una sola consulta y con esto, mejorar el rendimiento de la aplicación, para ello, debemos de especificar la relación al momento de realizar la consulta principal:

app\Http\Controllers\Dashboard\PostController.php

$posts = Post::with(['category'])->paginate(10);

Si vamos a nuestra página de detalle de categorías, veremos que funciona correctamente.

Esta función tiene muchas implementaciones, por ejemplo, si tenemos una relación anidada:

class Tutorial extends Model
{
    ***
}

También podemos definir en el modelo el uso de esta técnica por defecto:

class Post extends Model
{
    protected $with = ['category'];
}

El método de with() lo podemos extender en relaciones más complejas, como la siguiente que tenemos una relación de dos niveles:

class Tutorial extends Model
{
  ***
    public function sections()
    {
        return $this->hasMany(Tutorial::class);
    }
}
class TutorialSection extends Model
{
  ***
    public function tutorial()
    {
        return $this->belongsTo(Tutorial::class);
    }
    public function classes()
    {
        return $this->hasMany(Tutorial::class);
    }
}
class TutorialSectionClass extends Model
{
    ***
    public function tutorialSection()
    {
        return $this->belongsTo(TutorialSection::class);
    }
}

Podemos hacer consultas de la siguiente forma, indicando más de una relación a obtener:

$posts = Post::with(['categories','tags'])->get();

O si quieres colocar alguna condición sobre algunas de las relaciones, puedes implementar un callback de la siguiente forma:

Tutorial::with('sections')->with(['sections.classes' => function ($query) {
     $query->where('posted', 'yes');
     $query->orderBy('orden');
    }])->where('posted', 'yes')->find($tutorial->id);
}

Conclusión

Es importante mencionar que no hay una técnica mejor que la otra, ya que, todo depende de lo que quieras realizar pero, lo podemos simplificar de la siguiente manera, si tenemos una colección de registros relacionados mediante una FK como en el ejemplo anterior y no vas a emplear la relación foránea, la técnica que deberías de emplear sería la de carga perezosa, pero, si vas a emplear la colección con los registros relacionados, debes de emplear la carga ansiosa.

 

Finalmente, por cada relación especificada en el with() solamente suma una consulta adicional, también recuerda especificar la columnas en la medida de lo posible al momento de obtener las relaciones.

- Andrés Cruz

In english

Este material forma parte de mi curso y libro completo; puedes adquirirlos desde el apartado de libros y/o cursos Curso y libro Laravel 11 con Tailwind Vue 3, introducción a Jetstream Livewire e Inerta desde cero - 2024.

Andrés Cruz

Desarrollo con Laravel, Django, Flask, CodeIgniter, HTML5, CSS3, MySQL, JavaScript, Vue, Android, iOS, Flutter

Andrés Cruz En Udemy

Acepto recibir anuncios de interes sobre este Blog.