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 *` в учебе норм. В проде чаще выбирают конкретные поля: меньше данных, меньше сюрпризов при изменении схемы.

Почему 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: пишем сверху вниз, БД выполняет в другом порядке

Мы пишем запрос сверху вниз, начиная со 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;
  1. FROM users - берём источник строк.
  2. WHERE deleted_at IS NULL - выкидываем строки. Здесь ещё нет ни групп, ни alias, только колонки таблицы.
  3. GROUP BY city - сворачиваем строки в группы.
  4. HAVING COUNT(*) > 10 - выкидываем группы. Строк уже нет, есть группы, поэтому в HAVING пишут агрегаты, а в WHERE их писать нельзя.
  5. SELECT city, COUNT(*) AS users_count - только здесь появляется alias users_count.
  6. ORDER BY users_count DESC - сортируем то, что получилось. Alias виден, потому что шаг 5 уже прошёл.
  7. 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 без индекса на большой таблице работает секунды.
Так запрос выглядит по правилам языка. Реально база вольна делать шаги в другой последовательности и выкидывать лишнюю работу, лишь бы результат совпадал: например, применить `LIMIT` во время чтения индекса, а не после сортировки. Что произошло на самом деле, показывает [EXPLAIN](./11-explain.md).

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 берут курсор.

Если в коде встретился `LIMIT` без `ORDER BY`, это либо запрос, где строка ровно одна, либо ошибка. Третьего варианта практически не бывает.

Мини-задание

  • Выведи 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)

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой, вести прогресс.