Optimiser ses applications Django

September 9, 2010 Tags: django, caching, python, sql

Un résumé de ce que j'ai présenté à PyConFR le 29 Août 2010.

PyConFR a donc eu lieu les 28 et 29 Août. C'était ma première PyConFR et l'occasion de parler d'un sujet qui m'intéresse. Les vidéos sont déjà disponibles, n'hésitez pas à les regarder !

J'avais promis à la fin de ma présentation un résumé de tout ce dont j'ai parlé, voici donc un guide sans prétention sur l'amélioration des performances avec Django. Attention c'est un peu long.

Disclaimer

On dit souvent que les optimisations prématurées, c'est mal. Un BDFL de Django nous le rappelle très bien :

Le principal enseignement à tirer, c'est qu'il faut se concentrer d'abord sur le développement et attendre d'avoir une application fonctionnelle : les optimisations ont tendance à ajouter de la complexité, mieux vaut les faire au moment où on en a vraiment besoin et où le projet sur lequel on travaille a une certaine stabilité dans ses fonctionnalités.

Profiling

Avant d'améliorer quoi que ce soit, il est important d'identifier les goulots d'étranglement. Pour cela, plusieurs techniques.

Django-debug-toolbar

La debug-toolbar est un outil à utiliser absolument avant tout déploiement. C'est un utilitaire qui permet de voir :

  • Le temps nécessaire pour générer la page (à ne pas prendre au pied de la lettre, l'activation de la debug-toolbar peut ralentir fortement l'affichage des pages. J'ai eu dans un cas un passage de 100ms à 5 secondes).
  • Les différentes variables de configuration
  • Les en-têtes HTTP
  • Les cookies, variables GET et POST et variables de session
  • Les requêtes SQL exécutées, avec la possibilité de voir quelle instruction est responsable de telle ou telle requête et l'affichage du résultat de la clause EXPLAIN
  • Les templates utilisés
  • Les signaux et les différentes receiver functions qui y sont connectées
  • Les logs

Bref, énormément d'informations. Évidemment il n'est pas question de l'utiliser en production, mais je ne me vois pas déployer un site sans avoir fait une vérification de toutes les vues avec la debug-toolbar.

ProfilingMiddleware

Plusieurs middlewares utilisant les outils de profiling de Python sont disponibles sur djangosnippets : 186, 605, 727, 1579, 2126. Faites votre marché, ces middlewares ne sont cependant utiles que si vos vues sont lourdes en code python.

En production

Pour savoir ce qui se passe en production au niveau de la base de données, une technique expliquée ici permet d'ajouter l'URL de la requête HTTP en commentaire dans les requêtes SQL. Un coup d’œil dans les logs des requêtes les plus intensives permet donc de remonter à la vue qui pose problème.

Optimiser les accès à la base de données

Indexes

Une des premières choses à vérifier est que tous les champs qui le nécessitent sont indexés par la base de données. Indexer un champ se fait tout simplement avec l'argument db_index=True:

class Article(models.Model):
    slug = models.CharField(max_length=255, db_index=True)
    # ...

Si la table a déjà été créée, rajoutez l'index manuellement en exécutant les instructions affichées par manage.py sqlall <votre_application>.

Les champs géographiques de GeoDjango sont automatiquement indexés, il est possible de ne pas les indexer en spécifiant spatial_index=False.

Les QuerySets

Pour rappel, les QuerySets sont les objets python utilisés par Django pour représenter une liste d'objets récupérés dans la base de données. Ce sont des objets qui sont :

  • paresseux : si on écrit users = User.objects.all(), à ce moment aucune requête n'a été envoyée à la base de données.
  • chaînables : la plupart des méthodes des QuerySets renvoient des QuerySets et on peut donc facilement chainer des opérations, toujours de manière paresseuse

Les QuerySets sont évalués lorsqu'on itère dessus, typiquement dans un bloc {% for %} dans les templates. Il est également possible de "forcer" l'évaluation en appelant list(queryset).

Certaines méthodes des QuerySets ne sont ni paresseuses, ni chainables : c'est le cas par exemple de QuerySet.count() qui est évaluée immédiatement.

Le nombre de requêtes

Prenons un exemple simple. Supposons que vous avez ce modèle de données :

class Article(models.Model):
    author = models.ForeignKey(User)
    title = models.CharField(max_length=255)

Dans vos vues vous récupérez une liste d'articles :

articles = Article.objects.all()

Et dans les templates, vous affichez cette liste d'articles :

{% for article in articles %}
  <h1>{{ article.title }}</h1>
  <p>{{ article.content }}</p>
  <p>{{ article.author.get_full_name }}</p>
{% endfor %}

Et là, on remarque qu'à chaque itération, une requête SQL supplémentaire est exécutée pour récupérer l'auteur de chaque article dans la base de données. Une requête par article, même s'ils sont tous du même auteur.

Le problème vient du fait que Django a commencé par faire une requête SELECT sur les champs du modèle Article mais sans faire de jointure sur les tables voisines. Lorsqu'on a besoin de suivre les relations, il est possible de le spécifier avec select_related():

articles = Article.objects.select_related('author')

Le fonctionnement de select_related() est intéressant. La manière dont Django fonctionne pour les clés étrangères est qu'il regarde si quelque chose est présent dans un champ _<nom_du_champ>_cache. S'il y a quelque chose, la valeur est renvoyée. S'il n'y a rien, une requête est exécutée et l'objet est mis en cache pour la fois suivante. Le rôle de select_related() consiste donc simplement à construire les jointures avec les tables nécessaires et remplir le cache. Chose que l'on peut faire aussi à la main, par exemple :

articles = Article.objects.all()

author_ids = set([p.author_id for a in articles])
users = User.objects.filter(pk__in=author_ids)

authors = dict((u.pk, u) for u in users)

for a in articles:
    a._author_cache = authors.get(p.author_id)

C'est un select_related() différent, celui-ci effectue deux requêtes mais pas de jointure. C'est en général plus lent que select_related() mais peut servir si les deux tables sont situées dans des bases de données différentes.

select_related() fonctionne très bien pour les clés étrangères mais ne couvre pas certains cas d'utilisation fréquents. Par exemple, si l'on modifie le modèle de données pour ajouter des tags à nos articles :

class Tag(models.Model):
    name = models.CharField(max_length=255)

class Article(models.Model):
    slug = models.CharField(max_length=255, db_index=True)
    tags = models.ManyToManyField(Tag)

Ici, lister les articles et leurs tags en une seule requête n'est pas possible et select_related() n'est pas utilisable dans ce cas. En utilisant le hack décrit précédemment, on peut récupérer les informations en deux requêtes et les rassembler à la main. Mais il existe une application qui fait ça pour nous, django-batch-select. Il suffit de modifier le modèle Article comme ceci :

from batch_select.models import BatchManager

class Article(models.Model):
    slug = models.CharField(max_length=255, db_index=True)
    tags = models.ManyToManyField(Tag)

    objects = BatchManager()

Puis les articles et leurs tags peuvent être récupérés ainsi :

a = Article.objects.batch_select('tags').all()

Pour chaque article, le champ tags_all contient les tags associés.

Dans le même style, j'avais écrit un article sur ce blog présentant une technique plus artisanale qui permet de tout faire en une seule requête moyennant quelques champs en plus. C'est ici : Django signals for consistent caching

Problème suivant : supposons que l'on veut lister un très grand nombre d'articles, mais que plusieurs champs ne nous intéressent pas. Par défaut, l'ORM inclut tous les champs dans les requêtes. Il est possible d'alléger la requête SQL en utilisant deux méthodes sur les QuerySets :

  • QuerySet.defer('content', 'abstract') permet d'exclure des champs
  • QuerySet.only('title', 'slug') permet d'inclure seulement quelques champs

Les champs exclus de la requête sont des instances de DeferredField: si on cherche a y accéder dans son code ou dans les templates, une requête sera exécutée pour récupérer les champs manquants. Attention donc aux mauvaises surprises quand vous affichez un champ supplémentation dans les templates et qu'il est exclu du QuerySet... Le nombre de requêtes explose de nouveau !

Pour revenir au sujet des micro-optimisations, il faut savoir qu'un QuerySet est un objet relativement lourd en mémoire et qu'à chaque fois qu'il est chainé, il est copié dans un nouvel objet, modifié et retourné. Le fait d'appeler defer() ou only() fait gagner en performances du côté de la base de données mais on peut se retrouver perdant du fait du code Python supplémentaire. Faites vos benchmarks pour vérifier mais sur une vingtaine d'objets c'est souvent inutile.

Enfin, se pose le problème de la requête qu'on arrive à formuler en SQL mais pas avec l'ORM. Dans ce cas, Django 1.2 propose QuerySet.raw() pour exécuter du SQL brut. Il n'autorise que les requêtes de type SELECT mais retourne des vraies instances de modèles avec même des DefferedFields pour les champs manquants. Avec des versions plus anciennes, il faut importer son curseur pour lancer la requête et reconstruire les objets à la main.

Le cache

La documentation sur le sujet est très complète, inutile de la paraphraser. Il y a cependant des applications intéressants à regarder pour chercher de l'inspiration et pourquoi pas les intégrer dans ses projets.

Django-newcache par Eric Florenzano se veut un remplacement de django.core.cache et ajoute plusieurs fonctionnalités intéressantes comme des préfixes pour les clés configurables par projet, entre autres choses.

Django-redis-cache est un backend Redis pour le cache de Django, il permet d'ajouter un peu de persistance dans ce monde volatil.

Django-cache-machine et Johnny-cache (enfin de la créativité dans les noms !) permettent de faire du caching au niveau des QuerySets : dès qu'une lecture est faite sur la base de données, les objets sont mis en cache. Le cache est invalidé dès qu'une écriture se fait sur les objets ou la table correspondante. C'est utile si la majorité des opérations sur votre base de données sont des opérations de lecture.

Les templates

Edit: voir aussi le commentaire de mat sur django.template.loaders.cached.Loader.

Ici, point de magie. Les templates de Django sont ce qu'ils sont, les rendre plus rapides était il me semble un projet GSOC cet été mais je n'en ai pas entendu parler. Bref, accélérer le rendu des templates n'est pas quelque chose dont tout le monde a besoin mais au cas où, la solution est de passer à un autre langage comme Jinja. On dit Jinja plusieurs fois plus rapide que les templates de Django. C'est un projet qui a démarré comme un fork de django.template donc la syntaxe est très similaire, bien que plus de logique soit autorisée dans les templates. Il faut aussi garder en tête que l'admin ne fonctionne pas avec Jinja et que les templatetags doivent être réécrits.

Les queues

Enfin, il y a des cas où toute l'optimisation du monde est sans effet, lorsqu'une tâche à effectuer nécessite de toute façon du temps. Faire de l'encodage vidéo, poster des statuts Twitter ou Facebook, envoyer massivement des messages, faire des opérations lourdes sur la base de données, sont différentes actions qui prennent du temps et qui ne sont pas forcément très fiables. Les "message queues" permettent de sortir ces tâches du cycle requête-réponse moyennant quelques composants en plus dans votre architecture.

Les vues contactent un "message broker" qui enregistre les différentes tâches à effectuer. Les tâches sont lancées dans les vues et une réponse est envoyée à l'utilisateur avec un message lui disant que la tâche est en cours. Des "workers" se connectent au broker, exécutent les tâches et enregistrent le résultat. La page sur laquelle est renvoyé l'utilisateur peut par exemple afficher une vue Ajax en attendant que le résultat arrive.

L'acteur principal du monde de l'asynchrone est celery et son complément django-celery, qui fonctionnent avec RabbitMQ. Cue est un autre projet basé sur Redis et conçu pour Django.

Liens

Enfin, si vous avez des recettes et astuces sur le sujet, n'hésitez pas à les partager dans les commentaires.

Comments

September 9, 2010mat

Au sujet des templates de django: Une partie de leur lenteur est due au fait qu'ils sont recompilés à chaque fois... Sauf si on active django.template.loaders.cached.Loader. Ce dernier est une nouveauté de django 1.2 et permet, si vous ne faites pas trop de bidouilles dans vos templatetags (voir la doc pour les explications), d'éviter cette étape de recompilation en cachant en mémoire les objets Template.

Plus d'infos: http://docs.djangoproject.com/en/dev/ref/templates/api/#loading-templates

September 9, 2010providenz

Limpide.
Merci pour l'astuce de Simon Willison.
Ceci dit, les principales optimisations à apporter sont souvent en frontend.

Add a comment

Comments are closed for this entry.