Resolución de problemas con el ORM Django - I

21 de Junio de 2021 · 6 min de lectura

django+keyword

Introducción

Durante el desarrollo de las aplicaciones, todos los programadores nos hemos tenido que enfrentar a problemas que debemos resolver para que nuestros proyectos finalicen con éxito. Un clásico es encontrar fallos de rendimiento cuando las aplicaciones se empiezan a probar con el conjunto de datos de producción, típicamente de un volumen mucho mayor que el conjunto de datos de pruebas utilizados durante el desarrollo.

Tomando de referencia el desarrollo de aplicaciones Django, proponemos una serie de posts donde vamos a repasar los errores más comunes relacionados con las consultas a la base de datos y cómo enfocar su resolución.

Los puntos que iremos analizando para evitar dolores de cabeza futuros son los siguientes:

  • Gran cantidad de datos sin filtrar.
  • Devolver más información de la necesaria de las tablas.
  • N + 1 queries - JOINS.
  • Reducir queries de escritura: detalles de implementación.
  • Índices: declararlos y asegurarse de que se utilizan.
  • Queries complejas.

En este post inicial, empezaremos con el problema más sencillo, siendo su resolución de nivel básico. A medida que avancemos en el listado, la complejidad de los problemas a resolver irá aumentando a la par que el nivel necesario para su total comprensión. Con esta serie de posts de posts se espera dotar de una guía de los pasos a seguir para resolver este tipo de situaciones.

Modelo de datos

Para esta serie de posts, tomaremos como referencia un posible modelo de datos para el proyecto del pasaporte COVID, que se espera mejore el control de los viajes dentro de la Unión Europea en los próximos meses.

Modelo datos

El modelo propuesto gira alrededor de la entidad Person, donde se almacenará la información de los usuarios de la aplicación. Esta entidad tiene diferentes relaciones con:

  • Country: país de residencia del usuario.
  • CovidRegister: 0-N en caso de que el usuario haya pasado la enfermedad.
  • DiagnosticProve: 0-N si el usuario se ha realizado alguna prueba diagnóstica para poder viajar.
  • Contacts: N-M contactos en caso de que el usuario haya dado positivo en una prueba diagnóstica.

Para este primer problema, definimos los siguientes modelos de Django:

class Country(models.Model):
   created_at = models.DateTimeField(verbose_name="Creation date", auto_now_add=True)
   updated_at = models.DateTimeField(verbose_name="Update date", auto_now_add=True)
   iso = models.CharField(max_length=3)
   name = models.CharField(max_length=100)

class Person(models.Model):
   created_at = models.DateTimeField(verbose_name="Creation date", auto_now_add=True)
   updated_at = models.DateTimeField(verbose_name="Update date", auto_now_add=True)
   name = models.CharField(max_length=100)
   surnames = models.CharField(max_length=150)
   passport = models.CharField(max_length=10, unique=True)
   phone = models.CharField(max_length=20)
   email = models.CharField(max_length=100, blank=True)
   country = models.ForeignKey(Country, on_delete=models.PROTECT)
   contacts = models.ManyToManyField("self", through="Contacts", blank=True)

class Contacts(models.Model):
   origin = models.ForeignKey(Person, verbose_name="main_person", on_delete=models.CASCADE)
   contact = models.ForeignKey(Person, verbose_name="contact_person", on_delete=models.CASCADE)
   enabled = models.BooleanField(default=True)

El problema

El problema que planteamos para este post es que queremos recuperar los datos de los ciudadanos que utilizan el pasaporte europeo. Dado que los usuarios potenciales son todos los ciudadanos europeos, estamos hablando de millones de registros.

Cuando se estén diseñando las consultas a la base de datos, no hay que perder de vista que todas las operaciones tienen un coste y que, entre otros factores, el volumen de datos se ha de tener en cuenta para evitar problemas de rendimiento.

Por tanto, hay que considerar que devolver este número de registros a la vez puede hacer que nuestra aplicación tarde demasiado en proveer resultados o que acabe fallando, ya que el volumen de datos potencial es muy grande y debe ser limitado para evitar la saturación de la aplicación y/o la caída del servicio. Por este motivo, hay que aplicar condiciones para filtrar los resultados de las consultas.

Como solucionarlo

Trabajar con un ORM a veces nos lleva a caer en este tipo de errores, dado que en local no solemos trabajar con un conjunto de datos adecuado. Normalmente tenemos un conjunto de datos pequeño que no representa la realidad del sistema una vez se ha desplegado en producción.

Es importante que al elaborar las consultas consideremos mentalmente el volumen de datos que podrán contener las tablas. Igualmente, disponer de un conjunto de datos con los límites reales del sistema nos ayudará a detectar antes los posibles errores o cuellos de botella.

El ORM de Django nos proporciona dos métodos para filtrar los querysets como son el método filter y el método exclude. También podemos acceder a un único registro utilizando el método get.

El método filter se utiliza para filtrar un conjunto de datos y devuelve un Queryset que cumple con las condiciones indicadas. El método permite que se le pasen varios parámetros para concatenar filtros.

El método exclude tiene un comportamiento muy similar al método filter. También devuelve un Queryset filtrado, pero en este caso excluyendo los registros que cumplen las condiciones indicadas por los parámetros.

Los parámetros utilizados para filtrar en los métodos filter y exclude tienen que seguir el formato de los Field Lookups.

Utilizando estos métodos, podríamos, por ejemplo, recuperar todos los usuarios de un país:

Person.objects.filter(country_iso="ENG")

o devolver todos los usuarios que no son de un país concreto:

Person.objects.exclude(country_iso="GER")

o recuperar todos los usuarios que se dieron de alta un día y proporcionaron su email.

Person.objects.filter(created_at=datetime(2021, 6, 15)).exclude(email="")

Estos ejemplos son ilustrativos y nos permiten vislumbrar las opciones que nos da el trabajar con el filtrado de datos. Está claro que si solo se aplicaran estos filtros la aplicación podría devolver aún miles o millones de resultados, por lo que es importante afinar bien las consultas. Se puede tener en cuenta que, el paginado de una aplicación web al final se convierte en un filtro en las consultas a la base de datos para controlar el número de registros devueltos en ellas.

El método get devuelve un objeto que cumpla las condiciones indicadas. Igual que los métodos filter y exclude, los parámetros tienen que seguir el formato de los "Field Lookups".

Al utilizar este método nos tenemos que asegurar que se utilizan lookups donde esté garantizada la unicidad, dado que después de realizar la consulta a la base de datos, Django trata el resultado para devolver un solo elemento.

Person.objects.get(passort="12547896P")

Si los filtros añadidos no encuentran ningún resultado, se lanzará un lanzará una excepción de tipo Model.DoesNotExist y si se encuentran múltiples resultados la excepción será Model.MultipleObjectsReturned.

Traducción a SQL

Aunque el ORM de Django se encarga de transformar las consultas a la base de datos de código python a código SQL, para comprender correctamente las queries realizadas es importante no perder de vista las queries SQL que se generan.

El método filter se representa en SQL mediante la cláusula WHERE:

Person.objects.filter(name="John")

SELECT * FROM person WHERE name = "John";

Al añadir múltiples parámetros al método, las condiciones se unen a través de un AND en la instrucción de SQL.

Person.objects.filter(name="John", surnames="Doe")

SELECT * FROM person WHERE name = "John" AND surnames = "Doe";

En el método exclude la diferencia radica en que, además de utilizar la cláusula WHERE en SQL añade un NOT al inicio de la condición. Y en caso de múltiples parámetros también se unen las condiciones con un AND.

Person.objects.exclude(name="John", surnames="Doe")

SELECT * FROM person WHERE NOT (name = "John" AND surnames = "Doe");

Si se quiere modificar este comportamiento estándar de los filtros se puede hacer utilizando Complex Lookups con objetos Q.

En el caso del método get, las consultas SQL se elaboran de la misma forma que los métodos filter y exclude.

Conclusión

En este primer post de la serie, hemos visto cómo enfocar la resolución del problema en el que la cantidad de datos con las que trabaja el proyecto es muy elevada hasta el punto que se producen problemas de rendimiento. La forma de atacar este problema es realizar consultas a la base de datos donde se puedan filtrar el número de registros a devolver, de manera que la aplicación sea capaz de manejarlos.

En el próximo post trataremos el problema de "Devolver más información de la necesaria de las tablas", donde veremos como podemos actuar cuando no necesitemos todos los campos de los registros.

Para este post y los siguientes, hemos tomado como referencia la sección QuerySet Api reference de la documentación oficial de Django.

Comparte este artículo
Etiquetas
Artículos relacionados