Partage
  • Partager sur Facebook
  • Partager sur Twitter

[MySQL 5.5] Optimisation de requêtes multiples

Comment se passer de foreach() ?

Sujet résolu
    3 septembre 2011 à 14:22:05

    Salut à tous les Zér0s !
    Je viens aujourd'hui poser une question qui me tracasse :

    Dans un fichier PHP, je veux procéder à plusieurs opérations :
    1) Récupérer les émissions auxquelles je suis abonné, en triant par date de la dernière vidéo.
    2) Pour chaque émission, récupérer les 2 derniers épisodes

    Actuellement, je procède de la manière suivante :

    <?php
    
    /**
      *  CLASSE DES ABONNEMENTS - VUE DETAIL 
      */
    
    class AbonnementDetail {
        
        private $abonnements;
        public function __construct(){
            global $db;
            global $user;
            
            $queryGetAbonnementsDetail = $db->prepare('SELECT e.id, e.slug, e.bann_description, e.bann_logo, (SELECT name FROM jeux j WHERE j.id = e.jeu_id) AS jeu_name, (SELECT date FROM episodes ep WHERE e.id = ep.emission_id ORDER BY date DESC LIMIT 1) AS last_maj FROM emissions e WHERE e.id IN((SELECT GROUP_CONCAT(DISTINCT emission_id SEPARATOR \',\') FROM abonnements ab WHERE ab.user_id = :userID)) ORDER BY last_maj DESC');
            $queryGetAbonnementsDetail->execute(array('userID' => $user->getID()));
            $abonnementsDetail = $queryGetAbonnementsDetail->fetchAll();
            
            foreach($abonnementsDetail as $i => $a){
                $queryGetEpisodes = $db->prepare('SELECT id, slug, titre, saison, episode, emission_id, date FROM episodes WHERE emission_id = :emID ORDER BY date DESC LIMIT 2');
                $queryGetEpisodes->execute(array('emID' => $a['id']));
                $abonnementsDetail[$i]['episodes'] = $queryGetEpisodes->fetchAll();
            }
            
            $this->abonnements = $abonnementsDetail;
        }
        
        public function getAbonnements(){
            return $this->abonnements;
        }
        
    }
    
    ?>
    


    Cependant, quelque-chose me dérange : si je suis abonné à 12 émissions, cette classe fera en tout 13 requêtes vers la base de données pour obtenir toutes les informations :( .
    N'y aurait-il pas un quelconque moyen de se passer du foreach(), d'obtenir les 2 derniers épisodes de chaque émission dans une seule requête, et de trier le tout en PHP derrière ?

    Merci d'avance pout vos réponses,
    R0ro3lectr0
    • Partager sur Facebook
    • Partager sur Twitter
      3 septembre 2011 à 20:23:49

      Il est grand temps que tu te mettes aux jointures. Ta première requête est apparemment équivalente à celle-ci :

      SELECT e.id, 
             e.slug, 
             e.bann_description, 
             e.bann_logo, 
             j.name AS jeu_name
             ep.date AS last_maj 
      FROM emissions e 
      INNER JOIN jeux j
          ON j.id = e.jeu_id
      INNER JOIN (SELECT emission_id, MAX(date) AS date
                  FROM episodes
                  GROUP BY emission_id) ep
          ON e.id = ep.emission_id
      INNER JOIN abonnements a
          ON e.id = a.emission_id
      WHERE a.user_id = :userID
      ORDER BY last_maj DESC
      


      D'ailleurs, je ne comprends pas comment ta requête fonctionnait correctement avec le GROUP_CONCAT dans ta sous-requête.
      Ensuite, le problème de la requête dans ta boucle. Il est possible de faire quelques jointures de plus dans la première requête pour en faire une seule qui retournerait exactement toutes tes données, par contre je doute que ce soit plus efficace que faire une deuxième requête et que ce soit très pratique pour toi pour construire tes arrays. Bref, mieux vaut y aller pour une seconde requête.

      Ton problème est très connu (greatest-n-per-group). Cependant, MySQL n'offre pas les fonctions de fenêtrage, ce qui fait que la requête typique pour sélectionner 2 épisodes par émission devient très moche. Et que je n'ai absolument pas envie d'écrire une telle requête (c'est l'une des raisons qui m'a fait quitter MySQL). Donc, voici deux exemples de requêtes qui font ce que tu cherches : requête 1 et requête 2.

      Sur une note plus générale, je t'encourage fortement à te renseigner un peu plus sur le sujet. Je suis très surpris que la structure de ta base de données semble aussi bonne alors que tu ne connaissais même pas les jointures. Par ailleurs, indente tes requêtes. Tu n'écrirais pas ton code PHP sur une seule ligne, ne le fais pas pour ton code SQL.
      • Partager sur Facebook
      • Partager sur Twitter
        4 septembre 2011 à 1:36:26

        Merci de ta réponse !
        En fait, je connaissais les jointures SQL, comme tout Zéro ayant suivi le tuto de M@theo21 sur PHP/MySQL, mais ayant découvert les subrequests ensuite, je n'ai pas cherché plus loin à comparer les performances des deux, mais si c'est une pratique à bannir, alors je tâcherai de les utiliser à la place des subrequests...

        Concernant la requête dans la boucle, j'ai effectivement décidé d'abandonner l'idée de tout compacter dans une même requête pour me résoudre à utiliser une boucle.
        Pour finir, concernant le GROUP_CONCAT, je me suis rendu compte après coup que je n'arrivait à sélectionner qu'un seul enregistrement, ce qui m'a surpris, étant donné que le code suivant fonctionne parfaitement :



        <?php
        $queryGetAllAbonnements = $db->prepare('SELECT GROUP_CONCAT(emission_id) AS gc FROM abonnements ab WHERE ab.user_id = :userID');
        $queryGetAllAbonnements->execute(array('userID' => $user->getID()));
        
        $allAbonnements = $queryGetAllAbonnements->fetch();
        $allAbonnements = $allAbonnements['gc'];
                
        $queryGetAbonnementsDetail = $db->query('SELECT e.id, e.titre, e.slug, e.bann_description, e.bann_logo, e.bann_logo, j.name AS jeu_name, ep.date AS last_maj FROM emissions e INNER JOIN jeux j ON j.id = e.jeu_id INNER JOIN (SELECT emission_id, MAX(date) AS date FROM episodes GROUP BY emission_id) ep ON e.id = ep.emission_id WHERE e.id IN(' . $allAbonnements . ') ORDER BY last_maj DESC LIMIT 4');
        $abonnementsDetail = $queryGetAbonnementsDetail->fetchAll();
        


        Si tu avais une idée de la raison pour laquelle ceci marche, et pas l'ancien code... o_O

        Merci beaucoup pour tes conseils !

        PS : Concernant l'indentation, je tacherai d'y penser :p !
        • Partager sur Facebook
        • Partager sur Twitter
          4 septembre 2011 à 2:14:29

          Citation

          si c'est une pratique à bannir, alors je tâcherai de les utiliser à la place des subrequests...


          Je ne dis pas que les sous-requêtes sont essentiellement mauvaises, loin de là. Par contre, MySQL les implémente d'une très mauvaise façon (pas possible d'utiliser les index). Je ne me suis jamais penché sur l'implémentation de celles-ci, mais il serait intéressant de vérifier comment le SGBDR réagit lorsque tu fais une sous-requête corrélée (comme celles dans ton SELECT). Intuitivement, je suppose qu'il exécute une requête pour chaque valeur, en réutilisant le même plan d'exécution, mais je me demande s'il peut optimiser le processus d'une autre façon.

          Citation

          Concernant la requête dans la boucle, j'ai effectivement décidé d'abandonner l'idée de tout compacter dans une même requête pour me résoudre à utiliser une boucle.


          Attention, je crois que nous ne nous sommes pas bien compris. Il est très mauvais de faire une requête dans une boucle comme tu le fais, peu importe le cas. En fait, je suggérais deux options :
          1) Faire une seule requête, avec des tonnes d'autres jointures pour sélectionner les 2 derniers épisodes pour chaque émission
          2) Faire deux requêtes : celle que j'ai proposée au-dessus, et une seule autre pour sélectionner les 2 derniers épisodes pour chaque émission.

          Avec un SGBDR qui implémente intelligemment le SQL comme PostgreSQL, une telle requête est triviale :
          WITH ranked_serie AS (
              SELECT id, slug, titre, saison, episode, emission_id, date,
                     ROW_NUMBER() OVER(PARTITION BY emission_id ORDER BY date DESC) N
                FROM episodes
          )
          SELECT id, slug, titre, saison, episode, emission_id
            FROM ranked_serie
           WHERE N <= 2
                 AND emission_id IN (SELECT emission_id
                                       FROM abonnements
                                      WHERE user_id = :userid);
          

          Il y a même un exemple de sous-requête : elle n'est calculée qu'une seule fois parce qu'elle n'est pas corrélée.
          Pour faire une requête comme celle-ci avec MySQL, on fait un truc sale avec des variables SQL, des tonnes de sous-requêtes, des ORDER BY et LIMIT dans des sous-requêtes, etc. Enfin, tu n'as qu'à regarder les exemples que j'ai donnés plus haut. Il faut donc écrire une requête compliquée et illogique du point de vue de la théorie relationnelle (il n'y a pas d'ordre dans une relation, pourtant on doit en créer un dans une sous-requête pour numéroter les lignes).

          Citation

          Si tu avais une idée de la raison pour laquelle ceci marche, et pas l'ancien code...


          C'est très différent, et il faut faire attention à ne pas mélanger les concepts de PHP avec ceux de MySQL. GROUP_CONCAT retourne toujours une chaîne de caractères. Quand tu compares directement dans ta requête une colonne avec le résultat de GROUP_CONCAT, tu dois donc t'assurer que ta colonne est une chaîne de caractères aussi qui contient, par exemple, "1,5,8,20" (note: ne fais pas une telle colonne, c'est une idée horrible). Ainsi, si tu fais une condition comme cela :
          -- ...
          WHERE some_col IN (SELECT GROUP_CONCAT(some_other_col) FROM some_table)
          

          Elle sera peut-être interprétée comme ceci :
          -- ...
          WHERE some_col IN ('1,3,5,8,12')
          


          Pour qu'elle soit vraie, il faut donc que some_col vaille exactement '1,3,5,8,12'. Sinon, la condition sera fausse.

          La raison pour laquelle ta requête fonctionne, c'est parce que tu ajoutes PHP dans le problème. GROUP_CONCAT retourne une chaîne de caractères comme '1,3,5,8,12', mais tu stockes ensuite cette chaîne de caractères dans une variable PHP. Puis, tu concatènes ta variable dans une seconde requête. La requête envoyée à MySQL devient donc celle-ci :
          -- ...
          WHERE some_col IN (1,3,5,8,12)
          

          Qui est valide. some_col peut donc valoir 3 ou 8 et la condition sera vraie.
          • Partager sur Facebook
          • Partager sur Twitter
            4 septembre 2011 à 2:23:44

            Merci beaucoup pour ton aide précieuse !
            Je vais regarder les liens que tu m'as envoyé pour le remplacement de la boucle, car effectivement, nous nous étions mal compris. Au final, je choisirai l'option des deux requêtes ;)

            Il me semble que toutes mes questions ont étés résolues, je passe donc le topic en "Résolu" ;)

            Bonne continuation,
            R0ro3lectr0
            • Partager sur Facebook
            • Partager sur Twitter

            [MySQL 5.5] Optimisation de requêtes multiples

            × Après avoir cliqué sur "Répondre" vous serez invité à vous connecter pour que votre message soit publié.
            × Attention, ce sujet est très ancien. Le déterrer n'est pas forcément approprié. Nous te conseillons de créer un nouveau sujet pour poser ta question.
            • Editeur
            • Markdown