Tres errores más comunes cometidos por desarrolladores al utilizar Eloquent
Muchas veces como programadores desviamos nuestra atención en algunos aspectos como el performance de nuestra aplicación. Olvidamos que en últimas un sistema se desarrolla para un usuario, por eso es importante que nuestra aplicación no solamente cumpla con sus requisitos funcionales sino que además lo haga de la mejor manera. Los requisitos no funcionales como los tiempos de respuesta, performance, entre otros se suelen obviar y pueden resultar ser una carga bastante pesada cuando nuestra aplicación está dirigida a un público grande de usuarios. Es por esto que el día de hoy, nos centraremos en aquellos errores más comunes de performance cometidos por desarrolladores al utilizar Eloquent.
1. El problema N+1
Este es uno de los problemas más conocidos y más comunes al trabajar con Eloquent. Supongamos que enviaste a la vista index la lista de posts con la siguiente sentencia.
$posts = \App\Post::all();
Ahora, si en la vista tienes algo como lo siguiente entonces muy probablemente estes experimentando el problema N+1.
@foreach ($posts as $post)
<tr>
<td>{{ '\{\{ $post->post_id \}\}' }}</td>
<td>{{ '\{\{ $post->title \}\}' }}</td>
<td>
<span class="badge badge-primary">{{ '\{\{ $post->category->name \}\}' }}</span>
</td>
<td>{{ ' \{\{$post->published_at \}\}' }}</td>
</tr>
@endforeach
Esto sucede porque existe una relación que es ejecutada cada vez por registro. Esto indica que la data no está disponible hasta que se accede a la propiedad de la relación, a esto se le conoce como lazy loading. En este caso, en cada iteración Eloquent realiza una consulta a la tabla categories para traer el nombre de la categoría. La solución a este problema consiste en cargar la relación antes de enviar los datos a la vista, algo conocido como eager loading.
$posts = \App\Post::with('categories')->all();
2. Desconocimiento del funcionamiento del QueryBuilder
Conteo de registros
Este punto tiene que ver con la diferencia entre contar objetos de una colección y contar registros en la base de datos. Observa el siguiente ejemplo:
$post->comments->count();
Eloquent realizará la consulta de todos los comentarios de un post en específico, posteriormente realizará el conteo de objetos obtenidos.
$post->comments()->count();
En este caso Eloquent realizará el conteo de registros directamente en la base de datos. Esto es debido a que la instrucción $post->comments()
devuelve un QueryBuilder, con lo cuál no estamos realizando aún ninguna operación al motor de bases de datos.
Obtención del primer elemento
Si leíste el punto anterior verás que este punto tiene el mismo enfoque. Al traer el primer elemento puedes pensar en ejecutar una sentencia como la siguiente:
$post->comments->first();
Sin embargo, Eloquent realizará la consulta de todos los comentarios pertenecientes a ese post en particular. Posteriormente devolverá el primer objeto de la colección.
$post->comments()->first();
En este segundo caso Eloquent enviará al motor de base de datos la consulta para traer solamente el primer elemento.
Sentencias con Filtros
Al realizar filtros también puede pasar desapercibido si lo realizas sobre una colección o sobre el QueryBuilder. La siguiente sentencia traerá todos los post de un usuario específico. Posteriormente filtrará de la colección el que sea igual al título seleccionado.
$user->posts->where('title', 'Eloquent at Glance')->get();
Al utilizar el QueryBuilder se consultará al motor de base de datos únicamente el registor deseado.
$user->posts()->where('title', 'Eloquent at Glance')->get();
Sentencias con Columnas específicas
Siguiendo este mismo hilo, al realizar filtros sobre columnas específicas se puede hacer sobre la colección o sobre el QueryBuilder. La siguiente sentencia realiza la consulta de todos los comentarios de un post en específico. Posteriormente filtra los campos indicados de la colección.
$posts->comments->pluck('user_id', 'text');
La siguiente sentencia consulta a la base de datos únicamente los campos seleccionados.
$posts->comments()->pluck('user_id', 'text');
2. Uso indiscriminado de filtros
Observemos la siguiente consulta que toma los post realizados por el usuario con user_id=3.
$user_id = 3;
\App\Post::all()->filter(function($post) use ($user_id){
return $post->user_id == $user_id;
});
El problema con esta consulta, es que si tenemos mil posts, eloquent consultará los mil posts en la base de datos y buscará internamente si cada uno de ellos tiene el campo user_id en 3 para retornar los resultados. Mi recomendación es utilizar filtros sobre resultados ya traídos de la base de datos. Si no es necesario traer todos los registros a memoria la solución a la anterior consulta sería la siguiente.
\App\Post::where('user_id', $user_id)->get();
3. Uso indiscriminado del comodín (*)
La idea en general aquí es no traer datos de la base de datos que no vamos a necesitar y evitar el uso del comodín ya que si no enviamos las columnas que queremos obtener, el motor se verá obligado a consultar las columnas disponibles en la tabla.
Resultados con Paginación
Cuando estamos mostrando la lista posts por lo general utilizamos la siguiente instrucción.
\App\Post::orderBy('created_at', 'desc')->paginate(5);
En principio no causa tanto malestar al motor debido a que estamos paginando y consultando los últimos cinco posts creados. Pero de cara al usuario realmente le estás mostrando todos los campos que obtuviste en la consulta ?. Si tu respuesta es NO, puedes optimizar tu consulta de la siguiente manera basándote en los campos que deseas mostrar.
\App\Post::orderBy('created_at', 'desc')
->paginate(5, ['title', 'description', 'created_at']);
Resultados sin paginación
Este mismo ejemplo aplica de distintas formas, no solamente para cuando realizas paginación. Observa que podemos seleccionar las columnas obteniendo resultados individuales o conjuntos de resultados.
\App\Post::where('user_id', $user_id)
->get(['title', 'description', 'created_at']);
\App\Post::where('user_id', $user_id)
->first(['title', 'description', 'created_at']);
\App\Post::find(3, ['title', 'description', 'created_at']);
\App\Post::all(['title', 'description', 'created_at']);
Resultados con Relaciones
Supongamos que queremos obtener el usuario con id=3 y también los posts de su autoría. Como ya hemos visto anteriormente una forma óptima de hacerlo sería la siguiente:
\App\User::with('posts')->find(3, ['name', 'email', 'created_at']);
Sin embargo en la relación traerá todos los campos de la tabla posts. Para optimizar esto podemos aplicar un select al QueryBuilder
de la relación de la siguiente manera.
\App\User::with(['posts' => function($query){
$query->select('user_id','title', 'description');
}])->find(3, ['id', 'name', 'email', 'created_at']);
Hay algo muy importante aquí y es que si no seleccionas la llave local y la llave foránea respectiva de cada tabla entonces la relación no funcionará.
Espero que que este post sea de tu ayuda y que obtengas el mejor performance de tus aplicaciones con Eloquent. Hasta pronto!.
4. Uso de Chunk
Cuando se realizan operaciones sobre grandes cantidades de datos, miles o millones de registros en la base de datos lo ideal no es consultar todos los datos e ir iterando sobre cada uno de ellos. Para solucionar esto, laravel ha desarrollado el método chunk. Supongamos que queremos realizar alguna operación sobre cada uno de los registros de posts en la base de datos. Incialmente podríamos pensar en algo como esto:
$posts = \App\Post::all();
foreach($posts as $post) {
$title = $post->title;
// more tasks
}
Sin embargo, mediante chunk podemos realizar esta operación por segmentos. El ejemplo anterior puede ser reescrito para tomar segumentos de 80 registros cada vez.
\App\Post::chunk(80, function($posts) {
foreach($posts as $post) {
$title = $post->title;
// more tasks
}
});
Al usar chunk evidentemente habrá más consultas a la base de datos debido a que laravel tomará segmentos en la tabla hasta agotar sus registros. Es por esto, que este método está recomendado cuando la memoria usada es más importante, dado que si traes una cantidad considerable de registros y comienzas a procesarlos lo más seguro es que te encuentres con el error "Allowed memory size of bytes exhausted" de PHP. Ten cuenta también que esto lo puedes combinar con lo que hemos visto anteriormente, por ejemplo, cargando una relación.
\App\Post::with('comments')->chunk(80, function($posts) {
foreach($posts as $post) {
$comments = $posts->comments;
// more tasks
}
});