Optimiser les requêtes SQL de CakePHP
Lorsque l’on crée une application dont les tables contiennent des dizaines ou des centaines de milliers d’enregistrements, il s’agit de bien contrôler les requêtes SQL qui vont être lancées afin d’éviter les “goulets d’étranglement” qui ne manqueront pas de ralentir l’application, voire de planter le serveur de base de données.
Au sommaire :
- Définir les bons index ;
- Spécifier les champs attendus ;
- Régler le niveau de récursivité ;
- Utiliser le Comportement
Containable; - Forcer les jointures avec les relations
hasMany.
Nous allons voir quelques règles à respecter, quelques bonnes habitudes à adopter, mais aussi un moyen de tricher pour parvenir à nos fins. Cependant, gardons bien à l’esprit cette règle générale : utilisons au maximum les méthodes d’accès aux données fournies par Cake (Model::find, Model::save, etc.), en évitant autant que possible d’exécuter nos propres requêtes (Model::query) faites à la main. Quatre raisons à cela :
- la compatibilité entre SGBD est conservée ;
- la sécurité de la requête est assurée ;
- le format de sortie habituel des résultats est conservé ;
- les callbacks sont disponibles (beforeFind, afterFind, beforeValidate, beforeSave, afterSave, beforeDelete, afterDelete)
1. Définir les bons index
La première et la plus bénéfique optimisation possible consiste à définir des bons index sur une table. Disons pour simplifier qu’un index est une table des matières que le SGBD consulte pour trouver rapidement un ou plusieurs enregistrements. Le parcours des index étant infiniment plus rapide que le parcours des lignes de la table, il est crucial d’en mettre, et sur les bonnes colonnes. D’une manière générale, les colonnes à indexer sont les clés primaires (automatique), les clés étrangères et ensuite, si besoin, les colonnes sur lesquelles vont s’effectuer des recherches.
Nous laissons le lecteur se documenter sur ce sujet qui ne concerne pas CakePHP directement.
2. Spécifier les champs attendus
Un moyen simple de réduire le volume de données qui transite entre les scripts et la base est de définir les champs dont nous avons réellement besoin. Par exemple, pour afficher simplement une liste d’articles, nous ne nous servirons pas de tous les champs, surtout pas du corps de l’article, souvent assez volumineux :
$this->Article->find('all'); // Renvoie toutes les colonnes $this->Article->find('all', array('fields' => array('id', 'titre'))); // Renvoie l'id et le titre
3. Régler le niveau de récursivité
Le niveau de récursivité d’un find indique à Cake jusqu’à quelle “distance” il doit rapatrier les données des modèles liés. Prenons l’exemple des Articles d’un blog : ils ont un Auteur et peuvent avoir plusieurs Tags. Suivant le niveau de récursivité, la méthode Article::find('all') renverra :
- recursive = -1 : les Articles uniquement
- recursive = 0 : les Articles et leur Auteur
- recursive = 1 : les Articles, leur Auteur et les éventuels Tags associés
- recursive = 2 : les Articles, leur Auteur et les Articles de celui-ci, les éventuels Tags associés et les Articles associés à chacun des Tags.
Attention donc à ne pas définir un niveau de récursivité trop élevé.
4. Utiliser le Comportement Containable
Cet indispensable Comportement permet de choisir les modèles qui seront effectivement rapatriés lors d’un Model::find. Reprenons l’exemple ci-dessus et imaginons que nous souhaitions afficher les Articles (titre et date de création uniquement) et leurs Tags (libellé uniquement) mais pas les Auteurs :
class Article extends AppModel { var $actsAs = array('Containable'); var $belongsTo = array('Auteur'); var $hasAndBelongsToMany = array('Tag'); }
class ArticlesController extends AppController { function index() { $data = $this->Article->find( 'all', array( 'fields' => array('Article.titre', 'Article.created'), 'recursive' => 1, 'contain' => array( 'Tag' => array( 'fields' => array('Tag.label') ) ) ) ); $this->set(compact('data')); } }
Notons que le paramètre 'recursive' => 1 n’est pas ici indispensable, le Comportement Containable étant capable de déterminer lui-même le niveau de récursivité requis pour que les modèles associés demandés soient bien inclus.
5. Forcer les jointures avec les relations hasMany
Essayons maintenant de trouver la liste des Auteurs et leurs Articles :
class Auteur extends AppModel { var $actsAs = array('Containable'); var $hasMany = array('Article'); }
class AuteursController extends AppController { function index() { $data = $this->Auteur->find( 'all', array( 'fields' => array('Auteur.nom', 'Auteur.prenom'), 'contain' => array( 'Article' => array( 'fields' => array('Article.titre') ) ) ) ); $this->set(compact('data')); } }
Si nous regardons le debug de l’exemple ci-dessus, nous voyons que CakePHP envoie deux requêtes : la première pour trouver le nom et le prénom des Auteurs (plus l’id des Auteurs, ajouté automatiquement par Containable), et la deuxième pour trouver les titres des Articles de chaque Auteur, avec l’instruction SQL :
Article.auteur_id IN(id des Auteurs)
Au delà de 1000 id d’Auteur, le serveur ne répond plus. Pour faire face à cela, nous allons tricher un peu et forcer Cake à n’effectuer qu’une seule requête avec un jointure LEFT JOIN entre les tables auteurs et articles, beaucoup plus rapide que les deux requêtes séparées.
Pour arriver à ce résultat, nous allons enlever l’association hasMany entre Auteur et Article, puis créer une association hasOne le temps du find :
class AuteursController extends AppController { function index() { $this->Auteur->unbindModel(array('hasMany' => array('Article'))); $this->Auteur->bindModel(array('hasOne' => array('Article'))); $data = $this->Auteur->find( 'all', array( 'fields' => array('Auteur.nom', 'Auteur.prenom'), 'contain' => array( 'Article' => array( 'fields' => array('Article.titre') ) ) ) ); $this->set(compact('data')); } }
Cette fois, nous voyons dans le debug que Cake n’a bien fait qu’une seule requête, et nous pouvons travailler avec plus d’un millier d’enregistrements.
Commentaires
22 août 2008 à 23:03
Pour la récursivité, le problème se résoudrait très aisément si, comme le fait RoR, Cake récupérait les données des fils qui sont trop bas dans l’arbre que lorsqu’ils sont nécessaires.
Cela permettrait d’éviter certaines récursivités traitres (un article récupère les commentaires qui re récupèrent l’article) et également de se soucier de cet élément plutôt agaçant.
22 août 2008 à 23:46
Tout à fait, d’où l’intérêt d’utiliser le Comportement Containable qui permet de définir finement le résultat attendu.
23 août 2008 à 1:55
Ah, merci, je n’avais pas complétement saisi l’utilité de Containable, voila qui est fait, c’est effectivement indispensable.
Par contre, quelque chose m’échappe sur le point 5.
Comment peut-on remplacer une relation HABTM par une relation hasOne et que pourtant Cake parvienne à retrouver les tags associés aux articles ?
23 août 2008 à 11:17
Je me suis mélangé les pinceaux, je voulais parler des hasMany… J’ai mis l’article à jour.
23 août 2008 à 14:53
Merci encore pour cet article et ces conseils.
Les performances sont bien l’un des points qui me chagrinent un peu dans CakePhp (ou tout autre framework).
24 août 2008 à 8:39
En effet, Containable permet de corriger cet éventuel problème.
Cela me semble très lourd cependant. Surtout lorsque c’est le genre de choses qui pourrait être fait automatiquement. M’enfin bon.
24 août 2008 à 11:44
@Jay : j’ai travaillé sur une appli avec des dizaines de milliers d’enregistrements, et à part cette astuce de jointure forcée sans laquelle l’application était à genoux, je n’ai rien eu à changer dans mes habitudes pour que tout marche vite et bien. Si cela peut te rassurer…
@Damien : Automatiquement ? Comment voudrais-tu qu’en faisant un findAll sur les Articles en ne voulant que les Tags associés et pas les Auteurs, Cake sache automatiquement associer les bons modèles ?
24 août 2008 à 11:50
Je suis d’accord qu’un peu d’automatisation ne serait peut-être pas un mal. Pour reprendre ton exemple que l’on n’ai qu’à écrire :
‘fields’ => array(’Article.titre’, ‘Article.created’, ‘Tag.name’)
et que le Containable aille chercher tout seul le Tag, sans avoir à le préciser
24 août 2008 à 11:56
Ah oui, là effectivement ce serait le pied. Il est cependant possible de simplifier un peu la notation du Containable, je n’ai présenté ici que la notation la plus stricte (que je trouve plus claire). L’exemple du 4 peut se simplifier comme suit :
25 août 2008 à 12:48
Très bon article, comme d’hab.
j’ajouterais une raison pour favoriser l’utilisation des méthodes “toutes faites” de CakePHP : à ma connaissance, quand on utilise Model::query(), les callbacks (afterXXX, beforeXXX) et ce qui peut leur être associés (behaviors…) ne sont pas appelés, ce qui peut créer des incohérences si on a mis dans ces callbacks un peu de code.
Sinon, Containable, c’est quand même quasi miraculeux. Je me demande comment on faisait avant. La multiplicité des syntaxes me perturbe encore un peu, toutefois. Et il y a quelques trucs qu’il ne gère pas encore, à ma connaissance (le “group by”, qui est géré dans Model::find() depuis peu, par exemple).
25 août 2008 à 17:13
@djenvert : merci pour l’argument, j’ai mis l’article à jour.
26 août 2008 à 9:47
[...] premier rappelle quelques principes d’optimisation des requêtes SQL dans le contexte de CakePHP, [...]
23 octobre 2008 à 19:31
Es-tu sûr que tu n’as pas de duplication pour le changement de hasMany en hasOne ?
Ce n’est pas si facile que ça sinon les développeurs aurait codé hasMany avec un LEFT JOIN. Il faut faire DISTINCT Auteur.id
Un autre problème avec ça est la pagination.
Cake ne gère pas la pagination avec DISTINCT.
Car il faut deux requêtes ( un COUNT() et un SELECT normal ) avec DISTINCT, la bonne rêquete est COUNT(DISTINCT field). La dernière consomme énormément de ressources.
24 avril 2009 à 15:50
Bonjour,
J’ai bien lu cet article, il m’a permet déjà d’y voir un peu plus clair mais j’ai un problème plus profond pour ma part, et j’espère que vous pourrai m’aider
J’ai 5 tables principales : categories, sub_categories, marques, modeles et annonces
Dans annonces, j’ai un index modele_id
Dans modeles > marque_id
Dans marques > sub_category_id
Dans sub_categories > category_id
J’aimerai pouvoir faire plusieurs choses avec ceci !
La première que je puisse par exemple générer un plan de site total, les sous cat appartenant a chaque cat, puis chaque marque appartenant a chaque sous cat, etc…
Je n’y arrive pas car bien entendu c’est à 5 niveaux, et votre astuce montre seulement 2 niveaux.
Comment je peux faire svp ?
Seconde et dernière question, si par exemple, je souhaite afficher le nbre d’annonces (donc la dernière table) sur ma liste des catégories (cette catégorie abc contient y annonces), comment dois je m’y prendre.
Et biensur le tout d’un facon “propre”.
Car rien qu’en faisant un simple recursive = 4, je me retrouve avec 30 requetes SQL !! avec plein de IN.
Merci d’avance.
7 juin 2009 à 4:54
Juste pour savoir, comment pourrait -on faire pour réccupérer le nbre de commentaire associé à un arcticle, et l’afficle dans la vue “index par default”.