Раздел 34 · MySQL с нуля

SELECT, WHERE, ORDER BY и LIMIT

junior~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы меняли таблицы и разносили повторы по разным местам. Таблицы готовы, пора задавать им вопросы. С этого урока начинается кластер про запросы, и открывает его SELECT: какие колонки взять, какие строки оставить, в каком порядке их выдать и сколько показать.

Учебная база kino

Все уроки кластера работают на одной базе кинотеатра. В песочницах она уже загружена, у себя её можно развернуть этим скриптом в пустой базе kino:

CREATE TABLE halls (
  id INT PRIMARY KEY,
  name VARCHAR(30) NOT NULL,
  seats SMALLINT UNSIGNED NOT NULL
);
CREATE TABLE films (
  id INT AUTO_INCREMENT PRIMARY KEY,
  title VARCHAR(100) NOT NULL,
  director VARCHAR(60),
  release_year SMALLINT NOT NULL,
  duration_min SMALLINT UNSIGNED NOT NULL,
  age_rating ENUM('0+', '6+', '12+', '16+', '18+') NOT NULL,
  info JSON
);
CREATE TABLE viewers (
  id INT AUTO_INCREMENT PRIMARY KEY,
  name VARCHAR(50) NOT NULL,
  email VARCHAR(100) NOT NULL UNIQUE,
  phone VARCHAR(20),
  city VARCHAR(40),
  registered_at DATE NOT NULL
);
CREATE TABLE screenings (
  id INT AUTO_INCREMENT PRIMARY KEY,
  film_id INT NOT NULL,
  hall_id INT NOT NULL,
  starts_at DATETIME NOT NULL,
  price DECIMAL(7, 2) NOT NULL,
  FOREIGN KEY (film_id) REFERENCES films (id),
  FOREIGN KEY (hall_id) REFERENCES halls (id)
);
CREATE TABLE tickets (
  id INT AUTO_INCREMENT PRIMARY KEY,
  screening_id INT NOT NULL,
  viewer_id INT,
  seat VARCHAR(4) NOT NULL,
  status ENUM('paid', 'used', 'refunded') NOT NULL DEFAULT 'paid',
  sold_at DATETIME NOT NULL,
  UNIQUE KEY uq_ticket_seat (screening_id, seat),
  FOREIGN KEY (screening_id) REFERENCES screenings (id),
  FOREIGN KEY (viewer_id) REFERENCES viewers (id)
);
INSERT INTO halls VALUES
  (1, 'Большой', 120),
  (2, 'Малый', 40),
  (3, 'VIP', 12);
INSERT INTO films (title, director, release_year, duration_min, age_rating, info) VALUES
  ('Брат', 'Балабанов', 1997, 96, '16+', '{"genres": ["драма", "криминал"], "country": "Россия"}'),
  ('Москва слезам не верит', 'Меньшов', 1979, 150, '12+', '{"genres": ["драма", "мелодрама"], "awards": ["Оскар"]}'),
  ('Ирония судьбы', 'Рязанов', 1975, 184, '6+', '{"genres": ["комедия", "мелодрама"], "country": "СССР"}'),
  ('Сталкер', 'Тарковский', 1979, 163, '12+', '{"genres": ["фантастика", "драма"], "country": "СССР"}'),
  ('Ёжик в тумане', 'Норштейн', 1975, 10, '0+', '{"genres": ["мультфильм"], "country": "СССР"}'),
  ('Новые имена', NULL, 2026, 90, '18+', NULL),
  ('Солярис', 'Тарковский', 1972, 167, '12+', '{"genres": ["фантастика", "драма"], "country": "СССР"}');
INSERT INTO viewers (name, email, phone, city, registered_at) VALUES
  ('Анна Ковалёва', 'anna@example.com', '+79161234567', 'Москва', '2025-12-01'),
  ('Борис Орлов', 'boris@example.com', NULL, 'Казань', '2026-02-14'),
  ('Вера Ильина', 'vera@example.com', '+79035550101', 'Москва', '2026-03-30'),
  ('Глеб Соколов', 'gleb@example.com', NULL, NULL, '2026-06-11'),
  ('Дарья Белова', 'darya@example.com', '+79261112233', 'Санкт-Петербург', '2026-08-02'),
  ('Егор Ёлкин', 'egor@example.com', NULL, 'Казань', '2026-10-01');
INSERT INTO screenings (film_id, hall_id, starts_at, price) VALUES
  (1, 1, '2026-10-23 19:00:00', 450.00),
  (1, 2, '2026-10-24 21:30:00', 350.00),
  (2, 1, '2026-10-24 18:00:00', 400.00),
  (3, 3, '2026-10-25 20:00:00', 900.00),
  (4, 2, '2026-10-25 17:00:00', 300.00),
  (5, 1, '2026-10-26 11:00:00', 150.00),
  (6, 2, '2026-10-26 22:00:00', 250.00);
INSERT INTO tickets (screening_id, viewer_id, seat, status, sold_at) VALUES
  (1, 1, 'A1', 'used', '2026-10-20 12:05:00'),
  (1, 2, 'A2', 'used', '2026-10-20 12:07:00'),
  (1, NULL, 'B5', 'used', '2026-10-23 18:40:00'),
  (2, 1, 'C3', 'paid', '2026-10-22 09:15:00'),
  (3, 3, 'A1', 'paid', '2026-10-21 20:00:00'),
  (3, 3, 'A2', 'paid', '2026-10-21 20:00:00'),
  (3, 5, 'D4', 'refunded', '2026-10-22 13:30:00'),
  (4, 5, 'V1', 'paid', '2026-10-23 10:00:00'),
  (4, 4, 'V2', 'paid', '2026-10-23 10:02:00'),
  (5, 2, 'B1', 'paid', '2026-10-24 08:45:00'),
  (6, NULL, 'E7', 'paid', '2026-10-24 09:30:00');

Пять таблиц:

  • halls залы и число мест в них;
  • films фильмы: режиссёр, год, длительность, возрастной рейтинг и описание в JSON;
  • viewers зрители с почтой, телефоном, городом и датой регистрации;
  • screenings сеансы: какой фильм, в каком зале, когда и почём;
  • tickets билеты: на какой сеанс, кто купил, какое место и в каком статусе.

Неудобные места в данных оставлены нарочно. У фильма «Новые имена» нет режиссёра и описания. «Солярис» есть в каталоге, но сеансов у него нет. На сеанс 7 не продано ни одного билета. Егор Ёлкин зарегистрировался и ничего не купил. У трёх зрителей нет телефона, у Глеба Соколова не указан город. Билеты 3 и 11 продала касса, зрителя у них нет. Вера Ильина взяла два места на один сеанс, а билет 7 вернули. На таких местах запросы и ломаются, мы будем к ним возвращаться весь кластер.

Запрос как бланк заказа

Представь окошко кассы. Ты не идёшь сам в архив, а пишешь записку: «Дай названия и годы фильмов. Только старше 1980 года. Сначала самые длинные. Не больше трёх». Кассир сам знает, где лежат карточки и как их быстрее достать. Ты описываешь результат, а не путь к нему.

SELECT и есть такая записка. Каждая её строчка это отдельное предложение запроса:

SELECT title, duration_min      -- какие колонки показать
FROM films                      -- из какой таблицы
WHERE release_year < 1980       -- какие строки оставить
ORDER BY duration_min DESC      -- как отсортировать
LIMIT 3;                        -- сколько строк вернуть

Порядок предложений строгий: SELECT, FROM, WHERE, ORDER BY, LIMIT. Если переставить, MySQL ответит синтаксической ошибкой 1064.

SELECT и FROM: какие колонки

Самый короткий полезный запрос называет колонки и таблицу:

SELECT title, release_year FROM films;
+-------------------------------------------+--------------+
| title                                     | release_year |
+-------------------------------------------+--------------+
| Брат                                      |         1997 |
| Москва слезам не верит                    |         1979 |
| Ирония судьбы                             |         1975 |
| Сталкер                                   |         1979 |
| Ёжик в тумане                             |         1975 |
| Новые имена                               |         2026 |
| Солярис                                   |         1972 |
+-------------------------------------------+--------------+
7 rows in set (0.00 sec)

Обрати внимание на ширину колонки title: она заметно шире самого длинного названия. Клиент mysql считает ширину в байтах, а русская буква в UTF-8 занимает два байта. Это не ошибка, так выглядит настоящий вывод.

SELECT * выводит все колонки. Чтобы оглядеть таблицу, этого хватает. В коде приложения колонки перечисляют явно: если в таблицу добавят колонку, программа не получит неожиданных данных.

В списке колонок можно считать. Длительность в часах:

SELECT title, duration_min, duration_min / 60 AS hours FROM films WHERE id <= 3;
+-------------------------------------------+--------------+--------+
| title                                     | duration_min | hours  |
+-------------------------------------------+--------------+--------+
| Брат                                      |           96 | 1.6000 |
| Москва слезам не верит                    |          150 | 2.5000 |
| Ирония судьбы                             |          184 | 3.0667 |
+-------------------------------------------+--------------+--------+
3 rows in set (0.00 sec)

Деление целых чисел в MySQL даёт DECIMAL с четырьмя знаками после запятой, поэтому 1.6000. Слово AS даёт вычисленной колонке имя, его называют псевдонимом. Без него колонка называлась бы duration_min / 60. Псевдоним в кавычках может быть и русским:

SELECT title AS 'Фильм', release_year AS 'Год' FROM films WHERE id = 5;
+--------------------------+--------+
| Фильм                    | Год    |
+--------------------------+--------+
| Ёжик в тумане            |   1975 |
+--------------------------+--------+
1 row in set (0.00 sec)

WHERE: какие строки

WHERE оставляет строки, для которых условие истинно. Остальные в ответ не попадают.

SELECT title, release_year FROM films WHERE release_year < 1980;
+-------------------------------------------+--------------+
| title                                     | release_year |
+-------------------------------------------+--------------+
| Москва слезам не верит                    |         1979 |
| Ирония судьбы                             |         1975 |
| Сталкер                                   |         1979 |
| Ёжик в тумане                             |         1975 |
| Солярис                                   |         1972 |
+-------------------------------------------+--------------+
5 rows in set (0.00 sec)

Условия склеивают через AND (нужны оба) и OR (хватит одного). Для частых проверок есть короткие формы:

ЗаписьЧто значитПример
IN (...)значение из спискаage_rating IN ('0+', '6+')
BETWEEN a AND bот a до b, оба края включеныrelease_year BETWEEN 1975 AND 1979
LIKEсовпадение с шаблономtitle LIKE '%ер%'

Фильмы второй половины семидесятых длиннее ста минут:

SELECT title, release_year FROM films WHERE release_year BETWEEN 1975 AND 1979 AND duration_min > 100;
+-------------------------------------------+--------------+
| title                                     | release_year |
+-------------------------------------------+--------------+
| Москва слезам не верит                    |         1979 |
| Ирония судьбы                             |         1975 |
| Сталкер                                   |         1979 |
+-------------------------------------------+--------------+
3 rows in set (0.00 sec)

«Ёжик в тумане» тоже из 1975 года, но длится 10 минут и второе условие не прошёл.

В шаблоне LIKE знак % заменяет любое число символов, а _ ровно один символ. Здесь у MySQL есть особенность, которой нет в PostgreSQL. Сравнение строк идёт по правилам сравнения utf8mb4_0900_ai_ci, о которых шла речь в уроке про строки. Хвост ci значит «без учёта регистра», ai значит «без учёта диакритики». Поэтому маленькая «с» в шаблоне находит заглавную:

SELECT title FROM films WHERE title LIKE 'с%';
+----------------+
| title          |
+----------------+
| Сталкер        |
| Солярис        |
+----------------+
2 rows in set (0.00 sec)

А «ё» совпадает с «е». Ищем «ёл», а находим и Белову:

SELECT name FROM viewers WHERE name LIKE '%ёл%';
+-------------------------+
| name                    |
+-------------------------+
| Дарья Белова            |
| Егор Ёлкин              |
+-------------------------+
2 rows in set (0.00 sec)

Обычное равенство работает так же: WHERE title = 'солярис' найдёт «Солярис». Отдельный ILIKE, как в PostgreSQL, здесь не нужен. Но «й» и «и» для MySQL разные буквы, и пробел в конце строки тоже считается.

Ловушка с NULL

Найдём зрителей без телефона. Первая попытка выглядит логично:

SELECT name, phone FROM viewers WHERE phone = NULL;
Empty set (0.00 sec)

Empty set, хотя без телефона трое. NULL значит «неизвестно», а сравнение с неизвестным тоже даёт «неизвестно», не «да». WHERE пропускает только «да». Эта трёхзначная логика подробно разобрана в уроке теории про NULL. Для проверки на NULL есть своя запись:

SELECT name, phone FROM viewers WHERE phone IS NULL;
+-------------------------+-------+
| name                    | phone |
+-------------------------+-------+
| Борис Орлов             | NULL  |
| Глеб Соколов            | NULL  |
| Егор Ёлкин              | NULL  |
+-------------------------+-------+
3 rows in set (0.00 sec)

Та же ловушка прячется в отрицании. Зрители не из Москвы:

SELECT name, city FROM viewers WHERE city <> 'Москва';
+-------------------------+-------------------------------+
| name                    | city                          |
+-------------------------+-------------------------------+
| Борис Орлов             | Казань                        |
| Дарья Белова            | Санкт-Петербург               |
| Егор Ёлкин              | Казань                        |
+-------------------------+-------------------------------+
3 rows in set (0.00 sec)

Глеб пропал: его город неизвестен, и сравнение снова дало «неизвестно». Если Глеб нужен, добавь его явно:

SELECT name, city FROM viewers WHERE city <> 'Москва' OR city IS NULL;
+-------------------------+-------------------------------+
| name                    | city                          |
+-------------------------+-------------------------------+
| Борис Орлов             | Казань                        |
| Глеб Соколов            | NULL                          |
| Дарья Белова            | Санкт-Петербург               |
| Егор Ёлкин              | Казань                        |
+-------------------------+-------------------------------+
4 rows in set (0.00 sec)

ORDER BY: в каком порядке

Без ORDER BY порядок строк не гарантирован. Первый запрос урока выдал фильмы по порядку id, но это совпадение: стоит MySQL выбрать другой индекс, и порядок поменяется. Если порядок важен, задавай его явно.

SELECT title, director FROM films ORDER BY director, title;
+-------------------------------------------+----------------------+
| title                                     | director             |
+-------------------------------------------+----------------------+
| Новые имена                               | NULL                 |
| Брат                                      | Балабанов            |
| Москва слезам не верит                    | Меньшов              |
| Ёжик в тумане                             | Норштейн             |
| Ирония судьбы                             | Рязанов              |
| Солярис                                   | Тарковский           |
| Сталкер                                   | Тарковский           |
+-------------------------------------------+----------------------+
7 rows in set (0.00 sec)

ASC сортирует по возрастанию, его подразумевают по умолчанию, DESC по убыванию. Вторая колонка решает ничьи: у Тарковского два фильма, и между ними порядок задаёт название.

Посмотри, где оказался фильм без режиссёра. В MySQL NULL при сортировке меньше любого значения: при ASC он первый, при DESC последний. В PostgreSQL наоборот. Записи NULLS LAST в MySQL нет, но есть приём: сначала сортировать по условию director IS NULL. Для обычных строк оно даёт 0, для NULL даёт 1, и NULL уезжает в конец:

SELECT title, director FROM films ORDER BY director IS NULL, director, title;
+-------------------------------------------+----------------------+
| title                                     | director             |
+-------------------------------------------+----------------------+
| Брат                                      | Балабанов            |
| Москва слезам не верит                    | Меньшов              |
| Ёжик в тумане                             | Норштейн             |
| Ирония судьбы                             | Рязанов              |
| Солярис                                   | Тарковский           |
| Сталкер                                   | Тарковский           |
| Новые имена                               | NULL                 |
+-------------------------------------------+----------------------+
7 rows in set (0.00 sec)

LIMIT: сколько строк

Афиша на сайте не выводит тысячу фильмов разом, она показывает страницу. LIMIT ограничивает число строк в ответе. Три самых длинных фильма:

SELECT title, duration_min FROM films ORDER BY duration_min DESC LIMIT 3;
+---------------------------+--------------+
| title                     | duration_min |
+---------------------------+--------------+
| Ирония судьбы             |          184 |
| Солярис                   |          167 |
| Сталкер                   |          163 |
+---------------------------+--------------+
3 rows in set (0.00 sec)

Вторую страницу даёт OFFSET: он пропускает строки с начала.

SELECT title, duration_min FROM films ORDER BY duration_min DESC LIMIT 3 OFFSET 3;
+-------------------------------------------+--------------+
| title                                     | duration_min |
+-------------------------------------------+--------------+
| Москва слезам не верит                    |          150 |
| Брат                                      |           96 |
| Новые имена                               |           90 |
+-------------------------------------------+--------------+
3 rows in set (0.00 sec)

В MySQL есть и короткая запись LIMIT 3, 3, она вернёт то же самое. Запомни порядок: сначала сколько пропустить, потом сколько взять. В LIMIT 3 OFFSET 3 наоборот, поэтому длинная форма читается легче.

LIMIT без ORDER BY вернёт «какие-то три строки». Хуже того, LIMIT с сортировкой по колонке с повторами тоже ненадёжен: если третье и четвёртое место делят строки с одинаковым значением, какая из них попадёт в ответ, решает сервер. Добавляй в сортировку колонку, которая не повторяется, например id.

DISTINCT: без повторов

Из каких городов наши зрители? SELECT city FROM viewers вернёт шесть строк с повторами. DISTINCT оставит по одной:

SELECT DISTINCT city FROM viewers ORDER BY city;
+-------------------------------+
| city                          |
+-------------------------------+
| NULL                          |
| Казань                        |
| Москва                        |
| Санкт-Петербург               |
+-------------------------------+
4 rows in set (0.00 sec)

NULL тоже попал в список, и стоит первым по правилу сортировки выше. Для DISTINCT все NULL считаются одинаковыми. Как DISTINCT ведёт себя с несколькими колонками, разобрано в уроке для собеседования.

Попробуй сам

В песочнице база kino. Запрос ниже собирает всё из урока: фильтр, сортировку, страницу.

Поменяй шаблон в LIKE на заглавную букву и убедись, что ответ не изменится. Найди зрителей без телефона и проверь, что phone = NULL ничего не находит. Отсортируй фильмы по режиссёру по убыванию и посмотри, куда переедут «Новые имена».

Проверь себя

Задание

Дальше

Пока каждый запрос смотрел в одну таблицу. Но в screenings у сеанса только номер фильма, а название лежит в films. Чтобы увидеть их рядом, таблицы надо соединить: следующий урок про JOIN.

домашка

Домашка