Blog

Tous, Aucun et Un : l'astuce de la jointure gauche SQL

par Kenneth Rose 21 avril 2015 | 10 min de lecture

Introduction

PagerDuty a publié l'alerte multi-utilisateur début 2014, qui permettait de notifier et d'affecter plusieurs personnes lorsqu'un incident est déclenché. En plus d'affecter plusieurs utilisateurs à un incident, les alertes multi-utilisateurs permettent également à un incident d'avoir plusieurs accusés de réception. Cet article démontrera les modifications que nous avons apportées à notre modèle de données pour implémenter des alertes multi-utilisateurs et la sophistication qui en résulte ajoutée à nos requêtes SQL pour maintenir leurs performances.

Migrations de modèles de données

La logique et le modèle de données qui sous-tendent notre processus d'incident existent dans le cadre d'une application Rails. Avant la migration, notre modèle de données ressemblait à ceci :

 classe   Incident  < ActiveRecord::Base  en a un  :accusant_de_reconnaissance_utilisateur  en a un  :utilisateur_assigné 

   # Objets temporels  reconnu_à résolu_à  fin 

Après le déploiement des alertes multi-utilisateurs, le modèle de données ressemblait à ceci :

 classe   Incident  < ActiveRecord::Base  a beaucoup  :remerciements  ,  nom du cours:   :IncidentsRemerciements  ,  dépendant:   :supprimer_tout  ,  inverse_de:   :incident  ,  commande:   :créé à  a beaucoup  :accusant_de_reconnaissance_des_utilisateurs  ,  à travers:   :remerciements  ,  source:   :utilisateur  a beaucoup  :affectations  ,  nom du cours:   :Devoirs sur les incidents  ,  dépendant:   :détruire  ,  inverse_de:   :incident  a beaucoup  :assigné_aux_utilisateurs  ,  à travers:   :affectations  ,  source:   :utilisateur 

   # Objet de temps, acknowledge_at déplacé vers IncidentsAcknowledgement.created_at  résolu_à  fin 

IncidentsReconnaissance et Affectation des incidents sont des tables de jointure classiques :

 classe   IncidentsReconnaissance  < ActiveRecord::Base  appartient à  :incident  ,  inverse_de:   :remerciements  appartient à  :utilisateur 
 fin 

 classe   Affectation des incidents  < ActiveRecord::Base  appartient à  :incident  ,  inverse_de:   :affectations  appartient à  :utilisateur 
 fin 

À première vue, ce type de migration semble simple : une relation 1:1 devient une relation 1:plusieurs. Une véritable promenade de santé, tout droit sortie du cours de modélisation des données 101, n'est-ce pas ?

Pas vraiment.

Certaines des requêtes les plus courantes effectuées par PagerDuty nécessitent de rechercher tous les incidents d'un compte avec un état donné : déclenché, reconnu ou résolu. Nous verrons comment nous avons migré chacune de ces requêtes, en commençant par la plus simple.

Incidents résolus

La requête pour les incidents résolus n'a pas changé du tout avec l'alerte multi-utilisateur. Un incident ne peut être résolu qu'une seule fois, avec ou sans alerte multi-utilisateur. Cela conduit aux portées suivantes :

 portée  :résolu  , où.  pas  (  résolu_à:   néant  ) portée  :non résolu  , où(  résolu_à:   néant  ) 

Incidents déclenchés

Avant l'alerte multi-utilisateur, la portée déclenchée était :

 portée  :déclenché  , non résolu.où(  reconnu_à:   néant  ) 

Un incident déclenché était simplement un incident où les deux reconnu_à et résolu_à étaient néant . Dans le cadre des alertes multi-utilisateurs, un incident peut avoir plusieurs accusés de réception. Il était donc nécessaire de modifier cette portée. Nous avions besoin d'une requête pour trouver les incidents non résolus qui n'avaient exactement aucun accusé de réception.

En SQL, comment savoir si une table de jointure a exactement zéro association ? JOINT GAUCHE bien sûr!

Pour rappel, une jointure gauche garantit que l'ensemble de résultats contient tous des lignes de la table d'origine.

Par exemple, considérons les deux incidents suivants, pour lesquels on a quelques remerciements :

 # Incidents id | description | resolve_at ---------------------------------- 1 | CPU high | NULL 2 | Serveur en feu | NULL # Remerciements id | incident_id | user_id ----------------------------- 100 | 2 | 9875 101 | 2 | 9876 

Considérez la requête SQL suivante qui utilise une jointure gauche :

 SÉLECTIONNER   * 
 DEPUIS  incidents  JOINTURE EXTERNE GAUCHE  Remerciements  sur   Remerciements  .  incident_id   =   incidents  .  identifiant 

Le résultat de la requête ci-dessus est :

 id | description | résolu_à | id | incident_id | user_id ----------------------------------------------------------------- 1 | CPU élevé | NULL | NULL | NULL | NULL 2 | Serveur en feu | NULL | 100 | 2 | 9875 2 | Serveur en feu | NULL | 101 | 2 | 9876 

L'incident 1 (avec zéro accusé de réception) est celui où Remerciements.incident_id est NULL . Cela conduit à la définition simple de la portée suivante :

 portée  :déclenché  , arel_left_join(  :remerciements  ). où(  incidents_remerciements :  {  incident_id:   néant  }). non résolu 

arel_left_join est un assistant défini comme :

   déf   self.arel_join  (  association  ,  type_de_jointure  ) incident_t  =  modèle_association arel_table  =  réfléchir_sur_association(association).klass association_t  =  prédicat association_model.arel_table  =  association_t[  :incident_id  ].eq(incident_t[  :identifiant  ]) rejoint(incident_t.join(association_t, join_type).on(prédicat).join_sources)  fin 

   déf   auto-jointure.arel_inner_join  (  association  ) arel_join(association,  Arel  ::  Nœuds  ::  Jointure interne  )  fin 

   déf   self.arel_left_join  (  association  ) arel_join(association,  Arel  ::  Nœuds  ::  Jointure externe  )  fin 

Cette nouvelle portée déclenchée se traduit par le SQL suivant

 SÉLECTIONNER   `  incidents.*  ` 
 DEPUIS  incidents  JOINTURE EXTERNE GAUCHE  Remerciements  sur   Remerciements  .  incident_id   =   incidents  .  identifiant 
 (  Remerciements  .  incident_id  EST  NUL  )  ET  (résolu_at est  NUL  ) 

Incidents reconnus

Avant l'alerte multi-utilisateur, la portée des incidents reconnus était :

 portée  :reconnu  , non résolu.où(  '  acknowledge_at N'EST PAS NULL  '  ) 

L'extension de cette fonction pour prendre en charge plusieurs accusés de réception était similaire à la façon dont déclenché a été étendu. Dans ce cas, nous recherchons des incidents non résolus avec au moins une entrée dans la table des accusés de réception. Au lieu d'utiliser JOINT GAUCHE avec Remerciements.incident_id N'EST PAS NULL , nous pouvons simplement utiliser un simple vieux JOINTURE INTERNE .

Malheureusement, en utilisant naïvement JOINTURE INTERNE comme nous l'avons fait avec les incidents déclenchés est incorrect. Pour illustrer, considérons l'implémentation suivante de reconnu :

   # Naïf et très brisé  portée  :reconnu  , arel_inner_join(  :remerciements  ). non résolu 

Pour comprendre pourquoi cela est incorrect, considérez le SQL généré :

 SÉLECTIONNER  incidents.  * 
 DEPUIS  incidents  JOINTURE INTERNE  Remerciements  sur   Remerciements  .  incident_id   =   incidents  .  identifiant 
 (résolu_at est  NUL  ) 

ainsi que nos exemples de données d’incidents antérieurs :

 # Incidents id | description | resolve_at ---------------------------------- 2 | Serveur en feu | NULL # Remerciements id | incident_id | user_id ----------------------------- 100 | 2 | 9875 101 | 2 | 9876 

Le résultat sera :

 id | description | resolve_at | id | incident_id | user_id ----------------------------------------------------------------- 2 | Serveur en feu | NULL | 100 | 2 | 9875 2 | Serveur en feu | NULL | 101 | 2 | 9876 

L'ensemble de résultats contient chaque incident survenant une fois pour chaque accusé de réception. Cependant, nous voulons qu'un incident se produise exactement une fois s'il a au moins un accusé de réception, nous avons donc besoin d'un moyen de supprimer ces incidents en double. Il s'avère que efficacement Supprimer les doublons dans SQL n'est pas aussi simple qu'il y paraît.

SQL DISTINCT / Des rails unique

L’approche la plus simple consiste à utiliser .uniq dans Rails :

   # Naïf et très brisé  portée  :reconnu  , arel_inner_join(  :remerciements  ). non résolu. uniq 

ce qui se traduit par le SQL suivant :

 SÉLECTIONNEZ DISTINCT  incidents.  * 
 DEPUIS  incidents  JOINTURE INTERNE  Remerciements  sur   Remerciements  .  incident_id   =   incidents  .  identifiant 
 (résolu_at est  NUL  ) 

Malheureusement, la requête ci-dessus n'est pas très performante. Pour les comptes avec un grand nombre d'incidents reconnus, nous avons constaté que cette requête était 2 à 3 fois plus lente que la requête simple (bien qu'incorrecte). Le plan d'explication de la requête ci-dessus a montré Utilisation temporaire , ce qui indique que MySQL utilisait un table temporaire pour calculer les lignes distinctes. Nous soupçonnons qu'une grande partie du ralentissement est basée sur la copie MySQL tous des colonnes de chaque ligne vers la table temporaire pour la déduplication.

Utiliser PAR GROUPE

En soupçonnant que le ralentissement était causé par la copie de toutes les colonnes ( Incidents DISTINCTS.* ), nous avons essayé de trouver un moyen d'avoir DISTINCT fonctionner sur une seule colonne. PAR GROUPE peut être utilisé exactement à cette fin, nous avons donc tenté la requête suivante comme point de départ :

 SÉLECTIONNER  incidents.  * 
 DEPUIS  incidents  JOINTURE INTERNE  Remerciements  sur   Remerciements  .  incident_id   =   incidents  .  identifiant 
 (résolu_at est  NUL  )  PAR GROUPE   incident  .  identifiant 

Pour les comptes avec un grand nombre d'incidents reconnus, cette requête était nettement plus rapide que l'utilisation de DISTINCT . De plus, le plan d'explication de cette requête ne contenait pas Utilisation temporaire . Malheureusement, cette requête avait également un problème en raison de la mise en œuvre de .compter dans Rails.

La portée Rails qui a généré la requête précédente est :

   # Cela fonctionne, mais casse d'autres choses  portée  :reconnu  , arel_inner_join(  :remerciements  ). non résolu. groupe(  '  incident.id  '  ) 

PAR GROUPE affecte les résultats des fonctions d'agrégation, en particulier COMPTER . Dans Rails, pour compter le nombre total d'incidents d'un périmètre, vous utiliserez compter (par exemple, Incident.reconnu.nombre ). Cependant, avec groupe , .compter renvoie le nombre par groupe , pas le total compter. En d'autres termes,
au lieu d'obtenir un entier renvoyé :

 Incident  .reconnu.compte =>  7 

la valeur de retour est une carte d'identifiant d'incident à compter pour cet incident

 Incident  .accusé de réception.compte => {  19  =>  1  ,  20  =>  1  ,  21  =>  1  } 

De nombreux endroits dans notre application supposent que l'appel compter renvoie un entier, il n'était donc pas souhaitable de modifier ce comportement. PAR GROUPE la requête dans sa propre sous-requête a restauré le comportement correct de compter , mais cela a tué les gains de performances puisqu'une table temporaire était à nouveau nécessaire. Si proche, et pourtant si loin.

Utilisez une seconde JOINT GAUCHE

Le nœud du problème initial de l’adhésion Remerciements directement contre incidents était qu'un seul incident pouvait avoir plusieurs accusés de réception, ce qui entraînait des incidents en double dans le résultat joint.

Et s'il existait un moyen de sélectionner un seul exemple d'accusé de réception pour chaque incident ? Le manuel MySQL décrit une technique utilisant JOINT GAUCHE pour trouver le maximum par groupe d'une certaine colonne . En utilisant cette astuce avec notre requête, nous pouvons trouver le reconnaissance avec l'identifiant max pour un incident donné. On arrive finalement à la requête suivante :

 SÉLECTIONNER  incidents.  *   DEPUIS  incidents  JOINTURE INTERNE  Remerciements  SUR   Remerciements  .  incident_id   =   incidents  .  identifiant 
 JOINTURE EXTERNE GAUCHE  Remerciements  COMME  Remerciements_uniq  SUR 
   Remerciements  .  incident_id   =   Remerciements_uniq  .  incident_id   ET 
   Remerciements  .  identifiant   <   Remerciements_uniq  .  identifiant 
 (  incidents  .  résolu_à  EST  NUL  )  ET  (  Remerciements_uniq  .  identifiant  EST  NUL  ) 

Pour illustrer pourquoi cela fonctionne, considérons l’incident suivant avec quatre remerciements :

 # Incidents id | description | resolve_at ---------------------------------- 3 | Server inferno | NULL # Remerciements id | incident_id | user_id ----------------------------- 200 | 3 | 9875 201 | 3 | 9876 202 | 3 | 9874 203 | 3 | 9873 

et la requête suivante sans le acknowledgements_uniq.id est nul clause:

 SÉLECTIONNER   incident  .  identifiant  ,  reconnaissance  .  identifiant  ,  Remerciements_uniq  .  identifiant   DEPUIS  incidents  JOINTURE INTERNE  Remerciements  SUR   Remerciements  .  incident_id   =   incidents  .  identifiant 
 JOINTURE EXTERNE GAUCHE  Remerciements  COMME  Remerciements_uniq  SUR 
   Remerciements  .  incident_id   =   Remerciements_uniq  .  incident_id   ET 
   Remerciements  .  identifiant   <   Remerciements_uniq  .  identifiant 
 (  incidents  .  résolu_à  EST  NUL  ) 

Le résultat est:

 incident.id | acknowledgement.id | acknowledgements_uniq.id | ------------------------------------------------------------- 3 | 200 | 201 | 3 | 200 | 202 | 3 | 200 | 203 | 3 | 201 | 202 | 3 | 201 | 203 | 3 | 202 | 203 | 3 | 203 | NULL | 

Du Manuel MySQL , le JOINTURE EXTERNE GAUCHE fonctionne sur la base que lorsque Remerciements.id est à sa valeur maximale, il n'y a pas Remerciements_uniq.id avec une plus grande valeur et la Remerciements_uniq les valeurs des lignes seront NUL . Nick Kallen, auteur d'Arel, appelle cette technique « l’une des requêtes SQL les plus époustouflantes » .

L'avantage de cette requête est qu'elle est tout aussi performante que la PAR GROUPE version, et ne nécessite pas l'utilisation d'une table temporaire. De plus, cette requête n'affecte pas négativement compter dans Rails. Parfait !

La portée finale pour Incident reconnu est:

   déf   gauche_join_as_distinct  (  association  ) modèle_association  =  tableau de classes  =  association_model.arel_table table_alias  =  table.alias(  '  #{  nom de la table  }  _uniq  '  ) prédicat  =  tableau[  :incident_id  ].eq(alias_tableau[  :incident_id  ]) prédicat  =  prédicat.  et  (tableau[  :identifiant  ].lt(alias_tableau[  :identifiant  ])) join_sources  =  table.join(alias_table,  Arel  ::  Nœuds  ::  Jointure externe  ).on(prédicat).join_sources joins(join_sources).where(table_alias[  :identifiant  ].eq(  néant  ))  fin  portée  :reconnu  , arel_inner_join(  :remerciements  ). left_join_as_distinct(  :remerciements  ). non résolu 

Conclusion

L'opérateur de jointure gauche de SQL s'est avéré être un outil précieux pour écrire des requêtes correctes et efficaces pour les alertes multi-utilisateurs. Son utilisation était typique pour le périmètre des incidents déclenchés, qui impliquait la recherche d'incidents avec zéro reconnaissances. La jointure gauche s'est également avérée polyvalente pour la portée des incidents reconnus, où elle a été utilisée comme un proxy efficace pour DISTINCT pour trouver des incidents uniques avec au moins un reconnaissance.

Pour obtenir des requêtes efficaces, il fallait profiler les requêtes SQL d'origine, comprendre le comportement de l'optimiseur de requêtes de Percona et expérimenter des requêtes alternatives. Si des problèmes comme ceux-ci vous intéressent, PagerDuty recrute .

Merci à Steve Rice et Evan Gilman pour avoir révisé les versions préliminaires de cet article.

Monitoring_Ebook_728_90