Hoy en día trabajar con ORMs es algo muy común en cuanto a desarrollo backend se refiere.
Los ORMs nos permiten interactuar con nuestras bases de datos sin tener conocimientos de SQL, basta con dominar (o por lo menos conocer ) programación orientada a objetos y listo. Con un par de clases, métodos e instancias podremos fácilmente crear nuestros registros, consultarlos, editarlos y, por supuesto, eliminarlos.
Aquí un pequeño ejemplo.
User.objects.filter(active=True)
Sin bien es cierto el uso de ORMs nos permite acelerar el proceso de desarrollo y hacer que nuestro equipo sea mucho más productivo, también es importante destacar que si no hacemos un uso correcto de ellos podremos llegar a tener problemas de performance en nuestras aplicaciones. Consultas sencillas puede llagar a tornarse en cuellos de botella.
Uno de los problemas más comunes a los que podemos llegar a enfrenarnos cuando comenzamos a utilizar ORMs es el problema de N+1 Query, problema del cual me gustaría hablemos el día de hoy.
Para esta nueva entregar explicaremos qué es y cuando se presenta este problema, además, claro esta, estrategias para evitarlo.
En este artículo nos centraremos en trabajar con el ORM de Django, sin embargo el problema N+1 Query puede encontrarse presente en cualquier otro ORM o lenguaje de programación, así que debemos estar al pendiente de ello.
Bien, sin más introducción, comencemos con esta nueva entrega.
N+1 Query
Para poder comprender exactamente qué es el problema N+1 Query y en que tipo de escenarios se presenta trabajemos con un ejemplo. Para ello me apoyaré de los siguientes 2 modelos en Django.
Modelos sumamente sencillos, que para fines prácticos funcionara muy bien.
from django.db import models
class Author(models.Model):
name = models.CharField(max_length=100)
biography = models.TextField()
def __str__(self):
return self.name
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, related_name='books', on_delete=models.CASCADE)
publication_date = models.DateField()
def __str__(self):
return self.title
Para este ejemplo tenemos una relación uno a muchos, donde un author tiene muchos libros y un libro le pertenece a un autor.
Bajo esta premisa, ¿Qué pasa si deseamos imprimir en consola el nombre de todos los autores con sus correspondientes libros?
Bueno, en primera instancia el código pudiera quedar de la siguiente manera.
authors = Author.objects.all()
for author in authors:
books = author.books.all()
for book in books:
print(author.name, book.title)
En una primera consulta obtenemos todos los autores, iteramos, y en cada iteración obtenemos todos los libros de cada autor. Para cada libro se imprime en consola el nombre del autor y el titulo del libro.
A primera vista uno puede llegar a pensar que el código esta bien, porque funciona, cumple con su objetivo, sin embargo hay un pequeño gran problema, y este es la cantidad de peticiones que estamos haciendo a la base de datos. Y he aquí el problema de N+1 Query.
Verás, el problema N+1 Query es un problema de performance que ocurre al usar ORMs cuando, a partir de una primera consulta, iteramos sobre una colección, y en cada iteración, por cada objeto, realizamos una nueva consulta.
En nuestro ejemplo, para cada autor realizamos un nuevo select para obtener sus libros. Comenzamos con una consulta (La de obtención de autores), a partir de esta perdemos el control de cuantas nuevas consultas se realizará la base de datos, ya que todo depende de cuantos objetos se estén iterando. Pueden ser 5, 10, 20 o N.
Y he aquí el problema, al no conocer el número exacto de nuevas peticiones la base de datos se crea un problema de performance, ya que estamos obteniendo información de forma indiscriminada, un nuevo select tras otro.
Cuando se trabaja con base de datos lo ideal siempre será realizar la minima cantidad de consultas posibles obteniendo únicamente la información que se desea.
Tip: En Django, si deseas conocer cuantas peticiones se han realizado, puedes hacer uso del objeto connection. Ejemplo
from django.db import connection
authors = Author.objects.all()
for author in authors:
books = author.books.all()
for book in books:
print(author.name, book.title)
print(len(connection.queries))
Solución
Una vez hemos comprendido el problema, ahora toca el turno de solventarlo. En Django podemos usar 2 método para evitar el problema N+1 Query, me refiero a los métodos select_related y prefetch_related.
Para nuestro ejemplo nos enfocaremos en prefetch_related, en una segunda entrega hablaremos de select_relateds. El código puede quedar de la siguiente manera.
authors = Author.objects.prefetch_related('books').all()
for author in authors:
for book in author.books.all():
print(author.name, book.title)
Notemos como en la primera consulta, usando prefetch_related, le indicamos a Django que deseamos obtener, no solo los autores, si no también los libros relacionados a los autores. Con select_related estamos limitando la cantidad de peticiones que hacemos a la base de datos, pasando de N a únicamente 2. No importa cuantos autores o libros existan, usando prefetch_related cargamos todos los libros (en una sola consulta) a sus correspondientes autores. Siempre haciendo solo 2 consultas la base de datos, lo cual sin duda mejoraría enormemente el performance de nuestra aplicación.
El ejemplo fue sumamente sencillo, ahora, qué pasa si deseamos realizar algún tipo de condición sobre los libros publicados de nuestros autores, bueno, en esos casos nos apoyaríamos de la clase Prefetch.
Por ejemplo, listemos todo libros, de todos los autores, que este publicados. Es decir, donde su campo publication_date no sea null.
Nuestra consulta puede quedar de la siguiente forma.
from django.db.models import Prefetch
prefetch_books = Prefetch(
'books',
queryset=Book.objects.filter(publication_date__isnull=False)
)
authors = Author.objects.prefetch_related(prefetch_books).all()
for author in authors:
for book in author.books.all():
print(author.name, book.title)
Si bien al consulta se complica (típico de Django) también es cierto que usando prefetch_related mejoramos significativamente el performace de nuestra aplicación, así que te lo recomiendo ampliamente.
prefetch_related lo usaremos siempre y cuando nuestra relación entre modelos sea de muchos a muchos, o cómo este caso, uno a muchos. Para relaciones uno a uno haremos uso de select_related, del cual ya estaremos hablando en una siguiente entrega.