В прошлом уроке мы меняли таблицы и разносили повторы по разным местам. Таблицы готовы, пора задавать им вопросы. С этого урока начинается кластер про запросы, и открывает его 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.
домашка