Historique d'une seule enquĂȘte SQL

En décembre dernier, j'ai reçu un rapport de bug intéressant de l'équipe d'assistance VWO. Le temps de chargement d'un des rapports analytiques pour une grande entreprise cliente semble prohibitif. Et puisque c'est mon domaine de responsabilité, je me suis immédiatement concentré sur la résolution du problÚme.


Contexte


Pour bien comprendre de quoi je parle, je vais vous parler un peu de VWO. Il s'agit d'une plate-forme avec laquelle vous pouvez exécuter diverses campagnes ciblées sur vos sites: mener des expériences A / B, suivre les visiteurs et les conversions, analyser les entonnoirs de vente, afficher les cartes thermiques et lire les enregistrements des visites.


Mais la chose la plus importante dans la plateforme est le reporting. Toutes les fonctions ci-dessus sont interconnectées. Et pour les entreprises clientes, un large éventail d'informations serait tout simplement inutile sans une plate-forme puissante les présentant sous la forme d'analyses.


En utilisant la plate-forme, vous pouvez effectuer une demande arbitraire sur un grand ensemble de données. Voici un exemple simple:


  Afficher tous les clics sur abc.com
 DU <date d1> AU <date d2>
 pour les personnes qui
 Chrome utilisé OU
 (étaient en Europe et utilisaient l'iPhone) 

Faites attention aux opĂ©rateurs boolĂ©ens. Ils sont disponibles pour les clients dans l'interface de requĂȘte pour effectuer des requĂȘtes arbitrairement complexes pour rĂ©cupĂ©rer des Ă©chantillons.


Demande lente


Le client en question essayait de faire quelque chose qui devrait intuitivement fonctionner rapidement:


  Afficher toutes les notes de session
 pour les utilisateurs visitant n'importe quelle page
 avec URL oĂč il y a "/ jobs" 

Il y avait beaucoup de trafic sur ce site, et nous avons stocké plus d'un million d'URL uniques juste pour cela. Et ils voulaient trouver un modÚle d'URL assez simple lié à leur modÚle commercial.


EnquĂȘte prĂ©liminaire


Voyons ce qui se passe dans la base de donnĂ©es. Voici la requĂȘte SQL lente d'origine:


SELECT count(*) FROM acc_{account_id}.urls as recordings_urls, acc_{account_id}.recording_data as recording_data, acc_{account_id}.sessions as sessions WHERE recording_data.usp_id = sessions.usp_id AND sessions.referrer_id = recordings_urls.id AND ( urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[] ) AND r_time > to_timestamp(1542585600) AND r_time < to_timestamp(1545177599) AND recording_data.duration >=5 AND recording_data.num_of_pages > 0 ; 

Et voici les horaires:


  Temps prévu: 1,480 ms
 Délai: 1431924.650 ms 

La demande a contournĂ© 150 000 lignes. Le planificateur de requĂȘtes a montrĂ© quelques dĂ©tails intĂ©ressants, mais aucun goulot d'Ă©tranglement Ă©vident.


Étudions davantage la requĂȘte. Comme vous pouvez le voir, il crĂ©e trois tables JOIN :


  1. sessions : pour afficher les informations de session: navigateur, agent utilisateur, pays, etc.
  2. Recording_data : URL enregistrées, pages, durée des visites
  3. URL : pour Ă©viter la duplication d'URL extrĂȘmement volumineuses, nous les stockons dans une table sĂ©parĂ©e.

Notez également que toutes nos tables sont déjà divisées par account_id . Ainsi, une situation est exclue lorsque, en raison d'un compte particuliÚrement important, les autres ont des problÚmes.


À la recherche de preuves


En y regardant de plus prĂšs, nous constatons que quelque chose dans une demande particuliĂšre n'est pas correct. Cela vaut la peine de regarder cette ligne:


 urls && array( select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%' )::text[] 

La premiĂšre pensĂ©e Ă©tait que, peut-ĂȘtre en raison d' ILIKE dans toutes ces longues URL (nous avons plus de 1,4 million d'URL uniques collectĂ©es pour ce compte), les performances pourraient s'affaisser.


Mais non - ce n'est pas le sujet!


 SELECT id FROM urls WHERE url ILIKE '%enterprise_customer.com/jobs%'; id -------- ... (198661 rows) Time: 5231.765 ms 

La demande de recherche de modĂšle elle-mĂȘme ne prend que 5 secondes. La recherche d'un modĂšle sur un million d'URL uniques n'est clairement pas un problĂšme.


Le prochain suspect sur la liste est quelques JOIN . Peut-ĂȘtre que leur surutilisation a conduit Ă  un ralentissement? JOIN gĂ©nĂ©ralement les candidats les plus Ă©vidents pour les problĂšmes de performances, mais je ne pensais pas que notre cas Ă©tait typique.


 analytics_db=# SELECT count(*) FROM acc_{account_id}.urls as recordings_urls, acc_{account_id}.recording_data_0 as recording_data, acc_{account_id}.sessions_0 as sessions WHERE recording_data.usp_id = sessions.usp_id AND sessions.referrer_id = recordings_urls.id AND r_time > to_timestamp(1542585600) AND r_time < to_timestamp(1545177599) AND recording_data.duration >=5 AND recording_data.num_of_pages > 0 ; count ------- 8086 (1 row) Time: 147.851 ms 

Et ce n'était pas non plus notre cas. JOIN s'est avéré assez rapide.


Nous resserrons le cercle des suspects


J'Ă©tais prĂȘt Ă  commencer Ă  modifier la requĂȘte pour obtenir toutes les amĂ©liorations de performances possibles. Mon Ă©quipe et moi avons dĂ©veloppĂ© 2 idĂ©es principales:


  • Utilisez EXISTS pour l'URL de la sous-requĂȘte : nous voulions vĂ©rifier Ă  nouveau s'il y avait des problĂšmes avec la sous-requĂȘte des URL. Une façon d'y parvenir est d'utiliser simplement EXISTS . EXISTS peut grandement amĂ©liorer les performances car il se termine immĂ©diatement dĂšs qu'il trouve une seule ligne par condition.

 SELECT count(*) FROM acc_{account_id}.urls as recordings_urls, acc_{account_id}.recording_data as recording_data, acc_{account_id}.sessions as sessions WHERE recording_data.usp_id = sessions.usp_id AND ( 1 = 1 ) AND sessions.referrer_id = recordings_urls.id AND (exists(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')) AND r_time > to_timestamp(1547585600) AND r_time < to_timestamp(1549177599) AND recording_data.duration >=5 AND recording_data.num_of_pages > 0 ; count 32519 (1 row) Time: 1636.637 ms 

Et bien oui. La sous-requĂȘte, lorsqu'elle est enveloppĂ©e dans EXISTS , rend tout super rapide. La prochaine question logique est pourquoi la requĂȘte avec JOINs et la sous-requĂȘte elle-mĂȘme sont rapides individuellement, mais terriblement lentes ensemble?


  • Nous dĂ©plaçons la sous-requĂȘte vers le CTE : si la demande est rapide par elle-mĂȘme, nous pouvons simplement calculer le rĂ©sultat rapide en premier, puis le fournir Ă  la demande principale

 WITH matching_urls AS ( select id::text from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%' ) SELECT count(*) FROM acc_{account_id}.urls as recordings_urls, acc_{account_id}.recording_data as recording_data, acc_{account_id}.sessions as sessions, matching_urls WHERE recording_data.usp_id = sessions.usp_id AND ( 1 = 1 ) AND sessions.referrer_id = recordings_urls.id AND (urls && array(SELECT id from matching_urls)::text[]) AND r_time > to_timestamp(1542585600) AND r_time < to_timestamp(1545107599) AND recording_data.duration >=5 AND recording_data.num_of_pages > 0; 

Mais c'était encore trÚs lent.


Trouvez le coupable


Pendant tout ce temps, une petite chose a clignotĂ© devant mes yeux, dont j'ai constamment Ă©cartĂ©. Mais comme il ne restait plus rien, j'ai dĂ©cidĂ© de la regarder. Je parle de l'opĂ©rateur && . Alors EXISTS amĂ©liorait simplement les performances, && Ă©tait le seul facteur commun restant dans toutes les versions de la requĂȘte lente.


En regardant la documentation , nous voyons que && utilisé lorsque vous avez besoin de trouver des éléments communs entre deux tableaux.


Dans la demande d'origine, il s'agit de:


 AND ( urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[] ) 

Ce qui signifie que nous faisons une recherche de modÚle pour nos URL, puis nous trouvons l'intersection avec toutes les URL avec des enregistrements partagés. C'est un peu déroutant, car «urls» ici ne fait pas référence à une table contenant toutes les URL, mais à une colonne «urls» dans la table recording_data .


Alors que les soupçons de && , j'ai essayĂ© de trouver une confirmation dans le plan de requĂȘte gĂ©nĂ©rĂ© par EXPLAIN ANALYZE (j'avais dĂ©jĂ  un plan enregistrĂ©, mais il est gĂ©nĂ©ralement plus pratique d'expĂ©rimenter SQL que d'essayer de comprendre l'opacitĂ© des planificateurs de requĂȘte).


 Filter: ((urls && ($0)::text[]) AND (r_time > '2018-12-17 12:17:23+00'::timestamp with time zone) AND (r_time < '2018-12-18 23:59:59+00'::timestamp with time zone) AND (duration >= '5'::double precision) AND (num_of_pages > 0)) Rows Removed by Filter: 52710 

Il y avait quelques lignes de filtres de && uniquement. Ce qui signifiait que cette opération était non seulement coûteuse, mais également effectuée plusieurs fois.


J'ai vérifié cela en isolant la condition


 SELECT 1 FROM acc_{account_id}.urls as recordings_urls, acc_{account_id}.recording_data_30 as recording_data_30, acc_{account_id}.sessions_30 as sessions_30 WHERE urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[] 

Cette demande Ă©tait lente. Comme les JOIN rapides et les sous-requĂȘtes sont rapides, seul l'opĂ©rateur && reste.


Ce n'est qu'une opération clé. Nous devons toujours rechercher partout dans le tableau principal des URL pour rechercher par modÚle, et nous devons toujours trouver des intersections. Nous ne pouvons pas rechercher directement les entrées d'URL, car ce ne sont que des identifiants qui pointent vers des urls .


Vers une solution


&& lent parce que les deux ensembles sont énormes. L'opération sera relativement rapide si je remplace les urls par { "http://google.com/", "http://wingify.com/" } .


J'ai commencé à chercher un moyen de créer des intersections d'ensembles dans Postgres sans utiliser && , mais sans grand succÚs.


En fin de compte, nous avons décidé de résoudre simplement le problÚme de maniÚre isolée: donnez-moi toutes les urls chaßne pour laquelle l'URL correspond au modÚle. Sans conditions supplémentaires, ce sera -


 SELECT urls.url FROM acc_{account_id}.urls as urls, (SELECT unnest(recording_data.urls) AS id) AS unrolled_urls WHERE urls.id = unrolled_urls.id AND urls.url ILIKE '%jobs%' 

Au lieu de la syntaxe JOIN , j'ai simplement utilisĂ© une sous-requĂȘte et dĂ©veloppĂ© le tableau recording_data.urls afin que la condition puisse ĂȘtre directement appliquĂ©e Ă  WHERE .


La chose la plus importante ici est que && utilisĂ© pour vĂ©rifier si une entrĂ©e donnĂ©e contient une URL appropriĂ©e. En plissant les yeux, vous pouvez voir dans cette opĂ©ration se dĂ©placer Ă  travers les Ă©lĂ©ments du tableau (ou des lignes du tableau) et s'arrĂȘter lorsque la condition (correspondance) est remplie. Ça ne ressemble Ă  rien? Ouais, EXISTS .


Étant donnĂ© que recording_data.urls peut ĂȘtre rĂ©fĂ©rencĂ© de l'extĂ©rieur du contexte de la sous-requĂȘte lorsque cela se produit, nous pouvons revenir Ă  notre vieil ami EXISTS et les envelopper avec une sous-requĂȘte.


En combinant tout ensemble, nous obtenons la requĂȘte optimisĂ©e finale:


 SELECT count(*) FROM acc_{account_id}.urls as recordings_urls, acc_{account_id}.recording_data as recording_data, acc_{account_id}.sessions as sessions WHERE recording_data.usp_id = sessions.usp_id AND ( 1 = 1 ) AND sessions.referrer_id = recordings_urls.id AND r_time > to_timestamp(1542585600) AND r_time < to_timestamp(1545177599) AND recording_data.duration >=5 AND recording_data.num_of_pages > 0 AND EXISTS( SELECT urls.url FROM acc_{account_id}.urls as urls, (SELECT unnest(urls) AS rec_url_id FROM acc_{account_id}.recording_data) AS unrolled_urls WHERE urls.id = unrolled_urls.rec_url_id AND urls.url ILIKE '%enterprise_customer.com/jobs%' ); 

Et le Time: 1898.717 ms exécution final Time: 1898.717 ms Il est temps de célébrer?!?


Pas si vite! Vous devez d'abord vĂ©rifier l'exactitude. J'Ă©tais extrĂȘmement mĂ©fiant vis-Ă -vis de l'optimisation EXISTS , car elle change la logique Ă  une fin antĂ©rieure. Nous devons ĂȘtre sĂ»rs que nous n'avons pas ajoutĂ© une erreur non Ă©vidente Ă  la demande.


Une vĂ©rification simple consistait Ă  effectuer le count(*) sur les requĂȘtes lentes et rapides pour un grand nombre d'ensembles de donnĂ©es diffĂ©rents. Ensuite, pour un petit sous-ensemble de donnĂ©es, j'ai vĂ©rifiĂ© manuellement l'exactitude de tous les rĂ©sultats.


Tous les contrÎles ont donné des résultats systématiquement positifs. Nous l'avons réparé!


Leçons apprises


Il y a de nombreuses leçons à tirer de cette histoire:


  1. Les plans de requĂȘte ne racontent pas toute l'histoire, mais peuvent donner des indices
  2. Les principaux suspects ne sont pas toujours les vrais coupables
  3. Les requĂȘtes lentes peuvent ĂȘtre interrompues pour isoler les goulots d'Ă©tranglement
  4. Toutes les optimisations ne sont pas de nature réductrice
  5. Lorsque cela est possible, l'utilisation d' EXIST peut entraßner une forte augmentation de la productivité.

Conclusion


Nous sommes passĂ©s d'un temps de demande de ~ 24 minutes Ă  2 secondes - une augmentation trĂšs sĂ©rieuse des performances! Bien que cet article se soit avĂ©rĂ© ĂȘtre volumineux, toutes les expĂ©riences que nous avons faites ont eu lieu le mĂȘme jour, et selon les estimations, il a fallu de 1,5 Ă  2 heures pour les optimisations et les tests.


SQL est un langage merveilleux, s'il n'en a pas peur, mais essayez d'apprendre et d'utiliser. Ayant une bonne comprĂ©hension de la façon dont les requĂȘtes SQL sont exĂ©cutĂ©es, de la façon dont la base de donnĂ©es gĂ©nĂšre des plans de requĂȘte, du fonctionnement des index et simplement de la taille des donnĂ©es que vous traitez, vous pouvez grandement rĂ©ussir dans l'optimisation des requĂȘtes. Il est tout aussi important, cependant, de continuer Ă  essayer diffĂ©rentes approches et de rĂ©soudre lentement le problĂšme, en trouvant des goulots d'Ă©tranglement.


La meilleure partie pour obtenir de tels rĂ©sultats est une amĂ©lioration visible et sensible de la vitesse - lorsqu'un rapport qui n'avait mĂȘme pas Ă©tĂ© tĂ©lĂ©chargĂ© auparavant est maintenant chargĂ© presque instantanĂ©ment.


Un merci spécial à mes coéquipiers Aditya Misra , Aditya Gauru et Varun Malhotra pour le brainstorming et Dinkar Pandir pour avoir trouvé une erreur importante dans notre demande finale avant de finalement lui dire au revoir!

Source: https://habr.com/ru/post/fr455832/


All Articles