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
:
- sessions : pour afficher les informations de session: navigateur, agent utilisateur, pays, etc.
- Recording_data : URL enregistrées, pages, durée des visites
- 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:
- Les plans de requĂȘte ne racontent pas toute l'histoire, mais peuvent donner des indices
- Les principaux suspects ne sont pas toujours les vrais coupables
- Les requĂȘtes lentes peuvent ĂȘtre interrompues pour isoler les goulots d'Ă©tranglement
- Toutes les optimisations ne sont pas de nature réductrice
- 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!