En dĂ©cembre de l'annĂ©e derniĂšre, j'ai reçu un rapport intĂ©ressant sur une erreur de l'Ă©quipe de support VWO. Le temps de chargement d'un des rapports analytiques pour un grand client entreprise semblait excessivement long. Ătant donnĂ© que cela relĂšve de ma responsabilitĂ©, je me suis immĂ©diatement concentrĂ© sur la rĂ©solution du problĂšme.
Contexte
Pour comprendre de quoi il s'agit, je vais parler un peu de VWO. C'est une plateforme qui permet de lancer différentes campagnes ciblées sur ses sites : réaliser des expériences A/B, suivre les visiteurs et les conversions, analyser l'entonnoir de vente, afficher des cartes de chaleur et reproduire des enregistrements de visites.
Mais ce qui est le plus important dans la plateforme, c'est la création de rapports. Toutes les fonctions mentionnées ci-dessus sont interconnectées. Et pour les clients entreprise, un grand volume d'informations serait tout simplement inutile sans une plateforme puissante qui les présente sous forme d'analytique.
En utilisant la plateforme, il est possible de faire une requĂȘte libre sur un grand ensemble de donnĂ©es. Voici un exemple simple :
Afficher tous les clics sur la page "abc.com" DE <date d1> à <date d2> pour les personnes qui utilisaient Chrome OU (étaient en Europe ET utilisaient un iPhone)
Notez les opĂ©rateurs boolĂ©ens. Ils sont disponibles pour les clients dans l'interface de requĂȘte afin de rĂ©aliser des requĂȘtes aussi complexes que souhaitĂ© pour obtenir des Ă©chantillons.
RequĂȘte lente
Le client en question essayait de faire quelque chose qui, selon l'intuition, devrait fonctionner rapidement :
Montrez tous les enregistrements de sessions pour les utilisateurs ayant visité n'importe quelle page avec une URL contenant "/jobs"
Ce site avait un énorme trafic, et nous stockions plus d'un million d'URL uniques juste pour lui. Ils voulaient trouver un modÚle d'URL assez simple, pertinent pour leur modÚle commercial.
EnquĂȘte prĂ©liminaire
Regardons ce qui se passe dans la base de donnĂ©es. Voici la requĂȘte SQL lente originale :
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 ;Voici les temps :
Temps prévu : 1,480 ms Temps d'exécution : 1,431,924.650 ms
La requĂȘte a parcouru 150 000 lignes. Le planificateur de requĂȘtes a montrĂ© quelques dĂ©tails intĂ©ressants, mais aucune goulot d'Ă©tranglement Ă©vident.
Continuons Ă examiner la requĂȘte. Comme on peut le voir, elle effectue JOIN trois tables :
- sessions: pour afficher les informations de session : navigateur, agent utilisateur, pays, etc.
- recording_data: URLs enregistrées, pages, durée des visites
- urls: pour éviter la duplication d'URLs exceptionnellement longues, nous les stockons dans une table séparée.
Remarquez également que toutes nos tables sont déjà divisées par account_id. Ainsi, il est évité qu'en raison d'un compte particuliÚrement volumineux, des problÚmes se produisent pour les autres.
Ă la recherche d'indices
En y regardant de plus prĂšs, nous voyons qu'il y a quelque chose d'anormal dans cette requĂȘte spĂ©cifique. Il vaut la peine de porter attention Ă 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 Ă cause de ILIKE sur toutes ces longues URLs (nous avons plus de 1,4 million de URLs uniques rassemblĂ©es pour ce compte) la performance pourrait ĂȘtre impactĂ©e. Mais non â ce n'est pas ça !
SELECT id FROM urls WHERE url ILIKE '%enterprise_customer.com/jobs%'; id -------- ... (198661 lignes)Temps : 5231.765 ms
La requĂȘte de recherche par motif ne prend que 5 secondes. La recherche par motif sur un million d'URLs uniques n'est manifestement pas un problĂšme.Le prochain suspect sur la liste â quelques
. Peut-ĂȘtre leur utilisation excessive a-t-elle contribuĂ© Ă ralentir ? Habituellement JOINâs sont les candidats les plus Ă©vidents pour les problĂšmes de performance, mais je ne croyais pas que notre cas Ă©tait typique. JOINanalytics_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 =5 AND recording_data.num_of_pages > 0 ; count ------- 8086 (1 ligne)Temps : 147.851 ms
Et cela n'Ă©tait pas notre cas non plus.Les âs se sont rĂ©vĂ©lĂ©s trĂšs rapides. JOINRĂ©duire le cercle des suspects
J'Ă©tais prĂȘt Ă commencer Ă modifier la requĂȘte pour atteindre des amĂ©liorations de performance possibles. Avec l'Ă©quipe, nous avons Ă©laborĂ© 2 idĂ©es principales :
Utiliser EXISTS pour la sous-requĂȘte URL
- : Nous voulions vĂ©rifier Ă nouveau s'il n'y avait pas de problĂšmes avec la sous-requĂȘte pour les URLs. Une des façons d'y parvenir est simplement de utiliser: Nous voulions vĂ©rifier Ă nouveau s'il y avait des problĂšmes avec la requĂȘte pour les URL. L'une des façons d'y parvenir est simplement d'utiliser
soient « vrais », mais.soient « vrais », maisaméliorer considérablement les performances car il se termine dÚs qu'il trouve la premiÚre ligne correspondant à la 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 =5
AND recording_data.num_of_pages > 0 ;
count
32519
(1 row)
Time: 1636.637 msEh bien. La sous-requĂȘte, lorsqu'elle est encapsulĂ©e dans soient « vrais », mais, rend tout super rapide. La question logique suivante est : pourquoi la requĂȘte avec JOIN-s et la sous-requĂȘte elle-mĂȘme sont rapides sĂ©parĂ©ment, mais ralentissent terriblement ensemble ?
- DĂ©plaçons la sous-requĂȘte dans un CTE : si la requĂȘte est rapide en elle-mĂȘme, nous pouvons simplement d'abord calculer le rĂ©sultat rapide, puis le fournir Ă la requĂȘte 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 =5
AND recording_data.num_of_pages > 0;Mais cela restait encore trĂšs lent.
Trouvons le coupable
Tout ce temps, une petite chose me trottait dans la tĂȘte Ă laquelle je me suis constamment dĂ©robĂ©. Mais puisque je n'avais plus rien d'autre, j'ai dĂ©cidĂ© de m'y attarder. Je parle de && l'opĂ©rateur. Pendant que soient « vrais », mais a simplement amĂ©liorĂ© les performances, && Ă©tait le seul facteur commun restant dans toutes les versions de la requĂȘte lente.
En regardant , nous voyons que && est utilisé lorsque nous devons trouver des éléments communs entre deux tableaux.
Dans la requĂȘte originale, c'est :
AND ( urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[] )Ce qui signifie que nous effectuons une recherche par motif sur nos URLs, puis trouvons l'intersection avec toutes les URLs ayant des enregistrements en commun. C'est un peu confus, car « urls » ici ne fait pas référence à la table contenant toutes les adresses URL, mais à la colonne « urls » dans la table recording_data.
Avec des soupçons grandissants envers &&, j'ai tentĂ© de les confirmer dans le plan de requĂȘte gĂ©nĂ©rĂ©. EXPLAIN ANALYZE (j'avais dĂ©jĂ un plan enregistrĂ©, mais je prĂ©fĂšre gĂ©nĂ©ralement expĂ©rimenter avec SQL plutĂŽt que d'essayer de comprendre les opacitĂ©s des planificateurs de requĂȘtes).
Filtre : ((urls && ($0)::text[]) ET (r_time > '2018-12-17 12:17:23+00'::timestamp with time zone) ET (r_time = '5'::double precision) ET (num_of_pages > 0))
Lignes supprimées par le filtre : 52710Il y avait plusieurs lignes de filtres uniquement depuis &&. Ce qui signifiait que cette opération non seulement coûtait cher, mais était également exécuté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 requĂȘte s'exĂ©cutait lentement. Puisque JOIN-s sont rapides et les sous-requĂȘtes sont rapides, il ne restait que && l'opĂ©rateur.
C'est juste que c'est l'opération clé. Nous devons toujours rechercher dans l'ensemble principal des URLs pour rechercher par motif, et nous devons toujours trouver des intersections. Nous ne pouvons pas rechercher directement dans les enregistrements d'urls, car ce ne sont que des identifiants faisant référence à urls.
En chemin vers la solution
&& lente, car les deux ensembles sont énormes. L'opération sera relativement rapide si je remplace urls sur { "http://google.com/", "http://wingify.com/" }.
J'ai commencé à chercher un moyen de faire en Postgres l'intersection de plusieurs ensembles sans utiliser &&, mais sans grand succÚs.
Finalement, nous avons dĂ©cidĂ© de simplement rĂ©soudre le problĂšme de maniĂšre isolĂ©e : donne-moi toutes les urls lignes oĂč l'url correspond au motif. 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 JOIN syntaxe, j'ai simplement utilisĂ© une sous-requĂȘte et dĂ©ballĂ© recording_data.urls le tableau, afin qu'il soit possible d'appliquer directement la condition dans OĂ.
Ce qui est le plus important ici, c'est que && est utilisĂ© pour vĂ©rifier si cette entrĂ©e contient l'URL correspondante. En plissant un peu les yeux, on peut voir dans cette opĂ©ration le dĂ©placement Ă travers les Ă©lĂ©ments du tableau (ou les lignes d'une table) et l'arrĂȘt lors de l'exĂ©cution de la condition (correspondance). Ăa ne vous rappelle rien ? Ah, soient « vrais », mais.
Puisque sur recording_data.urls peut ĂȘtre rĂ©fĂ©rencĂ© en dehors du contexte de la sous-requĂȘte, lorsqu'elle se produit, nous pouvons revenir Ă notre vieux camarade soient « vrais », mais et l'envelopper dans la sous-requĂȘte.
En combinant le tout, nous obtenons la requĂȘte optimisĂ©e finale :
SĂLECTIONNER
COUNT(*)
DE
acc_{account_id}.urls comme recordings_urls,
acc_{account_id}.recording_data comme recording_data,
acc_{account_id}.sessions comme sessions
OĂ
recording_data.usp_id = sessions.usp_id
ET ( 1 = 1 )
ET sessions.referrer_id = recordings_urls.id
ET r_time > to_timestamp(1542585600)
ET r_time =5
ET recording_data.num_of_pages > 0
ET EXISTE(
SĂLECTIONNER urls.url
DE
acc_{account_id}.urls comme urls,
(SĂLECTIONNER unnest(urls) COMME rec_url_id DE acc_{account_id}.recording_data)
COMME unrolled_urls
OĂ
urls.id = unrolled_urls.rec_url_id ET
urls.url ILIKE '%enterprise_customer.com/jobs%'
);
Et le temps d'exécution final Temps : 1898,717 ms Il est temps de célébrer?!?
Pas si vite ! D'abord, il faut vĂ©rifier la validitĂ©. J'Ă©tais extrĂȘmement suspicieux concernant soient « vrais », mais l'optimisation, car elle modifie la logique pour une fin anticipĂ©e. Nous devons ĂȘtre sĂ»rs de ne pas avoir introduit une erreur subtile dans la requĂȘte.
La simple vĂ©rification consistait Ă exĂ©cuter count(*) Ă la fois sur des requĂȘtes lentes et rapides pour un large Ă©ventail de jeux de donnĂ©es. Ensuite, pour un petit sous-ensemble de donnĂ©es, j'ai vĂ©rifiĂ© manuellement l'exactitude de tous les rĂ©sultats.
Tous les tests ont donné des résultats positifs stables. Tout est réparé !
Leçons tirées
Il y a beaucoup de leçons à tirer de cette histoire :
- Les plans de requĂȘtes ne racontent pas toute l'histoire, mais peuvent donner des indices
- Les principaux suspects ne sont pas toujours les véritables coupables
- Les requĂȘtes lentes peuvent ĂȘtre dĂ©composĂ©es pour isoler les goulets d'Ă©tranglement
- Toutes les optimisations ne sont pas par nature réductrices
- Utilisation
EXIST, lĂ oĂč c'est possible, cela peut conduire Ă une augmentation radicale des performances
Sortie
Nous sommes passĂ©s d'un temps de requĂȘte d'environ 24 minutes Ă 2 secondes â une augmentation de performance assez sĂ©rieuse ! Bien que cet article soit long, toutes les expĂ©riences que nous avons menĂ©es se sont dĂ©roulĂ©es en une journĂ©e et ont pris entre 1,5 et 2 heures pour les optimisations et les tests.
SQL est un merveilleux langage, si l'on n'en a pas peur, mais que l'on cherche Ă le comprendre et Ă l'utiliser. Avec une bonne comprĂ©hension de la façon dont les requĂȘtes SQL sont exĂ©cutĂ©es, de la maniĂšre dont la base de donnĂ©es gĂ©nĂšre des plans de requĂȘte, de la façon dont les index fonctionnent et simplement de la taille des donnĂ©es avec lesquelles vous avez affaire, vous pourrez vraiment exceller dans l'optimisation des requĂȘtes. Il est aussi crucial de continuer Ă essayer diffĂ©rentes approches et Ă dĂ©composer progressivement le problĂšme, en identifiant les goulets d'Ă©tranglement.
La meilleure partie d'obtenir de tels rĂ©sultats est l'amĂ©lioration visible et significative de la vitesse â lorsque le rapport qui ne se chargeait mĂȘme pas auparavant se charge maintenant presque instantanĂ©ment.
Un remerciement particulier à mes collĂšgues de l'Ă©quipe Aditya Mishra, Aditya Gaur et pour le brainstorming et Dinkar Pandir pour avoir trouvĂ© une erreur importante dans notre requĂȘte finale, avant que nous ne nous en sĂ©parions dĂ©finitivement !
Source : habr.com
