Aunque los ORM son muy útiles para los desarrolladores, abstraer el acceso a una base de datos tiene un precio. Los desarrolladores que decidan profundizar en la base de datos descubrirán que algunas cosas podrían haberse hecho más fáciles.

Este artículo se inspira en nuestra experiencia de optimización del uso de la base de datos para mejorar el tiempo de carga de las páginas. Para alcanzar el mejor resultado posible, tuvimos que cometer muchos errores y decidimos compartirlos. Este artículo le será útil si está planeando desarrollar o apoyar un proyecto escrito con Django.

Utilice model.fk_id en lugar de model.fk.id

A primera vista, parece que este método es absolutamente inútil. ¿Qué puede averiguar sobre el objeto sólo por su identificador? De hecho, todo tipo de filtro o mapa y otras funciones lambda no pueden prescindir de él.

Por ejemplo, es necesario encontrar todos los eventos del mismo almacén que nuestro evento. Lo primero que se nos ocurre es utilizar una simple petición:

Todo parece sencillo y sin complicaciones, pero si comprobamos nuestras peticiones al registro de la base de datos, veremos allí dos peticiones. En la primera petición se obtiene el objeto tienda, pero no se necesita ningún dato sobre la tienda, salvo su ID. Para solucionar esto, es necesario acceder directamente a la clave externa. En este caso, el código tendrá el siguiente aspecto:

Finalmente, ya no habrá más peticiones de información sobre la tienda y su consulta sólo hará una petición a la base de datos.

Obtener objetos relacionados con los métodos select_related y prefetch_related

Imaginemos que necesita obtener todos los eventos de la base de datos y luego insertarlos en la plantilla junto con las tiendas y la lista de marcas de cada evento.
Vamos a implementar la vista utilizando ListView:

En la plantilla se muestra la información sobre el evento, la tienda y las marcas:

En este caso, primero se reciben todos los eventos con una única consulta SQL y luego, para cada uno de estos eventos, la tienda y las marcas se solicitan por separado. Tiene que forzar a Django a solicitar todos estos datos con un número menor de peticiones.

Empecemos por obtener las tiendas. Para que el QuerySet obtenga los datos de ciertas claves foráneas por adelantado, existe un método select_related. Actualice el QuerySet en su vista para utilizar este método:

El método select_related () devuelve un QuerySet que incluye automáticamente los datos de los objetos relacionados en la consulta cuando ésta se ejecuta. El acceso a los objetos relacionados a través del modelo no requerirá consultas adicionales a la base de datos. Es conveniente utilizarlo para las relaciones “uno a muchos” o “uno a uno”.

El método select_related sólo funciona con claves externas en el modelo actual. Para reducir el número de solicitudes cuando se recibe un conjunto de objetos relacionados (como las marcas en nuestro ejemplo), es necesario utilizar el método prefetch_related.

De nuevo, actualice el atributo QuerySet de la clase EventListView:

El método prefetch_related () devuelve un QuerySet, que para una aproximación obtiene objetos relacionados para cada uno de los parámetros de búsqueda especificados.

Restricción de campos en las selecciones (sólo en diferido)

Si observa detenidamente las consultas SQL del ejemplo anterior, verá que obtiene más campos de los que necesita. Obtiene todos los campos de la tienda y de las marcas, incluida una enorme descripción del evento. Puede reducir significativamente la cantidad de datos transferidos utilizando el método defer, que le permite retrasar la recepción de ciertos campos. Si el código sigue llamando a dicho campo, Django realizará una petición adicional para recibirlo. Añada una llamada al método defer en el queryset:

Ahora no se solicita el campo ‘descripción’ innecesario, lo que reduce el tiempo de procesamiento de la solicitud.

Aun así, se obtienen muchos campos de eventos que no se utilizan. Sería más sencillo indicar sólo' los campos que realmente necesita. Para ello, existe el método only(), ante el cual se trasladarían los nombres de los campos y se dejarían de lado los restantes:

Estos, defer() y only() realizan la misma tarea, limitando los campos en las muestras. La única diferencia es que

  • defer() pospone la obtención de los campos pasados como argumentos,
  • only() pospone la recepción de todos los campos excepto los transmitidos.

Nunca utilice len(queryset)

Si necesita obtener la cantidad de objetos QuerySet, no utilice el método len(). El método count() es mucho mejor para este propósito.

Por ejemplo, si necesita obtener el número total de todos los eventos, la forma incorrecta sería

En este caso, primero se realizará la consulta para seleccionar todos los datos de la tabla, luego se transformará en un objeto Python y se encontrará la longitud de este objeto con la ayuda del método len(). Por supuesto, esta no es la mejor opción y le bastaría con obtener un número de la base de datos: el número de eventos.

Para ello, utilice el método count():

Con count(), se realizaría una consulta más sencilla en la base de datos y se necesitarían menos recursos para el rendimiento del código python.

si queryset es siempre una mala idea

Si necesita comprobar si el resultado del QuerySet existe, no utilice QuerySet como valor booleano o queryset.count() > 0. Utilice en su lugar queryset.exists().

El método exists() devuelve True si el QuerySet contiene algún resultado, y False en caso contrario. Intenta realizar la consulta de la forma más sencilla y rápida posible, pero ejecuta casi la misma consulta que un QuerySet normal.

Exists() es útil para las búsquedas relacionadas tanto con la pertenencia de los objetos a un QuerySet como con la existencia de cualquier objeto en un QuerySet, especialmente en el contexto de un QuerySet grande.

Índices de la base de datos

Asegúrese de que los campos por los que busca están indexados. Utilice el parámetro de campo db_index = True en su modelo.

El índice se almacena en el árbol B, por lo que el objeto se encontrará en tiempo logarítmico - O (log (n)). Si tuviera mil millones de ítems, la búsqueda del objeto llevaría tanto tiempo como una búsqueda lineal de 30 ítems.

Si no utiliza db_index, la búsqueda conducirá a la exploración de la tabla. Imagine un vocabulario en el que las palabras están completamente mezcladas y la única forma de encontrar la palabra es pasar todas las páginas una por una. Puede imaginar cuánto tiempo le llevaría, si tuviera mil millones de elementos sin índices y ejecutara la consulta anterior.

Los índices son útiles no sólo para filtrar los datos, sino también para ordenarlos. Además, muchos SGBD permiten hacer índices sobre varios campos, lo que resulta útil si está filtrando datos por un conjunto de campos. Le aconsejamos que conozca la documentación de su SGBD para obtener más detalles.

Operaciones a granel

a. Inserción masiva con el método bulk_create

Supongamos que su nueva aplicación Django sustituye a la antigua y necesita transferir los datos de los usuarios a los nuevos modelos. Usted exportó los datos de la antigua aplicación a enormes archivos JSON.

El archivo con los usuarios tiene la siguiente estructura:

Hagamos un método para importar usuarios desde el archivo JSON a la base de datos:

Compruebe cuántas consultas SQL se ejecutan cuando se cargan 200 usuarios y vea que ha completado 200 consultas. Significa que para cada usuario se ejecuta una consulta SQL INSERT distinta. Si tiene una gran cantidad de datos, este enfoque puede ser muy lento. Utilicemos el método bulk_create del gestor del modelo de usuario:

Después de llamar al método, verá que se ha ejecutado una enorme consulta a la base de datos para todos los usuarios.

Si realmente necesita insertar una gran cantidad de datos, puede que tenga que dividirla en varias consultas. Para ello, existe un parámetro batch_size para el método bulk_create, que especifica el número máximo de objetos que se insertarán en una sola consulta. Así, si tiene 200 objetos, especificando bulk_size = 50 obtendrá 4 peticiones.

El método bulk_size tiene una serie de restricciones que puede ver en la documentación.

b. Inserción masiva a la relación M2M

En este caso, necesita insertar en la base de datos los eventos y las marcas que se encuentran en un archivo JSON separado con la siguiente estructura:

El método para esto será el siguiente:

¡Al llamar al método obtenemos una gran cantidad de consultas SQL!

El hecho es que la adición de cada marca a un artículo se realiza mediante una petición independiente. Se puede mejorar pasando una lista de marcas al método event.brands.add:

Esta opción envía casi 2 veces menos peticiones, lo que es un buen resultado, teniendo en cuenta que sólo ha cambiado un par de líneas de código.

c. Actualización masiva

Después de la transferencia de datos, es posible que tenga la idea de que los eventos antiguos (anteriores a 2018) se vuelvan inactivos. Para ello, se ha añadido un campo booleano "activo" al modelo de eventos y es necesario poner su valor:

Al ejecutar este código, se obtiene un número de solicitudes igual al número de eventos antiguos.

Además, para cada evento que se ajuste a la condición, hay una consulta SQL independiente, y todos los campos de estos eventos se sobrescriben.

Esto puede llevar a sobreescribir los cambios realizados entre las consultas SELECT y UPDATE, y además de los problemas de rendimiento, también se obtiene una condición de carrera.

En su lugar, puede utilizar el método de actualización que está disponible en los objetos QuerySet:

Este código genera sólo una consulta SQL. ¡Impresionante!

d. Eliminación masiva

A continuación, es necesario eliminar los eventos inactivos.

El código generará una consulta 2N + 1 a la base de datos.

Primero se borra la conexión entre el evento y la marca en la tabla intermedia, y luego el propio evento. Puede hacer esto en menos consultas utilizando el método delete de la clase QuerySet:

Este código hace lo mismo en sólo 3 consultas a la base de datos.

Primero, una única petición obtiene una lista de identificadores de todos los eventos inactivos, luego la segunda petición borra todas las relaciones entre eventos y marcas a la vez, y la última petición borra los eventos.

Muy bien

Utilización del iterador

Supongamos que necesita añadir la posibilidad de exportar eventos al formato CSV. Hagamos un método simple para esto, teniendo en cuenta sólo el trabajo con la base de datos:

Para probar este comando, se generaron unos 100.000 eventos. Luego, al ejecutar el comando a través del perfilador de memoria, se comprobó que el comando utiliza unos 200Mb de memoria porque al ejecutar la consulta el QuerySet obtiene todos los eventos de la base de datos a la vez y los almacena en caché en la memoria para que las consultas posteriores a este QuerySet no ejecuten consultas adicionales. Puede reducir la cantidad de memoria utilizada utilizando el método iterator() de la clase QuerySet, que permite obtener los resultados uno a uno utilizando el cursor del lado del servidor, y simultáneamente desactiva el almacenamiento en caché de los resultados en el QuerySet:

Ejecutando el ejemplo actualizado en el perfilador, el equipo utiliza sólo ~ 40Mb. además, para cualquier tamaño de datos, al utilizar el iterador, el comando utiliza una cantidad constante de memoria.

Conclusiones

  1. Si sólo necesita un valor de clave foránea, utilice el valor de la clave foránea que ya está en el objeto que tiene en lugar de obtener todo el objeto relacionado y tomar su clave primaria.
  2. Utilice select_related para las claves foráneas en el modelo actual. Para obtener objetos M2M y objetos de modelos que hacen referencia al actual, utilice prefetch_related.
  3. Utilice defer() y only() para limitar el número de campos que obtiene de la base de datos.
  4. Para obtener el número total de objetos de la base de datos que coinciden con el QuerySet, utilice el método count().
  5. Utilice el método exists()` si lo único que desea es determinar si existe al menos un resultado.
  6. Asegúrese de que los campos por los que busca están indexados.
  7. No se olvide de utilizar los métodos bulk al crear/actualizar/borrar varios objetos a la vez.
  8. El iterador puede optimizar un tamaño de uso de RAM cuando se trabaja con QuerySets enormes.

Vea cómo refactorizamos y desarrollamos un mercado B2B usando Django

Este campo es obligatorio.

See how we enhanced an open-source solution for censorship resistance on the web