Django es un framework bastante completo que brinda un entorno de desarrollo bastantante ágil facilitandonos una de sus mejores y quizás más grandes y complejos componentes el ORM, esta herramienta permite al desarrollador modelar la información que desea persistir utilizando clases, donde estas clases permite ejecutar métodos para consultar la información (Ver Figura 1) hasta aquí todo va bien, sin embargo este "ocultamiento de complejidad" de cómo sucede el trabajo difícil conlleva muchas veces a saltarse algunas optimizaciones y cuando se tienen grandes bloques de información empiezan afectar el rendimiento y empezamos a "tambalear" por no saber realmente qué sucede (la crisis del problema raro que yo llamo)
Este problema con el cual me he encontrado ocurre en la mayoría de ORM , lo he visto con muchos nombres pero de una fuente lo encontré con el nombre de The n+1 selects problem (Ver Figura 2) y en qué consiste? veamos un ejemplo
en código el ejemplo de la Figura 2 usando el ORM de django sería algo similar a lo siguiente
from .models import Vehicle
def get_vehicles():
vehicles = Vehicle.objects.all()
for vehicle in vehicles:
print(vehicle.owner.name)
Arriba vimos un ejemplo algo sencillo donde obtenemos todos los vehículos registrados y para cada registro obtengo el nombre del dueño del vehículo, para esto django por defecto no sabe de optimización y por cada vehículo realiza un select para traer el dueño utilizando como parámetro el id, de aquí viene el nombre debido a que por cada select realiza un select adicional, esto en principio no parece costoso pero que tal y si existen 900 vehículos? serían 901 querys a la base de datos, esto puede empeorar cuando hablamos de miles de datos, ya que se ha entendido un poco mejor el problema vayamos al código y veamos este problemas más aterrizado en el django admin
empezaré con un proyecto ya configurado, para poder debuggear e inspeccionar que está pasando dentro de django admin instalare la librería django debug toolbar
pip install django-debug-toolbar
además usare un proyecto que he creado previamente con los siguientes modelos y utilizando django-seed he cargado información faker (por lo tanto no tiene mucha lógica la información que se verá)
from django.db import models
class Person(models.Model):
name = models.CharField(max_length=255, null=True)
def __str__(self) -> str:
return self.name
class Vehicle(models.Model):
color = models.CharField(max_length=255, null=True)
owner = models.ForeignKey(Person, on_delete=models.CASCADE)
def __str__(self) -> str:
return f"id: {self.id} - owner: {self.owner}"
y la siguiente configuración en el admin.py
from django.contrib import admin
from .models import Person
from .models import Vehicle
admin.site.register(Person)
admin.site.register(Vehicle)
ahora entrare al admin, y entrare al modelo vehicle y veo la cantidad de consultas que está ejecutando y el tiempo que le está tomando
105 consultas a la base de datos, veamos a un poco que consultas esta haciendo
esta consultando por cada objeto vehicle un query a la base de datos para traer el objeto person, aquí tenemos 100 registros y estamos viendo el problema que se describió anteriormente, visto el problema podemos ver algunas soluciones:
- para el admin agregaremos un atributo llamado list_select_related en los atributos de django admin este utiliza el método del ORM llamado select_related() este método es un método de mejora rendimiento ya que permite cargar información relacionada es decir llaves foráneas en el mismo query traducido a SQL serían joins, en este caso para el admin solo se agregara el atributo al list select y list display para los demás atributos como se ve a continuación
from django.contrib import admin
from .models import Person
from .models import Vehicle
admin.site.register(Person)
@admin.register(Vehicle)
class VehicleAdmin(admin.ModelAdmin):
list_display = ('id','color')
list_select_related = ('owner',)
con este pequeño cambio ire al admin y veré el rendimiento de la información que se está cargando ahora en la Figura 5
adicionalmente podemos ver ahora el query que realiza es un join
- Otro problema que podemos solucionar es que el ORM de django por defecto cuando tiene llaves foráneas utiliza <selects>, el problema con estos selects es que carga toda la información al tiempo y si se tienen 10.000 registros, cargará los 10k registros (Ver Figura 7), aquí tenemos prácticamente tres opciones
- Utilizar el atributo raw_id_fields este atributo es bastante sencillo y reduce drásticamente la carga de una página en el django admin donde se tiene bastantes registros el código en el admin.py sería el siguiente
from django.contrib import admin
from .models import Person
from .models import Vehicle
admin.site.register(Person)
@admin.register(Vehicle)
class VehicleAdmin(admin.ModelAdmin):
list_display = ('id','color')
raw_id_fields = ('owner',)
y en el django admin se vería de la siguiente forma
en este caso el tiempo de consultas a la base de datos se redujo a 1.12 ms cuando utilizando selects demoró 9.26 ms una gran reducción teniendo en cuenta que en entornos con más información digamos 100k o 200k registros sería una gran disminución en la carga
2. Utilizar el atributo autocomplete_fields este atributo es otra opción para llaves foráneas, usa un campo de texto con autocompletado para buscar los atributos lo importante es que en primera instancia no trae todos los modelos relacionados, funcionalmente es muy similar al raw_id_fields pero para que este funcione al atributo relacionado se le debe agregar el atributo search_fields que será el atributo por el cual autocompletara, asi se veria el código en el admin.py
from django.contrib import admin
from .models import Person
from .models import Vehicle
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
search_fields = ('name',)
@admin.register(Vehicle)
class VehicleAdmin(admin.ModelAdmin):
list_display = ('id','color')
autocomplete_fields = ('owner',)
en el django admin se vería de la siguiente forma
se puede apreciar en el debug tool que el SQL queries time toma 1.32 ms un buen tiempo también comparado con los 9 ms mencionados al principio
3. Utilizar el atributo readonly_fields django por defecto las llaves foráneas las muestra en un select pero adicional permite que se puedan modificar desde este componente, este atributo como su nombre lo indica hace que esta llave funcione solo de lectura, es decir no se podrá modificar, el código sería el siguiente
from django.contrib import admin
from .models import Person
from .models import Vehicle
@admin.register(Person)
class PersonAdmin(admin.ModelAdmin):
search_fields = ('name',)
@admin.register(Vehicle)
class VehicleAdmin(admin.ModelAdmin):
list_display = ('id','color')
readonly_fields = ('owner',)
desde el django admin se vería de la siguiente forma
a nivel de rendimiento en las consultas también es 1.32 ms bastante superior a los 9 ms que se demoraba al principio
Conclusión
Como vimos en los anteriores ejemplos django por defecto y en general cualquier framework no buscan la forma más eficiente pues esto depende del contexto y la problemática a la que nos enfrentemos aquí vimos unos ejemplos en donde quizás es buena idea desde un principio tener estas configuraciones para a futuro no enfrentar estos casos ya que como mencione no son problemas que se diagnostican fácil si no que se debe recurrir a la trazabilidad de lo que está pasando, en estos ejemplos vimos como solucionar el problema del n+1 select, pero y qué pasa si existe el 2n+1? (Ver Figura 11)
Por lo anterior seguramente este artículo tendrá segunda parte.