През декември миналата година получих интересен доклад за грешка от екипа на VWO. Времето за зареждане на един от аналитичните отчети за голям корпоративен клиент изглеждаше непосилно дълго. Тъй като това е в сферата на моята отговорност, веднага се насочих към решаването на проблема.
Предистория
За да стане ясно за какво става въпрос, ще разкажа малко за VWO. Това е платформа, с която можете да стартирате различни целеви кампании на вашите сайтове: да провеждате A/B експерименти, да следите посетителите и конверсиите, да правите анализ на продажбената фуния, да показвате топлинни карти и да възпроизвеждате записи на посещения.
Но най-важното в платформата е изготвянето на отчети. Всички изброени функции са свързани помежду си. За корпоративни клиенти, огромен масив от информация би бил просто безполезен без мощна платформа, която да ги представя в аналитичен вид.
Използвайки платформата, можете да направите произволно запитване върху голям набор от данни. Ето един прост пример:
Покажи всички кликове на страницата "abc.com" ОТ ДО за хора, които използвали Chrome ИЛИ (били в Европа И използвали iPhone)
Обърнете внимание на логическите оператори. Те са налични за клиентите в интерфейса за запитвания, за да правят колкото се може по-сложни запитвания за получаване на извадки.
Бавно запитване
Клиентът, за когото става въпрос, се опитваше да направи нещо, което интуитивно трябва да работи бързо:
Покажи всички записи на сесиите за потребители, посетили всяка страница с URL, съдържащ "jobs"
На този сайт имаше огромно количество трафик и ние съхранявахме над един милион уникални URL адреса само за него. И те искаха да намерят доста прост шаблон на URL, свързан с бизнес модела им.
Предварително разследване
Нека видим какво се случва в базата данни. По-долу е представен оригиналният бавен 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 ;А ето тайминги:
Планирано време: 1.480 ms Време на изпълнение: 1431924.650 ms
Запитът обходи 150 хиляди реда. Планировчикът на запитвания показва няколко интересни детайли, но няма очевидни тесни места.
Нека разгледаме запитването по-надълбоко. Както се вижда, то извършва JOIN три таблици:
- sessions: за показване на сесийната информация: браузър, потребителски агент, страна и т.н.
- recording_data: записани URL адреси, страници, продължителност на посещенията
- urls: за да избегнем дублирането на изключително големи URL адреси, ги съхраняваме в отделна таблица.
Също така имайте предвид, че всички наши таблици вече са разделени по account_id. Така ситуацията, при която един особено голям акаунт създава проблеми за останалите, е изключена.
В търсене на улики
При по-подробно разглеждане виждаме, че нещо в конкретното запитване не е наред. Струва си да се обърне внимание на този ред:
urls && array(
select id from acc_{account_id}.urls
where url ILIKE '%enterprise_customer.com/jobs%'
)::text[]Първата ми мисъл беше, че вероятно заради ILIKE всички тези дълги URL адреси (имаме над 1.4 милиона уникални URL адреси, събрани за този акаунт) производителността може да е засегната.
Но, не — не е това!
SELECT id FROM urls WHERE url ILIKE '%enterprise_customer.com/jobs%';
id
--------
...
(198661 реда)
Време: 5231.765 msСамото запитване за търсене по шаблона отнема само 5 секунди. Търсенето по шаблон на милион уникални URL адреси определено не е проблем.
Следващият заподозрян в списъка — няколко JOIN. Възможно е прекалено използване да е довело до забавяне? Обикновено JOIN‘ите са най-очевидните кандидати за проблеми с производителността, но не вярвах, че нашият случай е типичен.
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 ред)
Време: 147.851 msИ това също не бе нашият случай. JOIN‘ите се оказаха доста бързи.
Съсредоточавам се на заподозрените
Бях готов да започна да променям запитването, за да постигна каквито и да е възможни подобрения в производителността. Ние с екипа разработихме 2 основни идеи:
- Да използваме EXISTS за подзапитване на URL адресите: Искахме да проверим отново дали има проблеми с подзапитването за URL адресите. Един от начините да постигнем това е просто да използваме
EXISTS.EXISTSзначително да подобри производителността, тъй като завършва незабавно, щом намери единствен ред по условието.
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 ред)
Време: 1636.637 msДа, подзаявка, когато е обвита в EXISTS, прави всичко супер бързо. Следващият логичен въпрос е защо заявката с JOIN-ите и самата подзаявка са бързи поотделно, но ужасно забавят заедно?
- Преместваме подзаявката в CTE : ако самата заявка е бърза, можем просто първо да изчислим бързия резултат, а след това да го предоставим на основната заявка.
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;Но и това все още беше много бавно.
Намираме виновника
През цялото време пред очите ми се мяркаше един дребен детайл, от който постоянно отвръщах. Но тъй като вече не оставаше нищо друго, реших да погледна и него. Става въпрос за && оператора. Време е EXISTS просто да подобрим производителността, && беше единственият оставащ общ фактор във всички версии на бавната заявка.
Гледайки на , виждаме, че && се използва, когато трябва да намерим общи елементи между два масива.
В оригиналната заявка това е:
AND (urls && array(select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%')::text[])Което означава, че правим търсене по шаблон по нашите URL адреси, след което намираме пресечката с всички URL адреси с общи записи. Това е малко объркващо, тъй като "urls" тук не се отнася до таблицата, съдържаща всички URL адреси, а до колоната "urls" в таблицата recording_data.
С увеличаващите се съмнения относно &&, се опитах да намеря потвърждение в плана за заявка, генериран от EXPLAIN ANALYZE (имах вече запазен план, но обикновено предпочитам да експериментирам в SQL, отколкото да се опитвам да разбера неясноти в планирания на заявки).
Филтър: ((urls && ($0)::text[]) И (r_time > '2018-12-17 12:17:23+00'::timestamp with time zone) И (r_time = '5'::double precision) И (num_of_pages > 0))
Редове, отстранени от филтъра: 52710Имаше няколко реда филтри само от &&. Което означаваше, че тази операция не само, че беше скъпа, но и се изпълняваше няколко пъти.
Проверих това, изолирайки условието
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[]Тази заявка се изпълняваше бавно. Тъй като JOIN-ите са бързи и подзапитванията са бързи, оставаше само && оператор.
Това е ключовата операция. Винаги трябва да търсим в цялата основна таблица с URL адреси, за да търсим по шаблона, и винаги трябва да намираме пресечените части. Не можем да търсим директно по записите с URL адреси, защото това просто са идентификатори, които сочат към urls.
На пътя към решението
&& е бавен, защото и двете множества са огромни. Операцията ще бъде относително бърза, ако заменя urls на { "http://google.com/", "http://wingify.com/" }.
Започнах да търся начин да направя в Postgres пресичане на множества без да използвам &&, но без особен успех.
В крайна сметка решихме просто да решим проблема изолирано: дай ми всички urls редове, за които URL адресът съответства на шаблона. Без допълнителни условия това ще бъде —
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%'Вместо JOIN аз просто използвах подзаявка и разширих recording_data.urls масив, за да може условието да бъде прилагано директно в WHERE.
Най-важното тук е, че && се използва за проверка дали даден запис съдържа съответния URL адрес. Малко присвитавайки, човек може да види в тази операция преминаване през елементите на масива (или редовете на таблицата) и спиране при изпълнение на условието (съответствието). Напомня ли на нещо? Аха, EXISTS.
Тъй като на recording_data.urls може да се реферира извън контекста на подзаявката, когато това се случва, можем да се върнем при нашия стар приятел EXISTS и да обвием подзаявката.
Събирайки всичко заедно, получаваме окончателната оптимизирана заявка:
ИЗБЕРЕТЕ
брой(*)
ОТ
acc_{account_id}.urls като recordings_urls,
acc_{account_id}.recording_data като recording_data,
acc_{account_id}.sessions като sessions
КЪДЕ
recording_data.usp_id = sessions.usp_id
И ( 1 = 1 )
И sessions.referrer_id = recordings_urls.id
И r_time > to_timestamp(1542585600)
И r_time =5
И recording_data.num_of_pages > 0
И СЪЩЕСТВУВА(
ИЗБЕРЕТЕ urls.url
ОТ
acc_{account_id}.urls като urls,
(ИЗБЕРЕТЕ unnest(urls) КАТО rec_url_id ОТ acc_{account_id}.recording_data)
КАТО unrolled_urls
КЪДЕ
urls.id = unrolled_urls.rec_url_id И
urls.url ILIKE '%enterprise_customer.com/jobs%'
);
И окончателно време на изпълнение Време: 1898.717 ms Време за празнуване?!?
Не толкова бързо! Първо трябва да проверим правилността. Бях изключително подозрителен към EXISTS оптимизацията, тъй като тя променя логиката за по-ранно завършване. Трябва да сме сигурни, че не сме добавили неочевидна грешка в заявката.
Простата проверка се състоеше в изпълнението на брой(*) и на бавни, и на бързи заявки за голямо количество различни набори от данни. След това, за малко подмножество от данни, ръчно проверих правилността на всички резултати.
Всички проверки дадоха стабилно положителни резултати. Всичко поправихме!
Извлечени Уроци
От тази история могат да се извлекат много уроци:
- Плановете за заявки не разказват цялата история, но могат да дадат насоки
- Главните заподозрени не винаги са истинските виновници
- Бавните заявки могат да бъдат разделени, за да се изолират тесните места
- Не всички оптимизации по природа са редуктивни
- Използване
СЪЩЕСТВУВА, където е възможно, може да доведе до рязко повишаване на производителността
Извод
Преминахме от време за заявка от ~24 минути до 2 секунди — наистина сериозно увеличение на производителността! Въпреки че тази статия стана дълга, всички експерименти, които проведохме, се случиха в един ден и по оценка отнеха между 1.5 до 2 часа за оптимизации и тестове.
SQL е чудесен език, ако не се страхувате от него, а се опитате да го разберете и използвате. Има добро разбиране за това как се изпълняват SQL заявките, как базата данни генерира планове за заявки, как работят индексите и просто размера на данните, с които се сблъсквате, ще можете да напреднете значително в оптимизацията на заявките. Не по-малко важно обаче е да продължите да изпитвате различни подходи и бавно да разделяте проблема, намирайки тесните места.
Най-добрата част от постигането на такива резултати е значителното видимо подобрение в скоростта на работа — когато отчет, който преди дори не се зареждаше, сега се зарежда почти незабавно.
Специални благодарности на моите колеги от екипа на Адитье Мишре, Адитье Гауру и за мозъчната атака и Динкару Пандиру за това, че откри важна грешка в нашето окончателно запитване, преди да се разделим с него завинаги!
Източник: habr.com
