В декабре прошлого года я получил интересный отчет об ошибке от команды поддержки VWO. Время загрузки одного из аналитических отчетов для крупного корпоративного клиента казалось непомерно большим. А так как это сфера моей ответственности, я тут же сосредоточился на решении проблемы.
Предыстория
Чтобы было понятно о чём речь, я расскажу совсем немного о VWO. Это платформа, с помощью которой можно запускать разные таргетированные кампании на своих сайтах: проводить A/B эксперименты, отслеживать посетителей и конверсии, делать анализ воронки продаж, отображать тепловые карты и проигрывать записи визитов.
Но самое главное в платформе — составление отчетов. Все вышеперечисленные функции связаны между собой. И для корпоративных клиентов, огромный массив из информации был бы просто бесполезен без мощной платформы, представляющей их в виде для аналитики.
Используя платформу, можно сделать произвольный запрос на большом наборе данных. Вот простенький пример:
Показать все клики на странице "abc.com" ОТ <даты d1> ДО <даты d2> для людей, которые использовали Chrome ИЛИ (находились в Европе И использовали iPhone)
Обратите внимание на булевы операторы. Они доступны для клиентов в интерфейсе запроса, чтобы делать сколь угодно сложные запросы для получения выборок.
Медленный запрос
Клиент, о котором идет речь, пытался сделать что-то, что интуитивно должно работать быстро:
Покажи все записи сессий для пользователей посетивших любую страницу с урлом, где есть "/jobs"
На этом сайте было огромное количество трафика, и мы хранили более миллиона уникальных 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 < to_timestamp(1545177599) AND recording_data.duration >=5 AND recording_data.num_of_pages > 0 ;
А вот тайминги:
Планируемое время: 1.480 ms Время выполнения: 1431924.650 ms
Запрос обходил 150 тысяч строк. Планировщик запросов показал пару интересных деталей, но никаких очевидных узких мест.
Давайте поизучаем запрос дальше. Как видно, он делает JOIN
трёх таблиц:
- sessions: для отображения сессионной информации: браузер, юзер агент, страна и так далее.
- recording_data: записанные урлы, страницы, продолжительность визитов
- urls: чтобы избежать дублирования чрезвычайно больших урлов, мы храним их в отдельной таблице.
Также обратите внимание, что все наши таблицы уже разделены по account_id
. Таким образом, исключена ситуация, когда из-за одного особо большого аккаунта проблемы возникают у остальных.
В поисках улик
При ближайшем рассмотрении мы видим, что что-то в конкретном запросе не так. Стоит присмотреться к этой строке:
urls && array( select id from acc_{account_id}.urls where url ILIKE '%enterprise_customer.com/jobs%' )::text[]
Первая мысль была, что возможно, из-за ILIKE
на всех этих длинных урлах (у нас есть более 1,4 миллиона уникальных URL-адресов, собранных для этого аккаунта) производительность может проседать.
Но, нет — дело не в этом!
SELECT id FROM urls WHERE url ILIKE '%enterprise_customer.com/jobs%'; id -------- ... (198661 rows) Time: 5231.765 ms
Сам запрос поиска по шаблону занимает всего 5 секунд. Поиск по шаблону на миллионе уникальных урлов явно не является проблемой.
Следующий подозреваемый по списку — несколько 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 < to_timestamp(1545177599) AND recording_data.duration >=5 AND recording_data.num_of_pages > 0 ; count ------- 8086 (1 row) Time: 147.851 ms
И это так же был не наш случай. JOIN
'ы оказались весьма быстрыми.
Сужаем круг подозреваемых
Я был готов начать менять запрос для достижения любых возможных улучшений производительности. Мы с командой разработали 2 главных идеи:
- Использовать EXISTS для 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 < to_timestamp(1549177599) AND recording_data.duration >=5 AND recording_data.num_of_pages > 0 ; count 32519 (1 row) Time: 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 < to_timestamp(1545107599) AND recording_data.duration >=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[] )
Что означает, что мы делаем поиск по шаблону по нашим урлам, затем находим пересечение со всеми урлами с общими записями. Это немного запутанно, поскольку «urls» здесь не ссылается на таблицу, содержащую все URL-адреса, а на столбец «urls» в таблице recording_data
.
С ростом подозрений в отношении &&
, я попытался найти им подтверждение в плане запроса, сгенерированном EXPLAIN ANALYZE
(у меня уже был сохранённый план, но мне обычно удобнее экспериментировать в SQL, чем пытаться понять непрозрачности планировщиков запросов).
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
Там было несколько строк фильтров только из &&
. Что означало, что эта операция не только была дорогостоящей, но и выполнялась несколько раз.
Я проверил это, изолировав условие
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-адресов, чтобы искать по шаблону, и нам всегда нужно находить пересечения. Мы не можем искать по записям урлов напрямую, потому что это просто айдишники ссылающиеся на urls
.
На пути к решению
&&
медленный, потому что оба сета огромны. Операция будет относительно быстрой, если я заменю urls
на { "http://google.com/", "http://wingify.com/" }
.
Я начал искать способ сделать в Postgres пересечение множеств без использования &&
, но без особого успеха.
В конце концов, мы решили просто решить проблему изолированно: дай мне все urls
строки, для которых урл соответствует шаблону. Без дополнительных условий это будет -
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
и обернуть им подзапрос.
Объединяя всё вместе, мы получаем окончательный оптимизированный запрос:
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%' );
И окончательное время выполнения Time: 1898.717 ms
Пора праздновать?!?
Не так быстро! Сначала нужно проверить корректность. Я был крайне подозрителен в отношении EXISTS
оптимизации, так как она меняет логику на более раннее завершение. Мы должны быть уверены, что мы не добавили неочевидную ошибку в запрос.
Простая проверка заключалась в выполнении count(*)
и на медленных, и на быстрых запросах для большого количества разных наборов данных. Затем, для небольшого подмножества данных я проверил правильность всех результатов вручную.
Все проверки дали стабильно положительные результаты. Мы всё починили!
Извлеченные Уроки
Из этой истории можно извлечь немало уроков:
- Планы запросов не рассказывают всю историю, но могут давать подсказки
- Главные подозреваемые не всегда являются настоящими виновниками
- Медленные запросы можно разбить, чтобы изолировать узкие места
- Не все оптимизации по природе редуктивны
- Использование
EXIST
, где это возможно, способно привести к резому росту производительности
Вывод
Мы прошли от времени запроса в ~24 минуты до 2 секунд — весьма серьёзный рост производительности! Хотя эта статья и вышла большой, все эксперименты, которые мы делали, произошли в один день, и по прикидкам, заняли от 1,5 до 2 часов для оптимизаций и тестирования.
SQL — чудесный язык, если не бояться его, а попытаться познать и использовать. Имея хорошее понимание того, как выполняются SQL-запросы, как БД генерирует планы запросов, как работают индексы и просто размера данных, с которым имеете дело, вы сможете очень преуспеть в оптимизации запросов. Не менее важно, однако, продолжать пробовать различные подходы и медленно разбивать проблему, находя узкие места.
Лучшая часть в достижении подобных результатов это заметное видимое улучшение скорости работы — когда отчет, который раньше даже не загружался, теперь загружается почти мгновенно.
Особая благодарность моим товарищам по команде Адитье Мишре, Адитье Гауру и Варуну Малхотре за мозговой штурм и Динкару Пандиру за то, что нашёл важную ошибку в нашем финальном запросе, прежде чем мы окончательно с ним распрощались!
Автор: germn