Django ORM

Bien que les ORM soient très utiles pour les développeurs, l’abstraction de l’accès à une base de données a un prix. Les développeurs qui décident d’approfondir la base de données découvriront que certaines choses auraient pu être simplifiées.

Cet article a été inspiré par notre expérience dans l’optimisation de l’utilisation de la base de données pour améliorer le temps de chargement des pages. Pour obtenir les meilleurs résultats possibles, nous avons dû faire de nombreuses erreurs et avons décidé de les partager. Cet article vous sera utile si vous prévoyez de développer ou de prendre en charge un projet écrit avec Django.

Utilisez model.fk_id au lieu de model.fk.id

À première vue, cette méthode semble absolument inutile. Que pouvez-vous savoir sur un objet par son simple identifiant ? En réalité, divers filtres, fonctions map et autres fonctions lambda ne peuvent s’en passer.

Par exemple, vous devez trouver tous les événements du même magasin que notre événement. La première chose qui vous vient à l’esprit est d’utiliser une requête simple :

Event.objects.filter(store_id=event.store.id)

Tout semble simple et sans nuage, mais si nous vérifions notre journal de requêtes vers la base de données, nous y verrons 2 requêtes. Dans la première requête, vous obtenez l’objet du magasin mais vous n’avez besoin d’aucune donnée sur le magasin, sauf son ID. Pour corriger cela, vous devez accéder directement à la clé étrangère. Dans ce cas, le code ressemblera à ceci :

Event.objects.filter(store_id=event.store_id)

Finalement, il n’y a plus de requêtes d’informations sur le magasin et votre requête ne fera qu’une seule requête à la base de données.

Obtenir des objets liés avec les méthodes select_related et prefetch_related

Imaginons que vous ayez besoin de récupérer tous les événements de la base de données, puis de les insérer dans le modèle avec les magasins et la liste des marques pour chaque événement.
Implémentons la vue en utilisant `ListView` :

class EventsListView(ListView):
  template_name = 'events/events_list.html'
  model = Event
  context_object_name = 'events'

Dans le modèle, vous affichez les informations sur l’événement, le magasin et les marques :

 <h2>{{ event.title }}</h2>
  Store: {{ event.store.title }}

  Brands:
  {% for brand in event.brands.all %}
  {{ brand }}{% if not forloop.last %}, {% endif %}
  {% endfor %}

Dans ce cas, vous recevez d’abord tous les événements avec une seule requête SQL, puis pour chacun de ces événements, le magasin et les marques sont demandés séparément. Vous devez forcer Django à demander toutes ces données avec un nombre réduit de requêtes.

Commençons par récupérer les magasins. Pour que le QuerySet obtienne à l’avance les données de certaines clés étrangères, il existe une méthode `select_related`. Mettez à jour le QuerySet dans votre vue pour utiliser cette méthode :


  queryset = Event.objects.select_related('store')

La méthode `select_related()` renvoie un QuerySet qui inclut automatiquement les données des objets liés dans la requête lorsque celle-ci est exécutée. L’accès aux objets liés via le modèle ne nécessitera pas de requêtes de base de données supplémentaires. Il est pratique à utiliser pour les relations « un à plusieurs » ou « un à un ».

La méthode `select_related` ne fonctionne qu’avec les clés étrangères du modèle actuel. Pour réduire le nombre de requêtes lors de la récupération d’un ensemble d’objets liés (comme les marques dans notre exemple), vous devez utiliser la méthode `prefetch_related`.

Encore une fois, mettez à jour l’attribut QuerySet de la classe `EventListView` :

queryset = Event.objects.select_related('store').prefetch_related('brands')

La méthode `prefetch_related()` renvoie un QuerySet qui, pour une approche, récupère les objets liés pour chacun des paramètres de recherche spécifiés.

Restriction des champs dans les sélections (defer, only)

Si vous regardez de plus près les requêtes SQL de l’exemple précédent, vous verrez que vous obtenez plus de champs que nécessaire. Vous obtenez tous les champs du magasin et des marques, y compris une description volumineuse de l’événement. Vous pouvez réduire considérablement la quantité de données transférées en utilisant la méthode `defer`, qui permet de retarder la réception de certains champs. Si le code appelle toujours un tel champ, Django effectuera une requête supplémentaire pour le récupérer. Ajoutez un appel à la méthode `defer` dans le queryset :

Event.objects.select_related('store').prefetch_related('brands').defer('description')

Désormais, le champ `description` inutile n’est plus demandé, ce qui réduit le temps de traitement de la requête.

Cependant, vous obtenez toujours de nombreux champs d’événement que vous n’utilisez pas. Il serait plus simple d’indiquer « seulement » les champs dont vous avez réellement besoin. Pour cela, il existe la méthode `only()`, avant laquelle les noms de champs seraient transférés et les champs restants seraient mis de côté :

Event.objects.select_related('store').prefetch_related('brands').only('title', 'store__title', 'brands__title')

Ainsi, `defer()` et `only()` effectuent la même tâche, limitant les champs dans les sélections. La seule différence est que :

  • `defer()` reporte la récupération des champs passés en arguments,
  • `only()` reporte la récupération de tous les champs sauf ceux transmis.

N’utilisez jamais len(queryset)

Si vous avez besoin d’obtenir le nombre d’objets d’un QuerySet, n’utilisez pas la méthode `len()`. La méthode `count()` est bien meilleure à cet effet.

Par exemple, si vous avez besoin d’obtenir le nombre total de tous les événements, la mauvaise approche serait :

len(Event.objects.all())

Dans ce cas, la requête pour sélectionner toutes les données de la table sera exécutée en premier, puis transformée en objet Python, et la longueur de cet objet sera trouvée à l’aide de la méthode `len()`. Bien sûr, ce n’est pas la meilleure option et il vous suffirait d’obtenir un seul chiffre de la base de données : le nombre d’événements.

Pour cela, utilisez la méthode `count()` :

Event.objects.count()

Avec `count()`, une requête plus simple sera effectuée dans la base de données et moins de ressources seront nécessaires pour les performances du code python.

if queryset est toujours une mauvaise idée

Si vous avez besoin de vérifier si le résultat d’un QuerySet existe, n’utilisez pas le QuerySet comme valeur booléenne ou `queryset.count() > 0`. Utilisez `queryset.exists()` à la place.

La méthode `exists()` renvoie `True` si le QuerySet contient des résultats, et `False` s’il n’en contient aucun. Elle tente d’exécuter la requête de la manière la plus simple et la plus rapide possible, mais exécute une requête presque identique à une requête QuerySet normale.

`Exists()` est utile pour les recherches relatives à la fois à l’appartenance d’un objet à un QuerySet et à l’existence d’objets dans un QuerySet, en particulier dans le contexte d’un QuerySet volumineux.

Index de base de données

Assurez-vous que les champs sur lesquels vous effectuez des recherches sont indexés. Utilisez le paramètre de champ `db_index = True` dans votre modèle.

L’index est stocké dans un arbre B, de sorte que l’objet sera trouvé en temps logarithmique – `O (log (n))`. Si vous aviez un milliard d’éléments, la recherche d’un objet prendrait autant de temps qu’une recherche linéaire de 30 éléments.

Si vous n’utilisez pas `db_index`, la recherche entraînera un balayage de table. Imaginez un vocabulaire dans lequel les mots sont complètement mélangés et la seule façon de trouver un mot est de tourner toutes les pages une par une. Vous pouvez imaginer le temps que cela prendrait si vous aviez un milliard d’éléments sans index et que vous exécutiez la requête ci-dessus.

Les index sont utiles non seulement lors du filtrage des données, mais aussi lors de leur tri. De plus, de nombreux SGBD permettent de créer des index sur plusieurs champs, ce qui est utile si vous filtrez des données par un ensemble de champs. Nous vous conseillons de consulter la documentation de votre SGBD pour plus de détails.

Opérations en masse

a. Insertion en masse avec la méthode bulk_create

Supposons que votre nouvelle application Django remplace l’ancienne et que vous deviez transférer les données des utilisateurs vers de nouveaux modèles. Vous avez exporté les données de l’ancienne application dans d’énormes fichiers JSON.

Le fichier contenant les utilisateurs a la structure suivante :

[{
      "email": "[email protected]",
      “first_name": “Example”,
      "last_name": “Example”
  }]

Créons une méthode pour importer les utilisateurs du fichier JSON dans la base de données :

def import_users(data_file_path):
  with open(data_file_path, 'r') as json_file:
  data = json.loads(json_file.read())
  for user_data in data:
  user = User(
      email=user_data['email'],
      first_name=user_data['first_name'],
      last_name=user_data['last_name'],
  )
  user.save()

Vérifiez le nombre de requêtes SQL exécutées lors du chargement de 200 utilisateurs et constatez que vous avez effectué 200 requêtes. Cela signifie que pour chaque utilisateur, une requête SQL INSERT distincte est exécutée. Si vous avez une grande quantité de données, cette approche peut être très lente. Utilisons la méthode `bulk_create` du gestionnaire de modèles `User` :

def import_users(data_file_path):
  with open(data_file_path, 'r') as json_file:
  data = json.loads(json_file.read())
  user_instances = []
  for user_data in data:
  user = User(
      email=user_data['email'],
      first_name=user_data[first_name],
      last_name=user_data[last_name],
  )
  user_instances.append(user)
  User.objects.bulk_create(user_instances)

Après avoir appelé la méthode, vous constaterez qu’une seule requête massive a été exécutée vers la base de données pour tous les utilisateurs.

Si vous avez vraiment besoin d’insérer une grande quantité de données, vous devrez peut-être les diviser en plusieurs requêtes. Pour cela, il existe un paramètre `batch_size` pour la méthode `bulk_create`, qui spécifie le nombre maximum d’objets qui seront insérés en une seule requête. Ainsi, si vous avez 200 objets, en spécifiant `bulk_size = 50`, vous obtiendrez 4 requêtes.

La méthode `batch_size` a un certain nombre de restrictions que vous pouvez consulter dans la documentation.

b. Insertion en masse dans une relation M2M

Dans ce cas, vous devez insérer dans la base de données des événements et des marques qui se trouvent dans un fichier JSON séparé avec la structure suivante :

[{
      "title": "Best event",
      "brands": [
      "Brand",
      "Famous brand",
      "Most famous brand"
  ],
      "slug": "...",
      "description": "..."
  }]

La méthode pour cela sera la suivante :

def import_events(data_file_path):
  with open(data_file_path, 'r') as json_file:
  data = json.loads(json_file.read())
  for event_data in data:
  event = Event(
      title=event_data['title'],
      slug=event_data['slug'],
      description=event_data['description'])
      event.save()
      for brand in event_data['brands']:
      brand_instance, _ = Brand.objects.get_or_create(title=brand)
      event.brands.add(brand_instance
  )

En appelant la méthode, nous obtenons une énorme quantité de requêtes SQL !

Le fait est que l’ajout de chaque marque à un article est effectué par une requête distincte. Cela peut être amélioré en passant une liste de marques à la méthode `event.brands.add` :

brands = []
  for brand in event_data['brands']:
  brand_instance, _ = Brand.objects.get_or_create(title=brand)
  brands.append(brand_instance)
  event.brands.add(*brands)

Cette option envoie presque 2 fois moins de requêtes, ce qui est un bon résultat, étant donné que vous n’avez modifié que quelques lignes de code.

c. Mise à jour en masse

Après le transfert des données, vous pourriez avoir l’idée que les anciens événements (avant 2018) doivent être rendus inactifs. Pour cela, un champ booléen ‘active’ a été ajouté au modèle Event et vous devez y définir sa valeur :

for event in Event.objects.filter(created_at__year__lt=2018):
  event.active = False
  event.save()

En exécutant ce code, vous obtenez un nombre de requêtes égal au nombre d’anciens événements.

De plus, pour chaque événement qui correspond à la condition, il y a une requête SQL distincte, et tous les champs de ces événements sont écrasés.

Cela peut entraîner l’écrasement des modifications apportées entre les requêtes `SELECT` et `UPDATE`, et en plus des problèmes de performance, vous obtenez également des conditions de concurrence.

Au lieu de cela, vous pouvez utiliser la méthode `update` qui est disponible sur les objets QuerySet :

 Event.objects.filter(created_at__year__lt=2018).update(active=False)

Ce code ne génère qu’une seule requête SQL. Impressionnant !

d. Suppression en masse

Ensuite, vous avez dû supprimer les événements inactifs.

for event in Event.objects.filter(active=False):
  event.delete()

Le code générera une requête 2N + 1 vers la base de données.

Premièrement, la connexion entre l’événement et la marque dans la table intermédiaire est supprimée, puis l’événement lui-même. Vous pouvez le faire en moins de requêtes en utilisant la méthode `delete` de la classe QuerySet :

Event.objects.filter(active=False).delete()

Ce code fait la même chose en seulement 3 requêtes vers la base de données.

Premièrement, une requête unique récupère la liste des identifiants de tous les événements inactifs, puis la deuxième requête supprime toutes les relations entre les événements et les marques en une seule fois, et la dernière requête supprime les événements.

Génial !

Utilisation de l’itérateur

Supposons que vous deviez ajouter la possibilité d’exporter des événements au format CSV. Créons une méthode simple pour cela, en tenant compte uniquement du travail avec la base de données :

def export_events(export_file_path, columns):
  with open(export_file_path, 'w') as export_file:
  events_writer = csv.writer(export_file, delimiter=';')
  events_writer.writerow(columns)
  for event in Event.objects.select_related('store').all():
  events_writer.writerow([getattr(event, column) for column in columns])

Pour tester cette commande, environ 100 000 événements ont été générés. Ensuite, en exécutant la commande via un profileur mémoire, il a été constaté que la commande utilise environ 200 Mo de mémoire car lors de l’exécution de la requête, le QuerySet récupère tous les événements de la base de données en une seule fois et les met en cache en mémoire afin que les requêtes ultérieures à ce QuerySet n’exécutent pas de requêtes supplémentaires. Vous pouvez réduire la quantité de mémoire utilisée en utilisant la méthode `iterator()` de la classe QuerySet, qui permet d’obtenir les résultats un par un en utilisant un curseur côté serveur, et désactive simultanément la mise en cache des résultats dans le QuerySet :

for event in Event.objects.select_related('store').iterator():

En exécutant l’exemple mis à jour dans le profileur, l’équipe utilise seulement ~ 40 Mo. De plus, quelle que soit la taille des données, lors de l’utilisation de l’itérateur, la commande utilise une quantité de mémoire constante.

Conclusions

  1. Si vous n’avez besoin que de la valeur d’une clé étrangère, utilisez la valeur de la clé étrangère qui se trouve déjà sur l’objet que vous avez obtenu, plutôt que d’obtenir l’objet lié complet et d’en prendre la clé primaire.
  2. Utilisez `select_related` pour les clés étrangères du modèle actuel. Pour obtenir des objets M2M et des objets de modèles qui font référence au modèle actuel, utilisez `prefetch_related`.
  3. Utilisez `defer()` et `only()` pour limiter le nombre de champs que vous récupérez de la base de données.
  4. Pour obtenir le nombre total d’objets en base de données correspondant au QuerySet, utilisez la méthode `count()`.
  5. Utilisez la méthode `exists()` si tout ce que vous voulez faire est de déterminer s’il existe au moins un résultat.
  6. Assurez-vous que les champs sur lesquels vous recherchez sont indexés.
  7. N’oubliez pas d’utiliser les méthodes en masse lors de la création/mise à jour/suppression de plusieurs objets à la fois.
  8. L’itérateur peut optimiser l’utilisation de la RAM lorsque vous travaillez avec d’énormes QuerySets.

Découvrez comment nous avons refactorisé et développé davantage une place de marché B2B utilisant Django

Veuillez saisir votre adresse courriel professionnelle n'est pas un courriel professionnel
Découvrez comment nous avons amélioré une solution open-source pour la résistance à la censure sur le web