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ę zaporowy. A ponieważ jest to obszar moich obowiązków, od razu skupiłem się na rozwiązaniu problemu.
prehistoria
Żeby było jasne o czym mówię opowiem trochę o VWO. To platforma, za pomocą której możesz uruchamiać na swoich stronach internetowych różnorodne kampanie targetowane: przeprowadzać eksperymenty A/B, śledzić odwiedziny i konwersje, analizować lejek sprzedażowy, wyświetlać mapy ciepła i odtwarzać nagrania wizyt.
Ale najważniejszą rzeczą na platformie jest raportowanie. Wszystkie powyższe funkcje są ze sobą powiązane. Natomiast dla klientów korporacyjnych ogromna ilość informacji byłaby po prostu bezużyteczna bez potężnej platformy, która prezentuje je w formie analitycznej.
Korzystając z platformy możesz wykonać losowe zapytanie na dużym zbiorze danych. Oto prosty przykład:
Pokaż wszystkie kliknięcia na stronie „abc.com” OD <data d1> DO <data d2> dla osób, które korzystały z przeglądarki Chrome LUB (z siedzibą w Europie ORAZ korzystały z iPhone'a)
Zwróć uwagę na operatory logiczne. Są one dostępne dla klientów w interfejsie zapytań, umożliwiając tworzenie dowolnie złożonych zapytań w celu uzyskania próbek.
Powolne żądanie
Klient, o którym mowa, próbował zrobić coś, co intuicyjnie powinno działać szybko:
Pokaż wszystkie rekordy sesji dla użytkowników, którzy odwiedzili dowolną stronę z adresem URL zawierającym „/jobs”
Ta witryna miała duży ruch i specjalnie dla niej przechowywaliśmy ponad milion unikalnych adresów URL. Chcieli znaleźć dość prosty szablon adresu URL, który byłby powiązany z ich modelem biznesowym.
Wstępne dochodzenie
Przyjrzyjmy się temu, co dzieje się 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 < to_timestamp(1545177599)
AND recording_data.duration >=5
AND recording_data.num_of_pages > 0 ;
A oto terminy:
Planowany czas: 1.480 ms Czas wykonania: 1431924.650 ms
Zapytanie przeszukało 150 tysięcy wierszy. Planista zapytań pokazał kilka interesujących szczegółów, ale nie pokazał żadnych oczywistych wąskich gardeł.
Przeanalizujmy tę prośbę dalej. Jak widać, robi to JOIN trzy tabele:
Sesje: aby wyświetlić informacje o sesji: przeglądarce, kliencie użytkownika, kraju i tak dalej.
dane_nagrania: zarejestrowane adresy URL, strony, czas trwania wizyt
urls: Aby uniknąć duplikowania bardzo dużych adresów URL, przechowujemy je w osobnej tabeli.
Należy również pamiętać, że wszystkie nasze tabele są już podzielone według account_id. W ten sposób wykluczona jest sytuacja, w której jedno szczególnie duże konto powoduje problemy dla innych.
Szukam wskazówek
Po bliższym przyjrzeniu się widzimy, że coś jest nie tak z konkretnym żądaniem. Warto przyjrzeć się bliżej tej linijce:
urls && array(
select id from acc_{account_id}.urls
where url ILIKE '%enterprise_customer.com/jobs%'
)::text[]
Pierwsza myśl była taka, że może dlatego ILIKE na wszystkich tych długich adresach URL (mamy ponad 1,4 miliona wyjątkowy adresy URL zebrane dla tego konta) wydajność może ulec pogorszeniu.
Ale nie, nie o to chodzi!
SELECT id FROM urls WHERE url ILIKE '%enterprise_customer.com/jobs%';
id
--------
...
(198661 rows)
Time: 5231.765 ms
Samo żądanie wyszukiwania szablonu zajmuje tylko 5 sekund. Znalezienie wzorca w milionie unikalnych adresów URL najwyraźniej nie stanowi problemu.
Kolejnym podejrzanym na liście jest kilku JOIN. Być może ich nadużywanie spowodowało spowolnienie? Zazwyczaj JOINsą najbardziej oczywistymi kandydatami na problemy z wydajnością, ale nie sądził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 < to_timestamp(1545177599)
AND recording_data.duration >=5
AND recording_data.num_of_pages > 0 ;
count
-------
8086
(1 row)
Time: 147.851 ms
I tak też nie było w naszym przypadku. JOINokazało się dość szybkie.
Zawężenie kręgu podejrzanych
Byłem gotowy rozpocząć zmianę zapytania, aby osiągnąć możliwą poprawę wydajności. Mój zespół i ja opracowaliśmy 2 główne pomysły:
Użyj EXISTS dla adresu URL podzapytania: Chcieliśmy ponownie sprawdzić, czy wystąpiły jakieś problemy z podzapytaniem o adresy URL. Jednym ze sposobów osiągnięcia tego jest po prostu użycie EXISTS. EXISTSmoże znacznie poprawia wydajność, ponieważ kończy się natychmiast po znalezieniu jedynego ciągu znaków spełniającego warunek.
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
No tak. Podzapytanie po opakowaniu EXISTS, sprawia, że wszystko jest superszybkie. Następne logiczne pytanie brzmi: dlaczego żądanie with JOIN-ami i samo podzapytanie są pojedyncze szybkie, ale razem strasznie powolne?
Przeniesienie podzapytania do CTE : Jeśli zapytanie samo w sobie jest szybkie, możemy po prostu najpierw obliczyć szybki wynik, a następnie dostarczyć 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 < to_timestamp(1545107599)
AND recording_data.duration >=5
AND recording_data.num_of_pages > 0;
Ale nadal było bardzo wolno.
Znalezienie winowajcy
Przez cały ten czas przed oczami mignęła mi jedna mała rzecz, którą ciągle odsuwałem na bok. Ale ponieważ nie pozostało już nic innego, postanowiłem też na nią spojrzeć. mówię o && operator. Do widzenia EXISTS po prostu poprawiona wydajność && był jedynym pozostałym wspólnym czynnikiem we wszystkich wersjach powolnego zapytania.
Patrzeć na dokumentacja, widzimy to && używane, gdy trzeba znaleźć wspólne elementy między dwiema tablicami.
W pierwotnym żądaniu jest to:
AND ( urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[] )
Oznacza to, że wyszukujemy wzorce w naszych adresach URL, a następnie znajdujemy punkt przecięcia ze wszystkimi adresami URL z częstymi postami. Jest to nieco mylące, ponieważ „adresy URL” nie odnoszą się tutaj do tabeli zawierającej wszystkie adresy URL, ale do kolumny „adresy URL” w tabeli recording_data.
Wraz z rosnącymi podejrzeniami dot &&, próbowałem znaleźć dla nich potwierdzenie w wygenerowanym planie zapytań EXPLAIN ANALYZE (Mam już zapisany plan, ale zwykle wygodniej jest mi eksperymentować z SQL niż próbować zrozumieć nieprzejrzystość planistów zapytań).
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
Było tylko kilka linii filtrów &&. Co oznaczało, że operacja ta była nie tylko kosztowna, ale także wykonywana kilkukrotnie.
Przetestowałem to, izolując warunek
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[]
To zapytanie było powolne. Ponieważ JOIN-s są szybkie, a podzapytania są szybkie, jedyne, co pozostało, to && operator.
To tylko kluczowa operacja. Zawsze musimy przeszukać całą tabelę adresów URL w celu znalezienia wzorca i zawsze musimy znaleźć punkty przecięcia. Nie możemy wyszukiwać bezpośrednio według rekordów URL, ponieważ są to tylko identyfikatory, do których się odnoszą urls.
W drodze do rozwiązania
&& powoli, ponieważ oba zestawy są ogromne. Jeśli wymienię, operacja będzie stosunkowo szybka urls na { "http://google.com/", "http://wingify.com/" }.
Zacząłem szukać sposobu na ustawienie skrzyżowania w Postgres bez użycia &&, ale bez większych sukcesów.
Ostatecznie postanowiliśmy rozwiązać problem w izolacji: daj mi wszystko urls linie, dla których adres URL pasuje do wzorca. Bez dodatkowych warunków będzie to -
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%'
Zamiast JOIN składnia Właśnie użyłem podzapytania i rozwinąłem recording_data.urls array, dzięki czemu można bezpośrednio zastosować warunek w WHERE.
Najważniejszą rzeczą tutaj jest to && służy do sprawdzania, czy dany wpis zawiera pasujący adres URL. Jeśli trochę przymrużysz oczy, zobaczysz, że operacja ta przechodzi przez elementy tablicy (lub wiersze tabeli) i zatrzymuje się, gdy spełniony zostanie warunek (dopasowanie). Nic Ci nie przypomina? Tak, EXISTS.
Od recording_data.urls można się do niego odwoływać spoza kontekstu podzapytania, gdy tak się stanie, możemy odwołać się do naszego starego przyjaciela EXISTS i zawiń nim podzapytanie.
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%'
);
I ostateczny czas realizacji Time: 1898.717 ms Czas świętować?!?
Nie tak szybko! Najpierw musisz sprawdzić poprawność. Byłem bardzo podejrzliwy EXISTS optymalizacja, ponieważ zmienia logikę, aby zakończyć wcześniej. Musimy mieć pewność, że do żądania nie dodaliśmy nieoczywistego błędu.
Prostym testem było uruchomienie count(*) zarówno w przypadku wolnych, jak i szybkich zapytań o dużą liczbę różnych zestawów danych. Następnie dla małego podzbioru danych ręcznie sprawdziłem, czy wszystkie wyniki są prawidłowe.
Wszystkie testy dały niezmiennie pozytywne wyniki. Naprawiliśmy wszystko!
Zdobyta wiedza
Z tej historii można wyciągnąć wiele lekcji:
Plany zapytań nie mówią wszystkiego, ale mogą dostarczyć wskazówek
Nie zawsze głównymi podejrzanymi są prawdziwi winowajcy
Powolne zapytania można podzielić na części, aby wyizolować wąskie gardła
Nie wszystkie optymalizacje mają charakter redukcyjny
Używać EXISTtam, gdzie to możliwe, może prowadzić do dramatycznego wzrostu produktywności
Wniosek
Przeszliśmy z czasu zapytania wynoszącego ~24 minuty do 2 sekund — całkiem znaczny wzrost wydajności! Chociaż ten artykuł okazał się duży, wszystkie eksperymenty, które przeprowadziliśmy, odbyły się w ciągu jednego dnia, a szacuje się, że optymalizacja i testowanie zajmowały od 1,5 do 2 godzin.
SQL to wspaniały język, jeśli się go nie boisz, ale spróbuj się go nauczyć i używać. Mając dobre zrozumienie sposobu wykonywania zapytań SQL, sposobu, w jaki baza danych generuje plany zapytań, działania indeksów i po prostu rozmiaru danych, z którymi masz do czynienia, możesz odnieść sukces w optymalizacji zapytań. Równie ważne jest jednak dalsze wypróbowywanie różnych podejść i powolne rozwiązywanie problemu, znajdowanie wąskich gardeł.
Największą zaletą osiągania takich wyników jest zauważalna, widoczna poprawa szybkości — raport, który wcześniej nawet się nie ładował, teraz ładuje się niemal natychmiast.
Specjalne podziękowania dla moi towarzysze na rozkaz Adityi Mishry, Aditya Gauru и Varuna Malhotry do burzy mózgów i Dinkar Pandir za znalezienie ważnego błędu w naszej ostatniej prośbie, zanim w końcu się z nim pożegnaliśmy!