W grudniu ubiegłego roku otrzymałem interesujący raport o błędzie od zespołu wsparcia VWO. Czas ładowania jednego z raportów analitycznych dla dużego klienta korporacyjnego wydawał się nieproporcjonalnie długi. Ponieważ to leżało w moich kompetencjach, od razu skupiłem się na rozwiązaniu problemu.
Tło
Aby wyjaśnić, o co chodzi, opowiem krótko o VWO. To platforma, która umożliwia prowadzenie różnych ukierunkowanych kampanii na swoich stronach: przeprowadzanie eksperymentów A/B, śledzenie odwiedzających i konwersji, analizowanie lejków sprzedażowych, wyświetlanie map cieplnych oraz odtwarzanie nagrań wizyt.
Ale najważniejszą funkcją platformy jest tworzenie raportów. Wszystkie wymienione funkcje są ze sobą powiązane. Dla klientów korporacyjnych ogromna ilość informacji byłaby po prostu bezużyteczna bez solidnej platformy, która przedstawia je w formacie analitycznym.
Korzystając z platformy, można złożyć dowolne zapytanie na dużym zbiorze danych. Oto prosty przykład:
Pokaż wszystkie kliknięcia na stronie "abc.com" OD DO dla osób, które używały Chrome LUB (były w Europie I używały iPhone'a)
Zauważ, że operatory logiczne są dostępne dla klientów w interfejsie zapytania, aby tworzyć dowolnie skomplikowane zapytania do pozyskiwania próbek.
Wolne zapytanie
Klient, o którym mowa, próbował zrobić coś, co intuicyjnie powinno działać szybko:
Pokaż wszystkie nagrania sesji dla użytkowników, którzy odwiedzili dowolną stronę z URL-em zawierającym "\/jobs"
Na tej stronie było ogromne natężenie ruchu, a my przechowywaliśmy ponad milion unikalnych adresów URL tylko dla niej. A oni chcieli znaleźć dość prosty wzór URL-a, odnoszący się do ich modelu biznesowego.
Wstępne dochodzenie
Spójrzmy, co się dzieje w bazie danych. Poniżej znajduje się oryginalne powolne zapytanie SQL:
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 = 5
AND recording_data.num_of_pages > 0 ;A oto czasy:
Planowany czas: 1,480 ms Czas realizacji: 1431924,650 ms
Zapytanie obejmowało 150 tysięcy wierszy. Planner zapytań ujawnił kilka interesujących szczegółów, ale nie pokazał żadnych oczywistych wąskich gardeł.
Przyjrzyjmy się temu zapytaniu bardziej szczegółowo. Jak widać, wykonuje ono JOIN trzy tabele:
- sessions: do wyświetlania informacji o sesjach: przeglądarka, agent użytkownika, kraj itd.
- recording_data: zapisane URL-e, strony, czas trwania wizyt
- urls: aby uniknąć duplikacji niezwykle dużych URL-i, przechowujemy je w osobnej tabeli.
Zwróć uwagę, że wszystkie nasze tabele są już podzielone według account_id. W ten sposób wyklucza się sytuację, w której z powodu jednego szczególnie dużego konta problemy występują u innych.
W poszukiwaniu dowodów
Przy bliższym przyjrzeniu się widzimy, że coś jest nie tak z konkretnym zapytaniem. Warto zwrócić uwagę na ten wiersz:
urls && array(
select id from acc_{account_id}.urls
where url ILIKE '%enterprise_customer.com/jobs%'
)::text[]Pierwszą myślą było, że być może z powodu ILIKE na tych wszystkich długich URL-ach (mamy ponad 1,4 miliona unikalnych adresów URL zgromadzonych dla tego konta) wydajność może być niska.
Ale nie, to nie o to chodzi!
SELECT id FROM urls WHERE url ILIKE '%enterprise_customer.com/jobs%';
id
--------
...
(198661 wierszy)
Czas: 5231.765 msSame zapytanie wyszukiwania wzorca zajmuje zaledwie 5 sekund. Wyszukiwanie wzorca w milionie unikalnych URL-i zdecydowanie nie stanowi problemu.
Następny podejrzany na liście to kilka JOIN. Być może ich nadmierne użycie prowadzi do spowolnienia? Zwykle JOIN‘y są najbardziej oczywistymi kandydatami do problemów z wydajnością, ale nie wierzyłem, że nasz przypadek jest typowy.
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 = 5
AND recording_data.num_of_pages > 0 ;
count
-------
8086
(1 wiersz)
Czas: 147.851 msI to również nie był nasz przypadek. JOIN‘y okazały się bardzo szybkie.
Zawężamy krąg podejrzanych
Byłem gotów zacząć zmieniać zapytanie, aby osiągnąć jakiekolwiek możliwe poprawki wydajności. My i zespół opracowaliśmy 2 główne pomysły:
- Użyć EXISTS dla podzapytania URL: Chcieliśmy jeszcze raz sprawdzić, czy są jakieś problemy z podzapytaniem dla URL-i. Jednym ze sposobów, aby to osiągnąć, jest po prostu użycie
EXISTS.EXISTSznacząco poprawić wydajność, ponieważ kończy się natychmiast, gdy znajdzie jedną linię zgodnie z warunkiem.
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 msTak. Podzapytanie, gdy jest owinięte w EXISTS, sprawia, że wszystko działa super szybko. Następne logiczne pytanie to, dlaczego zapytanie z JOIN-ami i samo podzapytanie są szybkie z osobna, ale wolno działają razem?
- Przenosimy podzapytanie do CTE : jeśli zapytanie jest szybkie samo w sobie, możemy najpierw obliczyć szybki wynik, a następnie przekazać go do głównego zapytania
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;Ale to nadal było bardzo wolne.
Szukamy winowajcy
Cały czas przed oczyma migał jeden szczegół, od którego wciąż odsuwałem się. Ale ponieważ nie miałem już nic, postanowiłem na niego spojrzeć. Mówię o && operatorze. Dopóki EXISTS po prostu poprawił wydajność, && był jedynym wspólnym czynnikiem we wszystkich wersjach wolnego zapytania.
Patrząc na , widzimy, że && jest używany, gdy trzeba znaleźć wspólne elementy między dwoma tablicami.
W oryginalnym zapytaniu to:
AND ( urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[] )Co oznacza, że robimy wyszukiwanie wzorca w naszych URL-ach, a następnie znajdujemy przecięcie ze wszystkimi URL-ami z ogólnymi zapisami. To jest trochę mylące, ponieważ „urls” tutaj nie odnosi się do tabeli, która zawiera wszystkie adresy URL, lecz do kolumny „urls” w tabeli recording_data.
W miarę wzrastania podejrzeń dotyczących &&, próbowałem znaleźć potwierdzenie w planie zapytania, który został wygenerowany EXPLAIN ANALYZE (miałem już zapisany plan, ale zazwyczaj wygodniej jest mi eksperymentować w SQL, niż próbować zrozumieć nieprzezroczystości planistów zapytań).
Filtr: ((adresy URL && ($0)::text[]) I (r_time > '2018-12-17 12:17:23+00'::timestamp with time zone) I (r_time = '5'::double precision) I (liczba_stron > 0))\n Wiersze usunięte przez filtr: 52710Było tam kilka wierszy filtrów tylko z &&. Co oznaczało, że ta operacja była nie tylko kosztowna, ale także wykonywana wielokrotnie.
Sprawdziłem to, izolując warunek
SELECT 1\nFROM \n acc_{account_id}.urls jako recordings_urls, \n acc_{account_id}.recording_data_30 jako recording_data_30, \n acc_{account_id}.sessions_30 jako sessions_30 \nGDZIE \n\turls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[]To zapytanie działało wolno. Ponieważ JOIN-y są szybkie, a podzapytania są szybkie, pozostawał tylko && operator.
Ale to kluczowa operacja. Zawsze musimy przeszukiwać całą główną tabelę adresów URL w celu wyszukiwania według wzoru, a zawsze musimy znajdować przecięcia. Nie możemy przeszukiwać bezpośrednio po wpisach adresów URL, ponieważ są to tylko identyfikatory odnoszące się do urls.
W drodze do rozwiązania
&& wolna, ponieważ oba zestawy są ogromne. Operacja będzie stosunkowo szybka, jeśli zastąpię urls na { "http://google.com/", "http://wingify.com/" }.
Zacząłem szukać sposobu na wykonanie w Postgres przecięcia zbiorów bez używania &&, ale bez większego sukcesu.
W końcu postanowiliśmy po prostu rozwiązać problem w izolacji: daj mi wszystkie urls wiersze, dla których adres URL odpowiada wzorowi. Bez dodatkowych warunków to będzie —
SELECT urls.url\nFROM \n\tacc_{account_id}.urls jako urls,\n\t(SELECT unnest(recording_data.urls) AS id) AS unrolled_urls\nGDZIE\n\turls.id = unrolled_urls.id I\n\turls.url ILIKE '%jobs%'Zamiast JOIN syntaktyka, po prostu użyłem podzapytania i rozwinąłem recording_data.urls tablicę, aby można było bezpośrednio stosować warunek w WHERE.
Najważniejsze tutaj to, że && jest używane do sprawdzenia, czy dany wpis zawiera odpowiadający adres URL. Patrząc nieco bardziej uważnie, można dostrzec w tej operacji przechodzenie przez elementy tablicy (lub wiersze tabeli) i zatrzymanie się przy spełnieniu warunku (zgodności). Przypomina to coś? Aha, EXISTS.
Ponieważ na recording_data.urls można odnosić się zewnętrznie do kontekstu podzapytania, gdy to ma miejsce, możemy wrócić do naszego starego znajomego EXISTS i owinąć go podzapytaniem.
Łącząc wszystko razem, otrzymujemy ostateczne zoptymalizowane zapytanie:
WYBIERZ
count(*)
Z
acc_{account_id}.urls jako recordings_urls,
acc_{account_id}.recording_data jako recording_data,
acc_{account_id}.sessions jako sessions
GDZIE
recording_data.usp_id = sessions.usp_id
I ( 1 = 1 )
I sessions.referrer_id = recordings_urls.id
I r_time > to_timestamp(1542585600)
I r_time = 5
I recording_data.num_of_pages > 0
I ISTNIEJE(
WYBIERZ urls.url
Z
acc_{account_id}.urls jako urls,
(WYBIERZ unnest(urls) JAKO rec_url_id Z acc_{account_id}.recording_data)
AS unrolled_urls
GDZIE
urls.id = unrolled_urls.rec_url_id I
urls.url ILIKE '%enterprise_customer.com\/jobs%'
);
I ostateczny czas wykonania Czas: 1898.717 ms Czas na świętowanie?!?
Nie tak szybko! Najpierw musimy sprawdzić poprawność. Byłem bardzo sceptyczny wobec EXISTS optymalizacji, ponieważ zmienia ona logikę na wcześniejsze zakończenie. Musimy upewnić się, że nie dodaliśmy żadnego ukrytego błędu do zapytania.
Prosta weryfikacja polegała na wykonaniu count(*) zarówno w wolnych, jak i szybkich zapytaniach dla różnych zestawów danych. Następnie, dla małego podzbioru danych, sprawdziłem poprawność wszystkich wyników ręcznie.
Wszystkie kontrole dały stabilnie pozytywne wyniki. Wszystko naprawiliśmy!
Wyciągnięte Wnioski
Z tej historii można wyciągnąć wiele lekcji:
- Plany zapytań nie mówią całej historii, ale mogą dawać wskazówki
- Główni podejrzani nie zawsze są prawdziwymi winowajcami
- Wolne zapytania można podzielić, aby zlokalizować wąskie gardła
- Nie wszystkie optymalizacje są z natury redukcyjne
- Użycie
EXIST, gdzie to możliwe, może prowadzić do znacznego wzrostu wydajności
Wnioski
Przeszliśmy od czasu zapytania wynoszącego ~24 minuty do 2 sekund — to znaczny wzrost wydajności! Chociaż ten artykuł jest obszerny, wszystkie eksperymenty, które przeprowadziliśmy, miały miejsce w ciągu jednego dnia i szacunkowo zajęły od 1,5 do 2 godzin na optymalizację i testowanie.
SQL to wspaniały język, jeśli się go nie boicie, a spróbujecie poznać i wykorzystać. Posiadając dobre zrozumienie, jak wykonują się zapytania SQL, jak BD generuje plany zapytań, jak działają indeksy i po prostu rozmiar danych, z którymi się macie do czynienia, możecie bardzo skutecznie optymalizować zapytania. Niemniej jednak równie ważne jest, aby nadal próbować różnych podejść i stopniowo rozwiązywać problemy, znajdując wąskie gardła.
Najlepszą częścią osiągania takich wyników jest widoczne, zauważalne poprawienie szybkości działania – raport, który wcześniej nawet się nie ładował, teraz ładuje się niemal natychmiast.
Szczególne podziękowania moim towarzyszom z zespołu Aditya Mishra, Aditya Gaur i za burzę mózgów i Dinkarowi Pandirze za wykrycie ważnego błędu w naszym końcowym zapytaniu, zanim ostatecznie się z nim pożegnaliśmy!
Źródło: habr.com
