🐘 Руководство по SQL для начинающих. Часть 3: усложняем запросы, именуем вложенные запросы, анализируем скорость запроса
В последней части цикла статей мы составим сложные запросы, поработаем с вложенными запросами, поговорим о сохранности данных и немного о необходимости индексов.
Самостоятельное объединение (self join)
Иногда возникает ситуация, когда для получения необходимых данных нам понадобится объединить таблицу саму с собой. Наглядный пример вы можете посмотреть по этой ссылке. Суть в том, что, если строки в таблице содержат данные, которые могут быть связаны с данными из других строк этой же таблицы (например id
), мы может сделать join
таблицы к ней самой.
Давайте для начала добавим поле с идентификатором лучшего друга best_friend_id
в таблицу учеников и вставим в него некоторые данные.
Итак, в таблице мы храним id
лучшего друга для каждого ученика. Это эффективно, но не очень читабельно. Чтобы определить, кто же является лучшим другом, нам придётся соединить таблицу саму с собой. Возьмем таблицу students
и сделаем соединение (join
) снова с таблицей students
, в качестве ключей для связи у нас будет id
студента и id
лучшего друга (best_friend_id
). Определим псевдонимы таблиц x
и y
для улучшения понимания кода.
Оконные функции (Window functions)
Оконные функции очень похожи на агрегатные функции (group by
) тем, что они применяются для вычислений сгруппированных наборов данных. Но в отличие от агрегатных функций, оконные не уменьшают количество строк в результатах запроса.
Давайте вычислим средний балл для каждого ученика. На строках с четвертой по шестую в запросе ниже мы добавили OVER
и PARTITION BY
, чтобы из агрегатной функции сделать оконную.
Для агрегирующих операторов AVG
, MIN
, или MAX
, каждая строка в группировке PARTITION BY
будет отображать одинаковое значение. Возможно, для определенных видов анализа это и понадобится, но на самом деле, сила оконных функций в другом.
Возможность ранжирования данных более полезный случай. Попробуем сделать это с оценками наших учеников. Чтобы ранжировать оценки всех учеников мы будем использовать оператор RANK() OVER
, в который мы передадим нужный нам столбец.
Добавим всего одну строку в наш запрос, а именно PARTITION BY s.name
к оператору OVER
. В итоге мы получили ранжирование в рамках одного студента.
У оконных функций есть еще несколько интересных возможностей. Например, функции распределения cumulative distribution
, функции dense
и percent ranks
. dense_rank
– функция возвращает ранг каждой строки, но в отличие от функции RANK
, она для одинаковых значений возвращает ранг, не пропуская следующий. С функциями lag
и lead
вы можете более подробно ознакомиться по ссылке.
Оператор WITH
Давайте познакомимся еще с одним инструментом – именование вложенных запросов. Оператор WITH
позволяет нам присваивать имя нашему подзапросу, а это значит, что мы сможем обращаться к его результатам в других местах.
Например, мы хотим сравнить оценку ученика (колонка grades
) с его средним баллом. Сделать это в одном запросе вроде бы несложно, нам сначала нужно вычислить средний балл с помощью оператора GROUP BY
, а затем написать что-то вроде g.score > avg
. Начнем с агрегации GROUP BY
.
Это было просто. Но как же нам сравнить каждую оценку со средним баллом? Взгляните на примеры запросов ниже, все они выдадут ошибку.
Мы можем дважды использовать оконные функции, но это выглядит как минимум не очень читабельно.
Чтобы наш запрос был читабельным и масштабируемым, будем использовать WITH
. Разделим наш запрос на два подзапроса: первый будет считать средний балл, а второй добавит записи с индивидуальными оценками grades
.
Да, безусловно этот запрос получился заметно больше, нежели написание двух оконных функций. Однако, такой подробный запрос имеет два главных преимущества: читабельность и масштабируемость.
Запросы могут быть чудовищно длинные. Например в компании Meta*, мне встречался запрос, содержащий в себе 1000 строк и вызывал сразу 25 таблиц. Этот запрос был бы совершенно нечитаемым без применения оператора WITH
, который разграничивает отдельные участки кода и присваивает им псевдонимы.
Работая с большим объемом данных, мы не имеем таких роскошных возможностей как последовательное выполнение подзапросов, сохранение данных в CSV формат, затем объединение полученных данных и анализа их с помощью Python. Все взаимодействия с базой данных должны происходить за один раз.
Давайте разберем еще один вариант. Предположим, в нашей школе уволили директора, и система оценок претерпела изменения. У нас появился такой показатель как средневзвешенный балл, который вычисляется как оценка (grades
) умноженная на коэффициент сложности (weight
). Теперь для сдачи экзамена необходимо иметь: средневзвешенный балл по всем предметам не ниже 85% или этот же балл не ниже 70% по собственному проекту. Объединение данной логики в один блок CASE WHEN
достаточно сложно, но, если мы разделим запрос с помощью WITH
всё станет гораздо проще.
Давайте начнем с того, что выявим учеников, у которых средневзвешенный балл больше 85%.
Прекрасно. Теперь узнаем у кого из учеников больше 70% по персональному проекту.
Но, как мы помним, нам нужно, чтобы наш запрос выдал всех учеников, которые удовлетворяют либо первому, либо второму условию.
Это очень просто с использованием WITH
. Мы присвоим псевдонимы нашим запросам weighted_pass
и project_pass
, а потом объединим их.
Смотрим вглубь – EXPLAIN
В завершении данной статьи, давайте разберем еще одну важную тему. Чем больше мы изучаем SQL
, тем больше способов построения сложных запросов нам известно. Когда лучше использовать EXCEPT
, а когда NOT IN
? Нужно ли нам использовать несколько JOIN
для объединения таблиц, либо лучше применить WITH
и UNION ALL
?
И в итоге, как нам понять, что один запрос более эффективен, чем другой?
Postgres может нам об этом рассказать. Ключевое слово explain
предоставляет нам план выполнения, который подробно описывает как выполняется ваш запрос. Вернемся к запросу из начала статьи и увидим, что Postgres выполняет запрос совершенно не в том порядке, в каком мы написали.
Можно пойти еще дальше и использовать оператор EXPLAIN ANALYZE
, который отобразит еще более детальную информацию (например, время выполнения каждой части запроса и используемую память).
Например в результате выше можно увидеть, что Postgres последовательно сканирует (Seq Scan
) таблицы grades
и students
, потому что они не индексированы. Иначе говоря, Postgres не знает что строка внизу таблицы имеет id
меньше или больше чем строка наверху таблицы. В нашем случае, это не имеет особого значения ввиду размеров нашей базы данных, но если бы мы работали с миллионами строк данных, то нам определенно потребовалось бы найти и устранить такие узкие места в производительности.
Данная статья была обзором тех навыков в SQL, которые вам точно понадобятся сразу после получения базовых знаний. Мы начали с установки Postgres и pgAdmin, тем самым получив возможность экспериментировать с базой данных на своём компьютере.
Затем мы узнали про полезные синтаксические конструкции, которые позволили нам составлять более сложные запросы. Мы начали с фильтрации данных и узнали, чем отличается WHERE
от HAVING
. Далее мы познакомились с условными конструкциями, узнали как сегментировать данные с помощью CASE WHEN
и обрабатывать null
, используя COALESCE
. Мы перешли от горизонтального к вертикальному объединению таблиц с помощью операций над множествами, разобрав как UNION
, UNION ALL
, INTERSECT
, и EXCEPT
работают с повторяющимися строками в таблицах. И в конце второй части статьи узнали как создавать массивы и работать с ними.
В финальной части мы разобрали, как составлять еще более сложные запросы, такие как объединение таблицы с самой собой, оконные функции для сравнения данных, и оператор WITH
для именования вложенных запросов. И в конце мы узнали, что с помощью EXPLAIN
и EXPLAIN ANALYZE
можно оценить производительность наших запросов и узнать в каких местах их можно оптимизировать.
И конечно же это далеко не всё. Есть еще много функций, про которые стоит знать, например CAST
(для приведения одного типа данных к другому, например float
к integer
), или функции, определяемые пользователем (user-defined functions
), которые можно использовать для дальнейшего упрощения кода. Всё это безусловно очень полезно, но я бы рекомендовал всегда думать об оптимизации запросов. Даже в FAANG компаниях с практически неограниченными вычислительными ресурсами, запросы могут завершаться с ошибкой в том случае, если они используют больше памяти, чем может предоставить сервер. Выбор правильного подхода к написанию запроса, упрощает работу с потоком данных, что снижает шанс получить гневный звонок в полночь.
Дополнение
1. CASCADE
Что же на самом деле происходит когда мы используем CASCADE
при удалении таблицы?
Давайте предположим, что мы удаляем таблицу classrooms
и не затрагиваем остальные. Данные в таблице students
не затронуты, в результате запроса мы по прежнему видим id
класса.
Теперь заново создадим таблицу classrooms
и добавим в нее новых учителей, которых не было в оригинальной таблице. Связь между таблицами students
и classrooms
уже не точная, поскольку имена учителей изменили в сравнении с оригинальной таблицей.
Так произошло, потому что CASCADE
удалил внешний ключ в таблице students
. Мы можем это проверить. Изменим значение classroom_id
в таблице students
, поскольку оно больше не является внешним ключом и не связано с таблицей classrooms
, запрос выполнится успешно. Но если мы попробуем сделать то же самое с полем student_id
в таблице grades
, мы получим ошибку, потому что student_id
– это внешний ключ.
И еще одно замечание по поводу CASCADE
. Если мы используем ON DELETE CASCADE
при создании внешнего ключа в таблице students
, а потом удаляем запись в classrooms
, у нас также исчезнут связанные строки в таблице students
. Такой способ может быть полезен при работе с личными данными пользователей, например мы хотим удалить все данные о клиенте или о сотруднике.
2. Сохранность данных
Систематизирование схемы базы данных и создание скриптов – это лучшая практика с инженерной точки зрения, но при работе с реальными данными очень часто используются резервные копии. Существует много подходов, иногда используются полные копии баз данных, которые занимают большое количество памяти, а иногда фиксируют только конкретные изменения. В идеале, такие копии должны храниться на географически удаленном сервере, чтобы снизить вариант возможного влияния стихии на сохранность ваших данных.
3. Длина строк
Мы установили, что тип данных в колонке учитель
– это строка с максимальной длиной в 100 символов, так как не предполагаем, что будут значения длиннее. Но на самом ли деле мы экономим таким образом место на диске?
С технической точки зрения, в Postgres не имеет значения, какую длину строки мы определили (10, 100 или 500 символов). Указание длины строки – это скорее хороший способ коммуникации между разработчиками, потому что таким образом вы указываете, что вы ожидаете получить в этом поле.
Но, например, в MySQL длина строк имеет значение: временные таблицы и таблицы MEMORY
будут хранить строки одинаковой длины и дополнять их до максимального значения. Это означает, что, если вы указали тип поля VARCHAR(1000)
, то все данные в этой колонке будут дополняться до указанной длины, даже если строка состоит из 100 символов.
4. Оператор If
Если вам интересно, ниже пример того, как в Postgres выглядит синтаксис с оператором if
.
5. Индексы
При увеличении базы данных, установка индексов становится критически важной. Для примера, давайте создадим индекс на поле с оценками в таблице grades
:
Тем не менее, если сейчас мы применим оператор EXPLAIN ANALYZE
, мы увидим, что Postgres по-прежнему выполняет последовательное сканирование. Так происходит потому, что Postgres уже достаточно неплохо оптимизирован. Если количество записей в таблице невелико, быстрее выполнить последовательное сканирование, нежели использовать индексы. То есть Postgres сам выбрал наиболее быстрый путь.