SELECT: базовый запрос
SELECT: базовый запрос
Если SQL был бы игрой, то SELECT - это кнопка «начать». Жмёшь - и понеслось. Это та самая группа DQL, о которой шла речь в первом уроке.
Базовый SELECT
SELECT 1;
SELECT 'Привет, SQL' AS message;
Даже без таблиц SELECT работает. Используй это для быстрой проверки:
SELECT NOW(); -- текущее время
SELECT 10 + 5; -- арифметика
SELECT 'test' LIKE 't%'; -- логика
SELECT из таблицы
SELECT * FROM users;
SELECT id, name, email FROM users;
Почему SELECT * выкидывают из прода
Формулировка «меньше сюрпризов» звучит абстрактно, поэтому разберём конкретные сюрпризы. Все четыре встречаются в реальных проектах.
Схема меняется, код ломается молча или громко. Кто-то добавил в users колонку password_hash или internal_notes. Запрос SELECT * начал их отдавать: в API поехали лишние поля, а если ответ сериализуется целиком, наружу утекло то, что не должно было выйти. В Go то же изменение падает иначе - rows.Scan ждёт 5 значений, а получает 6, и ты видишь sql: expected 6 destination arguments in Scan, not 5. Ошибка честная, но приходит она в рантайме на проде, а не при код-ревью.
Трафик и память. У таблицы есть колонка description на 20 КБ текста. Список из 100 записей превращается из 10 КБ в 2 МБ, которые база прочитала с диска, передала по сети и приложение сложило в память. Список статей при этом рендерит только заголовок.
Двусмысленные имена в JOIN. SELECT * FROM orders JOIN users ON ... вернёт две колонки id и две created_at. Драйвер отдаст их по позиции, и id в результате окажется тот, который стоял первым, а не тот, который ты имел в виду. Подробнее про соединения - в уроке про JOIN.
Индекс перестаёт помогать. Если запросу нужны только id и email, а по ним есть индекс, база может ответить прямо из индекса, не заглядывая в таблицу. Попроси * - придётся идти в таблицу за остальными колонками. Механику разбираем в уроках про индексы и EXPLAIN.
Единственное место, где * уместен в проде, - разовые запросы руками в psql и COUNT(*), где никакие колонки читать не нужно вообще.
Alias (AS) - переименование столбцов
Хочешь вывести столбец с другим именем? Используй AS:
SELECT id, name AS username, email AS user_email FROM users;
SELECT COUNT(*) AS total_users FROM users; -- подробнее про такие функции в уроке про [агрегаты и GROUP BY](./05-group-by.md)
SELECT price * quantity AS total_amount FROM orders;
Alias в WHERE использовать нельзя:
-- Так база откажет
SELECT id, age * 2 AS double_age
FROM users
WHERE double_age > 36;
-- ERROR: column "double_age" does not exist
А в ORDER BY тот же alias работает:
SELECT id, age * 2 AS double_age
FROM users
WHERE age > 18 -- здесь только исходное имя
ORDER BY double_age DESC; -- а здесь alias уже виден
Разница не в прихоти разработчиков PostgreSQL, а в порядке вычисления запроса - о нём чуть ниже. Если alias нужен именно в фильтре, есть два выхода: повторить выражение (WHERE age * 2 > 36) или обернуть запрос в подзапрос и фильтровать снаружи:
SELECT * FROM (
SELECT id, age * 2 AS double_age FROM users
) t
WHERE t.double_age > 36;
DISTINCT - только уникальные значения
Если хочешь узнать, какие города есть в базе, но не все 10 млн строк:
SELECT DISTINCT city FROM users;
Вместо вывода каждого города из каждого пользователя (10 млн строк) получишь уникальные города (допустим, 50 строк).
Можно и по нескольким столбцам:
SELECT DISTINCT city, country FROM users;
NULL в результатах
NULL - это «пусто» или «неизвестно». Это не 0, не пустая строка, а специальное значение:
SELECT id, name, phone FROM users;
-- Результат:
-- id | name | phone
-- 1 | Alice | +7999...
-- 2 | Bob | NULL - нет номера
-- 3 | Carol | +7888...
NULL в расчётах обычно заразителен:
SELECT NULL + 10; -- результат: NULL
SELECT NULL || 'text'; -- результат: NULL
Два места, где NULL ведёт себя не так, как ожидают:
-- DISTINCT считает все NULL одним значением
SELECT DISTINCT phone FROM users;
-- в результате будет ровно один NULL, а не по одному на каждого пользователя без телефона
-- ORDER BY в PostgreSQL по умолчанию кладёт NULL в конец при ASC и в начало при DESC
SELECT id, phone FROM users ORDER BY phone DESC; -- сверху пустые телефоны
SELECT id, phone FROM users ORDER BY phone DESC NULLS LAST; -- пустые ушли в конец
Забавно, что DISTINCT считает два NULL одинаковыми, а оператор = - нет: NULL = NULL даёт не true, а NULL. Это не баг, а два разных правила стандарта: группировка сравнивает значения «не отличимы ли они», а = спрашивает «равны ли два известных значения». Практический вывод: в колонках, по которым сортируешь и фильтруешь, NULL лучше не допускать вовсе, и для этого есть NOT NULL в ограничениях.
Как с NULL работать в условиях: IS NULL разбираем в уроке про WHERE, там же - почему = NULL молча возвращает пустоту.
Порядок выполнения SELECT
Мы пишем запрос сверху вниз, начиная со SELECT. База вычисляет его в другом порядке:
FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT
Пройдём по шагам на конкретном запросе:
SELECT city, COUNT(*) AS users_count
FROM users
WHERE deleted_at IS NULL
GROUP BY city
HAVING COUNT(*) > 10
ORDER BY users_count DESC
LIMIT 5;
FROM users- берём источник строк.WHERE deleted_at IS NULL- выкидываем строки. Здесь ещё нет ни групп, ни alias, только колонки таблицы.GROUP BY city- сворачиваем строки в группы.HAVING COUNT(*) > 10- выкидываем группы. Строк уже нет, есть группы, поэтому вHAVINGпишут агрегаты, а вWHEREих писать нельзя.SELECT city, COUNT(*) AS users_count- только здесь появляется aliasusers_count.ORDER BY users_count DESC- сортируем то, что получилось. Alias виден, потому что шаг 5 уже прошёл.LIMIT 5- отрезаем хвост.
Из этой цепочки сразу следует несколько правил, которые иначе приходится запоминать наизусть:
- alias из
SELECTне работает вWHERE(шаг 2 идёт раньше шага 5), но работает вORDER BY(шаг 6 идёт позже); - агрегат вроде
COUNT(*)вWHEREневозможен, для этого естьHAVING- подробнее в уроке про GROUP BY; WHEREдешевлеHAVING: он отсекает строки до группировки, то есть группировать придётся меньше;LIMIT 10не означает «база прочитала 10 строк». Она сначала выполнилаWHERE, потом отсортировала весь результат и только затем отдала 10 строк. Именно поэтомуORDER BY created_at DESC LIMIT 10без индекса на большой таблице работает секунды.
ORDER BY: ASC vs DESC
-- По возрастанию (по умолчанию)
SELECT * FROM users ORDER BY created_at ASC;
-- По убыванию
SELECT * FROM users ORDER BY age DESC;
-- По нескольким полям (сначала по country, потом по name внутри каждой страны)
SELECT * FROM users
ORDER BY country ASC, name ASC;
LIMIT и OFFSET для пагинации
LIMIT 10 - выведи максимум 10 строк.
OFFSET 20 - пропусти первые 20 строк.
Вместе это пагинация:
-- Страница 1: строки 1-10
SELECT * FROM users LIMIT 10 OFFSET 0;
-- Страница 2: строки 11-20
SELECT * FROM users LIMIT 10 OFFSET 10;
-- Страница 3: строки 21-30
SELECT * FROM users LIMIT 10 OFFSET 20;
Или более читаемо (некоторые БД поддерживают):
SELECT * FROM users LIMIT 10 OFFSET 20;
-- то же самое:
SELECT * FROM users LIMIT 20, 10; - MySQL синтаксис
Сортировку по такому полю обычно ускоряет индекс - без него база сначала прочитает всю таблицу и только потом отсортирует.
Практический пример:
-- Последние 5 добавленных пользователей
SELECT id, name, created_at FROM users
ORDER BY created_at DESC
LIMIT 5;
Три подвоха пагинации через OFFSET
Пагинация выше выглядит безобидно, но в этом месте регулярно ловят баги.
Подвох первый: OFFSET без ORDER BY. Порядок строк база не гарантирует, значит «первые 10» и «следующие 10» - понятия неопределённые. На практике часть записей приезжает на двух страницах подряд, часть не приезжает вообще, и воспроизводится это раз в сто открытий. OFFSET без ORDER BY бессмыслен всегда.
Подвох второй: сортировка по неуникальному полю. Даже с ORDER BY порядок внутри одинаковых значений не определён:
-- Если у 50 заказов один и тот же created_at (импорт пачкой),
-- порядок между ними база выберет сама и может выбрать по-разному на разных страницах
SELECT * FROM orders ORDER BY created_at DESC LIMIT 10 OFFSET 10;
-- Лечится добавлением уникального поля в конец сортировки
SELECT * FROM orders ORDER BY created_at DESC, id DESC LIMIT 10 OFFSET 10;
Правило простое: последним ключом сортировки всегда ставь что-то уникальное, обычно id.
Подвох третий: данные меняются между запросами страниц. Пользователь смотрит страницу 1, в этот момент кто-то добавляет новую запись в начало списка. Пользователь листает дальше, и последняя запись первой страницы съезжает на вторую - он видит её дважды. Это не лечится сортировкой, это природа OFFSET.
Плюс отдельная проблема с ценой: OFFSET 100000 не «прыгает» к строке 100001. База честно достаёт 100010 строк и выбрасывает первые 100000. Чем глубже страница, тем медленнее запрос, и в логах это выглядит как «сайт тормозит только у тех, кто листает далеко».
Альтернатива - пагинация по курсору (keyset pagination). Вместо номера страницы клиент передаёт последний увиденный id:
-- Первая порция
SELECT id, name, created_at FROM users
ORDER BY id DESC
LIMIT 10;
-- Следующая: last_id - это id последней строки предыдущей порции
SELECT id, name, created_at FROM users
WHERE id < 4271
ORDER BY id DESC
LIMIT 10;
Цена одинакова для первой и для тысячной порции, дублей от вставок не возникает. Минус тоже есть: нельзя перейти сразу «на страницу 50», поэтому в админках со списком номеров страниц обычно оставляют OFFSET, а в бесконечных лентах и в API берут курсор.
Мини-задание
- Выведи 5 последних пользователей (с помощью DESC и LIMIT)
- Выведи только
idиname(не всё подряд) - Получи список уникальных городов, где живут пользователи (DISTINCT)
- Выведи 10 пользователей, начиная со второй страницы (LIMIT + OFFSET)
- Переименуй столбцы так, чтобы они были на русском:
id→ID,name→Имя - Напиши запрос с alias в
WHERE, прочитай ошибку, потом перенеси этот alias вORDER BYи убедись, что там он работает - Сделай
SELECT DISTINCT phone FROM usersна таблице, где есть несколько пользователей без телефона: сколько NULL в результате? - Отсортируй пользователей по
phone DESC, потом добавьNULLS LASTи сравни выдачу - Перепиши пагинацию с
LIMIT 10 OFFSET 20на курсорную (WHERE id < :last_id ORDER BY id DESC LIMIT 10)