Django, performance et SQL

De mon expérience de nombreux cas de lenteurs de page (ou d’API) sont liés aux appels à la base de données. Ou plus simplement à un mauvais usage de la base de données. En général les frameworks web sont limités par les entrées et sortie. L’une des principales sources d’entrée sortie dans une application en avec Django est la base de données (avec le réseau, le disque et d’autres applications comme les caches, les files, etc.).

Pré-chargement des données

Imaginons que votre application ait des utilisateurs (je l’espère) et que ces utilisateurs aient une adresse. Nous pourrions imaginer les modèles suivants :

from django.db import models 


class Address(models.Model):
    ...
    
class User(models.Model): 
    ...     
    address = models.ForeignKey(Address)     

En tant que développeur poli nous devrions souhaiter la bienvenue à nos utilisateurs :

new_users = User.objects.filter(...) 
for new_user in new_users:  
    send_welcome_letter(user.address)

Comment pensez-vous que ce code va se comporter ? Combien de requêtes sont faites dans cet extrait ? La réponse est que cela dépend du nombre d’utilisateurs. En effet chaque accès à l’adresse d’un utilisateur va provoquer une requête à la base de données. Cela veut dire que pour 10 utilisateurs ce bout de code va générer 11 requêtes SQL (une pour les utilisateurs plus une par adresse). Au plus vous aurez d’utilisateurs au plus votre code générera d’appels à la base de données.

Une manière de se prémunir de cela est de precharger les données dont vous aurez besoins lors du premier appel à la base de données. Pour cela Django mets à disposition deux mécanismes : select_related et prefetch_related.

En effet select_related est une méthode de queryset qui permet de récupérer des valeurs associée simple (foreign key et relation un à un) au même moment que la requête principale. Par exemple dans notre cas :

new_users = User.objects.filter(...).select_related("address")

Une fois dans la boucle le code ne réalise plus aucune requête. Cela permet de diminuer les nombres de requêtes faite à la base de données en ajoutant très peu de travail a celle-ci.

Relations complexes

Django fourni aussi une solution pour des cas plus complexes, charger plusieurs valeurs pour un attribut par exemple (relation ManytoMany, clés étrangères inversées, etc..). Voyons cela avec un exemple. Imaginons que nous ayons un modèle Client et un modèle Commande et que nous avons une clé étrangère de la commande vers le client (ce qui permet à un client d’avoir plusieurs commandes).

customers = Customer.objects.all() 
for customer in customers: 
    print(customer.orders.all())

Ceci provoquera une requête pour la liste de clients et une requête pour avoir les commandes de chaque client. Dans ce cas on ne peut pas utiliser select_related car nos modèles ont une relation "complexe", c’est dans ce cas qu’on utilise prefetch_related.

customers = Customer.objects.all().prefetch_related("orders") 
for customer in customers:
    print(customer.orders.all())

Le seul inconvénient de cette méthode est qu’elle réalise deux requêtes vers la base de données. La première pour aller chercher les objets qui nous intéressent (ici les clients) et la deuxième pour aller chercher les objets reliés en utilisant les clés primaires des clients pour filtrer les commandes. Django s’occupera ensuite tout seul de la réconciliation pour attribuer les bonnes commandes aux bons clients. Tout ce mécanisme est transparent pour nous.

Quand vous réalisez ce genre de modifications, pensez toujours à bien vérifier vos résultats sur l’ensemble de vos environnements. La première technique utilise une jointure, là où la deuxième filtre en utilisant une clause IN en SQL. De mon expérience cela toujours plus rapide que le code "naïf", mais j’imagine qu’avec certains jeux de données particulier cela ne pourrait pas être le cas.

Avez-vous besoin de toutes ces données ?

Un point de conception intéressant dans l’ORM de Django est que par défaut tous les champs de l’objet sont requêtés lors d’un appel à la base de données. Si un modèle possède 100 champs alors les 100 champs sont récupérés et les attributs instanciés quand on utilise une instance de l’objet. Dans la plupart des cas nous n’avons pas besoin de toutes ces données, mais on les récupère quand même car il s’agit du fonctionnement par défaut. Cela peut être assez gênant du point de vue de la performance, en particulier avec les plus gros modèles ou avec des champs très importants (un champ texte de plusieurs milliers de caractères par exemple). Imaginons que votre site internet vende des habits, avec un modèle représentant les produits que vous vendez:

class Product(models.Model): 
    # A clothe in our case     
    brand =      
    color =      
    price =      
    description =    
    seo_text =     
    # etc.

Sur votre site vous voudrez probablement afficher une liste de produits sur certaines pages et nous n’aurons pas besoin de toutes les données du modèle. Imaginons que nous réalisons une API qui sera utilisée par un front-end pour créer l’âge de liste des produits:

# In a Django Rest Framework Context 
from rest_framework import serializers
from rest_framework.generics import RetrieveAPIView 
from my_app.models import Product 


class ProductSerializer(serializeris.Serializer): 
    model = Product          
    class Meta(Object):  
        model = Product         
        fields = ("brand", "color", "price") # This is all you need 
        
        
class ProductDetailView(RetrieveAPIView):
    model = Product    
    serializer_class = ProductSerializer

Dans ce cas si on converti la requête faite implicitement par Django REST Framework en requête SQL, on obtient le résultat suivant:

'SELECT `product`.`id`, `product`.`brand`, `product`.`color`, `product`.`price`, `product`.`description`, `product`.`seo_text` from `my_app__product` where `product`.`id` = ?'

Comme vous pouvez le voir, Django sélectionne automatiquement tous les champs du modèle, ce qui est équivalent dans notre cas à un SELECT * FROM .... Dans cet exemple le texte prévu pour le SEO ainsi que la description du produit seront chargés depuis la base de données même s’ils ne sont pas utilisés. Heureusement Django met à notre disposition plusieurs méthodes pour corriger ce problème.

Première solution

La technique qui semble être la plus utilisée est la méthode values . Cette méthode retourne un queryset de dictionnaires à la place d’un queryset d’objets. Dans notre exemple nous pourrions l’utiliser de la manière suivante.

  products = Products.objects.values("brand", "color", "price")

Cela sera converti en une requête SQL dans laquelle uniquement trois champs seront récupérés, ce qui sera plus rapide dans la plupart des configurations SQL. Même si c’est une bonne solution qui est généralement beaucoup utilisé, ce n’est pas mon approche favorite. En effet en changeant du code qui utilise des objets a un code utilisant des dictionnaires vous devrez probablement réécrire une partie de la logique, ce qui peut demander un peu de refactoring.

Seconde solution

Une autre option est d’utiliser la méthode only pour requêter certains champs en particulier. Par exemple dans notre cas Product.objects.filter(...).only("color", "price", "brand") requêterait uniquement trois champs. Il est important de noter qu’une liste d’objets est renvoyée. Contrairement à la méthode values vous pouvez utiliser tous les attributs de l’objet Produits (méthode, propriété, etc.). Cela en fait un outil très puissant pour éviter de casser une interface existante tout en permettant des optimisations. Attention, parfois on pense optimiser alors que ce n’est pas le cas. Par exemple :

class Product(models.Model):
    brand =      
    color =      
    price =       
    
    @property     
    def human_variant_price_display():  
        return "Color {}, starting from {}€ (without shippings fee)".format(color, price) 
        
        
# another_file.py or even an HTML template
def get_list_data_for_product(): 
    product = Product.objects.filter(...).first().only("color", "brand")     
    return product.color, product.brand. product.human_variant_price_display

Dans cet exemple vous vous serez peut-être rendu compte que l’on réalise 2 requêtes. La première pour sélectionner uniquement la couleur et la marque grâce à l’utilisation de only. La seconde en utilisant une propriété de l’objet, car l’attribut prix de l’objet n’a pas été chargé lors de la première utilisation. Dans ce cas Django fera un appel supplémentaire en utilisant un SELECT * pour récupérer les données. Dans cet exemple précis en ajoutant la méthode only vous avez probablement empiré vos performances. Il faut donc noter que même si c’est un outil très puissant il faut faire très attention lorsque l’on s’en sert pour éviter de faire pire que mieux. Il est possible d’ajouter des tests qui évite que quelqu’un empire la situation dans le futur (cf prochain billet).

Troisième solution

Django mets à notre disposition un dernier outil pour ces situations, qui est la méthode defer. Cette méthode vous permet de nommer explicitement un ou plusieurs champs du modèle que vous ne souhaitez pas charger. Cette méthode est pratique quand vous avez besoin d’une grande majorité des champs de votre modèle et que vous souhaitez juste en exclure quelques-uns. L’inconvénient de cette méthode et que les champs ajoutés au modèle seront automatiquement ajouté à cette requête (car ils ne seront pas dans la liste des champs à deferrer). À termes, cela ajoutera des données inutiles dans une requête que vous avez optimisée. À mon avis il est aussi plus simple de travailler en logique positive avec only qu’en logique négative (defer), mais cela reste un bon outil dans certains cas très particuliers.

L'indexation SQL

L’indexation SQL est un sujet à part entière et assez complexe. Ainsi nous ne couvriront pas ce sujet en détails. En revanche il est tout de même important de savoir que des bons indexes peuvent en effet booster énormément les performances d’une application qui repose sur l’usage d’une base de données. Mon expérience m’a apprise que très peu de développeurs comprennent correctement les indexes1 et toute leurs complexités (cardinalité, index composé, index multi colonnes, etc.), ainsi les projets qui ont plusieurs années sont souvent farfelus sur ce point. On peut aussi noter qu'ajouter des indexes est extrêmement simple avec Django et que par effet de bord (et par incompréhension) les modèles ont parfois trop d’indexes. Si vous chercher à optimiser une application Django il y a probablement des pistes d’améliorations du côté des indexes ;).


  1. A voir si on considère que la compréhension du fonctionnement d’une base de données fait parti des prérequis pour être un développeur back.


Ce billet fait partie d’une série qui entrera en détails sur l’optimisation des performances web avec Django. Le but sera de compiler tous les billets dans un article. N’hésitez à réagir où à poser vos questions ;)

1 commentaire

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte